Repository: stashapp/stash Branch: develop Commit: c832e1a8a292 Files: 1651 Total size: 10.5 MB Directory structure: gitextract_5pun5gxb/ ├── .dockerignore ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ ├── PULL_REQUEST_TEMPLATE/ │ │ ├── BugFix.md │ │ └── Feature.md │ └── workflows/ │ ├── build-compiler.yml │ ├── build.yml │ └── golangci-lint.yml ├── .gitignore ├── .golangci.yml ├── .gqlgenc.yml ├── .idea/ │ ├── codeStyles/ │ │ └── codeStyleConfig.xml │ ├── dataSources.xml │ ├── encodings.xml │ ├── go.iml │ ├── misc.xml │ ├── modules.xml │ ├── sqldialects.xml │ └── vcs.xml ├── .mockery.yml ├── LICENSE ├── Makefile ├── README.md ├── cmd/ │ ├── phasher/ │ │ └── main.go │ └── stash/ │ ├── main.go │ └── main_test.go ├── docker/ │ ├── build/ │ │ └── x86_64/ │ │ ├── Dockerfile │ │ ├── Dockerfile-CUDA │ │ └── README.md │ ├── ci/ │ │ └── x86_64/ │ │ ├── Dockerfile │ │ ├── README.md │ │ └── docker_push.sh │ ├── compiler/ │ │ ├── Dockerfile │ │ ├── Makefile │ │ └── README.md │ └── production/ │ ├── README.md │ └── docker-compose.yml ├── docs/ │ ├── CONTRIBUTING.md │ └── DEVELOPMENT.md ├── go.mod ├── go.sum ├── gqlgen.yml ├── graphql/ │ ├── schema/ │ │ ├── schema.graphql │ │ └── types/ │ │ ├── config.graphql │ │ ├── dlna.graphql │ │ ├── file.graphql │ │ ├── filters.graphql │ │ ├── gallery-chapter.graphql │ │ ├── gallery.graphql │ │ ├── group.graphql │ │ ├── image.graphql │ │ ├── job.graphql │ │ ├── logging.graphql │ │ ├── metadata.graphql │ │ ├── migration.graphql │ │ ├── movie.graphql │ │ ├── package.graphql │ │ ├── performer.graphql │ │ ├── plugin.graphql │ │ ├── scalars.graphql │ │ ├── scene-marker-tag.graphql │ │ ├── scene-marker.graphql │ │ ├── scene.graphql │ │ ├── scraped-group.graphql │ │ ├── scraped-performer.graphql │ │ ├── scraper.graphql │ │ ├── sql.graphql │ │ ├── stash-box.graphql │ │ ├── stats.graphql │ │ ├── studio.graphql │ │ ├── tag.graphql │ │ └── version.graphql │ └── stash-box/ │ └── query.graphql ├── internal/ │ ├── api/ │ │ ├── authentication.go │ │ ├── bool_map.go │ │ ├── changeset_translator.go │ │ ├── check_version.go │ │ ├── context_keys.go │ │ ├── custom_fields.go │ │ ├── dir_list.go │ │ ├── doc.go │ │ ├── error.go │ │ ├── fields.go │ │ ├── images.go │ │ ├── input.go │ │ ├── json.go │ │ ├── json_test.go │ │ ├── loaders/ │ │ │ ├── customfieldsloader_gen.go │ │ │ ├── dataloaders.go │ │ │ ├── fileloader_gen.go │ │ │ ├── folderloader_gen.go │ │ │ ├── folderparentfolderidsloader_gen.go │ │ │ ├── galleryfileidsloader_gen.go │ │ │ ├── galleryloader_gen.go │ │ │ ├── grouploader_gen.go │ │ │ ├── imagefileidsloader_gen.go │ │ │ ├── imageloader_gen.go │ │ │ ├── performerloader_gen.go │ │ │ ├── scenefileidsloader_gen.go │ │ │ ├── scenelastplayedloader_gen.go │ │ │ ├── sceneloader_gen.go │ │ │ ├── sceneocountloader_gen.go │ │ │ ├── sceneohistoryloader_gen.go │ │ │ ├── sceneplaycountloader_gen.go │ │ │ ├── sceneplayhistoryloader_gen.go │ │ │ ├── studioloader_gen.go │ │ │ └── tagloader_gen.go │ │ ├── locale.go │ │ ├── models.go │ │ ├── plugin_map.go │ │ ├── resolver.go │ │ ├── resolver_model_config.go │ │ ├── resolver_model_file.go │ │ ├── resolver_model_folder.go │ │ ├── resolver_model_gallery.go │ │ ├── resolver_model_gallery_chapter.go │ │ ├── resolver_model_image.go │ │ ├── resolver_model_movie.go │ │ ├── resolver_model_performer.go │ │ ├── resolver_model_plugin.go │ │ ├── resolver_model_saved_filter.go │ │ ├── resolver_model_scene.go │ │ ├── resolver_model_scene_marker.go │ │ ├── resolver_model_studio.go │ │ ├── resolver_model_tag.go │ │ ├── resolver_mutation_configure.go │ │ ├── resolver_mutation_dlna.go │ │ ├── resolver_mutation_file.go │ │ ├── resolver_mutation_gallery.go │ │ ├── resolver_mutation_group.go │ │ ├── resolver_mutation_image.go │ │ ├── resolver_mutation_job.go │ │ ├── resolver_mutation_metadata.go │ │ ├── resolver_mutation_migrate.go │ │ ├── resolver_mutation_movie.go │ │ ├── resolver_mutation_package.go │ │ ├── resolver_mutation_performer.go │ │ ├── resolver_mutation_plugin.go │ │ ├── resolver_mutation_saved_filter.go │ │ ├── resolver_mutation_scene.go │ │ ├── resolver_mutation_scraper.go │ │ ├── resolver_mutation_stash_box.go │ │ ├── resolver_mutation_studio.go │ │ ├── resolver_mutation_tag.go │ │ ├── resolver_mutation_tag_test.go │ │ ├── resolver_query_configuration.go │ │ ├── resolver_query_dlna.go │ │ ├── resolver_query_find_file.go │ │ ├── resolver_query_find_folder.go │ │ ├── resolver_query_find_gallery.go │ │ ├── resolver_query_find_group.go │ │ ├── resolver_query_find_image.go │ │ ├── resolver_query_find_movie.go │ │ ├── resolver_query_find_performer.go │ │ ├── resolver_query_find_saved_filter.go │ │ ├── resolver_query_find_scene.go │ │ ├── resolver_query_find_scene_marker.go │ │ ├── resolver_query_find_studio.go │ │ ├── resolver_query_find_tag.go │ │ ├── resolver_query_job.go │ │ ├── resolver_query_logs.go │ │ ├── resolver_query_metadata.go │ │ ├── resolver_query_package.go │ │ ├── resolver_query_plugin.go │ │ ├── resolver_query_scene.go │ │ ├── resolver_query_scraper.go │ │ ├── resolver_subscription_job.go │ │ ├── resolver_subscription_logging.go │ │ ├── routes.go │ │ ├── routes_custom.go │ │ ├── routes_downloads.go │ │ ├── routes_gallery.go │ │ ├── routes_group.go │ │ ├── routes_image.go │ │ ├── routes_performer.go │ │ ├── routes_plugin.go │ │ ├── routes_scene.go │ │ ├── routes_studio.go │ │ ├── routes_tag.go │ │ ├── scraped_content.go │ │ ├── server.go │ │ ├── session.go │ │ ├── stash_box.go │ │ ├── timestamp.go │ │ ├── timestamp_test.go │ │ ├── types.go │ │ └── urlbuilders/ │ │ ├── doc.go │ │ ├── gallery.go │ │ ├── group.go │ │ ├── image.go │ │ ├── performer.go │ │ ├── scene.go │ │ ├── scene_markers.go │ │ ├── studio.go │ │ └── tag.go │ ├── autotag/ │ │ ├── doc.go │ │ ├── gallery.go │ │ ├── gallery_test.go │ │ ├── image.go │ │ ├── image_test.go │ │ ├── integration_test.go │ │ ├── performer.go │ │ ├── performer_test.go │ │ ├── scene.go │ │ ├── scene_test.go │ │ ├── studio.go │ │ ├── studio_test.go │ │ ├── tag.go │ │ ├── tag_test.go │ │ └── tagger.go │ ├── build/ │ │ └── version.go │ ├── desktop/ │ │ ├── desktop.go │ │ ├── desktop_platform_darwin.go │ │ ├── desktop_platform_nixes.go │ │ ├── desktop_platform_windows.go │ │ ├── dialog_nonwindows.go │ │ ├── dialog_windows.go │ │ ├── icon_windows.syso │ │ ├── systray_nixes.go │ │ └── systray_nonlinux.go │ ├── dlna/ │ │ ├── activity.go │ │ ├── activity_test.go │ │ ├── cd-service-desc.go │ │ ├── cds.go │ │ ├── cds_test.go │ │ ├── cm-service-desc.go │ │ ├── cms.go │ │ ├── dms.go │ │ ├── doc.go │ │ ├── html.go │ │ ├── mrrs.go │ │ ├── paging.go │ │ ├── service.go │ │ ├── whitelist.go │ │ └── xmsr-service-desc.go │ ├── identify/ │ │ ├── identify.go │ │ ├── identify_test.go │ │ ├── options.go │ │ ├── performer.go │ │ ├── performer_test.go │ │ ├── scene.go │ │ ├── scene_test.go │ │ ├── studio.go │ │ └── studio_test.go │ ├── log/ │ │ ├── hook.go │ │ ├── logger.go │ │ └── progress_formatter.go │ ├── manager/ │ │ ├── apikey.go │ │ ├── backup.go │ │ ├── checksum.go │ │ ├── config/ │ │ │ ├── config.go │ │ │ ├── config_concurrency_test.go │ │ │ ├── config_test.go │ │ │ ├── enums.go │ │ │ ├── init.go │ │ │ ├── stash_config.go │ │ │ ├── tasks.go │ │ │ └── ui.go │ │ ├── downloads.go │ │ ├── enums.go │ │ ├── exclude_files.go │ │ ├── exclude_files_test.go │ │ ├── fingerprint.go │ │ ├── generator.go │ │ ├── generator_interactive_heatmap_speed.go │ │ ├── generator_sprite.go │ │ ├── import.go │ │ ├── init.go │ │ ├── json_utils.go │ │ ├── log.go │ │ ├── manager.go │ │ ├── manager_tasks.go │ │ ├── models.go │ │ ├── repository.go │ │ ├── running_streams.go │ │ ├── scan_stashignore_test.go │ │ ├── scene.go │ │ ├── subscribe.go │ │ ├── task/ │ │ │ ├── clean_generated.go │ │ │ ├── download_ffmpeg.go │ │ │ ├── migrate.go │ │ │ ├── migrate_blobs.go │ │ │ ├── migrate_scene_screenshots.go │ │ │ └── packages.go │ │ ├── task.go │ │ ├── task_autotag.go │ │ ├── task_clean.go │ │ ├── task_export.go │ │ ├── task_generate.go │ │ ├── task_generate_clip_preview.go │ │ ├── task_generate_image_phash.go │ │ ├── task_generate_image_thumbnail.go │ │ ├── task_generate_interactive_heatmap_speed.go │ │ ├── task_generate_markers.go │ │ ├── task_generate_phash.go │ │ ├── task_generate_preview.go │ │ ├── task_generate_screenshot.go │ │ ├── task_generate_sprite.go │ │ ├── task_identify.go │ │ ├── task_import.go │ │ ├── task_migrate_hash.go │ │ ├── task_optimise.go │ │ ├── task_plugin.go │ │ ├── task_scan.go │ │ ├── task_stash_box_tag.go │ │ └── task_transcode.go │ └── static/ │ ├── embed.go │ ├── performer/ │ │ └── attribution.md │ └── performer_male/ │ └── attribution.md ├── pkg/ │ ├── exec/ │ │ ├── command.go │ │ ├── shell_nonwindows.go │ │ └── shell_windows.go │ ├── ffmpeg/ │ │ ├── browser.go │ │ ├── codec.go │ │ ├── codec_hardware.go │ │ ├── container.go │ │ ├── downloader.go │ │ ├── ffmpeg.go │ │ ├── ffmpeg_test.go │ │ ├── ffprobe.go │ │ ├── filter.go │ │ ├── format.go │ │ ├── frame_rate.go │ │ ├── generate.go │ │ ├── media_detection.go │ │ ├── options.go │ │ ├── stream.go │ │ ├── stream_segmented.go │ │ ├── stream_transcode.go │ │ ├── transcoder/ │ │ │ ├── image.go │ │ │ ├── screenshot.go │ │ │ ├── splice.go │ │ │ └── transcode.go │ │ └── types.go │ ├── file/ │ │ ├── clean.go │ │ ├── delete.go │ │ ├── file.go │ │ ├── folder.go │ │ ├── folder_rename_detect.go │ │ ├── fs.go │ │ ├── handler.go │ │ ├── image/ │ │ │ ├── orientation.go │ │ │ └── scan.go │ │ ├── import.go │ │ ├── move.go │ │ ├── scan.go │ │ ├── stashignore.go │ │ ├── stashignore_test.go │ │ ├── video/ │ │ │ ├── caption.go │ │ │ ├── caption_test.go │ │ │ ├── funscript.go │ │ │ └── scan.go │ │ ├── walk.go │ │ └── zip.go │ ├── fsutil/ │ │ ├── dir.go │ │ ├── dir_test.go │ │ ├── file.go │ │ ├── file_test.go │ │ ├── fs.go │ │ ├── fs_test.go │ │ ├── lock_manager.go │ │ ├── symwalk.go │ │ └── trash.go │ ├── gallery/ │ │ ├── chapter_import.go │ │ ├── delete.go │ │ ├── export.go │ │ ├── export_test.go │ │ ├── filter.go │ │ ├── import.go │ │ ├── import_test.go │ │ ├── query.go │ │ ├── scan.go │ │ ├── scan_test.go │ │ ├── service.go │ │ ├── update.go │ │ └── validation.go │ ├── group/ │ │ ├── create.go │ │ ├── doc.go │ │ ├── export.go │ │ ├── export_test.go │ │ ├── import.go │ │ ├── import_test.go │ │ ├── query.go │ │ ├── reorder.go │ │ ├── service.go │ │ ├── update.go │ │ └── validate.go │ ├── hash/ │ │ ├── imagephash/ │ │ │ └── phash.go │ │ ├── key.go │ │ ├── md5/ │ │ │ └── md5.go │ │ ├── oshash/ │ │ │ ├── oshash.go │ │ │ └── oshash_test.go │ │ └── videophash/ │ │ └── phash.go │ ├── image/ │ │ ├── delete.go │ │ ├── export.go │ │ ├── export_test.go │ │ ├── filter.go │ │ ├── import.go │ │ ├── import_test.go │ │ ├── query.go │ │ ├── scan.go │ │ ├── scan_test.go │ │ ├── service.go │ │ ├── thumbnail.go │ │ ├── update.go │ │ ├── vips.go │ │ ├── webp.go │ │ └── webp_internal_test.go │ ├── javascript/ │ │ ├── console.go │ │ ├── gql.go │ │ ├── log.go │ │ ├── util.go │ │ └── vm.go │ ├── job/ │ │ ├── job.go │ │ ├── manager.go │ │ ├── manager_test.go │ │ ├── progress.go │ │ ├── progress_test.go │ │ ├── subscribe.go │ │ └── task.go │ ├── logger/ │ │ ├── basic.go │ │ ├── logger.go │ │ ├── plugin.go │ │ └── progress_formatter.go │ ├── match/ │ │ ├── cache.go │ │ ├── path.go │ │ ├── path_test.go │ │ └── scraped.go │ ├── models/ │ │ ├── custom_fields.go │ │ ├── date.go │ │ ├── date_test.go │ │ ├── doc.go │ │ ├── errors.go │ │ ├── file.go │ │ ├── filename_parser.go │ │ ├── filter.go │ │ ├── find_filter.go │ │ ├── fingerprint.go │ │ ├── fingerprint_test.go │ │ ├── folder.go │ │ ├── fs.go │ │ ├── gallery.go │ │ ├── generate.go │ │ ├── group.go │ │ ├── image.go │ │ ├── import.go │ │ ├── json/ │ │ │ └── json_time.go │ │ ├── jsonschema/ │ │ │ ├── doc.go │ │ │ ├── file_folder.go │ │ │ ├── folder.go │ │ │ ├── gallery.go │ │ │ ├── group.go │ │ │ ├── image.go │ │ │ ├── load.go │ │ │ ├── performer.go │ │ │ ├── performer_test.go │ │ │ ├── saved_filter.go │ │ │ ├── scene.go │ │ │ ├── studio.go │ │ │ ├── tag.go │ │ │ └── utils.go │ │ ├── mocks/ │ │ │ ├── FileReaderWriter.go │ │ │ ├── FolderReaderWriter.go │ │ │ ├── GalleryChapterReaderWriter.go │ │ │ ├── GalleryReaderWriter.go │ │ │ ├── GroupReaderWriter.go │ │ │ ├── ImageReaderWriter.go │ │ │ ├── PerformerReaderWriter.go │ │ │ ├── SavedFilterReaderWriter.go │ │ │ ├── SceneMarkerReaderWriter.go │ │ │ ├── SceneReaderWriter.go │ │ │ ├── StudioReaderWriter.go │ │ │ ├── TagReaderWriter.go │ │ │ ├── database.go │ │ │ └── query.go │ │ ├── model_file.go │ │ ├── model_folder.go │ │ ├── model_gallery.go │ │ ├── model_gallery_chapter.go │ │ ├── model_group.go │ │ ├── model_image.go │ │ ├── model_joins.go │ │ ├── model_performer.go │ │ ├── model_saved_filter.go │ │ ├── model_scene.go │ │ ├── model_scene_marker.go │ │ ├── model_scene_test.go │ │ ├── model_scraped_item.go │ │ ├── model_scraped_item_test.go │ │ ├── model_studio.go │ │ ├── model_tag.go │ │ ├── orientation.go │ │ ├── package.go │ │ ├── paths/ │ │ │ ├── paths.go │ │ │ ├── paths_generated.go │ │ │ ├── paths_json.go │ │ │ ├── paths_scene_markers.go │ │ │ └── paths_scenes.go │ │ ├── performer.go │ │ ├── query.go │ │ ├── rating.go │ │ ├── rating_test.go │ │ ├── relationships.go │ │ ├── repository.go │ │ ├── repository_blob.go │ │ ├── repository_file.go │ │ ├── repository_folder.go │ │ ├── repository_gallery.go │ │ ├── repository_gallery_chapter.go │ │ ├── repository_group.go │ │ ├── repository_image.go │ │ ├── repository_performer.go │ │ ├── repository_scene.go │ │ ├── repository_scene_marker.go │ │ ├── repository_studio.go │ │ ├── repository_tag.go │ │ ├── resolution.go │ │ ├── saved_filter.go │ │ ├── scene.go │ │ ├── scene_marker.go │ │ ├── search.go │ │ ├── search_test.go │ │ ├── stash_box.go │ │ ├── stash_ids.go │ │ ├── studio.go │ │ ├── tag.go │ │ ├── update.go │ │ ├── update_test.go │ │ └── value.go │ ├── performer/ │ │ ├── doc.go │ │ ├── export.go │ │ ├── export_test.go │ │ ├── import.go │ │ ├── import_test.go │ │ ├── query.go │ │ ├── url.go │ │ ├── validate.go │ │ └── validate_test.go │ ├── pkg/ │ │ ├── cache.go │ │ ├── manager.go │ │ ├── pkg.go │ │ ├── repository.go │ │ ├── repository_http.go │ │ ├── repository_http_test.go │ │ └── store.go │ ├── plugin/ │ │ ├── args.go │ │ ├── common/ │ │ │ ├── doc.go │ │ │ ├── log/ │ │ │ │ └── log.go │ │ │ ├── msg.go │ │ │ └── rpc.go │ │ ├── config.go │ │ ├── convert.go │ │ ├── examples/ │ │ │ ├── README.md │ │ │ ├── common/ │ │ │ │ └── graphql.go │ │ │ ├── goraw/ │ │ │ │ ├── goraw.yml │ │ │ │ └── main.go │ │ │ ├── gorpc/ │ │ │ │ ├── gorpc.yml │ │ │ │ └── main.go │ │ │ ├── js/ │ │ │ │ ├── js.js │ │ │ │ └── js.yml │ │ │ ├── python/ │ │ │ │ ├── log.py │ │ │ │ ├── pyplugin.py │ │ │ │ ├── pyraw.yml │ │ │ │ └── stash_interface.py │ │ │ └── react-component/ │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── testReact.scss │ │ │ │ ├── testReact.tsx │ │ │ │ └── testReact.yml │ │ │ └── tsconfig.json │ │ ├── hook/ │ │ │ └── hooks.go │ │ ├── hooks.go │ │ ├── js.go │ │ ├── log.go │ │ ├── plugins.go │ │ ├── raw.go │ │ ├── rpc.go │ │ ├── setting.go │ │ ├── task.go │ │ └── util/ │ │ └── client.go │ ├── python/ │ │ ├── env.go │ │ └── exec.go │ ├── savedfilter/ │ │ ├── export.go │ │ ├── export_test.go │ │ ├── import.go │ │ └── import_test.go │ ├── scene/ │ │ ├── create.go │ │ ├── delete.go │ │ ├── export.go │ │ ├── export_test.go │ │ ├── filename_parser.go │ │ ├── filter.go │ │ ├── find.go │ │ ├── fingerprints.go │ │ ├── generate/ │ │ │ ├── generator.go │ │ │ ├── marker_preview.go │ │ │ ├── preview.go │ │ │ ├── screenshot.go │ │ │ ├── sprite.go │ │ │ └── transcode.go │ │ ├── hash.go │ │ ├── import.go │ │ ├── import_test.go │ │ ├── marker_import.go │ │ ├── marker_import_test.go │ │ ├── marker_query.go │ │ ├── merge.go │ │ ├── migrate_hash.go │ │ ├── migrate_screenshots.go │ │ ├── query.go │ │ ├── scan.go │ │ ├── scan_test.go │ │ ├── service.go │ │ ├── update.go │ │ └── update_test.go │ ├── scraper/ │ │ ├── action.go │ │ ├── autotag.go │ │ ├── cache.go │ │ ├── cookies.go │ │ ├── country.go │ │ ├── defined_scraper.go │ │ ├── definition.go │ │ ├── freeones.go │ │ ├── graphql.go │ │ ├── image.go │ │ ├── json.go │ │ ├── json_test.go │ │ ├── mapped.go │ │ ├── mapped_config.go │ │ ├── mapped_postprocessing.go │ │ ├── mapped_result.go │ │ ├── mapped_result_test.go │ │ ├── mapped_test.go │ │ ├── movie.go │ │ ├── performer.go │ │ ├── post_processing_test.go │ │ ├── postprocessing.go │ │ ├── query_url.go │ │ ├── scraper.go │ │ ├── script.go │ │ ├── stash.go │ │ ├── tag.go │ │ ├── url.go │ │ ├── xpath.go │ │ └── xpath_test.go │ ├── session/ │ │ ├── authentication.go │ │ ├── authentication_test.go │ │ ├── config.go │ │ ├── local.go │ │ ├── plugin.go │ │ └── session.go │ ├── sliceutil/ │ │ ├── collections.go │ │ ├── collections_test.go │ │ ├── intslice/ │ │ │ └── int_collections.go │ │ └── stringslice/ │ │ └── string_collections.go │ ├── sqlite/ │ │ ├── anonymise.go │ │ ├── anonymise_test.go │ │ ├── batch.go │ │ ├── blob/ │ │ │ └── fs.go │ │ ├── blob.go │ │ ├── blob_migrate.go │ │ ├── blob_test.go │ │ ├── common.go │ │ ├── criterion_handlers.go │ │ ├── custom_fields.go │ │ ├── custom_fields_test.go │ │ ├── custom_migrations.go │ │ ├── database.go │ │ ├── date.go │ │ ├── doc.go │ │ ├── driver.go │ │ ├── file.go │ │ ├── file_filter.go │ │ ├── file_filter_test.go │ │ ├── file_test.go │ │ ├── filter.go │ │ ├── filter_hierarchical.go │ │ ├── filter_internal_test.go │ │ ├── fingerprint.go │ │ ├── folder.go │ │ ├── folder_filter.go │ │ ├── folder_filter_test.go │ │ ├── folder_test.go │ │ ├── functions.go │ │ ├── gallery.go │ │ ├── gallery_chapter.go │ │ ├── gallery_chapter_test.go │ │ ├── gallery_filter.go │ │ ├── gallery_test.go │ │ ├── group.go │ │ ├── group_filter.go │ │ ├── group_relationships.go │ │ ├── group_test.go │ │ ├── history.go │ │ ├── image.go │ │ ├── image_filter.go │ │ ├── image_test.go │ │ ├── migrate.go │ │ ├── migrations/ │ │ │ ├── 10_image_tables.up.sql │ │ │ ├── 11_tag_image.up.sql │ │ │ ├── 12_oshash.up.sql │ │ │ ├── 12_postmigrate.go │ │ │ ├── 13_images.up.sql │ │ │ ├── 14_stash_box_ids.up.sql │ │ │ ├── 15_file_mod_time.up.sql │ │ │ ├── 16_organized_flag.up.sql │ │ │ ├── 17_reset_scene_size.up.sql │ │ │ ├── 18_scene_galleries.up.sql │ │ │ ├── 19_performer_tags.up.sql │ │ │ ├── 1_initial.down.sql │ │ │ ├── 1_initial.up.sql │ │ │ ├── 20_phash.up.sql │ │ │ ├── 21_performers_studios_details.up.sql │ │ │ ├── 22_performers_studios_rating.up.sql │ │ │ ├── 23_scenes_interactive.up.sql │ │ │ ├── 24_tag_aliases.up.sql │ │ │ ├── 25_saved_filters.up.sql │ │ │ ├── 26_tag_hierarchy.up.sql │ │ │ ├── 27_studio_aliases.up.sql │ │ │ ├── 28_images_indexes.up.sql │ │ │ ├── 29_interactive_speed.up.sql │ │ │ ├── 2_cover_image.up.sql │ │ │ ├── 30_ignore_autotag.up..sql │ │ │ ├── 31_scenes_captions.up.sql │ │ │ ├── 32_files.up.sql │ │ │ ├── 32_postmigrate.go │ │ │ ├── 32_premigrate.go │ │ │ ├── 33_noop.up.sql │ │ │ ├── 34_indexes.up.sql │ │ │ ├── 34_postmigrate.go │ │ │ ├── 35_assoc_tables.up.sql │ │ │ ├── 36_tags_description.up.sql │ │ │ ├── 37_iso_country_names.up.sql │ │ │ ├── 38_scenes_director_code.up.sql │ │ │ ├── 39_performer_height.up.sql │ │ │ ├── 3_o_counter.up.sql │ │ │ ├── 40_newratings.up.sql │ │ │ ├── 41_scene_activity.up.sql │ │ │ ├── 42_performer_disambig_aliases.up.sql │ │ │ ├── 42_postmigrate.go │ │ │ ├── 43_image_date_url.up.sql │ │ │ ├── 44_gallery_chapters.up.sql │ │ │ ├── 45_blobs.up.sql │ │ │ ├── 45_postmigrate.go │ │ │ ├── 46_penis_stats.up.sql │ │ │ ├── 47_scene_urls.up.sql │ │ │ ├── 48_cleanup.up.sql │ │ │ ├── 48_premigrate.go │ │ │ ├── 49_postmigrate.go │ │ │ ├── 49_saved_filter_refactor.up.sql │ │ │ ├── 4_movie.up.sql │ │ │ ├── 50_image_urls.up.sql │ │ │ ├── 51_gallery_urls.up.sql │ │ │ ├── 52_postmigrate.go │ │ │ ├── 52_zip_folder_data_correct.up.sql │ │ │ ├── 53_gallery_photographer_code.up.sql │ │ │ ├── 54_image_code_details_photographer.up.sql │ │ │ ├── 55_manual_history.up.sql │ │ │ ├── 55_postmigrate.go │ │ │ ├── 56_studio_favorite.up.sql │ │ │ ├── 57_tag_favorite.up.sql │ │ │ ├── 58_config_correct.up.sql │ │ │ ├── 58_postmigrate.go │ │ │ ├── 59_movie_urls.up.sql │ │ │ ├── 5_performer_gender.down.sql │ │ │ ├── 5_performer_gender.up.sql │ │ │ ├── 60_default_filter_move.up.sql │ │ │ ├── 60_postmigrate.go │ │ │ ├── 61_movie_tags.up.sql │ │ │ ├── 62_performer_urls.up.sql │ │ │ ├── 63_studio_tags.up.sql │ │ │ ├── 64_fixes.up.sql │ │ │ ├── 64_postmigrate.go │ │ │ ├── 65_movie_group_rename.up.sql │ │ │ ├── 65_postmigrate.go │ │ │ ├── 66_gallery_cover.up.sql │ │ │ ├── 67_group_relationships.up.sql │ │ │ ├── 68_image_studio_index.up.sql │ │ │ ├── 69_stash_id_updated_at.up.sql │ │ │ ├── 6_scenes_format.up.sql │ │ │ ├── 70_markers_end.up.sql │ │ │ ├── 71_custom_fields.up.sql │ │ │ ├── 72_tag_sort_name.up.sql │ │ │ ├── 73_studio_urls.up.sql │ │ │ ├── 74_tag_stash_ids.up.sql │ │ │ ├── 75_date_precision.up.sql │ │ │ ├── 76_studio_custom_fields.up.sql │ │ │ ├── 77_tag_custom_fields.up.sql │ │ │ ├── 78_performer_career_dates.up.sql │ │ │ ├── 78_postmigrate.go │ │ │ ├── 79_scene_custom_fields.up.sql │ │ │ ├── 7_performer_optimization.up.sql │ │ │ ├── 80_studio_organized.up.sql │ │ │ ├── 81_gallery_custom_fields.up.sql │ │ │ ├── 82_group_custom_fields.up.sql │ │ │ ├── 83_image_custom_fields.up.sql │ │ │ ├── 84_folder_basename.up.sql │ │ │ ├── 84_postmigrate.go │ │ │ ├── 85_performer_career_dates.up.sql │ │ │ ├── 8_movie_fix.up.sql │ │ │ ├── 9_studios_parent_studio.up.sql │ │ │ ├── README.md │ │ │ └── custom_migration.go │ │ ├── performer.go │ │ ├── performer_filter.go │ │ ├── performer_test.go │ │ ├── phash.go │ │ ├── query.go │ │ ├── record.go │ │ ├── regex.go │ │ ├── relationships.go │ │ ├── repository.go │ │ ├── saved_filter.go │ │ ├── saved_filter_test.go │ │ ├── scene.go │ │ ├── scene_filter.go │ │ ├── scene_marker.go │ │ ├── scene_marker_filter.go │ │ ├── scene_marker_test.go │ │ ├── scene_test.go │ │ ├── setup_test.go │ │ ├── sql.go │ │ ├── stash_id_test.go │ │ ├── studio.go │ │ ├── studio_filter.go │ │ ├── studio_test.go │ │ ├── table.go │ │ ├── tables.go │ │ ├── tag.go │ │ ├── tag_filter.go │ │ ├── tag_test.go │ │ ├── timestamp.go │ │ ├── transaction.go │ │ ├── transaction_test.go │ │ ├── tx.go │ │ └── values.go │ ├── stashbox/ │ │ ├── client.go │ │ ├── draft.go │ │ ├── graphql/ │ │ │ ├── generated_client.go │ │ │ └── generated_models.go │ │ ├── performer.go │ │ ├── scene.go │ │ ├── studio.go │ │ └── tag.go │ ├── studio/ │ │ ├── doc.go │ │ ├── export.go │ │ ├── export_test.go │ │ ├── import.go │ │ ├── import_test.go │ │ ├── query.go │ │ ├── validate.go │ │ └── validate_test.go │ ├── tag/ │ │ ├── doc.go │ │ ├── export.go │ │ ├── export_test.go │ │ ├── import.go │ │ ├── import_test.go │ │ ├── query.go │ │ ├── update.go │ │ ├── update_test.go │ │ ├── validate.go │ │ └── validate_test.go │ ├── txn/ │ │ ├── hooks.go │ │ └── transaction.go │ └── utils/ │ ├── boolean.go │ ├── date.go │ ├── date_test.go │ ├── doc.go │ ├── func.go │ ├── http.go │ ├── image.go │ ├── map.go │ ├── map_test.go │ ├── mutex.go │ ├── mutex_test.go │ ├── phash.go │ ├── reflect.go │ ├── reflect_test.go │ ├── resources.go │ ├── strings.go │ ├── strings_test.go │ ├── time.go │ ├── url.go │ ├── url_test.go │ ├── urlmap.go │ ├── urlmap_test.go │ ├── user_agent.go │ ├── vtt.go │ └── vtt_test.go ├── scripts/ │ ├── generateLoginLocales.go │ ├── generate_icons.sh │ ├── getDate.go │ ├── macos-bundle/ │ │ └── Contents/ │ │ ├── Info.plist │ │ └── Resources/ │ │ └── icon.icns │ └── test_db_generator/ │ ├── README.md │ ├── config.yml │ ├── female.txt │ ├── makeTestDB.go │ ├── male.txt │ ├── naming.go │ ├── scene.txt │ ├── studio.txt │ └── surname.txt ├── tools.go └── ui/ ├── login/ │ ├── login.css │ └── login.html ├── ui.go └── v2.5/ ├── .editorconfig ├── .eslintrc.json ├── .prettierignore ├── .stylelintrc ├── README.md ├── codegen.ts ├── graphql/ │ ├── client-schema.graphql │ ├── data/ │ │ ├── config.graphql │ │ ├── file.graphql │ │ ├── filter.graphql │ │ ├── gallery-chapter.graphql │ │ ├── gallery-slim.graphql │ │ ├── gallery.graphql │ │ ├── group-slim.graphql │ │ ├── group.graphql │ │ ├── image-slim.graphql │ │ ├── image.graphql │ │ ├── job.graphql │ │ ├── log.graphql │ │ ├── package.graphql │ │ ├── performer-slim.graphql │ │ ├── performer.graphql │ │ ├── scene-marker.graphql │ │ ├── scene-slim.graphql │ │ ├── scene.graphql │ │ ├── scrapers.graphql │ │ ├── studio-slim.graphql │ │ ├── studio.graphql │ │ ├── tag-slim.graphql │ │ └── tag.graphql │ ├── mutations/ │ │ ├── config.graphql │ │ ├── dlna.graphql │ │ ├── file.graphql │ │ ├── filter.graphql │ │ ├── gallery-chapter.graphql │ │ ├── gallery.graphql │ │ ├── group.graphql │ │ ├── image.graphql │ │ ├── job.graphql │ │ ├── metadata.graphql │ │ ├── migration.graphql │ │ ├── performer.graphql │ │ ├── plugins.graphql │ │ ├── scene-marker.graphql │ │ ├── scene.graphql │ │ ├── scrapers.graphql │ │ ├── stash-box.graphql │ │ ├── studio.graphql │ │ └── tag.graphql │ ├── queries/ │ │ ├── dlna.graphql │ │ ├── filter.graphql │ │ ├── folder.graphql │ │ ├── gallery.graphql │ │ ├── image.graphql │ │ ├── job.graphql │ │ ├── legacy.graphql │ │ ├── misc.graphql │ │ ├── movie.graphql │ │ ├── performer.graphql │ │ ├── plugins.graphql │ │ ├── scene-marker.graphql │ │ ├── scene.graphql │ │ ├── scrapers/ │ │ │ └── scrapers.graphql │ │ ├── settings/ │ │ │ ├── config.graphql │ │ │ └── metadata.graphql │ │ ├── studio.graphql │ │ └── tag.graphql │ └── subscriptions.graphql ├── index.html ├── package.json ├── pnpm-workspace.yaml ├── public/ │ └── manifest.json ├── src/ │ ├── @types/ │ │ ├── mousetrap-pause.d.ts │ │ ├── string.prototype.replaceall.d.ts │ │ ├── videojs-abloop.d.ts │ │ ├── videojs-contrib-dash.d.ts │ │ ├── videojs-vr.d.ts │ │ └── videojs-vtt.d.ts │ ├── App.tsx │ ├── ConnectionMonitor.tsx │ ├── components/ │ │ ├── Changelog/ │ │ │ ├── Changelog.tsx │ │ │ ├── Version.tsx │ │ │ └── styles.scss │ │ ├── Dialogs/ │ │ │ ├── GenerateDialog.tsx │ │ │ ├── IdentifyDialog/ │ │ │ │ ├── FieldOptions.tsx │ │ │ │ ├── IdentifyDialog.tsx │ │ │ │ ├── Options.tsx │ │ │ │ ├── Sources.tsx │ │ │ │ ├── ThreeStateBoolean.tsx │ │ │ │ ├── constants.ts │ │ │ │ └── styles.scss │ │ │ ├── ReleaseNotesDialog.tsx │ │ │ ├── SubmitDraft.tsx │ │ │ └── styles.scss │ │ ├── ErrorBoundary.tsx │ │ ├── FrontPage/ │ │ │ ├── Control.tsx │ │ │ ├── FilteredRecommendationRow.tsx │ │ │ ├── FrontPage.tsx │ │ │ ├── FrontPageConfig.tsx │ │ │ ├── RecommendationRow.tsx │ │ │ └── styles.scss │ │ ├── Galleries/ │ │ │ ├── DeleteGalleriesDialog.tsx │ │ │ ├── EditGalleriesDialog.tsx │ │ │ ├── Galleries.tsx │ │ │ ├── GalleryCard.tsx │ │ │ ├── GalleryCardGrid.tsx │ │ │ ├── GalleryDetails/ │ │ │ │ ├── ChapterEntry.tsx │ │ │ │ ├── Gallery.tsx │ │ │ │ ├── GalleryAddPanel.tsx │ │ │ │ ├── GalleryChapterForm.tsx │ │ │ │ ├── GalleryChaptersPanel.tsx │ │ │ │ ├── GalleryCreate.tsx │ │ │ │ ├── GalleryDetailPanel.tsx │ │ │ │ ├── GalleryEditPanel.tsx │ │ │ │ ├── GalleryFileInfoPanel.tsx │ │ │ │ ├── GalleryImagesPanel.tsx │ │ │ │ ├── GalleryScenesPanel.tsx │ │ │ │ └── GalleryScrapeDialog.tsx │ │ │ ├── GalleryList.tsx │ │ │ ├── GalleryListTable.tsx │ │ │ ├── GalleryPreviewScrubber.tsx │ │ │ ├── GalleryRecommendationRow.tsx │ │ │ ├── GallerySelect.tsx │ │ │ ├── GalleryViewer.tsx │ │ │ ├── GalleryWallCard.tsx │ │ │ └── styles.scss │ │ ├── Groups/ │ │ │ ├── ContainingGroupsMultiSet.tsx │ │ │ ├── EditGroupsDialog.tsx │ │ │ ├── GroupCard.tsx │ │ │ ├── GroupCardGrid.tsx │ │ │ ├── GroupDetails/ │ │ │ │ ├── AddGroupsDialog.tsx │ │ │ │ ├── Group.tsx │ │ │ │ ├── GroupCreate.tsx │ │ │ │ ├── GroupDetailsPanel.tsx │ │ │ │ ├── GroupEditPanel.tsx │ │ │ │ ├── GroupPerformersPanel.tsx │ │ │ │ ├── GroupScenesPanel.tsx │ │ │ │ ├── GroupScrapeDialog.tsx │ │ │ │ ├── GroupSubGroupsPanel.tsx │ │ │ │ └── RelatedGroupTable.tsx │ │ │ ├── GroupList.tsx │ │ │ ├── GroupRecommendationRow.tsx │ │ │ ├── GroupSelect.tsx │ │ │ ├── GroupTag.tsx │ │ │ ├── Groups.tsx │ │ │ ├── RelatedGroupPopover.tsx │ │ │ └── styles.scss │ │ ├── Help/ │ │ │ ├── Manual.tsx │ │ │ ├── context.tsx │ │ │ └── styles.scss │ │ ├── Images/ │ │ │ ├── DeleteImagesDialog.tsx │ │ │ ├── EditImagesDialog.tsx │ │ │ ├── ImageCard.tsx │ │ │ ├── ImageCardGrid.tsx │ │ │ ├── ImageDetails/ │ │ │ │ ├── Image.tsx │ │ │ │ ├── ImageDetailPanel.tsx │ │ │ │ ├── ImageEditPanel.tsx │ │ │ │ ├── ImageFileInfoPanel.tsx │ │ │ │ └── ImageScrapeDialog.tsx │ │ │ ├── ImageList.tsx │ │ │ ├── ImageRecommendationRow.tsx │ │ │ ├── ImageWallItem.tsx │ │ │ ├── Images.tsx │ │ │ └── styles.scss │ │ ├── List/ │ │ │ ├── CriterionEditor.tsx │ │ │ ├── EditFilterDialog.tsx │ │ │ ├── FilterProvider.tsx │ │ │ ├── FilterTags.tsx │ │ │ ├── FilteredListToolbar.tsx │ │ │ ├── Filters/ │ │ │ │ ├── BooleanFilter.tsx │ │ │ │ ├── CustomFieldsFilter.tsx │ │ │ │ ├── DateFilter.tsx │ │ │ │ ├── DuplicateFilter.tsx │ │ │ │ ├── DurationFilter.tsx │ │ │ │ ├── FilterButton.tsx │ │ │ │ ├── FilterSidebar.tsx │ │ │ │ ├── FolderFilter.tsx │ │ │ │ ├── HierarchicalLabelValueFilter.tsx │ │ │ │ ├── InputFilter.tsx │ │ │ │ ├── LabeledIdFilter.tsx │ │ │ │ ├── NumberFilter.tsx │ │ │ │ ├── OptionFilter.tsx │ │ │ │ ├── PathFilter.tsx │ │ │ │ ├── PerformersFilter.tsx │ │ │ │ ├── PhashFilter.tsx │ │ │ │ ├── RatingFilter.tsx │ │ │ │ ├── SelectableFilter.tsx │ │ │ │ ├── SidebarAgeFilter.tsx │ │ │ │ ├── SidebarDurationFilter.tsx │ │ │ │ ├── SidebarListFilter.tsx │ │ │ │ ├── StashIDFilter.tsx │ │ │ │ ├── StudiosFilter.tsx │ │ │ │ ├── TagsFilter.tsx │ │ │ │ └── TimestampFilter.tsx │ │ │ ├── ItemList.tsx │ │ │ ├── ListFilter.tsx │ │ │ ├── ListOperationButtons.tsx │ │ │ ├── ListProvider.tsx │ │ │ ├── ListTable.tsx │ │ │ ├── ListViewOptions.tsx │ │ │ ├── ModifierSelect.tsx │ │ │ ├── PagedList.tsx │ │ │ ├── Pagination.tsx │ │ │ ├── SavedFilterList.tsx │ │ │ ├── ZoomSlider.tsx │ │ │ ├── styles.scss │ │ │ ├── util.ts │ │ │ └── views.ts │ │ ├── MainNavbar.tsx │ │ ├── PageNotFound.tsx │ │ ├── Performers/ │ │ │ ├── EditPerformersDialog.tsx │ │ │ ├── GenderIcon.tsx │ │ │ ├── PerformerCard.tsx │ │ │ ├── PerformerCardGrid.tsx │ │ │ ├── PerformerDetails/ │ │ │ │ ├── Performer.tsx │ │ │ │ ├── PerformerCreate.tsx │ │ │ │ ├── PerformerDetailsPanel.tsx │ │ │ │ ├── PerformerEditPanel.tsx │ │ │ │ ├── PerformerGalleriesPanel.tsx │ │ │ │ ├── PerformerGroupsPanel.tsx │ │ │ │ ├── PerformerImagesPanel.tsx │ │ │ │ ├── PerformerScenesPanel.tsx │ │ │ │ ├── PerformerScrapeDialog.tsx │ │ │ │ ├── PerformerScrapeModal.tsx │ │ │ │ ├── PerformerStashBoxModal.tsx │ │ │ │ ├── PerformerSubmitButton.tsx │ │ │ │ └── performerAppearsWithPanel.tsx │ │ │ ├── PerformerList.tsx │ │ │ ├── PerformerListTable.tsx │ │ │ ├── PerformerMergeDialog.tsx │ │ │ ├── PerformerPopover.tsx │ │ │ ├── PerformerRecommendationRow.tsx │ │ │ ├── PerformerSelect.tsx │ │ │ ├── Performers.tsx │ │ │ └── styles.scss │ │ ├── SceneDuplicateChecker/ │ │ │ ├── SceneDuplicateChecker.tsx │ │ │ └── styles.scss │ │ ├── SceneFilenameParser/ │ │ │ ├── ParserField.ts │ │ │ ├── ParserInput.tsx │ │ │ ├── SceneFilenameParser.tsx │ │ │ ├── SceneParserRow.tsx │ │ │ ├── ShowFields.tsx │ │ │ └── styles.scss │ │ ├── ScenePlayer/ │ │ │ ├── PlaylistButtons.ts │ │ │ ├── ScenePlayer.tsx │ │ │ ├── ScenePlayerScrubber.tsx │ │ │ ├── autostart-button.ts │ │ │ ├── big-buttons.ts │ │ │ ├── live.ts │ │ │ ├── markers.ts │ │ │ ├── media-session.ts │ │ │ ├── persist-volume.ts │ │ │ ├── source-selector.ts │ │ │ ├── styles.scss │ │ │ ├── track-activity.ts │ │ │ ├── util.ts │ │ │ ├── vrmode.ts │ │ │ ├── vtt-thumbnails.ts │ │ │ └── wake-sentinel.ts │ │ ├── Scenes/ │ │ │ ├── DeleteSceneMarkersDialog.tsx │ │ │ ├── DeleteScenesDialog.tsx │ │ │ ├── EditSceneMarkersDialog.tsx │ │ │ ├── EditScenesDialog.tsx │ │ │ ├── PreviewScrubber.tsx │ │ │ ├── SceneCard.tsx │ │ │ ├── SceneCardGrid.tsx │ │ │ ├── SceneDetails/ │ │ │ │ ├── ExternalPlayerButton.tsx │ │ │ │ ├── OCounterButton.tsx │ │ │ │ ├── OrganizedButton.tsx │ │ │ │ ├── PrimaryTags.tsx │ │ │ │ ├── QueueViewer.tsx │ │ │ │ ├── Scene.tsx │ │ │ │ ├── SceneCreate.tsx │ │ │ │ ├── SceneDetailPanel.tsx │ │ │ │ ├── SceneEditPanel.tsx │ │ │ │ ├── SceneFileInfoPanel.tsx │ │ │ │ ├── SceneGalleriesPanel.tsx │ │ │ │ ├── SceneGroupPanel.tsx │ │ │ │ ├── SceneGroupTable.tsx │ │ │ │ ├── SceneHistoryPanel.tsx │ │ │ │ ├── SceneMarkerForm.tsx │ │ │ │ ├── SceneMarkersPanel.tsx │ │ │ │ ├── SceneQueryModal.tsx │ │ │ │ ├── SceneScrapeDialog.tsx │ │ │ │ └── SceneVideoFilterPanel.tsx │ │ │ ├── SceneList.tsx │ │ │ ├── SceneListTable.tsx │ │ │ ├── SceneMarkerCard.tsx │ │ │ ├── SceneMarkerCardGrid.tsx │ │ │ ├── SceneMarkerList.tsx │ │ │ ├── SceneMarkerRecommendationRow.tsx │ │ │ ├── SceneMarkerWallPanel.tsx │ │ │ ├── SceneMergeDialog.tsx │ │ │ ├── SceneRecommendationRow.tsx │ │ │ ├── SceneSelect.tsx │ │ │ ├── SceneWallPanel.tsx │ │ │ ├── Scenes.tsx │ │ │ └── styles.scss │ │ ├── Settings/ │ │ │ ├── GeneratePreviewOptions.tsx │ │ │ ├── Inputs.tsx │ │ │ ├── PluginPackageManager.tsx │ │ │ ├── ScraperPackageManager.tsx │ │ │ ├── SettingSection.tsx │ │ │ ├── Settings.tsx │ │ │ ├── SettingsAboutPanel.tsx │ │ │ ├── SettingsInterfacePanel/ │ │ │ │ ├── CheckboxGroup.tsx │ │ │ │ └── SettingsInterfacePanel.tsx │ │ │ ├── SettingsLibraryPanel.tsx │ │ │ ├── SettingsLogsPanel.tsx │ │ │ ├── SettingsPluginsPanel.tsx │ │ │ ├── SettingsScrapingPanel.tsx │ │ │ ├── SettingsSecurityPanel.tsx │ │ │ ├── SettingsServicesPanel.tsx │ │ │ ├── SettingsSystemPanel.tsx │ │ │ ├── SettingsToolsPanel.tsx │ │ │ ├── StashBoxConfiguration.tsx │ │ │ ├── StashConfiguration.tsx │ │ │ ├── Tasks/ │ │ │ │ ├── CleanGeneratedDialog.tsx │ │ │ │ ├── DataManagementTasks.tsx │ │ │ │ ├── DirectorySelectionDialog.tsx │ │ │ │ ├── GenerateOptions.tsx │ │ │ │ ├── ImportDialog.tsx │ │ │ │ ├── JobTable.tsx │ │ │ │ ├── LibraryTasks.tsx │ │ │ │ ├── PluginTasks.tsx │ │ │ │ ├── ScanOptions.tsx │ │ │ │ └── SettingsTasksPanel.tsx │ │ │ ├── context.tsx │ │ │ └── styles.scss │ │ ├── SettingsButton.tsx │ │ ├── Setup/ │ │ │ ├── Migrate.tsx │ │ │ ├── Setup.tsx │ │ │ └── styles.scss │ │ ├── Shared/ │ │ │ ├── Alert.tsx │ │ │ ├── BatchModals.tsx │ │ │ ├── BulkUpdate.tsx │ │ │ ├── ClearableInput.tsx │ │ │ ├── CollapseButton.tsx │ │ │ ├── CountButton.tsx │ │ │ ├── Counter.tsx │ │ │ ├── CountryFlag.tsx │ │ │ ├── CountryLabel.tsx │ │ │ ├── CountrySelect.tsx │ │ │ ├── CustomFields.tsx │ │ │ ├── Date.tsx │ │ │ ├── DateInput.tsx │ │ │ ├── DeleteEntityDialog.tsx │ │ │ ├── DeleteFilesDialog.tsx │ │ │ ├── DetailImage.tsx │ │ │ ├── DetailItem.tsx │ │ │ ├── DetailsEditNavbar.tsx │ │ │ ├── DetailsPage/ │ │ │ │ ├── AliasList.tsx │ │ │ │ ├── BackgroundImage.tsx │ │ │ │ ├── DetailTitle.tsx │ │ │ │ ├── HeaderImage.tsx │ │ │ │ └── Tabs.tsx │ │ │ ├── DoubleRangeInput.tsx │ │ │ ├── DurationInput.tsx │ │ │ ├── ErrorMessage.tsx │ │ │ ├── ExportDialog.tsx │ │ │ ├── ExternalLink.tsx │ │ │ ├── ExternalLinksButton.tsx │ │ │ ├── FavoriteIcon.tsx │ │ │ ├── FileSize.tsx │ │ │ ├── FilterSelect.tsx │ │ │ ├── FolderSelect/ │ │ │ │ ├── FolderSelect.tsx │ │ │ │ ├── FolderSelectDialog.tsx │ │ │ │ └── useDirectoryPaths.ts │ │ │ ├── GridCard/ │ │ │ │ ├── GridCard.tsx │ │ │ │ ├── StudioOverlay.tsx │ │ │ │ ├── dragMoveSelect.ts │ │ │ │ └── styles.scss │ │ │ ├── HoverPopover.tsx │ │ │ ├── HoverScrubber.tsx │ │ │ ├── Icon.tsx │ │ │ ├── ImageInput.tsx │ │ │ ├── ImageSelector.tsx │ │ │ ├── IndeterminateCheckbox.tsx │ │ │ ├── Link.tsx │ │ │ ├── LoadingIndicator.tsx │ │ │ ├── MarkdownPage.tsx │ │ │ ├── Modal.tsx │ │ │ ├── MultiSet.tsx │ │ │ ├── OperationButton.tsx │ │ │ ├── PackageManager/ │ │ │ │ ├── PackageManager.tsx │ │ │ │ └── styles.scss │ │ │ ├── PercentInput.tsx │ │ │ ├── PerformerPopoverButton.tsx │ │ │ ├── PopoverCountButton.tsx │ │ │ ├── Rating/ │ │ │ │ ├── RatingNumber.tsx │ │ │ │ ├── RatingStars.tsx │ │ │ │ ├── RatingSystem.tsx │ │ │ │ └── styles.scss │ │ │ ├── RatingBanner.tsx │ │ │ ├── ReassignFilesDialog.tsx │ │ │ ├── RevealInFilesystemButton.tsx │ │ │ ├── ScrapeDialog/ │ │ │ │ ├── CreateLinkTagDialog.tsx │ │ │ │ ├── ScrapeDialog.tsx │ │ │ │ ├── ScrapeDialogRow.tsx │ │ │ │ ├── ScrapedObjectsRow.tsx │ │ │ │ ├── createObjects.ts │ │ │ │ ├── scrapeResult.ts │ │ │ │ └── scrapedTags.tsx │ │ │ ├── ScraperMenu.tsx │ │ │ ├── Select.tsx │ │ │ ├── Sidebar.tsx │ │ │ ├── StashBoxIDSearchModal.tsx │ │ │ ├── StashID.tsx │ │ │ ├── StringListInput.tsx │ │ │ ├── SuccessIcon.tsx │ │ │ ├── SweatDrops.tsx │ │ │ ├── TagLink.tsx │ │ │ ├── ThreeStateCheckbox.tsx │ │ │ ├── TruncatedText.tsx │ │ │ ├── URLField.tsx │ │ │ └── styles.scss │ │ ├── Stats.tsx │ │ ├── Studios/ │ │ │ ├── EditStudiosDialog.tsx │ │ │ ├── StudioCard.tsx │ │ │ ├── StudioCardGrid.tsx │ │ │ ├── StudioDetails/ │ │ │ │ ├── Studio.tsx │ │ │ │ ├── StudioChildrenPanel.tsx │ │ │ │ ├── StudioCreate.tsx │ │ │ │ ├── StudioDetailsPanel.tsx │ │ │ │ ├── StudioEditPanel.tsx │ │ │ │ ├── StudioGalleriesPanel.tsx │ │ │ │ ├── StudioGroupsPanel.tsx │ │ │ │ ├── StudioImagesPanel.tsx │ │ │ │ ├── StudioPerformersPanel.tsx │ │ │ │ └── StudioScenesPanel.tsx │ │ │ ├── StudioList.tsx │ │ │ ├── StudioRecommendationRow.tsx │ │ │ ├── StudioSelect.tsx │ │ │ ├── Studios.tsx │ │ │ └── styles.scss │ │ ├── Tagger/ │ │ │ ├── FieldSelector.tsx │ │ │ ├── IncludeButton.tsx │ │ │ ├── LinkButton.tsx │ │ │ ├── PerformerModal.tsx │ │ │ ├── StashBoxSelector.tsx │ │ │ ├── TaggerConfig.tsx │ │ │ ├── config.ts │ │ │ ├── constants.ts │ │ │ ├── context.tsx │ │ │ ├── performers/ │ │ │ │ ├── PerformerTagger.tsx │ │ │ │ └── StashSearchResult.tsx │ │ │ ├── queries.ts │ │ │ ├── scenes/ │ │ │ │ ├── Config.tsx │ │ │ │ ├── PerformerResult.tsx │ │ │ │ ├── SceneTagger.tsx │ │ │ │ ├── StashSearchResult.tsx │ │ │ │ ├── StudioModal.tsx │ │ │ │ ├── StudioResult.tsx │ │ │ │ ├── TaggerScene.tsx │ │ │ │ ├── sceneTaggerModals.tsx │ │ │ │ └── utils.ts │ │ │ ├── studios/ │ │ │ │ ├── StashSearchResult.tsx │ │ │ │ └── StudioTagger.tsx │ │ │ ├── styles.scss │ │ │ ├── tags/ │ │ │ │ ├── StashSearchResult.tsx │ │ │ │ ├── TagModal.tsx │ │ │ │ └── TagTagger.tsx │ │ │ └── utils.ts │ │ ├── Tags/ │ │ │ ├── EditTagsDialog.tsx │ │ │ ├── TagCard.tsx │ │ │ ├── TagCardGrid.tsx │ │ │ ├── TagDetails/ │ │ │ │ ├── Tag.tsx │ │ │ │ ├── TagCreate.tsx │ │ │ │ ├── TagDetailsPanel.tsx │ │ │ │ ├── TagEditPanel.tsx │ │ │ │ ├── TagGalleriesPanel.tsx │ │ │ │ ├── TagGroupsPanel.tsx │ │ │ │ ├── TagImagesPanel.tsx │ │ │ │ ├── TagMarkersPanel.tsx │ │ │ │ ├── TagPerformersPanel.tsx │ │ │ │ ├── TagScenesPanel.tsx │ │ │ │ └── TagStudiosPanel.tsx │ │ │ ├── TagList.tsx │ │ │ ├── TagListTable.tsx │ │ │ ├── TagMergeDialog.tsx │ │ │ ├── TagPopover.tsx │ │ │ ├── TagRecommendationRow.tsx │ │ │ ├── TagSelect.tsx │ │ │ ├── Tags.tsx │ │ │ └── styles.scss │ │ ├── TroubleshootingMode/ │ │ │ ├── TroubleshootingModeButton.tsx │ │ │ ├── TroubleshootingModeOverlay.tsx │ │ │ └── useTroubleshootingMode.ts │ │ └── Wall/ │ │ ├── WallItem.tsx │ │ ├── WallPanel.tsx │ │ └── styles.scss │ ├── core/ │ │ ├── StashService.ts │ │ ├── config.ts │ │ ├── createClient.ts │ │ ├── enums.ts │ │ ├── files.ts │ │ ├── galleries.ts │ │ ├── groups.ts │ │ ├── markers.ts │ │ ├── performers.ts │ │ ├── recommendations.ts │ │ ├── studios.ts │ │ └── tags.ts │ ├── docs/ │ │ └── en/ │ │ ├── Changelog/ │ │ │ ├── v010.md │ │ │ ├── v0100.md │ │ │ ├── v011.md │ │ │ ├── v0110.md │ │ │ ├── v0120.md │ │ │ ├── v0130.md │ │ │ ├── v0131.md │ │ │ ├── v0140.md │ │ │ ├── v0150.md │ │ │ ├── v0160.md │ │ │ ├── v0161.md │ │ │ ├── v0170.md │ │ │ ├── v0180.md │ │ │ ├── v0190.md │ │ │ ├── v020.md │ │ │ ├── v0200.md │ │ │ ├── v021.md │ │ │ ├── v0210.md │ │ │ ├── v0220.md │ │ │ ├── v0230.md │ │ │ ├── v0240.md │ │ │ ├── v0250.md │ │ │ ├── v0260.md │ │ │ ├── v0270.md │ │ │ ├── v0280.md │ │ │ ├── v0290.md │ │ │ ├── v030.md │ │ │ ├── v0300.md │ │ │ ├── v0310.md │ │ │ ├── v040.md │ │ │ ├── v050.md │ │ │ ├── v060.md │ │ │ ├── v070.md │ │ │ ├── v080.md │ │ │ └── v090.md │ │ ├── Manual/ │ │ │ ├── AutoTagging.md │ │ │ ├── Browsing.md │ │ │ ├── Captions.md │ │ │ ├── Configuration.md │ │ │ ├── Contributing.md │ │ │ ├── Deduplication.md │ │ │ ├── EmbeddedPlugins.md │ │ │ ├── ExternalPlugins.md │ │ │ ├── Help.md │ │ │ ├── Identify.md │ │ │ ├── Images.md │ │ │ ├── Interactive.md │ │ │ ├── Interface.md │ │ │ ├── Introduction.md │ │ │ ├── JSONSpec.md │ │ │ ├── KeyboardShortcuts.md │ │ │ ├── Plugins.md │ │ │ ├── SceneFilenameParser.md │ │ │ ├── ScraperDevelopment.md │ │ │ ├── Scraping.md │ │ │ ├── Tagger.md │ │ │ ├── Tasks.md │ │ │ ├── TroubleshootingMode.md │ │ │ └── UIPluginApi.md │ │ ├── MigrationNotes/ │ │ │ ├── 32.md │ │ │ ├── 39.md │ │ │ ├── 48.md │ │ │ ├── 58.md │ │ │ ├── 60.md │ │ │ └── index.ts │ │ └── ReleaseNotes/ │ │ ├── index.ts │ │ ├── v0170.md │ │ ├── v0200.md │ │ ├── v0240.md │ │ ├── v0250.md │ │ ├── v0260.md │ │ ├── v0270.md │ │ └── v0290.md │ ├── globals.d.ts │ ├── hooks/ │ │ ├── Config.tsx │ │ ├── Interactive/ │ │ │ ├── context.tsx │ │ │ ├── interactive.scss │ │ │ ├── interactive.ts │ │ │ ├── status.tsx │ │ │ └── utils.ts │ │ ├── Interval.ts │ │ ├── Lightbox/ │ │ │ ├── Lightbox.tsx │ │ │ ├── LightboxImage.tsx │ │ │ ├── LightboxLink.tsx │ │ │ ├── context.tsx │ │ │ ├── hooks.ts │ │ │ ├── lightbox.scss │ │ │ └── types.ts │ │ ├── LocalForage.ts │ │ ├── OutsideClick.tsx │ │ ├── PageVisibility.ts │ │ ├── Toast.tsx │ │ ├── data.ts │ │ ├── debounce.ts │ │ ├── detailsPanel.ts │ │ ├── event.ts │ │ ├── keybinds.ts │ │ ├── modal.ts │ │ ├── scrollToTop.ts │ │ ├── sprite.ts │ │ ├── state.ts │ │ ├── tagsEdit.tsx │ │ ├── throttle.ts │ │ ├── title.ts │ │ ├── useScript.tsx │ │ └── useTableColumns.ts │ ├── index.scss │ ├── index.tsx │ ├── locales/ │ │ ├── README.md │ │ ├── af-ZA.json │ │ ├── ar.json │ │ ├── bg-BG.json │ │ ├── bn-BD.json │ │ ├── ca-ES.json │ │ ├── countryNames/ │ │ │ └── zh-TW.json │ │ ├── cs-CZ.json │ │ ├── da-DK.json │ │ ├── de-DE.json │ │ ├── en-GB.json │ │ ├── en-US.json │ │ ├── es-ES.json │ │ ├── et-EE.json │ │ ├── fa-IR.json │ │ ├── fi-FI.json │ │ ├── fr-FR.json │ │ ├── hi-IN.json │ │ ├── hr-HR.json │ │ ├── hu-HU.json │ │ ├── id-ID.json │ │ ├── index.ts │ │ ├── it-IT.json │ │ ├── ja-JP.json │ │ ├── ko-KR.json │ │ ├── lt-LT.json │ │ ├── lv-LV.json │ │ ├── nb-NO.json │ │ ├── ne-NP.json │ │ ├── nl-NL.json │ │ ├── nn-NO.json │ │ ├── pl-PL.json │ │ ├── pt-BR.json │ │ ├── ro-RO.json │ │ ├── ru-RU.json │ │ ├── sk-SK.json │ │ ├── sv-SE.json │ │ ├── th-TH.json │ │ ├── tr-TR.json │ │ ├── uk-UA.json │ │ ├── ur-PK.json │ │ ├── vi-VN.json │ │ ├── zh-CN.json │ │ └── zh-TW.json │ ├── models/ │ │ ├── list-filter/ │ │ │ ├── criteria/ │ │ │ │ ├── captions.ts │ │ │ │ ├── circumcised.ts │ │ │ │ ├── country.ts │ │ │ │ ├── criterion.ts │ │ │ │ ├── custom-fields.ts │ │ │ │ ├── favorite.ts │ │ │ │ ├── folder.ts │ │ │ │ ├── galleries.ts │ │ │ │ ├── gender.ts │ │ │ │ ├── groups.ts │ │ │ │ ├── has-chapters.ts │ │ │ │ ├── has-markers.ts │ │ │ │ ├── interactive.ts │ │ │ │ ├── is-missing.ts │ │ │ │ ├── organized.ts │ │ │ │ ├── orientation.ts │ │ │ │ ├── path.ts │ │ │ │ ├── performers.ts │ │ │ │ ├── phash.ts │ │ │ │ ├── rating.ts │ │ │ │ ├── resolution.ts │ │ │ │ ├── scenes.ts │ │ │ │ ├── stash-ids.ts │ │ │ │ ├── studios.ts │ │ │ │ └── tags.ts │ │ │ ├── factory.ts │ │ │ ├── filter-options.ts │ │ │ ├── filter.ts │ │ │ ├── galleries.ts │ │ │ ├── groups.ts │ │ │ ├── images.ts │ │ │ ├── performers.ts │ │ │ ├── scene-markers.ts │ │ │ ├── scenes.ts │ │ │ ├── studios.ts │ │ │ ├── tags.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ └── sceneQueue.ts │ ├── patch.tsx │ ├── pluginApi.d.ts │ ├── pluginApi.tsx │ ├── plugins.tsx │ ├── polyfills.ts │ ├── serviceWorker.ts │ ├── sfw-mode.scss │ ├── styles/ │ │ ├── _range.scss │ │ ├── _scrollbars.scss │ │ └── _theme.scss │ └── utils/ │ ├── apple.ts │ ├── bulkUpdate.ts │ ├── caption.ts │ ├── circumcised.ts │ ├── country.ts │ ├── data.ts │ ├── dlnaVideoSort.ts │ ├── download.ts │ ├── errors.ts │ ├── field.tsx │ ├── flattenMessages.ts │ ├── focus.ts │ ├── form.tsx │ ├── gender.ts │ ├── hamming.ts │ ├── history.ts │ ├── image.tsx │ ├── imageWall.ts │ ├── index.ts │ ├── job.ts │ ├── keyboard.ts │ ├── lazyComponent.ts │ ├── navigation.ts │ ├── orientation.ts │ ├── percent.ts │ ├── query.ts │ ├── rating.ts │ ├── resolution.ts │ ├── screen.ts │ ├── session.ts │ ├── stashIds.ts │ ├── stashbox.ts │ ├── text.ts │ ├── units.ts │ ├── visualFile.ts │ └── yup.ts ├── tsconfig.json └── vite.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ #### # Go #### # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, built with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # GraphQL generated output pkg/models/generated_*.go ui/v2.5/src/core/generated-graphql.ts #### # Jetbrains #### # User-specific stuff .idea/**/workspace.xml .idea/**/tasks.xml .idea/**/usage.statistics.xml .idea/**/dictionaries .idea/**/shelf # Generated files .idea/**/contentModel.xml # Sensitive or high-churn files .idea/**/dataSources/ .idea/**/dataSources.ids .idea/**/dataSources.local.xml .idea/**/sqlDataSources.xml .idea/**/dynamic.xml .idea/**/uiDesigner.xml .idea/**/dbnavigator.xml #### # Random #### ui/v2.5/node_modules ui/v2.5/build *.db stash dist docker ================================================ FILE: .gitattributes ================================================ go.mod text eol=lf go.sum text eol=lf *.go text eol=lf vendor/** -text ui/v2.5/**/*.ts* text eol=lf ui/v2.5/**/*.scss text eol=lf ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug Report description: Create a report to help us fix the bug labels: ["bug report"] body: - type: markdown attributes: value: | Thanks for taking the time to fill out this bug report! - type: textarea id: description attributes: label: Describe the bug description: Provide a clear and concise description of what the bug is. validations: required: true - type: textarea id: reproduction attributes: label: Steps to reproduce description: Detail the steps that would replicate this issue. placeholder: | 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error validations: required: true - type: textarea id: expected attributes: label: Expected behaviour description: Provide clear and concise description of what you expected to happen. validations: required: true - type: textarea id: context attributes: label: Screenshots or additional context description: Provide any additional context and SFW screenshots here to help us solve this issue. validations: required: false - type: input id: stashversion attributes: label: Stash version description: This can be found in Settings > About. placeholder: (e.g. v0.28.1) validations: required: true - type: input id: devicedetails attributes: label: Device details description: | If this is an issue that occurs when using the Stash interface, please provide details of the device/browser used which presents the reported issue. placeholder: (e.g. Firefox 97 (64-bit) on Windows 11) validations: required: false - type: textarea id: logs attributes: label: Relevant log output description: Please copy and paste any relevant log output from Settings > Logs. This will be automatically formatted into code, so no need for backticks. render: shell ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Community forum url: https://discourse.stashapp.cc about: Start a discussion on the community forum. - name: Community Discord url: https://discord.gg/Y8MNsvQBvZ about: Chat with the community on Discord. - name: Documentation url: https://docs.stashapp.cc about: Check out documentation for help and information. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: Feature Request description: Request a new feature or idea to be added to Stash labels: ["feature request"] body: - type: textarea id: description attributes: label: Describe the feature you'd like description: Provide a clear description of the feature you'd like implemented validations: required: true - type: textarea id: benefits attributes: label: Describe the benefits this would bring to existing users description: | Explain the measurable benefits this feature would achieve for existing users. The benefits should be described in terms of outcomes for users, not specific implementations. validations: required: true - type: textarea id: already_possible attributes: label: Is there an existing way to achieve this goal? description: | Yes/No. If Yes, describe how your proposed feature differs from or improves upon the current method validations: required: true - type: checkboxes id: confirm-search attributes: label: Have you searched for an existing open/closed issue? description: | To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/stashapp/stash/issues?q=is%3Aissue) for any existing issues that cover the core request or benefit of your proposal. options: - label: I have searched for existing issues and none cover the core request of my proposal required: true - type: textarea id: context attributes: label: Additional context description: Add any other context or screenshots about the feature request here. validations: required: false ================================================ FILE: .github/PULL_REQUEST_TEMPLATE/BugFix.md ================================================ --- name: Bug Fix about: Add a bug fix this project! title: "[Bug Fix] Short Form Title (50 chars or less.)" labels: bug assignees: 'WithoutPants, bnkai, Leopere' --- # Scope ## Closes/Fixes Issues ## Other testing QA Notes ================================================ FILE: .github/PULL_REQUEST_TEMPLATE/Feature.md ================================================ --- name: Feature Addition about: Add a feature to this project! title: "[Feature] Short Form Title (50 chars or less.)" labels: enhancement assignees: 'WithoutPants, bnkai, Leopere' --- # Scope ## Closes/Fixes Issues ## Other testing QA Notes ================================================ FILE: .github/workflows/build-compiler.yml ================================================ name: Compiler Build on: workflow_dispatch: env: COMPILER_IMAGE: ghcr.io/stashapp/compiler:13 jobs: build-compiler: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/setup-buildx-action@v3 - uses: docker/build-push-action@v6 with: push: true context: "{{defaultContext}}:docker/compiler" tags: | ${{ env.COMPILER_IMAGE }} ghcr.io/stashapp/compiler:latest cache-from: type=gha,scope=all,mode=max cache-to: type=gha,scope=all,mode=max ================================================ FILE: .github/workflows/build.yml ================================================ name: Build on: push: branches: - develop - master - 'releases/**' pull_request: release: types: [ published ] concurrency: group: ${{ github.ref }} cancel-in-progress: true env: COMPILER_IMAGE: ghcr.io/stashapp/compiler:13 jobs: # Job 1: Generate code and build UI # Runs natively (no Docker) — go generate/gqlgen and node don't need cross-compilers. # Produces artifacts (generated Go files + UI build) consumed by test and build jobs. generate: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 - name: Setup Go uses: actions/setup-go@v6 # pnpm version is read from the packageManager field in package.json # very broken (4.3, 4.4) - name: Install pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 with: package_json_file: ui/v2.5/package.json - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: '20' cache: 'pnpm' cache-dependency-path: ui/v2.5/pnpm-lock.yaml - name: Install UI dependencies run: cd ui/v2.5 && pnpm install --frozen-lockfile - name: Generate run: make generate - name: Cache UI build uses: actions/cache@v5 id: cache-ui with: path: ui/v2.5/build key: ${{ runner.os }}-ui-build-${{ hashFiles('ui/v2.5/pnpm-lock.yaml', 'ui/v2.5/public/**', 'ui/v2.5/src/**', 'graphql/**/*.graphql') }} - name: Validate UI # skip UI validation for pull requests if UI is unchanged if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }} run: make validate-ui - name: Build UI # skip UI build for pull requests if UI is unchanged (UI was cached) if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }} run: make ui # Bundle generated Go files + UI build for downstream jobs (test + build) - name: Upload generated artifacts uses: actions/upload-artifact@v7 with: name: generated retention-days: 1 path: | internal/api/generated_exec.go internal/api/generated_models.go ui/v2.5/build/ ui/login/locales/ # Job 2: Integration tests # Runs natively (no Docker) — only needs Go + GCC (for CGO/SQLite), both on ubuntu-22.04. # Runs in parallel with the build matrix jobs. test: needs: generate runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 - name: Setup Go uses: actions/setup-go@v6 with: go-version-file: 'go.mod' # Places generated Go files + UI build into the working tree so the build compiles - name: Download generated artifacts uses: actions/download-artifact@v8 with: name: generated - name: Test Backend run: make it # Job 3: Cross-compile for all platforms # Each platform gets its own runner and Docker container (ghcr.io/stashapp/compiler:13). # Each build-cc-* make target is self-contained (sets its own GOOS/GOARCH/CC), # so running them in separate containers is functionally identical to one container. # Runs in parallel with the test job. build: needs: generate runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: include: - platform: windows make-target: build-cc-windows artifact-paths: | dist/stash-win.exe tag: win - platform: macos make-target: build-cc-macos artifact-paths: | dist/stash-macos dist/Stash.app.zip tag: osx - platform: linux make-target: build-cc-linux artifact-paths: | dist/stash-linux tag: linux - platform: linux-arm64v8 make-target: build-cc-linux-arm64v8 artifact-paths: | dist/stash-linux-arm64v8 tag: arm - platform: linux-arm32v7 make-target: build-cc-linux-arm32v7 artifact-paths: | dist/stash-linux-arm32v7 tag: arm - platform: linux-arm32v6 make-target: build-cc-linux-arm32v6 artifact-paths: | dist/stash-linux-arm32v6 tag: arm - platform: freebsd make-target: build-cc-freebsd artifact-paths: | dist/stash-freebsd tag: freebsd steps: - uses: actions/checkout@v6 with: fetch-depth: 1 fetch-tags: true - name: Download generated artifacts uses: actions/download-artifact@v8 with: name: generated - name: Cache Go build uses: actions/cache@v5 with: path: .go-cache key: ${{ runner.os }}-go-cache-${{ matrix.platform }}-${{ hashFiles('go.mod', '**/go.sum') }} # kept seperate to test timings - name: pull compiler image run: docker pull $COMPILER_IMAGE - name: Start build container env: official-build: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/develop') || (github.event_name == 'release' && github.ref != 'refs/tags/latest_develop') }} run: | mkdir -p .go-cache docker run -d --name build --mount type=bind,source="$(pwd)",target=/stash,consistency=delegated --mount type=bind,source="$(pwd)/.go-cache",target=/root/.cache/go-build,consistency=delegated --env OFFICIAL_BUILD=${{ env.official-build }} -w /stash $COMPILER_IMAGE tail -f /dev/null - name: Build (${{ matrix.platform }}) run: docker exec -t build /bin/bash -c "make ${{ matrix.make-target }}" - name: Cleanup build container run: docker rm -f -v build - name: Upload build artifact uses: actions/upload-artifact@v7 with: name: build-${{ matrix.platform }} retention-days: 1 path: ${{ matrix.artifact-paths }} # Job 4: Release # Waits for both test and build to pass, then collects all platform artifacts # into dist/ for checksums, GitHub releases, and multi-arch Docker push. release: needs: [test, build] runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 with: fetch-depth: 0 fetch-tags: true # Downloads all artifacts (generated + 7 platform builds) into artifacts/ subdirectories - name: Download all build artifacts uses: actions/download-artifact@v8 with: path: artifacts # Reassemble platform binaries from matrix job artifacts into a single dist/ directory # make sure that artifacts have executable bit set # upload-artifact@v4 strips the common path prefix (dist/), so files are at the artifact root - name: Collect binaries run: | mkdir -p dist cp artifacts/build-*/* dist/ chmod +x dist/* - name: Zip UI run: | cd artifacts/generated/ui/v2.5/build && zip -r ../../../../../dist/stash-ui.zip . - name: Generate checksums run: | git describe --tags --exclude latest_develop | tee CHECKSUMS_SHA1 sha1sum dist/Stash.app.zip dist/stash-* dist/stash-ui.zip | sed 's/dist\///g' | tee -a CHECKSUMS_SHA1 echo "STASH_VERSION=$(git describe --tags --exclude latest_develop)" >> $GITHUB_ENV echo "RELEASE_DATE=$(date +'%Y-%m-%d %H:%M:%S %Z')" >> $GITHUB_ENV - name: Upload Windows binary # only upload binaries for pull requests if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} uses: actions/upload-artifact@v7 with: name: stash-win.exe path: dist/stash-win.exe - name: Upload macOS binary # only upload binaries for pull requests if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} uses: actions/upload-artifact@v7 with: name: stash-macos path: dist/stash-macos - name: Upload Linux binary # only upload binaries for pull requests if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} uses: actions/upload-artifact@v7 with: name: stash-linux path: dist/stash-linux - name: Upload UI # only upload for pull requests if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} uses: actions/upload-artifact@v7 with: name: stash-ui.zip path: dist/stash-ui.zip - name: Update latest_develop tag if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} run: git tag -f latest_develop; git push -f --tags - name: Development Release if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} uses: marvinpinto/action-automatic-releases@v1.1.2 with: repo_token: "${{ secrets.GITHUB_TOKEN }}" prerelease: true automatic_release_tag: latest_develop title: "${{ env.STASH_VERSION }}: Latest development build" files: | dist/Stash.app.zip dist/stash-macos dist/stash-win.exe dist/stash-linux dist/stash-linux-arm64v8 dist/stash-linux-arm32v7 dist/stash-linux-arm32v6 dist/stash-freebsd dist/stash-ui.zip CHECKSUMS_SHA1 - name: Master release # NOTE: this isn't perfect, but should cover most scenarios # DON'T create tag names starting with "v" if they are not stable releases if: ${{ github.event_name == 'release' && startsWith(github.ref, 'refs/tags/v') }} uses: WithoutPants/github-release@v2.0.4 with: token: "${{ secrets.GITHUB_TOKEN }}" allow_override: true files: | dist/Stash.app.zip dist/stash-macos dist/stash-win.exe dist/stash-linux dist/stash-linux-arm64v8 dist/stash-linux-arm32v7 dist/stash-linux-arm32v6 dist/stash-freebsd dist/stash-ui.zip CHECKSUMS_SHA1 gzip: false - name: Development Docker if: ${{ github.repository == 'stashapp/stash' && github.event_name == 'push' && github.ref == 'refs/heads/develop' }} env: DOCKER_CLI_EXPERIMENTAL: enabled DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} run: | docker run --rm --privileged tonistiigi/binfmt docker info docker buildx create --name builder --use docker buildx inspect --bootstrap docker buildx ls bash ./docker/ci/x86_64/docker_push.sh development - name: Release Docker # NOTE: this isn't perfect, but should cover most scenarios # DON'T create tag names starting with "v" if they are not stable releases if: ${{ github.repository == 'stashapp/stash' && github.event_name == 'release' && startsWith(github.ref, 'refs/tags/v') }} env: DOCKER_CLI_EXPERIMENTAL: enabled DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} run: | docker run --rm --privileged tonistiigi/binfmt docker info docker buildx create --name builder --use docker buildx inspect --bootstrap docker buildx ls bash ./docker/ci/x86_64/docker_push.sh latest "${{ github.event.release.tag_name }}" ================================================ FILE: .github/workflows/golangci-lint.yml ================================================ name: Lint (golangci-lint) on: push: tags: - v* branches: - master - develop - 'releases/**' pull_request: jobs: golangci: name: lint runs-on: ubuntu-latest steps: # no tags or depth needed for lint - uses: actions/checkout@v6 - uses: actions/setup-go@v6 # generate-backend runs natively (just go generate + touch-ui) — no Docker needed - name: Generate Backend run: make generate-backend ## WARN ## using v1, update in a later PR - name: Run golangci-lint uses: golangci/golangci-lint-action@v6 ================================================ FILE: .gitignore ================================================ #### # Go #### # Vendored dependencies vendor # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, built with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # GraphQL generated output internal/api/generated_*.go # Generated locale files ui/login/locales/* #### # Visual Studio #### /.vs # User-specific stuff .idea/**/workspace.xml .idea/**/tasks.xml .idea/**/usage.statistics.xml .idea/**/dictionaries .idea/**/shelf .vscode .devcontainer # Generated files .idea/**/contentModel.xml # Sensitive or high-churn files .idea/**/dataSources/ .idea/**/dataSources.ids .idea/**/dataSources.local.xml .idea/**/sqlDataSources.xml .idea/**/dynamic.xml .idea/**/uiDesigner.xml .idea/**/dbnavigator.xml #### # Random #### node_modules *.db /stash /Stash.app /phasher dist .DS_Store /.local* ================================================ FILE: .golangci.yml ================================================ # options for analysis running run: timeout: 5m linters: disable-all: true enable: # Default set of linters from golangci-lint - errcheck - gosimple - govet - ineffassign - staticcheck - typecheck - unused # Linters added by the stash project. # - contextcheck - copyloopvar - dogsled - errchkjson - errorlint # - exhaustive - gocritic # - goerr113 - gofmt # - gomnd # - ifshort - misspell # - nakedret - noctx - revive - rowserrcheck - sqlclosecheck # Project-specific linter overrides linters-settings: gofmt: simplify: false errorlint: # Disable errorf because there are false positives, where you don't want to wrap # an error. errorf: false asserts: true comparison: true revive: ignore-generated-header: true severity: error confidence: 0.8 rules: - name: blank-imports disabled: true - name: context-as-argument - name: context-keys-type - name: dot-imports - name: error-return - name: error-strings - name: error-naming - name: exported disabled: true - name: if-return disabled: true - name: increment-decrement - name: var-naming disabled: true - name: var-declaration - name: package-comments - name: range - name: receiver-naming - name: time-naming - name: unexported-return disabled: true - name: indent-error-flow disabled: true - name: errorf - name: empty-block disabled: true - name: superfluous-else - name: unused-parameter disabled: true - name: unreachable-code - name: redefines-builtin-id rowserrcheck: packages: - github.com/jmoiron/sqlx ================================================ FILE: .gqlgenc.yml ================================================ model: package: graphql filename: ./pkg/stashbox/graphql/generated_models.go client: package: graphql filename: ./pkg/stashbox/graphql/generated_client.go models: Date: model: github.com/99designs/gqlgen/graphql.String endpoint: # This points to stashdb.org currently, but can be directed at any stash-box # instance. It is used for generation only. url: https://stashdb.org/graphql query: - "./graphql/stash-box/*.graphql" generate: clientV2: false clientInterfaceName: "StashBoxGraphQLClient" ================================================ FILE: .idea/codeStyles/codeStyleConfig.xml ================================================ ================================================ FILE: .idea/dataSources.xml ================================================ sqlite.xerial true org.sqlite.JDBC jdbc:sqlite:$USER_HOME$/.stash/stash-go.sqlite ================================================ FILE: .idea/encodings.xml ================================================ ================================================ FILE: .idea/go.iml ================================================ ================================================ FILE: .idea/misc.xml ================================================ ================================================ FILE: .idea/modules.xml ================================================ ================================================ FILE: .idea/sqldialects.xml ================================================ ================================================ FILE: .idea/vcs.xml ================================================ ================================================ FILE: .mockery.yml ================================================ dir: ./pkg/models name: ".*ReaderWriter" outpkg: mocks output: ./pkg/models/mocks ================================================ FILE: LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: Makefile ================================================ IS_WIN_SHELL = ifeq (${SHELL}, sh.exe) IS_WIN_SHELL = true endif ifeq (${SHELL}, cmd) IS_WIN_SHELL = true endif ifdef IS_WIN_SHELL RM := del /s /q RMDIR := rmdir /s /q NOOP := @@ else RM := rm -f RMDIR := rm -rf NOOP := @: endif # set LDFLAGS environment variable to any extra ldflags required LDFLAGS := $(LDFLAGS) # set OUTPUT environment variable to generate a specific binary name # this will apply to both `stash` and `phasher`, so build them separately # alternatively use STASH_OUTPUT or PHASHER_OUTPUT to set the value individually ifdef OUTPUT STASH_OUTPUT := $(OUTPUT) PHASHER_OUTPUT := $(OUTPUT) endif ifdef STASH_OUTPUT STASH_OUTPUT := -o $(STASH_OUTPUT) endif ifdef PHASHER_OUTPUT PHASHER_OUTPUT := -o $(PHASHER_OUTPUT) endif # set GO_BUILD_FLAGS environment variable to any extra build flags required GO_BUILD_FLAGS := $(GO_BUILD_FLAGS) # set GO_BUILD_TAGS environment variable to any extra build tags required GO_BUILD_TAGS := $(GO_BUILD_TAGS) GO_BUILD_TAGS += sqlite_stat4 sqlite_math_functions # set STASH_NOLEGACY environment variable or uncomment to disable legacy browser support # STASH_NOLEGACY := true # set STASH_SOURCEMAPS environment variable or uncomment to enable UI sourcemaps # STASH_SOURCEMAPS := true export CGO_ENABLED := 1 # define COMPILER_IMAGE for cross-compilation docker container ifndef COMPILER_IMAGE COMPILER_IMAGE := ghcr.io/stashapp/compiler:latest endif .PHONY: release release: pre-ui generate ui build-release # targets to set various build flags # use combinations on the make command-line to configure a build, e.g.: # for a static-pie release build: `make flags-static-pie flags-release stash` # for a static windows debug build: `make flags-static-windows stash` # $(NOOP) prevents "nothing to be done" warnings .PHONY: flags-release flags-release: $(NOOP) $(eval LDFLAGS += -s -w) $(eval GO_BUILD_FLAGS += -trimpath) .PHONY: flags-pie flags-pie: $(NOOP) $(eval GO_BUILD_FLAGS += -buildmode=pie) .PHONY: flags-static flags-static: $(NOOP) $(eval LDFLAGS += -extldflags=-static) $(eval GO_BUILD_TAGS += sqlite_omit_load_extension osusergo netgo) .PHONY: flags-static-pie flags-static-pie: $(NOOP) $(eval LDFLAGS += -extldflags=-static-pie) $(eval GO_BUILD_FLAGS += -buildmode=pie) $(eval GO_BUILD_TAGS += sqlite_omit_load_extension osusergo netgo) # identical to flags-static-pie, but excluding netgo, which is not needed on windows .PHONY: flags-static-windows flags-static-windows: $(NOOP) $(eval LDFLAGS += -extldflags=-static-pie) $(eval GO_BUILD_FLAGS += -buildmode=pie) $(eval GO_BUILD_TAGS += sqlite_omit_load_extension osusergo) .PHONY: build-info build-info: ifndef BUILD_DATE $(eval BUILD_DATE := $(shell go run scripts/getDate.go)) endif ifndef GITHASH $(eval GITHASH := $(shell git rev-parse --short HEAD)) endif ifndef STASH_VERSION $(eval STASH_VERSION := $(shell git describe --tags --exclude latest_develop)) endif ifndef OFFICIAL_BUILD $(eval OFFICIAL_BUILD := false) endif .PHONY: build-flags build-flags: build-info $(eval BUILD_LDFLAGS := $(LDFLAGS)) $(eval BUILD_LDFLAGS += -X 'github.com/stashapp/stash/internal/build.buildstamp=$(BUILD_DATE)') $(eval BUILD_LDFLAGS += -X 'github.com/stashapp/stash/internal/build.githash=$(GITHASH)') $(eval BUILD_LDFLAGS += -X 'github.com/stashapp/stash/internal/build.version=$(STASH_VERSION)') $(eval BUILD_LDFLAGS += -X 'github.com/stashapp/stash/internal/build.officialBuild=$(OFFICIAL_BUILD)') $(eval BUILD_FLAGS := -v -tags "$(GO_BUILD_TAGS)" $(GO_BUILD_FLAGS) -ldflags "$(BUILD_LDFLAGS)") .PHONY: stash stash: build-flags go build $(STASH_OUTPUT) $(BUILD_FLAGS) ./cmd/stash .PHONY: phasher phasher: build-flags go build $(PHASHER_OUTPUT) $(BUILD_FLAGS) ./cmd/phasher # builds dynamically-linked debug binaries .PHONY: build build: stash # builds dynamically-linked PIE release binaries .PHONY: build-release build-release: flags-release flags-pie build # compile and bundle into Stash.app # for when on macOS itself .PHONY: stash-macapp stash-macapp: STASH_OUTPUT := -o stash stash-macapp: flags-release flags-pie stash rm -rf Stash.app cp -R scripts/macos-bundle Stash.app mkdir Stash.app/Contents/MacOS cp stash Stash.app/Contents/MacOS/stash # build-cc- targets should be run within the compiler docker container .PHONY: build-cc-windows build-cc-windows: export GOOS := windows build-cc-windows: export GOARCH := amd64 build-cc-windows: export CC := x86_64-w64-mingw32-gcc build-cc-windows: STASH_OUTPUT := -o dist/stash-win.exe build-cc-windows: PHASHER_OUTPUT :=-o dist/phasher-win.exe build-cc-windows: flags-release build-cc-windows: flags-static-windows build-cc-windows: build .PHONY: build-cc-macos-intel build-cc-macos-intel: export GOOS := darwin build-cc-macos-intel: export GOARCH := amd64 build-cc-macos-intel: export CC := o64-clang build-cc-macos-intel: STASH_OUTPUT := -o dist/stash-macos-intel build-cc-macos-intel: PHASHER_OUTPUT := -o dist/phasher-macos-intel build-cc-macos-intel: flags-release # can't use static build for macOS build-cc-macos-intel: flags-pie build-cc-macos-intel: build .PHONY: build-cc-macos-arm build-cc-macos-arm: export GOOS := darwin build-cc-macos-arm: export GOARCH := arm64 build-cc-macos-arm: export CC := oa64e-clang build-cc-macos-arm: STASH_OUTPUT := -o dist/stash-macos-arm build-cc-macos-arm: PHASHER_OUTPUT := -o dist/phasher-macos-arm build-cc-macos-arm: flags-release # can't use static build for macOS build-cc-macos-arm: flags-pie build-cc-macos-arm: build .PHONY: build-cc-macos build-cc-macos: make build-cc-macos-arm make build-cc-macos-intel # Combine into universal binaries lipo -create -output dist/stash-macos dist/stash-macos-intel dist/stash-macos-arm rm dist/stash-macos-intel dist/stash-macos-arm # Place into bundle and zip up rm -rf dist/Stash.app cp -R scripts/macos-bundle dist/Stash.app mkdir dist/Stash.app/Contents/MacOS cp dist/stash-macos dist/Stash.app/Contents/MacOS/stash cd dist && rm -f Stash.app.zip && zip -r Stash.app.zip Stash.app rm -rf dist/Stash.app .PHONY: build-cc-macos-phasher build-cc-macos-phasher: make build-cc-macos-arm make build-cc-macos-intel # Combine into universal binaries lipo -create -output dist/phasher-macos dist/phasher-macos-intel dist/phasher-macos-arm rm dist/phasher-macos-intel dist/phasher-macos-arm # do not bundle phasher .PHONY: build-cc-freebsd build-cc-freebsd: export GOOS := freebsd build-cc-freebsd: export GOARCH := amd64 build-cc-freebsd: export CC := clang -target x86_64-unknown-freebsd12.0 --sysroot=/opt/cross-freebsd build-cc-freebsd: STASH_OUTPUT := -o dist/stash-freebsd build-cc-freebsd: PHASHER_OUTPUT := -o dist/phasher-freebsd build-cc-freebsd: flags-release build-cc-freebsd: flags-static-pie build-cc-freebsd: build .PHONY: build-cc-linux build-cc-linux: export GOOS := linux build-cc-linux: export GOARCH := amd64 build-cc-linux: STASH_OUTPUT := -o dist/stash-linux build-cc-linux: PHASHER_OUTPUT := -o dist/phasher-linux build-cc-linux: flags-release build-cc-linux: flags-static-pie build-cc-linux: build .PHONY: build-cc-linux-arm64v8 build-cc-linux-arm64v8: export GOOS := linux build-cc-linux-arm64v8: export GOARCH := arm64 build-cc-linux-arm64v8: export CC := aarch64-linux-gnu-gcc build-cc-linux-arm64v8: STASH_OUTPUT := -o dist/stash-linux-arm64v8 build-cc-linux-arm64v8: PHASHER_OUTPUT := -o dist/phasher-linux-arm64v8 build-cc-linux-arm64v8: flags-release build-cc-linux-arm64v8: flags-static-pie build-cc-linux-arm64v8: build .PHONY: build-cc-linux-arm32v7 build-cc-linux-arm32v7: export GOOS := linux build-cc-linux-arm32v7: export GOARCH := arm build-cc-linux-arm32v7: export GOARM := 7 build-cc-linux-arm32v7: export CC := arm-linux-gnueabi-gcc -march=armv7-a build-cc-linux-arm32v7: STASH_OUTPUT := -o dist/stash-linux-arm32v7 build-cc-linux-arm32v7: PHASHER_OUTPUT := -o dist/phasher-linux-arm32v7 build-cc-linux-arm32v7: flags-release build-cc-linux-arm32v7: flags-static build-cc-linux-arm32v7: build .PHONY: build-cc-linux-arm32v6 build-cc-linux-arm32v6: export GOOS := linux build-cc-linux-arm32v6: export GOARCH := arm build-cc-linux-arm32v6: export GOARM := 6 build-cc-linux-arm32v6: export CC := arm-linux-gnueabi-gcc build-cc-linux-arm32v6: STASH_OUTPUT := -o dist/stash-linux-arm32v6 build-cc-linux-arm32v6: PHASHER_OUTPUT := -o dist/phasher-linux-arm32v6 build-cc-linux-arm32v6: flags-release build-cc-linux-arm32v6: flags-static build-cc-linux-arm32v6: build .PHONY: build-cc-all build-cc-all: make build-cc-windows make build-cc-macos make build-cc-linux make build-cc-linux-arm64v8 make build-cc-linux-arm32v7 make build-cc-linux-arm32v6 make build-cc-freebsd .PHONY: touch-ui touch-ui: ifdef IS_WIN_SHELL @if not exist "ui\\v2.5\\build" mkdir ui\\v2.5\\build @type nul >> ui/v2.5/build/index.html else @mkdir -p ui/v2.5/build @touch ui/v2.5/build/index.html endif # Regenerates GraphQL files .PHONY: generate generate: generate-backend generate-ui .PHONY: generate-ui generate-ui: cd ui/v2.5 && npm run gqlgen .PHONY: generate-backend generate-backend: touch-ui go generate ./cmd/stash .PHONY: generate-login-locale generate-login-locale: go generate ./ui .PHONY: generate-dataloaders generate-dataloaders: go generate ./internal/api/loaders # Regenerates stash-box client files .PHONY: generate-stash-box-client generate-stash-box-client: go run github.com/Yamashou/gqlgenc # Runs gofmt -w on the project's source code, modifying any files that do not match its style. .PHONY: fmt fmt: go fmt ./... .PHONY: lint lint: golangci-lint run # runs unit tests - excluding integration tests .PHONY: test test: go test ./... # runs all tests - including integration tests .PHONY: it it: $(eval GO_BUILD_TAGS += integration) go test -tags "$(GO_BUILD_TAGS)" ./... # generates test mocks .PHONY: generate-test-mocks generate-test-mocks: go run github.com/vektra/mockery/v2 # runs server # sets the config file to use the local dev config .PHONY: server-start server-start: export STASH_CONFIG_FILE := config.yml server-start: build-flags ifdef IS_WIN_SHELL @if not exist ".local" mkdir .local else @mkdir -p .local endif cd .local && go run $(BUILD_FLAGS) ../cmd/stash # removes local dev config files .PHONY: server-clean server-clean: $(RMDIR) .local # installs UI dependencies. Run when first cloning repository, or if UI # dependencies have changed # If CI is set, configures pnpm to use a local store to avoid # putting .pnpm-store in /stash # NOTE: to run in the docker build container, using the existing # node_modules folder, rename the .modules.yaml to .modules.yaml.bak # and a new one will be generated. This will need to be reversed after # building. .PHONY: pre-ui pre-ui: ifdef CI cd ui/v2.5 && pnpm config set store-dir ~/.pnpm-store && pnpm install --frozen-lockfile else cd ui/v2.5 && pnpm install --frozen-lockfile endif .PHONY: ui-env ui-env: build-info $(eval export VITE_APP_DATE := $(BUILD_DATE)) $(eval export VITE_APP_GITHASH := $(GITHASH)) $(eval export VITE_APP_STASH_VERSION := $(STASH_VERSION)) ifdef STASH_NOLEGACY $(eval export VITE_APP_NOLEGACY := true) endif ifdef STASH_SOURCEMAPS $(eval export VITE_APP_SOURCEMAPS := true) endif .PHONY: ui ui: ui-only generate-login-locale .PHONY: ui-only ui-only: ui-env cd ui/v2.5 && npm run build .PHONY: zip-ui zip-ui: rm -f dist/stash-ui.zip cd ui/v2.5/build && zip -r ../../../dist/stash-ui.zip . .PHONY: ui-start ui-start: ui-env cd ui/v2.5 && npm run start -- --host .PHONY: fmt-ui fmt-ui: cd ui/v2.5 && npm run format # runs all of the frontend PR-acceptance steps .PHONY: validate-ui validate-ui: cd ui/v2.5 && npm run validate # these targets run the same steps as fmt-ui and validate-ui, but only on files that have changed fmt-ui-quick: cd ui/v2.5 && \ files=$$(git diff --name-only --relative --diff-filter d . ../../graphql); \ if [ -n "$$files" ]; then \ npm run prettier -- --write $$files; \ fi # does not run tsc checks, as they are slow validate-ui-quick: cd ui/v2.5 && \ tsfiles=$$(git diff --name-only --relative --diff-filter d src | grep -e "\.tsx\?\$$"); \ scssfiles=$$(git diff --name-only --relative --diff-filter d src | grep "\.scss"); \ prettyfiles=$$(git diff --name-only --relative --diff-filter d . ../../graphql); \ if [ -n "$$tsfiles" ]; then npm run eslint -- $$tsfiles; fi && \ if [ -n "$$scssfiles" ]; then npm run stylelint -- $$scssfiles; fi && \ if [ -n "$$prettyfiles" ]; then npm run prettier -- --check $$prettyfiles; fi # runs all of the backend PR-acceptance steps .PHONY: validate-backend validate-backend: lint it # runs all of the tests and checks required for a PR to be accepted .PHONY: validate validate: validate-ui validate-backend # locally builds and tags a 'stash/build' docker image .PHONY: docker-build docker-build: build-info docker build --build-arg GITHASH=$(GITHASH) --build-arg STASH_VERSION=$(STASH_VERSION) -t stash/build -f docker/build/x86_64/Dockerfile . # locally builds and tags a 'stash/cuda-build' docker image .PHONY: docker-cuda-build docker-cuda-build: build-info docker build --build-arg GITHASH=$(GITHASH) --build-arg STASH_VERSION=$(STASH_VERSION) -t stash/cuda-build -f docker/build/x86_64/Dockerfile-CUDA . # start the build container - for cross compilation # this is adapted from the github actions build.yml file .PHONY: start-compiler-container start-compiler-container: docker run -d --name build --mount type=bind,source="$(PWD)",target=/stash,consistency=delegated $(EXTRA_CONTAINER_ARGS) -w /stash $(COMPILER_IMAGE) tail -f /dev/null # run the cross-compilation using # docker exec -t build /bin/bash -c "make build-cc-" .PHONY: remove-compiler-container remove-compiler-container: docker rm -f -v build ================================================ FILE: README.md ================================================ # Stash [![Build](https://github.com/stashapp/stash/actions/workflows/build.yml/badge.svg?branch=develop&event=push)](https://github.com/stashapp/stash/actions/workflows/build.yml) [![Docker pulls](https://img.shields.io/docker/pulls/stashapp/stash.svg)](https://hub.docker.com/r/stashapp/stash 'DockerHub') [![GitHub Sponsors](https://img.shields.io/github/sponsors/stashapp?logo=github)](https://github.com/sponsors/stashapp) [![Open Collective backers](https://img.shields.io/opencollective/backers/stashapp?logo=opencollective)](https://opencollective.com/stashapp) [![Go Report Card](https://goreportcard.com/badge/github.com/stashapp/stash)](https://goreportcard.com/report/github.com/stashapp/stash) [![Discord](https://img.shields.io/discord/559159668438728723.svg?logo=discord)](https://discord.gg/2TsNFKt) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/stashapp/stash?logo=github)](https://github.com/stashapp/stash/releases/latest) [![GitHub issues by-label](https://img.shields.io/github/issues-raw/stashapp/stash/bounty)](https://github.com/stashapp/stash/labels/bounty) ### **Stash is a self-hosted webapp written in Go which organizes and serves your diverse content collection, catering to both your SFW and NSFW needs.** ![Screenshot of Stash web application interface](docs/readme_assets/demo_image.png) * Stash gathers information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers and sites. * Stash supports a wide variety of both video and image formats. * You can tag videos and find them later. * Stash provides statistics about performers, tags, studios and more. You can [watch a SFW demo video](https://vimeo.com/545323354) to see it in action. For further information you can consult the [documentation](https://docs.stashapp.cc) or access the in-app manual from within the application (also available at [docs.stashapp.cc/in-app-manual](https://docs.stashapp.cc/in-app-manual)). # Installing Stash Step-by-step instructions are available at [docs.stashapp.cc/installation](https://docs.stashapp.cc/installation/). #### Windows Users: As of version 0.27.0, Stash no longer supports _Windows 7, 8, Server 2008 and Server 2012._ At least Windows 10 or Server 2016 is required. #### Mac Users: As of version 0.29.0, Stash requires _macOS 11 Big Sur_ or later. Stash can still be run through docker on older versions of macOS. Windows | macOS | Linux | Docker :---:|:---:|:---:|:---: [Latest Release](https://github.com/stashapp/stash/releases/latest/download/stash-win.exe)
[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/stash-win.exe) | [Latest Release](https://github.com/stashapp/stash/releases/latest/download/Stash.app.zip)
[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/Stash.app.zip) | [Latest Release (amd64)](https://github.com/stashapp/stash/releases/latest/download/stash-linux)
[Development Preview (amd64)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-linux)
[More Architectures...](https://github.com/stashapp/stash/releases/latest) | [Instructions](docker/production/README.md)
[Sample docker-compose.yml](docker/production/docker-compose.yml) Download links for other platforms and architectures are available on the [Releases](https://github.com/stashapp/stash/releases) page. ## First Run #### Windows/macOS Users: Security Prompt On Windows or macOS, running the app might present a security prompt since the application binary isn't yet signed. - On Windows, bypass this by clicking "more info" and then the "run anyway" button. - On macOS, Control+Click the app, click "Open", and then "Open" again. #### ffmpeg Stash requires FFmpeg. If you don't have it installed, Stash will prompt you to download a copy during setup. It is recommended that Linux users install `ffmpeg` from their distro's package manager. # Usage ## Quickstart Guide Stash is a web-based application. Once the application is running, the interface is available (by default) from `http://localhost:9999`. On first run, Stash will prompt you for some configuration options and media directories to index, called "Scanning" in Stash. After scanning, your media will be available for browsing, curating, editing, and tagging. Stash can pull metadata (performers, tags, descriptions, studios, and more) directly from many sites through the use of [scrapers](https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/docs/en/Manual/Scraping.md), which integrate directly into Stash. Identifying an entire collection will typically require a mix of multiple sources: - The stashapp team maintains [StashDB](https://stashdb.org/), a crowd-sourced repository of scene, studio, and performer information. Connecting it to Stash will allow you to automatically identify much of a typical media collection. It runs on our stash-box software and is primarily focused on mainstream digital scenes and studios. Instructions, invite codes, and more can be found in this guide to [Accessing StashDB](https://guidelines.stashdb.org/docs/faq_getting-started/stashdb/accessing-stashdb/). - Several community-managed stash-box databases can also be connected to Stash in a similar manner. Each one serves a slightly different niche and follows their own methodology. A rundown of each stash-box, their differences, and the information you need to sign up can be found in this guide to [Accessing Stash-Boxes](https://guidelines.stashdb.org/docs/faq_getting-started/stashdb/accessing-stash-boxes/). - Many community-maintained scrapers can also be downloaded, installed, and updated from within Stash, allowing you to pull data from a wide range of other websites and databases. They can be found by navigating to `Settings → Metadata Providers → Available Scrapers → Community (stable)`. These can be trickier to use than a stash-box because every scraper works a little differently. For more information, please visit the [CommunityScrapers repository](https://github.com/stashapp/CommunityScrapers). - All of the above methods of scraping data into Stash are also covered in more detail in our [Guide to Scraping](https://docs.stashapp.cc/beginner-guides/guide-to-scraping/). [StashDB](http://stashdb.org) is the canonical instance of our open source metadata API, [stash-box](https://github.com/stashapp/stash-box). # Translation [![Translate](https://translate.codeberg.org/widget/stash/stash/svg-badge.svg)](https://translate.codeberg.org/engage/stash/) Stash is available in 32 languages (so far!) and it could be in your language too. We use Weblate to coordinate community translations. If you want to help us translate Stash, you can make an account at [Codeberg's Weblate](https://translate.codeberg.org/projects/stash/stash/) to contribute to new or existing languages. Thanks! The badge below shows the current translation status of Stash across all supported languages: [![Translation status](https://translate.codeberg.org/widget/stash/stash/multi-auto.svg)](https://translate.codeberg.org/engage/stash/) # Support & Resources Need help or want to get involved? Start with the documentation, then reach out to the community if you need further assistance. - Documentation - Official docs: https://docs.stashapp.cc - official guides guides and troubleshooting. - In-app manual: press Shift + ? in the app or view the manual online: https://docs.stashapp.cc/in-app-manual. - FAQ: https://discourse.stashapp.cc/c/support/faq/28 - common questions and answers. - Community wiki: https://discourse.stashapp.cc/tags/c/community-wiki/22/stash - guides, how-to’s and tips. - Community & discussion - Community forum: https://discourse.stashapp.cc - community support, feature requests and discussions. - Discord: https://discord.gg/2TsNFKt - real-time chat and community support. - GitHub discussions: https://github.com/stashapp/stash/discussions - community support and feature discussions. - Lemmy community: https://discuss.online/c/stashapp - Reddit-style community space. - Community scrapers & plugins - Metadata sources: https://docs.stashapp.cc/metadata-sources/ - Plugins: https://docs.stashapp.cc/plugins/ - Themes: https://docs.stashapp.cc/themes/ - Other projects: https://docs.stashapp.cc/other-projects/ # For Developers Pull requests are welcome! See [Development](docs/DEVELOPMENT.md) and [Contributing](docs/CONTRIBUTING.md) for information on working with the codebase, getting a local development setup, and contributing changes. ================================================ FILE: cmd/phasher/main.go ================================================ // TODO: document in README.md package main import ( "fmt" "os" "os/exec" "path/filepath" flag "github.com/spf13/pflag" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/hash/imagephash" "github.com/stashapp/stash/pkg/hash/videophash" "github.com/stashapp/stash/pkg/models" ) func customUsage() { fmt.Fprintf(os.Stderr, "Usage:\n") fmt.Fprintf(os.Stderr, "%s [OPTIONS] FILE...\n\nOptions:\n", os.Args[0]) flag.PrintDefaults() } func printPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, quiet *bool) error { // Determine if this is a video or image file based on extension ext := filepath.Ext(inputfile) ext = ext[1:] // remove the leading dot // Common image extensions imageExts := map[string]bool{ "jpg": true, "jpeg": true, "png": true, "gif": true, "webp": true, "bmp": true, "avif": true, } if imageExts[ext] { return printImagePhash(ff, inputfile, quiet) } return printVideoPhash(ff, ffp, inputfile, quiet) } func printVideoPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, quiet *bool) error { ffvideoFile, err := ffp.NewVideoFile(inputfile) if err != nil { return err } // All we need for videophash.Generate() is // videoFile.Path (from BaseFile) // videoFile.Duration // The rest of the struct isn't needed. vf := &models.VideoFile{ BaseFile: &models.BaseFile{Path: inputfile}, Duration: ffvideoFile.FileDuration, } phash, err := videophash.Generate(ff, vf) if err != nil { return err } if *quiet { fmt.Printf("%x\n", *phash) } else { fmt.Printf("%x %v\n", *phash, vf.Path) } return nil } func printImagePhash(ff *ffmpeg.FFMpeg, inputfile string, quiet *bool) error { imgFile := &models.ImageFile{ BaseFile: &models.BaseFile{Path: inputfile}, } phash, err := imagephash.Generate(ff, imgFile) if err != nil { return err } if *quiet { fmt.Printf("%x\n", *phash) } else { fmt.Printf("%x %v\n", *phash, imgFile.Path) } return nil } func getPaths() (string, string) { ffmpegPath, _ := exec.LookPath("ffmpeg") ffprobePath, _ := exec.LookPath("ffprobe") return ffmpegPath, ffprobePath } func main() { flag.Usage = customUsage quiet := flag.BoolP("quiet", "q", false, "print only the phash") help := flag.BoolP("help", "h", false, "print this help output") flag.Parse() if *help { flag.Usage() os.Exit(2) } args := flag.Args() if len(args) < 1 { fmt.Fprintf(os.Stderr, "Missing FILE argument.\n") flag.Usage() os.Exit(2) } if len(args) > 1 { fmt.Fprintln(os.Stderr, "Files will be processed sequentially! If required, use e.g. GNU Parallel to run concurrently.") fmt.Fprintf(os.Stderr, "Example: parallel %v ::: *.mp4\n", os.Args[0]) } ffmpegPath, ffprobePath := getPaths() encoder := ffmpeg.NewEncoder(ffmpegPath) // don't need to InitHWSupport, phashing doesn't use hw acceleration ffprobe := ffmpeg.NewFFProbe(ffprobePath) for _, item := range args { if err := printPhash(encoder, ffprobe, item, quiet); err != nil { fmt.Fprintln(os.Stderr, err) } } } ================================================ FILE: cmd/stash/main.go ================================================ //go:generate go run github.com/99designs/gqlgen package main import ( "errors" "fmt" "net/http" "os" "os/signal" "runtime/debug" "runtime/pprof" "syscall" "github.com/spf13/pflag" "github.com/stashapp/stash/internal/api" "github.com/stashapp/stash/internal/build" "github.com/stashapp/stash/internal/desktop" "github.com/stashapp/stash/internal/log" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/ui" _ "github.com/golang-migrate/migrate/v4/database/sqlite3" _ "github.com/golang-migrate/migrate/v4/source/file" ) var exitCode = 0 func main() { defer func() { if exitCode != 0 { os.Exit(exitCode) } }() defer recoverPanic() initLogTemp() helpFlag := false pflag.BoolVarP(&helpFlag, "help", "h", false, "show this help text and exit") versionFlag := false pflag.BoolVarP(&versionFlag, "version", "v", false, "show version number and exit") cpuProfilePath := "" pflag.StringVar(&cpuProfilePath, "cpuprofile", "", "write cpu profile to file") pflag.Parse() if helpFlag { pflag.Usage() return } if versionFlag { fmt.Println(build.VersionString()) return } cfg, err := config.Initialize() if err != nil { exitError(fmt.Errorf("config initialization error: %w", err)) return } l := initLog(cfg) if cpuProfilePath != "" { if err := initProfiling(cpuProfilePath); err != nil { exitError(err) return } defer pprof.StopCPUProfile() } // initialise desktop.IsDesktop here so that it doesn't get affected by // ffmpeg hardware checks later on desktop.InitIsDesktop() mgr, err := manager.Initialize(cfg, l) if err != nil { exitError(fmt.Errorf("manager initialization error: %w", err)) return } defer mgr.Shutdown() server, err := api.Initialize() if err != nil { exitError(fmt.Errorf("api initialization error: %w", err)) return } defer server.Shutdown() exit := make(chan int) go func() { err := server.Start() if !errors.Is(err, http.ErrServerClosed) { exitError(fmt.Errorf("http server error: %w", err)) exit <- 1 } }() go handleSignals(exit) desktop.Start(exit, &ui.FaviconProvider) exitCode = <-exit } // initLogTemp initializes a temporary logger for use before the config is loaded. // Logs only error level message to stderr. func initLogTemp() *log.Logger { l := log.NewLogger() l.Init("", true, "Error", 0) logger.Logger = l return l } func initLog(cfg *config.Config) *log.Logger { l := log.NewLogger() l.Init(cfg.GetLogFile(), cfg.GetLogOut(), cfg.GetLogLevel(), cfg.GetLogFileMaxSize()) logger.Logger = l return l } func initProfiling(path string) error { f, err := os.Create(path) if err != nil { return fmt.Errorf("unable to create CPU profile file: %v", err) } if err = pprof.StartCPUProfile(f); err != nil { return fmt.Errorf("could not start CPU profiling: %v", err) } logger.Infof("profiling to %s", path) return nil } func recoverPanic() { if err := recover(); err != nil { exitCode = 1 logger.Errorf("panic: %v\n%s", err, debug.Stack()) if desktop.IsDesktop() { desktop.FatalError(fmt.Errorf("Panic: %v", err)) } } } func exitError(err error) { exitCode = 1 logger.Error(err) // #5784 - log to stdout as well as the logger // this does mean that it will log twice if the logger is set to stdout fmt.Println(err) if desktop.IsDesktop() { desktop.FatalError(err) } } func handleSignals(exit chan<- int) { // handle signals signals := make(chan os.Signal, 1) signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) <-signals exit <- 0 } ================================================ FILE: cmd/stash/main_test.go ================================================ package main import "testing" func TestStub(t *testing.T) { } ================================================ FILE: docker/build/x86_64/Dockerfile ================================================ # This dockerfile should be built with `make docker-build` from the stash root. # Build Frontend FROM node:24-alpine AS frontend RUN apk add --no-cache make git ## cache node_modules separately COPY ./ui/v2.5/package.json ./ui/v2.5/pnpm-lock.yaml /stash/ui/v2.5/ WORKDIR /stash COPY Makefile /stash/ COPY ./graphql /stash/graphql/ COPY ./ui /stash/ui/ # pnpm install with npm RUN npm install -g pnpm RUN make pre-ui RUN make generate-ui ARG GITHASH ARG STASH_VERSION RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only # Build Backend FROM golang:1.24.3-alpine AS backend RUN apk add --no-cache make alpine-sdk WORKDIR /stash COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/ COPY ./graphql /stash/graphql/ COPY ./scripts /stash/scripts/ COPY ./pkg /stash/pkg/ COPY ./cmd /stash/cmd/ COPY ./internal /stash/internal/ # needed for generate-login-locale COPY ./ui /stash/ui/ RUN make generate-backend generate-login-locale COPY --from=frontend /stash /stash/ ARG GITHASH ARG STASH_VERSION RUN make flags-release flags-pie stash # Final Runnable Image FROM alpine:latest RUN apk add --no-cache ca-certificates vips-tools ffmpeg COPY --from=backend /stash/stash /usr/bin/ ENV STASH_CONFIG_FILE=/root/.stash/config.yml EXPOSE 9999 ENTRYPOINT ["stash"] ================================================ FILE: docker/build/x86_64/Dockerfile-CUDA ================================================ # This dockerfile should be built with `make docker-cuda-build` from the stash root. ARG CUDA_VERSION=12.8.0 # Build Frontend FROM node:20-alpine AS frontend RUN apk add --no-cache make git ## cache node_modules separately COPY ./ui/v2.5/package.json ./ui/v2.5/pnpm-lock.yaml /stash/ui/v2.5/ WORKDIR /stash COPY Makefile /stash/ COPY ./graphql /stash/graphql/ COPY ./ui /stash/ui/ # pnpm install with npm RUN npm install -g pnpm RUN make pre-ui RUN make generate-ui ARG GITHASH ARG STASH_VERSION RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only # Build Backend FROM golang:1.24.3-bullseye AS backend RUN apt update && apt install -y build-essential golang WORKDIR /stash COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/ COPY ./graphql /stash/graphql/ COPY ./scripts /stash/scripts/ COPY ./pkg /stash/pkg/ COPY ./cmd /stash/cmd COPY ./internal /stash/internal # needed for generate-login-locale COPY ./ui /stash/ui/ RUN make generate-backend generate-login-locale COPY --from=frontend /stash /stash/ ARG GITHASH ARG STASH_VERSION RUN make flags-release flags-pie stash # Final Runnable Image FROM nvidia/cuda:${CUDA_VERSION}-base-ubuntu24.04 RUN apt update && apt upgrade -y && apt install -y \ # stash dependencies ca-certificates libvips-tools ffmpeg \ # intel dependencies intel-media-va-driver-non-free vainfo \ # python tools python3 python3-pip && \ # cleanup apt autoremove -y && apt clean && \ rm -rf /var/lib/apt/lists/* COPY --from=backend --chmod=555 /stash/stash /usr/bin/ # NVENC Patch RUN mkdir -p /usr/local/bin /patched-lib ADD --chmod=555 https://raw.githubusercontent.com/keylase/nvidia-patch/master/patch.sh /usr/local/bin/patch.sh ADD --chmod=555 https://raw.githubusercontent.com/keylase/nvidia-patch/master/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh ENV LANG=C.UTF-8 ENV NVIDIA_VISIBLE_DEVICES=all ENV NVIDIA_DRIVER_CAPABILITIES=video,utility ENV STASH_CONFIG_FILE=/root/.stash/config.yml EXPOSE 9999 ENTRYPOINT ["docker-entrypoint.sh", "stash"] # vim: ft=dockerfile ================================================ FILE: docker/build/x86_64/README.md ================================================ # Introduction This dockerfile is used to build a stash docker container using the current source code. This is ideal for testing your current branch in docker. Note that it does not include python, so python-based scrapers will not work in this image. The production docker images distributed by the project contain python and the necessary packages. # Building the docker container From the top-level directory (should contain `tools.go` file): ``` make docker-build ``` # Running the docker container ## Using docker-compose See the `README.md` file in `docker/production` for instructions on how to get docker-compose if needed. The `stash/build` container can be run with the `docker-compose.yml` file in `docker/production` by changing the `image` value to be `stash/build`. See the instructions in `docker/production` for how to run docker-compose. ## Using `docker run` After building the container: ``` docker run \ -e STASH_STASH=/data/ \ -e STASH_METADATA=/metadata/ \ -e STASH_CACHE=/cache/ \ -e STASH_GENERATED=/generated/ \ -v :/root/.stash \ -v :/data \ -v :/metadata \ -v :/cache \ -v :/generated \ -p 9999:9999 \ stash/build:latest ``` Change the `` to the appropriate paths. Note that the `` directory should be separate from the cache, generated and metadata directories. It is recommended to have the cache, generated and metadata directories in the same parent directory, for example: ``` /stash /config /metadata /generated /cache /media ``` Using this example directory structure, the above command would be: ``` docker run \ -e STASH_STASH=/data/ \ -e STASH_METADATA=/metadata/ \ -e STASH_CACHE=/cache/ \ -e STASH_GENERATED=/generated/ \ -v /stash/config:/root/.stash \ -v /media:/data \ -v /stash/metadata:/metadata \ -v /stash/cache:/cache \ -v /stash/generated:/generated \ -p 9999:9999 \ stash/build:latest ``` ================================================ FILE: docker/ci/x86_64/Dockerfile ================================================ FROM --platform=$BUILDPLATFORM alpine:latest AS binary ARG TARGETPLATFORM WORKDIR / COPY stash-* / RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-linux-arm32v6; \ elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then BIN=stash-linux-arm32v7; \ elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then BIN=stash-linux-arm64v8; \ elif [ "$TARGETPLATFORM" = "linux/amd64" ]; then BIN=stash-linux; \ fi; \ mv $BIN /stash FROM --platform=$TARGETPLATFORM alpine:latest AS app COPY --from=binary /stash /usr/bin/ RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg tzdata vips vips-tools \ && pip install --break-system-packages mechanicalsoup cloudscraper stashapp-tools ENV STASH_CONFIG_FILE=/root/.stash/config.yml # Basic build-time metadata as defined at https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys LABEL org.opencontainers.image.title="Stash" \ org.opencontainers.image.description="An organizer for your porn, written in Go." \ org.opencontainers.image.url="https://stashapp.cc" \ org.opencontainers.image.documentation="https://docs.stashapp.cc" \ org.opencontainers.image.source="https://github.com/stashapp/stash" \ org.opencontainers.image.licenses="AGPL-3.0" EXPOSE 9999 CMD ["stash"] ================================================ FILE: docker/ci/x86_64/README.md ================================================ This Dockerfile is used by CI to build the `stashapp/stash` Docker image. It must be run after cross-compiling - that is, `stash-linux` must exist in the `dist` directory. This image must be built from the `dist` directory. ================================================ FILE: docker/ci/x86_64/docker_push.sh ================================================ #!/bin/bash DOCKER_TAGS="" for TAG in "$@" do DOCKER_TAGS="$DOCKER_TAGS -t stashapp/stash:$TAG" done echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin # must build the image from dist directory docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 --push $DOCKER_TAGS -f docker/ci/x86_64/Dockerfile dist/ ================================================ FILE: docker/compiler/Dockerfile ================================================ ### OSXCROSS FROM debian:bookworm AS osxcross # add osxcross WORKDIR /tmp/osxcross ARG OSXCROSS_REVISION=5e1b71fcceb23952f3229995edca1b6231525b5b ADD --checksum=sha256:d3f771bbc20612fea577b18a71be3af2eb5ad2dd44624196cf55de866d008647 https://codeload.github.com/tpoechtrager/osxcross/tar.gz/${OSXCROSS_REVISION} /tmp/osxcross.tar.gz ARG OSX_SDK_VERSION=11.3 ARG OSX_SDK_DOWNLOAD_FILE=MacOSX${OSX_SDK_VERSION}.sdk.tar.xz ARG OSX_SDK_DOWNLOAD_URL=https://github.com/phracker/MacOSX-SDKs/releases/download/${OSX_SDK_VERSION}/${OSX_SDK_DOWNLOAD_FILE} ADD --checksum=sha256:cd4f08a75577145b8f05245a2975f7c81401d75e9535dcffbb879ee1deefcbf4 ${OSX_SDK_DOWNLOAD_URL} /tmp/osxcross/tarballs/${OSX_SDK_DOWNLOAD_FILE} ENV UNATTENDED=yes \ SDK_VERSION=${OSX_SDK_VERSION} \ OSX_VERSION_MIN=10.10 RUN apt update && \ apt install -y --no-install-recommends \ bash ca-certificates clang cmake git patch libssl-dev bzip2 cpio libbz2-dev libxml2-dev make python3 xz-utils zlib1g-dev # lzma-dev libxml2-dev xz RUN tar --strip=1 -C /tmp/osxcross -xf /tmp/osxcross.tar.gz RUN ./build.sh ### FREEBSD cross-compilation stage # use alpine for cacheable image since apt is notorous for not caching FROM alpine:3 AS freebsd # match golang latest # https://go.dev/wiki/FreeBSD ARG FREEBSD_VERSION=12.4 ADD --checksum=sha256:581c7edacfd2fca2bdf5791f667402d22fccd8a5e184635e0cac075564d57aa8 \ http://ftp-archive.freebsd.org/mirror/FreeBSD-Archive/old-releases/amd64/${FREEBSD_VERSION}-RELEASE/base.txz \ /tmp/base.txz WORKDIR /opt/cross-freebsd RUN apk add --no-cache tar xz RUN tar -xf /tmp/base.txz --strip-components=1 ./usr/lib ./usr/include ./lib RUN cd /opt/cross-freebsd/usr/lib && \ find . -type l -exec sh -c ' \ for link; do \ target=$(readlink "$link"); \ case "$target" in \ /lib/*) ln -sf "/opt/cross-freebsd$target" "$link";; \ esac; \ done \ ' sh {} + && \ ln -s libc++.a libstdc++.a && \ ln -s libc++.so libstdc++.so ### BUILDER FROM golang:1.24.3 AS builder ENV PATH=/opt/osx-ndk-x86/bin:$PATH # copy in nodejs instead of using nodesource :thumbsup: COPY --from=docker.io/library/node:24-bookworm /usr/local /usr/local # copy in osxcross COPY --from=osxcross /tmp/osxcross/target/lib /usr/lib COPY --from=osxcross /tmp/osxcross/target /opt/osx-ndk-x86 # copy in cross-freebsd COPY --from=freebsd /opt/cross-freebsd /opt/cross-freebsd # pnpm install with npm RUN npm install -g pnpm # git for getting hash # make and bash for building # clang for macos # zip for stashapp.zip # gcc-extensions for cross-arch build # we still target arm soft float? RUN apt-get update && \ apt-get install -y --no-install-recommends \ git make bash \ clang zip \ gcc-mingw-w64 \ gcc-arm-linux-gnueabi \ libc-dev-armel-cross linux-libc-dev-armel-cross \ gcc-aarch64-linux-gnu libc-dev-arm64-cross && \ rm -rf /var/lib/apt/lists/*; RUN git config --global safe.directory '*' # To test locally: # make generate # make ui # cd docker/compiler # docker build . -t ghcr.io/stashapp/compiler:latest # docker run --rm -v /PATH_TO_STASH:/stash -w /stash -i -t ghcr.io/stashapp/compiler:latest make build-cc-all # # binaries will show up in /dist ================================================ FILE: docker/compiler/Makefile ================================================ host=ghcr.io user=stashapp repo=compiler version=13 VERSION_IMAGE = ${host}/${user}/${repo}:${version} LATEST_IMAGE = ${host}/${user}/${repo}:latest latest: docker build -t ${LATEST_IMAGE} . build: docker build -t ${VERSION_IMAGE} -t ${LATEST_IMAGE} . build-no-cache: docker build --no-cache -t ${VERSION_IMAGE} -t ${LATEST_IMAGE} . # requires docker login ghcr.io # echo $CR_PAT | docker login ghcr.io -u USERNAME --password-stdin push: docker push ${VERSION_IMAGE} docker push ${LATEST_IMAGE} ================================================ FILE: docker/compiler/README.md ================================================ Modified from https://github.com/bep/dockerfiles/tree/master/ci-goreleaser When the Dockerfile is changed, the version number should be incremented in [.github/workflows/build-compiler.yml](../../.github/workflows/build-compiler.yml) and the workflow [manually ran](). `env: COMPILER_IMAGE` in [.github/workflows/build.yml](../../.github/workflows/build.yml) also needs to be updated to pull the correct image tag. ================================================ FILE: docker/production/README.md ================================================ # Docker Installation (for most 64-bit GNU/Linux systems) StashApp is supported on most systems that support Docker. Your OS likely ships with or makes available the necessary packages. ## Dependencies Only `docker` is required. For the most part your understanding of the technologies can be superficial. So long as you can follow commands and are open to reading a bit, you should be fine. Installation instructions are available below, and if your distributions's repository ships a current version of docker, you may use that. https://docs.docker.com/engine/install/ On some distributions, `docker compose` is shipped seperately, usually as `docker-cli-compose`. docker-compose is not recommended. ### Get the docker-compose.yml file Now you can either navigate to the [docker-compose.yml](https://raw.githubusercontent.com/stashapp/stash/develop/docker/production/docker-compose.yml) in the repository, or if you have curl, you can make your Linux console do it for you: ``` mkdir stashapp && cd stashapp curl -o docker-compose.yml https://raw.githubusercontent.com/stashapp/stash/develop/docker/production/docker-compose.yml ``` Once you have that file where you want it, modify the settings as you please, and then run: ``` docker compose up -d ``` Installing StashApp this way will by default bind stash to port 9999. This is available in your web browser locally at http://localhost:9999 or on your network as http://YOUR-LOCAL-IP:9999 Good luck and have fun! ### Docker Docker is effectively a cross-platform software package repository. It allows you to ship an entire environment in what's referred to as a container. Containers are intended to hold everything that is needed to run an application from one place to another, making it easy for everyone along the way to reproduce the environment. The StashApp docker container ships with everything you need to automatically run stash, including ffmpeg. ### docker compose Docker Compose lets you specify how and where to run your containers, and to manage their environment. The docker-compose.yml file in this folder gets you a fully working instance of StashApp exactly as you would need it to have a reasonable instance for testing / developing on. If you are deploying a live instance for production, a [reverse proxy](https://docs.stashapp.cc/guides/reverse-proxy/) (such as NGINX or Traefik) is recommended, but not required. The latest version is always recommended. ================================================ FILE: docker/production/docker-compose.yml ================================================ # APPNICENAME=Stash # APPDESCRIPTION=An organizer for your porn, written in Go services: stash: image: stashapp/stash:latest container_name: stash restart: unless-stopped ## the container's port must be the same with the STASH_PORT in the environment section ports: - "9999:9999" ## If you intend to use stash's DLNA functionality uncomment the below network mode and comment out the above ports section # network_mode: host logging: driver: "json-file" options: max-file: "10" max-size: "2m" environment: - STASH_STASH=/data/ - STASH_GENERATED=/generated/ - STASH_METADATA=/metadata/ - STASH_CACHE=/cache/ ## Adjust below to change default port (9999) - STASH_PORT=9999 volumes: - /etc/localtime:/etc/localtime:ro ## Adjust below paths (the left part) to your liking. ## E.g. you can change ./config:/root/.stash to ./stash:/root/.stash ## The left part is the path on your host, the right part is the path in the stash container. ## Keep configs, scrapers, and plugins here. - ./config:/root/.stash ## Point this at your collection. ## The left side is where your collection is on your host, the right side is where it will be in stash. - ./data:/data ## This is where your stash's metadata lives - ./metadata:/metadata ## Any other cache content. - ./cache:/cache ## Where to store binary blob data (scene covers, images) - ./blobs:/blobs ## Where to store generated content (screenshots,previews,transcodes,sprites) - ./generated:/generated ================================================ FILE: docs/CONTRIBUTING.md ================================================ ## Goals and design vision The goal of stash is to be: - an application for organising and viewing adult content - currently this is videos and images, in future this will be extended to include audio and text content - organising includes scraping of metadata from websites and metadata repositories - free and open-source - portable and offline - can be run on a USB stick without needing to install dependencies (with the exception of ffmpeg) - minimal, but highly extensible. The core feature set should be the minimum required to achieve the primary goal, while being extensible enough to extend via plugins - easy to learn and use, with minimal technical knowledge required The core stash system is not intended for: - managing downloading of content - managing content on external websites - publically sharing content Other requirements: - support as many video and image formats as possible - interfaces with external systems (for example stash-box) should be made as generic as possible. Design considerations: - features are easy to add and difficult to remove. Large superfluous features should be scrutinised and avoided where possible (eg DLNA, filename parser). Such features should be considered for third-party plugins instead. ## Technical Debt Please be sure to consider how heavily your contribution impacts the maintainability of the project long term, sometimes less is more. We don't want to merge collossal pull requests with hundreds of dependencies by a driveby contributor. ## Contributor Checklist Please make sure that you've considered the following before you submit your Pull Requests as ready for merging. * I've run Code linters and [gofmt](https://golang.org/cmd/gofmt/) to make sure that my code is readable. * I have read through formerly submitted [pull requests](https://github.com/stashapp/stash/pulls) and [git issues](https://github.com/stashapp/stash/issues) to make sure that this contribution is required and isn't a duplicate. Also, so that I can manage to close any git Issues needing closed relating to this feature submission. * I commented adequately on my code with the expectation in mind that anyone else should be able to look at this code I've submitted and know exactly what's happening and what the expectations are. ### Legal Agreements * I acknowledge that if applicable to me, submitting and subsequent acceptance of this Pull Request I, the code contributor of this Pull Request, agree and acknowledge my understanding that the new code license has now been updated to [AGPL](/LICENSE.md). I agree that all code before this Pull Request, which I've previously submitted, is now to be re-licensed under the new license AGPL and no longer the former MIT license. **In case you were unable to follow any of the above include an explanation as to why not in your Pull Request.** ================================================ FILE: docs/DEVELOPMENT.md ================================================ # Building from Source ## Pre-requisites * [Go](https://golang.org/dl/) * [GolangCI](https://golangci-lint.run/) - A meta-linter which runs several linters in parallel * To install, follow the [local installation instructions](https://golangci-lint.run/welcome/install/#local-installation) * [nodejs](https://nodejs.org/en/download) - nodejs runtime * corepack/[pnpm](https://pnpm.io/installation) - nodejs package manager (included with nodejs) ## Environment ### Windows 1. Download and install [Go for Windows](https://golang.org/dl/) 2. Download and extract [MinGW64](https://sourceforge.net/projects/mingw-w64/files/) (scroll down and select x86_64-posix-seh, don't use the autoinstaller, it doesn't work) 3. Search for "Advanced System Settings" and open the System Properties dialog. 1. Click the `Environment Variables` button 2. Under System Variables find `Path`. Edit and add `C:\MinGW\bin` (replace with the correct path to where you extracted MingW64). NOTE: The `make` command in Windows will be `mingw32-make` with MinGW. For example, `make pre-ui` will be `mingw32-make pre-ui`. ### macOS 1. If you don't have it already, install the [Homebrew package manager](https://brew.sh). 2. Install dependencies: `brew install go git gcc make node ffmpeg` ### Linux #### Arch Linux 1. Install dependencies: `sudo pacman -S go git gcc make nodejs ffmpeg --needed` #### Ubuntu 1. Install dependencies: `sudo apt-get install golang git gcc nodejs ffmpeg -y` ### OpenBSD 1. Install dependencies `doas pkg_add gmake go git node cmake ffmpeg` 2. Follow the instructions below to build a release, but replace the final step `make build-release` with `gmake flags-release stash`, to [avoid the PIE buildmode](https://github.com/golang/go/issues/59866). NOTE: The `make` command in OpenBSD will be `gmake`. For example, `make pre-ui` will be `gmake pre-ui`. ## Commands * `make pre-ui` - Installs the UI dependencies. This only needs to be run once after cloning the repository, or if the dependencies are updated. * `make generate` - Generates Go and UI GraphQL files. Requires `make pre-ui` to have been run. * `make generate-stash-box-client` - Generate Go files for the Stash-box client code. * `make ui` - Builds the UI. Requires `make pre-ui` to have been run. * `make stash` - Builds the `stash` binary (make sure to build the UI as well... see below) * `make stash-macapp` - Builds the `Stash.app` macOS app (only works when on macOS, for cross-compilation see below) * `make phasher` - Builds the `phasher` binary * `make build` - Builds both the `stash` and `phasher` binaries, alias for `make stash phasher` * `make build-release` - Builds release versions (debug information removed) of both the `stash` and `phasher` binaries, alias for `make flags-release flags-pie build` * `make docker-build` - Locally builds and tags a complete 'stash/build' docker image * `make docker-cuda-build` - Locally builds and tags a complete 'stash/cuda-build' docker image * `make validate` - Runs all of the tests and checks required to submit a PR * `make lint` - Runs `golangci-lint` on the backend * `make it` - Runs all unit and integration tests * `make fmt` - Formats the Go source code * `make fmt-ui` - Formats the UI source code * `make validate-ui` - Runs tests and checks for the UI only * `make fmt-ui-quick` - (experimental) Formats only changed UI source code * `make validate-ui-quick` - (experimental) Runs tests and checks of changed UI code * `make server-start` - Runs a development stash server in the `.local` directory * `make server-clean` - Removes the `.local` directory and all of its contents * `make ui-start` - Runs the UI in development mode. Requires a running Stash server to connect to - the server URL can be changed from the default of `http://localhost:9999` using the environment variable `VITE_APP_PLATFORM_URL`, but keep in mind that authentication cannot be used since the session authorization cookie cannot be sent cross-origin. The UI runs on port `3000` or the next available port. When building, you can optionally prepend `flags-*` targets to the target list in your `make` command to use different build flags: * `flags-release` (e.g. `make flags-release stash`) - Remove debug information from the binary. * `flags-pie` (e.g. `make flags-pie build`) - Build a PIE (Position Independent Executable) binary. This provides increased security, but it is unsupported on some systems (notably 32-bit ARM and OpenBSD). * `flags-static` (e.g. `make flags-static phasher`) - Build a statically linked binary (the default is a dynamically linked binary). * `flags-static-pie` (e.g. `make flags-static-pie stash`) - Build a statically linked PIE binary (using `flags-static` and `flags-pie` separately will not work). * `flags-static-windows` (e.g. `make flags-static-windows build`) - Identical to `flags-static-pie`, but does not enable the `netgo` build tag, which is not needed for static builds on Windows. ## Local development quickstart 1. Run `make pre-ui` to install UI dependencies 2. Run `make generate` to create generated files 3. In one terminal, run `make server-start` to run the server code 4. In a separate terminal, run `make ui-start` to run the UI in development mode 5. Open the UI in a browser: `http://localhost:3000/` Changes to the UI code can be seen by reloading the browser page. Changes to the backend code require a server restart (`CTRL-C` in the server terminal, followed by `make server-start` again) to be seen. On first launch: 1. On the "Stash Setup Wizard" screen, choose a directory with some files to test with 2. Press "Next" to use the default locations for the database and generated content 3. Press the "Confirm" and "Finish" buttons to get into the UI 4. On the side menu, navigate to "Tasks -> Library -> Scan" and press the "Scan" button 5. You're all set! Set any other configurations you'd like and test your code changes. To start fresh with new configuration: 1. Stop the server (`CTRL-C` in the server terminal) 2. Run `make server-clean` to clear all config, database, and generated files (under `.local`) 3. Run `make server-start` to restart the server 4. Follow the "On first launch" steps above ## Building a release Simply run `make` or `make release`, or equivalently: 1. Run `make pre-ui` to install UI dependencies 2. Run `make generate` to create generated files 3. Run `make ui` to build the frontend 4. Run `make build-release` to build a release executable for your current platform ## Cross-compiling This project uses a modification of the [CI-GoReleaser](https://github.com/bep/dockerfiles/tree/master/ci-goreleaser) Docker container for cross-compilation, defined in `docker/compiler/Dockerfile`. To cross-compile the app yourself: 1. Run `make pre-ui`, `make generate` and `make ui` outside the container, to generate files and build the UI. 2. Pull the latest compiler image from GHCR: `docker pull ghcr.io/stashapp/compiler` 3. Run `docker run --rm --mount type=bind,source="$(pwd)",target=/stash -w /stash -it ghcr.io/stashapp/compiler /bin/bash` to open a shell inside the container. 4. From inside the container, run `make build-cc-all` to build for all platforms, or run `make build-cc-{platform}` to build for a specific platform (have a look at the `Makefile` for the list of targets). 5. You will find the compiled binaries in `dist/`. NOTE: Since the container is run as UID 0 (root), the resulting binaries (and the `dist/` folder itself, if it had to be created) will be owned by root. ## Profiling Stash can be profiled using the `--cpuprofile ` command line flag. The resulting file can then be used with pprof as follows: `go tool pprof ` With `graphviz` installed and in the path, a call graph can be generated with: `go tool pprof -svg > ` ================================================ FILE: go.mod ================================================ module github.com/stashapp/stash go 1.24.3 require ( github.com/99designs/gqlgen v0.17.73 github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552 github.com/Yamashou/gqlgenc v0.32.1 github.com/anacrolix/dms v1.2.2 github.com/antchfx/htmlquery v1.3.5 github.com/asticode/go-astisub v0.25.1 github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d github.com/chromedp/chromedp v0.14.2 github.com/corona10/goimagehash v1.1.0 github.com/disintegration/imaging v1.6.2 github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d github.com/doug-martin/goqu/v9 v9.18.0 github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/cors v1.2.1 github.com/go-chi/httplog v0.3.1 github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 github.com/golang-jwt/jwt/v4 v4.5.2 github.com/golang-migrate/migrate/v4 v4.16.2 github.com/google/uuid v1.6.0 github.com/gorilla/securecookie v1.1.1 github.com/gorilla/sessions v1.2.1 github.com/gorilla/websocket v1.5.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/hasura/go-graphql-client v0.13.1 github.com/jinzhu/copier v0.4.0 github.com/jmoiron/sqlx v1.4.0 github.com/json-iterator/go v1.1.12 github.com/kermieisinthehouse/gosx-notifier v0.1.2 github.com/kermieisinthehouse/systray v1.2.4 github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/env v1.1.0 github.com/knadh/koanf/providers/file v1.2.0 github.com/knadh/koanf/providers/posflag v1.0.1 github.com/knadh/koanf/v2 v2.2.1 github.com/lucasb-eyer/go-colorful v1.2.0 github.com/mattn/go-sqlite3 v1.14.22 github.com/mitchellh/mapstructure v1.5.0 github.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/remeh/sizedwaitgroup v1.0.0 github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cast v1.6.0 github.com/spf13/pflag v1.0.6 github.com/stretchr/testify v1.10.0 github.com/tidwall/gjson v1.16.0 github.com/vearutop/statigz v1.4.0 github.com/vektah/dataloaden v0.3.0 github.com/vektah/gqlparser/v2 v2.5.27 github.com/vektra/mockery/v2 v2.10.0 github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e github.com/zencoder/go-dash/v3 v3.0.2 golang.org/x/crypto v0.45.0 golang.org/x/image v0.18.0 golang.org/x/net v0.47.0 golang.org/x/sys v0.38.0 golang.org/x/term v0.37.0 golang.org/x/text v0.31.0 golang.org/x/time v0.10.0 gopkg.in/guregu/null.v4 v4.0.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v2 v2.4.0 ) require ( github.com/agnivade/levenshtein v1.2.1 // indirect github.com/antchfx/xpath v1.3.5 // indirect github.com/asticode/go-astikit v0.20.0 // indirect github.com/asticode/go-astits v1.8.0 // indirect github.com/chromedp/sysutil v1.1.0 // indirect github.com/coder/websocket v1.8.12 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.7.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rs/zerolog v1.30.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sosodev/duration v1.3.1 // indirect github.com/spf13/afero v1.9.5 // indirect github.com/spf13/cobra v1.7.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/viper v1.16.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/urfave/cli/v2 v2.27.6 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.3 // indirect golang.org/x/mod v0.29.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/tools v0.38.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: go.sum ================================================ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM= cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/99designs/gqlgen v0.17.73 h1:A3Ki+rHWqKbAOlg5fxiZBnz6OjW3nwupDHEG15gEsrg= github.com/99designs/gqlgen v0.17.73/go.mod h1:2RyGWjy2k7W9jxrs8MOQthXGkD3L3oGr0jXW3Pu8lGg= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w= github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552 h1:eukVk+mGmbSZppLw8WJGpEUgMC570eb32y7FOsPW4Kc= github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552/go.mod h1:LKbO1i6L1lSlwWx4NHWVECxubHNKFz2YQoEMGXAFVy8= github.com/Yamashou/gqlgenc v0.32.1 h1:EHs9//xQxXlyltkSFXM+fhO2rTXcWNw6FPKRJ6t+iQQ= github.com/Yamashou/gqlgenc v0.32.1/go.mod h1:o5SxKt9d3+oUZ2i0V3CW8lHFyunfLR+KcKHubS4zf5E= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/anacrolix/dms v1.2.2 h1:0mk2/DXNqa5KDDbaLgFPf3oMV6VCGdFNh3d/gt4oafM= github.com/anacrolix/dms v1.2.2/go.mod h1:msPKAoppoNRfrYplJqx63FZ+VipDZ4Xsj3KzIQxyU7k= github.com/anacrolix/envpprof v0.0.0-20180404065416-323002cec2fa/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c= github.com/anacrolix/envpprof v1.0.0/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c= github.com/anacrolix/ffprobe v1.0.0/go.mod h1:BIw+Bjol6CWjm/CRWrVLk2Vy+UYlkgmBZ05vpSYqZPw= github.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo= github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/antchfx/htmlquery v1.3.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0= github.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA= github.com/antchfx/xpath v1.3.5 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ= github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/asticode/go-astikit v0.20.0 h1:+7N+J4E4lWx2QOkRdOf6DafWJMv6O4RRfgClwQokrH8= github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= github.com/asticode/go-astisub v0.25.1 h1:RZMGfZPp7CXOkI6g+zCU7DRLuciGPGup921uKZnMXPI= github.com/asticode/go-astisub v0.25.1/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8= github.com/asticode/go-astits v1.8.0 h1:rf6aiiGn/QhlFjNON1n5plqF3Fs025XLUwiQ0NB6oZg= github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bool64/dev v0.2.28 h1:6ayDfrB/jnNr2iQAZHI+uT3Qi6rErSbJYQs1y8rSrwM= github.com/bool64/dev v0.2.28/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo= github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU= github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM= github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo= github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI= github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo= github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d h1:wi6jN5LVt/ljaBG4ue79Ekzb12QfJ52L9Q98tl8SWhw= github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4= github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM= github.com/doug-martin/goqu/v9 v9.18.0 h1:/6bcuEtAe6nsSMVK/M+fOiXUNfyFF3yYtE07DBPFMYY= github.com/doug-martin/goqu/v9 v9.18.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ= github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httplog v0.3.1 h1:uC3IUWCZagtbCinb3ypFh36SEcgd6StWw2Bu0XSXRtg= github.com/go-chi/httplog v0.3.1/go.mod h1:UoiQQ/MTZH5V6JbNB2FzF0DynTh5okpXxlhsyxoP5m8= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE= github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-migrate/migrate/v4 v4.16.2 h1:8coYbMKUyInrFk1lfGfRovTLAW7PhWp8qQDT2iKfuoA= github.com/golang-migrate/migrate/v4 v4.16.2/go.mod h1:pfcJX4nPHaVdc5nmdCikFBWtm+UBpiZjRNNsyBbp0/o= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= github.com/hashicorp/consul/api v1.12.0/go.mod h1:6pVBMo0ebnYdt2S3H87XhekM/HHrUoTD2XXb/VrZVy0= github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v1.0.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4= github.com/hasura/go-graphql-client v0.13.1 h1:kKbjhxhpwz58usVl+Xvgah/TDha5K2akNTRQdsEHN6U= github.com/hasura/go-graphql-client v0.13.1/go.mod h1:k7FF7h53C+hSNFRG3++DdVZWIuHdCaTbI7siTJ//zGQ= github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo= github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kermieisinthehouse/gosx-notifier v0.1.2 h1:KV0KBeKK2B24kIHY7iK0jgS64Q05f4oB+hUZmsPodxQ= github.com/kermieisinthehouse/gosx-notifier v0.1.2/go.mod h1:xyWT07azFtUOcHl96qMVvKhvKzsMcS7rKTHQyv8WTho= github.com/kermieisinthehouse/systray v1.2.4 h1:pdH5vnl+KKjRrVCRU4g/2W1/0HVzuuJ6WXHlPPHYY6s= github.com/kermieisinthehouse/systray v1.2.4/go.mod h1:axh6C/jNuSyC0QGtidZJURc9h+h41HNoMySoLVrhVR4= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc= github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY= github.com/knadh/koanf/providers/file v1.2.0 h1:hrUJ6Y9YOA49aNu/RSYzOTFlqzXSCpmYIDXI7OJU6+U= github.com/knadh/koanf/providers/file v1.2.0/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= github.com/knadh/koanf/providers/posflag v1.0.1 h1:EnMxHSrPkYCFnKgBUl5KBgrjed8gVFrcXDzaW4l/C6Y= github.com/knadh/koanf/providers/posflag v1.0.1/go.mod h1:3Wn3+YG3f4ljzRyCUgIwH7G0sZ1pMjCOsNBovrbKmAk= github.com/knadh/koanf/v2 v2.2.1 h1:jaleChtw85y3UdBnI0wCqcg1sj1gPoz6D3caGNHtrNE= github.com/knadh/koanf/v2 v2.2.1/go.mod h1:PSFru3ufQgTsI7IF+95rf9s8XA1+aHxKuO/W+dPoHEY= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007 h1:Ohgj9L0EYOgXxkDp+bczlMBiulwmqYzQpvQNUdtt3oc= github.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007/go.mod h1:wKCOWMb6iNlvKiOToY2cNuaovSXvIiv1zDi9QDR7aGQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/remeh/sizedwaitgroup v1.0.0 h1:VNGGFwNo/R5+MJBf6yrsr110p0m4/OX4S3DCy7Kyl5E= github.com/remeh/sizedwaitgroup v1.0.0/go.mod h1:3j2R4OIe/SeS6YDhICBy22RWjJC5eNCJ1V+9+NVNYlo= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc= github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU= github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c= github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= github.com/sagikazarmark/crypt v0.4.0/go.mod h1:ALv2SRj7GxYV4HO9elxH9nS6M9gW+xDNxqmyJ6RfDFM= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/afero v1.8.0/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo= github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.3.0/go.mod h1:BrRVncBjOJa/eUcVVm9CE+oC6as8k+VYr4NY7WCi9V4= github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM= github.com/spf13/viper v1.10.1/go.mod h1:IGlFPqhNAPKRxohIzWpI5QEy4kuI7tcl5WvR+8qy1rU= github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc= github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tidwall/gjson v1.16.0 h1:SyXa+dsSPpUlcwEDuKuEBJEz5vzTvOea+9rjyYodQFg= github.com/tidwall/gjson v1.16.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= github.com/vearutop/statigz v1.4.0 h1:RQL0KG3j/uyA/PFpHeZ/L6l2ta920/MxlOAIGEOuwmU= github.com/vearutop/statigz v1.4.0/go.mod h1:LYTolBLiz9oJISwiVKnOQoIwhO1LWX1A7OECawGS8XE= github.com/vektah/dataloaden v0.3.0 h1:ZfVN2QD6swgvp+tDqdH/OIT/wu3Dhu0cus0k5gIZS84= github.com/vektah/dataloaden v0.3.0/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U= github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/vektra/mockery/v2 v2.10.0 h1:MiiQWxwdq7/ET6dCXLaJzSGEN17k758H7JHS9kOdiks= github.com/vektra/mockery/v2 v2.10.0/go.mod h1:m/WO2UzWzqgVX3nvqpRQq70I4Z7jbSCRhdmkgtp+Ab4= github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e h1:GruPsb+44XvYAzuAgJW1d1WHqmcI73L2XSjsbx/eJZw= github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e/go.mod h1:wA8kQ8WFipMciY9WcWzqQgZordm/P7l8IZdvx1crwmc= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zencoder/go-dash/v3 v3.0.2 h1:oP1+dOh+Gp57PkvdCyMfbHtrHaxfl3w4kR3KBBbuqQE= github.com/zencoder/go-dash/v3 v3.0.2/go.mod h1:30R5bKy1aUYY45yesjtZ9l8trNc2TwNqbS17WVQmCzk= go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190415214537-1da14a5a36f2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190415145633-3fd5a3612ccd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4= google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw= google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU= google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k= google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI= google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU= google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw= google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w= google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= google.golang.org/grpc v1.43.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/guregu/null.v4 v4.0.0 h1:1Wm3S1WEA2I26Kq+6vcW+w0gcDo44YKYD7YIEJNHDjg= gopkg.in/guregu/null.v4 v4.0.0/go.mod h1:YoQhUrADuG3i9WqesrCmpNRwm1ypAgSHYqoOcTu/JrI= gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.66.3/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= ================================================ FILE: gqlgen.yml ================================================ # Refer to https://gqlgen.com/config/ for detailed .gqlgen.yml documentation. schema: - "graphql/schema/types/*.graphql" - "graphql/schema/*.graphql" exec: filename: internal/api/generated_exec.go model: filename: internal/api/generated_models.go struct_tag: gqlgen autobind: - github.com/stashapp/stash/internal/api - github.com/stashapp/stash/pkg/models - github.com/stashapp/stash/pkg/plugin - github.com/stashapp/stash/pkg/scraper - github.com/stashapp/stash/internal/identify - github.com/stashapp/stash/internal/dlna - github.com/stashapp/stash/pkg/stashbox models: # Scalars ID: model: - github.com/99designs/gqlgen/graphql.ID - github.com/99designs/gqlgen/graphql.IntID - github.com/stashapp/stash/pkg/models.FileID - github.com/stashapp/stash/pkg/models.FolderID Int64: model: github.com/99designs/gqlgen/graphql.Int64 Timestamp: model: github.com/stashapp/stash/internal/api.Timestamp BoolMap: model: github.com/stashapp/stash/internal/api.BoolMap PluginConfigMap: model: github.com/stashapp/stash/internal/api.PluginConfigMap File: model: github.com/stashapp/stash/internal/api.File VideoFile: fields: # override float fields - #1572 duration: fieldName: DurationFinite frame_rate: fieldName: FrameRateFinite # movie is group under the hood Movie: model: github.com/stashapp/stash/pkg/models.Group MovieFilterType: model: github.com/stashapp/stash/pkg/models.GroupFilterType # autobind on config causes generation issues BlobsStorageType: model: github.com/stashapp/stash/internal/manager/config.BlobsStorageType StashConfig: model: github.com/stashapp/stash/internal/manager/config.StashConfig StashConfigInput: model: github.com/stashapp/stash/internal/manager/config.StashConfigInput StashBoxInput: model: github.com/stashapp/stash/internal/manager/config.StashBoxInput ConfigImageLightboxResult: model: github.com/stashapp/stash/internal/manager/config.ConfigImageLightboxResult ImageLightboxDisplayMode: model: github.com/stashapp/stash/internal/manager/config.ImageLightboxDisplayMode ImageLightboxScrollMode: model: github.com/stashapp/stash/internal/manager/config.ImageLightboxScrollMode ConfigDisableDropdownCreate: model: github.com/stashapp/stash/internal/manager/config.ConfigDisableDropdownCreate ScanMetadataOptions: model: github.com/stashapp/stash/internal/manager/config.ScanMetadataOptions CleanGeneratedInput: model: github.com/stashapp/stash/internal/manager/task.CleanGeneratedOptions AutoTagMetadataOptions: model: github.com/stashapp/stash/internal/manager/config.AutoTagMetadataOptions SystemStatus: model: github.com/stashapp/stash/internal/manager.SystemStatus SystemStatusEnum: model: github.com/stashapp/stash/internal/manager.SystemStatusEnum ImportDuplicateEnum: model: github.com/stashapp/stash/internal/manager.ImportDuplicateEnum SetupInput: model: github.com/stashapp/stash/internal/manager.SetupInput MigrateInput: model: github.com/stashapp/stash/internal/manager.MigrateInput ScanMetadataInput: model: github.com/stashapp/stash/internal/manager.ScanMetadataInput GenerateMetadataInput: model: github.com/stashapp/stash/internal/manager.GenerateMetadataInput GeneratePreviewOptionsInput: model: github.com/stashapp/stash/internal/manager.GeneratePreviewOptionsInput AutoTagMetadataInput: model: github.com/stashapp/stash/internal/manager.AutoTagMetadataInput CleanMetadataInput: model: github.com/stashapp/stash/internal/manager.CleanMetadataInput StashBoxBatchTagInput: model: github.com/stashapp/stash/internal/manager.StashBoxBatchTagInput SceneStreamEndpoint: model: github.com/stashapp/stash/internal/manager.SceneStreamEndpoint ExportObjectTypeInput: model: github.com/stashapp/stash/internal/manager.ExportObjectTypeInput ExportObjectsInput: model: github.com/stashapp/stash/internal/manager.ExportObjectsInput ImportObjectsInput: model: github.com/stashapp/stash/internal/manager.ImportObjectsInput ScanMetaDataFilterInput: model: github.com/stashapp/stash/internal/manager.ScanMetaDataFilterInput # renamed types BulkUpdateIdMode: model: github.com/stashapp/stash/pkg/models.RelationshipUpdateMode DLNAStatus: model: github.com/stashapp/stash/internal/dlna.Status DLNAIP: model: github.com/stashapp/stash/internal/dlna.Dlnaip IdentifySource: model: github.com/stashapp/stash/internal/identify.Source IdentifyMetadataTaskOptions: model: github.com/stashapp/stash/internal/identify.Options IdentifyMetadataInput: model: github.com/stashapp/stash/internal/identify.Options IdentifyMetadataOptions: model: github.com/stashapp/stash/internal/identify.MetadataOptions IdentifyFieldOptions: model: github.com/stashapp/stash/internal/identify.FieldOptions IdentifyFieldStrategy: model: github.com/stashapp/stash/internal/identify.FieldStrategy ScraperSource: model: github.com/stashapp/stash/pkg/scraper.Source IdentifySourceInput: model: github.com/stashapp/stash/internal/identify.Source IdentifyFieldOptionsInput: model: github.com/stashapp/stash/internal/identify.FieldOptions IdentifyMetadataOptionsInput: model: github.com/stashapp/stash/internal/identify.MetadataOptions ScraperSourceInput: model: github.com/stashapp/stash/pkg/scraper.Source SavedFindFilterType: model: github.com/stashapp/stash/pkg/models.FindFilterType # force resolvers ConfigResult: fields: plugins: resolver: true Performer: fields: career_length: resolver: true ================================================ FILE: graphql/schema/schema.graphql ================================================ "The query root for this schema" type Query { # Filters findSavedFilter(id: ID!): SavedFilter findSavedFilters(mode: FilterMode): [SavedFilter!]! findDefaultFilter(mode: FilterMode!): SavedFilter @deprecated(reason: "default filter now stored in UI config") "Find a file by its id or path" findFile(id: ID, path: String): BaseFile! "Queries for Files" findFiles( file_filter: FileFilterType filter: FindFilterType ids: [ID!] ): FindFilesResultType! "Find a file by its id or path" findFolder(id: ID, path: String): Folder! "Queries for Files" findFolders( folder_filter: FolderFilterType filter: FindFilterType ids: [ID!] ): FindFoldersResultType! "Find a scene by ID or Checksum" findScene(id: ID, checksum: String): Scene findSceneByHash(input: SceneHashInput!): Scene "A function which queries Scene objects" findScenes( scene_filter: SceneFilterType scene_ids: [Int!] @deprecated(reason: "use ids") ids: [ID!] filter: FindFilterType ): FindScenesResultType! findScenesByPathRegex(filter: FindFilterType): FindScenesResultType! """ Returns any groups of scenes that are perceptual duplicates within the queried distance and the difference between their duration is smaller than durationDiff """ findDuplicateScenes( distance: Int """ Max difference in seconds between files in order to be considered for similarity matching. Fractional seconds are ok: 0.5 will mean only files that have durations within 0.5 seconds between them will be matched based on PHash distance. """ duration_diff: Float ): [[Scene!]!]! "Return valid stream paths" sceneStreams(id: ID): [SceneStreamEndpoint!]! parseSceneFilenames( filter: FindFilterType config: SceneParserInput! ): SceneParserResultType! "A function which queries SceneMarker objects" findSceneMarkers( scene_marker_filter: SceneMarkerFilterType filter: FindFilterType ids: [ID!] ): FindSceneMarkersResultType! findImage(id: ID, checksum: String): Image "A function which queries Scene objects" findImages( image_filter: ImageFilterType image_ids: [Int!] @deprecated(reason: "use ids") ids: [ID!] filter: FindFilterType ): FindImagesResultType! "Find a performer by ID" findPerformer(id: ID!): Performer "A function which queries Performer objects" findPerformers( performer_filter: PerformerFilterType filter: FindFilterType performer_ids: [Int!] @deprecated(reason: "use ids") ids: [ID!] ): FindPerformersResultType! "Find a studio by ID" findStudio(id: ID!): Studio "A function which queries Studio objects" findStudios( studio_filter: StudioFilterType filter: FindFilterType ids: [ID!] ): FindStudiosResultType! "Find a movie by ID" findMovie(id: ID!): Movie @deprecated(reason: "Use findGroup instead") "A function which queries Movie objects" findMovies( movie_filter: MovieFilterType filter: FindFilterType ids: [ID!] ): FindMoviesResultType! @deprecated(reason: "Use findGroups instead") "Find a group by ID" findGroup(id: ID!): Group "A function which queries Group objects" findGroups( group_filter: GroupFilterType filter: FindFilterType ids: [ID!] ): FindGroupsResultType! findGallery(id: ID!): Gallery findGalleries( gallery_filter: GalleryFilterType filter: FindFilterType ids: [ID!] ): FindGalleriesResultType! findTag(id: ID!): Tag findTags( tag_filter: TagFilterType filter: FindFilterType ids: [ID!] ): FindTagsResultType! "Retrieve random scene markers for the wall" markerWall(q: String): [SceneMarker!]! "Retrieve random scenes for the wall" sceneWall(q: String): [Scene!]! "Get marker strings" markerStrings(q: String, sort: String): [MarkerStringsResultType]! "Get stats" stats: StatsResultType! "Organize scene markers by tag for a given scene ID" sceneMarkerTags(scene_id: ID!): [SceneMarkerTag!]! logs: [LogEntry!]! # Scrapers "List available scrapers" listScrapers(types: [ScrapeContentType!]!): [Scraper!]! "Scrape for a single scene" scrapeSingleScene( source: ScraperSourceInput! input: ScrapeSingleSceneInput! ): [ScrapedScene!]! "Scrape for multiple scenes" scrapeMultiScenes( source: ScraperSourceInput! input: ScrapeMultiScenesInput! ): [[ScrapedScene!]!]! "Scrape for a single studio" scrapeSingleStudio( source: ScraperSourceInput! input: ScrapeSingleStudioInput! ): [ScrapedStudio!]! "Scrape for a single tag" scrapeSingleTag( source: ScraperSourceInput! input: ScrapeSingleTagInput! ): [ScrapedTag!]! "Scrape for a single performer" scrapeSinglePerformer( source: ScraperSourceInput! input: ScrapeSinglePerformerInput! ): [ScrapedPerformer!]! "Scrape for multiple performers" scrapeMultiPerformers( source: ScraperSourceInput! input: ScrapeMultiPerformersInput! ): [[ScrapedPerformer!]!]! "Scrape for a single gallery" scrapeSingleGallery( source: ScraperSourceInput! input: ScrapeSingleGalleryInput! ): [ScrapedGallery!]! "Scrape for a single movie" scrapeSingleMovie( source: ScraperSourceInput! input: ScrapeSingleMovieInput! ): [ScrapedMovie!]! @deprecated(reason: "Use scrapeSingleGroup instead") "Scrape for a single group" scrapeSingleGroup( source: ScraperSourceInput! input: ScrapeSingleGroupInput! ): [ScrapedGroup!]! "Scrape for a single image" scrapeSingleImage( source: ScraperSourceInput! input: ScrapeSingleImageInput! ): [ScrapedImage!]! "Scrapes content based on a URL" scrapeURL(url: String!, ty: ScrapeContentType!): ScrapedContent "Scrapes a complete performer record based on a URL" scrapePerformerURL(url: String!): ScrapedPerformer "Scrapes a complete scene record based on a URL" scrapeSceneURL(url: String!): ScrapedScene "Scrapes a complete gallery record based on a URL" scrapeGalleryURL(url: String!): ScrapedGallery "Scrapes a complete image record based on a URL" scrapeImageURL(url: String!): ScrapedImage "Scrapes a complete movie record based on a URL" scrapeMovieURL(url: String!): ScrapedMovie @deprecated(reason: "Use scrapeGroupURL instead") "Scrapes a complete group record based on a URL" scrapeGroupURL(url: String!): ScrapedGroup # Plugins "List loaded plugins" plugins: [Plugin!] "List available plugin operations" pluginTasks: [PluginTask!] # Packages "List installed packages" installedPackages(type: PackageType!): [Package!]! "List available packages" availablePackages(type: PackageType!, source: String!): [Package!]! # Config "Returns the current, complete configuration" configuration: ConfigResult! "Returns an array of paths for the given path" directory( "The directory path to list" path: String "Desired collation locale. Determines the order of the directory result. eg. 'en-US', 'pt-BR', ..." locale: String = "en" ): Directory! validateStashBoxCredentials(input: StashBoxInput!): StashBoxValidationResult! # System status systemStatus: SystemStatus! # Job status jobQueue: [Job!] findJob(input: FindJobInput!): Job dlnaStatus: DLNAStatus! # Get everything allScenes: [Scene!]! @deprecated(reason: "Use findScenes instead") allSceneMarkers: [SceneMarker!]! @deprecated(reason: "Use findSceneMarkers instead") allImages: [Image!]! @deprecated(reason: "Use findImages instead") allGalleries: [Gallery!]! @deprecated(reason: "Use findGalleries instead") allPerformers: [Performer!]! allTags: [Tag!]! @deprecated(reason: "Use findTags instead") allStudios: [Studio!]! @deprecated(reason: "Use findStudios instead") allMovies: [Movie!]! @deprecated(reason: "Use findGroups instead") # Get everything with minimal metadata # Version version: Version! # LatestVersion latestversion: LatestVersion! } type Mutation { setup(input: SetupInput!): Boolean! "Migrates the schema to the required version. Returns the job ID" migrate(input: MigrateInput!): ID! "Downloads and installs ffmpeg and ffprobe binaries into the configuration directory. Returns the job ID." downloadFFMpeg: ID! sceneCreate(input: SceneCreateInput!): Scene sceneUpdate(input: SceneUpdateInput!): Scene sceneMerge(input: SceneMergeInput!): Scene bulkSceneUpdate(input: BulkSceneUpdateInput!): [Scene!] sceneDestroy(input: SceneDestroyInput!): Boolean! scenesDestroy(input: ScenesDestroyInput!): Boolean! scenesUpdate(input: [SceneUpdateInput!]!): [Scene] "Increments the o-counter for a scene. Returns the new value" sceneIncrementO(id: ID!): Int! @deprecated(reason: "Use sceneAddO instead") "Decrements the o-counter for a scene. Returns the new value" sceneDecrementO(id: ID!): Int! @deprecated(reason: "Use sceneRemoveO instead") "Increments the o-counter for a scene. Uses the current time if none provided." sceneAddO(id: ID!, times: [Timestamp!]): HistoryMutationResult! "Decrements the o-counter for a scene, removing the last recorded time if specific time not provided. Returns the new value" sceneDeleteO(id: ID!, times: [Timestamp!]): HistoryMutationResult! "Resets the o-counter for a scene to 0. Returns the new value" sceneResetO(id: ID!): Int! "Sets the resume time point (if provided) and adds the provided duration to the scene's play duration" sceneSaveActivity(id: ID!, resume_time: Float, playDuration: Float): Boolean! "Resets the resume time point and play duration" sceneResetActivity( id: ID! reset_resume: Boolean reset_duration: Boolean ): Boolean! "Increments the play count for the scene. Returns the new play count value." sceneIncrementPlayCount(id: ID!): Int! @deprecated(reason: "Use sceneAddPlay instead") "Increments the play count for the scene. Uses the current time if none provided." sceneAddPlay(id: ID!, times: [Timestamp!]): HistoryMutationResult! "Decrements the play count for the scene, removing the specific times or the last recorded time if not provided." sceneDeletePlay(id: ID!, times: [Timestamp!]): HistoryMutationResult! "Resets the play count for a scene to 0. Returns the new play count value." sceneResetPlayCount(id: ID!): Int! "Generates screenshot at specified time in seconds. Leave empty to generate default screenshot" sceneGenerateScreenshot(id: ID!, at: Float): String! sceneMarkerCreate(input: SceneMarkerCreateInput!): SceneMarker sceneMarkerUpdate(input: SceneMarkerUpdateInput!): SceneMarker bulkSceneMarkerUpdate(input: BulkSceneMarkerUpdateInput!): [SceneMarker!] sceneMarkerDestroy(id: ID!): Boolean! sceneMarkersDestroy(ids: [ID!]!): Boolean! sceneAssignFile(input: AssignSceneFileInput!): Boolean! imageUpdate(input: ImageUpdateInput!): Image bulkImageUpdate(input: BulkImageUpdateInput!): [Image!] imageDestroy(input: ImageDestroyInput!): Boolean! imagesDestroy(input: ImagesDestroyInput!): Boolean! imagesUpdate(input: [ImageUpdateInput!]!): [Image] "Increments the o-counter for an image. Returns the new value" imageIncrementO(id: ID!): Int! "Decrements the o-counter for an image. Returns the new value" imageDecrementO(id: ID!): Int! "Resets the o-counter for a image to 0. Returns the new value" imageResetO(id: ID!): Int! galleryCreate(input: GalleryCreateInput!): Gallery galleryUpdate(input: GalleryUpdateInput!): Gallery bulkGalleryUpdate(input: BulkGalleryUpdateInput!): [Gallery!] galleryDestroy(input: GalleryDestroyInput!): Boolean! galleriesUpdate(input: [GalleryUpdateInput!]!): [Gallery] addGalleryImages(input: GalleryAddInput!): Boolean! removeGalleryImages(input: GalleryRemoveInput!): Boolean! setGalleryCover(input: GallerySetCoverInput!): Boolean! resetGalleryCover(input: GalleryResetCoverInput!): Boolean! galleryChapterCreate(input: GalleryChapterCreateInput!): GalleryChapter galleryChapterUpdate(input: GalleryChapterUpdateInput!): GalleryChapter galleryChapterDestroy(id: ID!): Boolean! performerCreate(input: PerformerCreateInput!): Performer performerUpdate(input: PerformerUpdateInput!): Performer performerDestroy(input: PerformerDestroyInput!): Boolean! performersDestroy(ids: [ID!]!): Boolean! bulkPerformerUpdate(input: BulkPerformerUpdateInput!): [Performer!] performerMerge(input: PerformerMergeInput!): Performer! studioCreate(input: StudioCreateInput!): Studio studioUpdate(input: StudioUpdateInput!): Studio studioDestroy(input: StudioDestroyInput!): Boolean! studiosDestroy(ids: [ID!]!): Boolean! bulkStudioUpdate(input: BulkStudioUpdateInput!): [Studio!] movieCreate(input: MovieCreateInput!): Movie @deprecated(reason: "Use groupCreate instead") movieUpdate(input: MovieUpdateInput!): Movie @deprecated(reason: "Use groupUpdate instead") movieDestroy(input: MovieDestroyInput!): Boolean! @deprecated(reason: "Use groupDestroy instead") moviesDestroy(ids: [ID!]!): Boolean! @deprecated(reason: "Use groupsDestroy instead") bulkMovieUpdate(input: BulkMovieUpdateInput!): [Movie!] @deprecated(reason: "Use bulkGroupUpdate instead") groupCreate(input: GroupCreateInput!): Group groupUpdate(input: GroupUpdateInput!): Group groupDestroy(input: GroupDestroyInput!): Boolean! groupsDestroy(ids: [ID!]!): Boolean! bulkGroupUpdate(input: BulkGroupUpdateInput!): [Group!] addGroupSubGroups(input: GroupSubGroupAddInput!): Boolean! removeGroupSubGroups(input: GroupSubGroupRemoveInput!): Boolean! "Reorder sub groups within a group. Returns true if successful." reorderSubGroups(input: ReorderSubGroupsInput!): Boolean! tagCreate(input: TagCreateInput!): Tag tagUpdate(input: TagUpdateInput!): Tag tagDestroy(input: TagDestroyInput!): Boolean! tagsDestroy(ids: [ID!]!): Boolean! tagsMerge(input: TagsMergeInput!): Tag bulkTagUpdate(input: BulkTagUpdateInput!): [Tag!] """ Moves the given files to the given destination. Returns true if successful. Either the destination_folder or destination_folder_id must be provided. If both are provided, the destination_folder_id takes precedence. Destination folder must be a subfolder of one of the stash library paths. If provided, destination_basename must be a valid filename with an extension that matches one of the media extensions. Creates folder hierarchy if needed. """ moveFiles(input: MoveFilesInput!): Boolean! deleteFiles(ids: [ID!]!): Boolean! "Deletes file entries from the database without deleting the files from the filesystem" destroyFiles(ids: [ID!]!): Boolean! fileSetFingerprints(input: FileSetFingerprintsInput!): Boolean! "Reveal the file in the system file manager" revealFileInFileManager(id: ID!): Boolean! "Reveal the folder in the system file manager" revealFolderInFileManager(id: ID!): Boolean! # Saved filters saveFilter(input: SaveFilterInput!): SavedFilter! destroySavedFilter(input: DestroyFilterInput!): Boolean! setDefaultFilter(input: SetDefaultFilterInput!): Boolean! @deprecated(reason: "now uses UI config") "Change general configuration options" configureGeneral(input: ConfigGeneralInput!): ConfigGeneralResult! configureInterface(input: ConfigInterfaceInput!): ConfigInterfaceResult! configureDLNA(input: ConfigDLNAInput!): ConfigDLNAResult! configureScraping(input: ConfigScrapingInput!): ConfigScrapingResult! configureDefaults( input: ConfigDefaultSettingsInput! ): ConfigDefaultSettingsResult! "overwrites the entire plugin configuration for the given plugin" configurePlugin(plugin_id: ID!, input: Map!): Map! """ overwrites the UI configuration if input is provided, then the entire UI configuration is replaced if partial is provided, then the partial UI configuration is merged into the existing UI configuration """ configureUI(input: Map, partial: Map): Map! """ sets a single UI key value key is a dot separated path to the value """ configureUISetting(key: String!, value: Any): Map! "Generate and set (or clear) API key" generateAPIKey(input: GenerateAPIKeyInput!): String! "Returns a link to download the result" exportObjects(input: ExportObjectsInput!): String "Performs an incremental import. Returns the job ID" importObjects(input: ImportObjectsInput!): ID! "Start an full import. Completely wipes the database and imports from the metadata directory. Returns the job ID" metadataImport: ID! "Start a full export. Outputs to the metadata directory. Returns the job ID" metadataExport: ID! "Start a scan. Returns the job ID" metadataScan(input: ScanMetadataInput!): ID! "Start generating content. Returns the job ID" metadataGenerate(input: GenerateMetadataInput!): ID! "Start auto-tagging. Returns the job ID" metadataAutoTag(input: AutoTagMetadataInput!): ID! "Clean metadata. Returns the job ID" metadataClean(input: CleanMetadataInput!): ID! "Clean generated files. Returns the job ID" metadataCleanGenerated(input: CleanGeneratedInput!): ID! "Identifies scenes using scrapers. Returns the job ID" metadataIdentify(input: IdentifyMetadataInput!): ID! "Migrate generated files for the current hash naming" migrateHashNaming: ID! "Migrates legacy scene screenshot files into the blob storage" migrateSceneScreenshots(input: MigrateSceneScreenshotsInput!): ID! "Migrates blobs from the old storage system to the current one" migrateBlobs(input: MigrateBlobsInput!): ID! "Anonymise the database in a separate file. Optionally returns a link to download the database file" anonymiseDatabase(input: AnonymiseDatabaseInput!): String "Optimises the database. Returns the job ID" optimiseDatabase: ID! "Reload scrapers" reloadScrapers: Boolean! """ Enable/disable plugins - enabledMap is a map of plugin IDs to enabled booleans. Plugins not in the map are not affected. """ setPluginsEnabled(enabledMap: BoolMap!): Boolean! """ Run a plugin task. If task_name is provided, then the task must exist in the plugin config and the tasks configuration will be used to run the plugin. If no task_name is provided, then the plugin will be executed with the arguments provided only. Returns the job ID """ runPluginTask( plugin_id: ID! "if provided, then the default args will be applied" task_name: String "displayed in the task queue" description: String args: [PluginArgInput!] @deprecated(reason: "Use args_map instead") args_map: Map ): ID! """ Runs a plugin operation. The operation is run immediately and does not use the job queue. Returns a map of the result. """ runPluginOperation(plugin_id: ID!, args: Map): Any reloadPlugins: Boolean! """ Installs the given packages. If a package is already installed, it will be updated if needed.. If an error occurs when installing a package, the job will continue to install the remaining packages. Returns the job ID """ installPackages(type: PackageType!, packages: [PackageSpecInput!]!): ID! """ Updates the given packages. If a package is not installed, it will not be installed. If a package does not need to be updated, it will not be updated. If no packages are provided, all packages of the given type will be updated. If an error occurs when updating a package, the job will continue to update the remaining packages. Returns the job ID. """ updatePackages(type: PackageType!, packages: [PackageSpecInput!]): ID! """ Uninstalls the given packages. If an error occurs when uninstalling a package, the job will continue to uninstall the remaining packages. Returns the job ID """ uninstallPackages(type: PackageType!, packages: [PackageSpecInput!]!): ID! stopJob(job_id: ID!): Boolean! stopAllJobs: Boolean! "Submit fingerprints to stash-box instance" submitStashBoxFingerprints( input: StashBoxFingerprintSubmissionInput! ): Boolean! "Submit scene as draft to stash-box instance" submitStashBoxSceneDraft(input: StashBoxDraftSubmissionInput!): ID "Submit performer as draft to stash-box instance" submitStashBoxPerformerDraft(input: StashBoxDraftSubmissionInput!): ID "Backup the database. Optionally returns a link to download the database file" backupDatabase(input: BackupDatabaseInput!): String "DANGEROUS: Execute an arbitrary SQL statement that returns rows." querySQL(sql: String!, args: [Any]): SQLQueryResult! "DANGEROUS: Execute an arbitrary SQL statement without returning any rows." execSQL(sql: String!, args: [Any]): SQLExecResult! "Run batch performer tag task. Returns the job ID." stashBoxBatchPerformerTag(input: StashBoxBatchTagInput!): String! "Run batch studio tag task. Returns the job ID." stashBoxBatchStudioTag(input: StashBoxBatchTagInput!): String! "Run batch tag tag task. Returns the job ID." stashBoxBatchTagTag(input: StashBoxBatchTagInput!): String! "Enables DLNA for an optional duration. Has no effect if DLNA is enabled by default" enableDLNA(input: EnableDLNAInput!): Boolean! "Disables DLNA for an optional duration. Has no effect if DLNA is disabled by default" disableDLNA(input: DisableDLNAInput!): Boolean! "Enables an IP address for DLNA for an optional duration" addTempDLNAIP(input: AddTempDLNAIPInput!): Boolean! "Removes an IP address from the temporary DLNA whitelist" removeTempDLNAIP(input: RemoveTempDLNAIPInput!): Boolean! } type Subscription { "Update from the metadata manager" jobsSubscribe: JobStatusUpdate! loggingSubscribe: [LogEntry!]! scanCompleteSubscribe: Boolean! } schema { query: Query mutation: Mutation subscription: Subscription } ================================================ FILE: graphql/schema/types/config.graphql ================================================ input SetupInput { "Empty to indicate $HOME/.stash/config.yml default" configLocation: String! stashes: [StashConfigInput!]! "True if SFW content mode is enabled" sfwContentMode: Boolean "Empty to indicate default" databaseFile: String! "Empty to indicate default" generatedLocation: String! "Empty to indicate default" cacheLocation: String! storeBlobsInDatabase: Boolean! "Empty to indicate default - only applicable if storeBlobsInDatabase is false" blobsLocation: String! } enum StreamingResolutionEnum { "240p" LOW "480p" STANDARD "720p" STANDARD_HD "1080p" FULL_HD "4k" FOUR_K "Original" ORIGINAL } enum PreviewPreset { "X264_ULTRAFAST" ultrafast "X264_VERYFAST" veryfast "X264_FAST" fast "X264_MEDIUM" medium "X264_SLOW" slow "X264_SLOWER" slower "X264_VERYSLOW" veryslow } enum HashAlgorithm { MD5 "oshash" OSHASH } enum BlobsStorageType { # blobs are stored in the database "Database" DATABASE # blobs are stored in the filesystem under the configured blobs directory "Filesystem" FILESYSTEM } input ConfigGeneralInput { "Array of file paths to content" stashes: [StashConfigInput!] "Path to the SQLite database" databasePath: String "Path to backup directory" backupDirectoryPath: String "Path to trash directory - if set, deleted files will be moved here instead of being permanently deleted" deleteTrashPath: String "Path to generated files" generatedPath: String "Path to import/export files" metadataPath: String "Path to scrapers" scrapersPath: String "Path to plugins" pluginsPath: String "Path to cache" cachePath: String "Path to blobs - required for filesystem blob storage" blobsPath: String "Where to store blobs" blobsStorage: BlobsStorageType "Path to the ffmpeg binary. If empty, stash will attempt to find it in the path or config directory" ffmpegPath: String "Path to the ffprobe binary. If empty, stash will attempt to find it in the path or config directory" ffprobePath: String "Whether to calculate MD5 checksums for scene video files" calculateMD5: Boolean "Hash algorithm to use for generated file naming" videoFileNamingAlgorithm: HashAlgorithm "Number of parallel tasks to start during scan/generate" parallelTasks: Int "Include audio stream in previews" previewAudio: Boolean "Number of segments in a preview file" previewSegments: Int "Preview segment duration, in seconds" previewSegmentDuration: Float "Duration of start of video to exclude when generating previews" previewExcludeStart: String "Duration of end of video to exclude when generating previews" previewExcludeEnd: String "Preset when generating preview" previewPreset: PreviewPreset "Transcode Hardware Acceleration" transcodeHardwareAcceleration: Boolean "Max generated transcode size" maxTranscodeSize: StreamingResolutionEnum "Max streaming transcode size" maxStreamingTranscodeSize: StreamingResolutionEnum """ ffmpeg transcode input args - injected before input file These are applied to generated transcodes (previews and transcodes) """ transcodeInputArgs: [String!] """ ffmpeg transcode output args - injected before output file These are applied to generated transcodes (previews and transcodes) """ transcodeOutputArgs: [String!] """ ffmpeg stream input args - injected before input file These are applied when live transcoding """ liveTranscodeInputArgs: [String!] """ ffmpeg stream output args - injected before output file These are applied when live transcoding """ liveTranscodeOutputArgs: [String!] "whether to include range in generated funscript heatmaps" drawFunscriptHeatmapRange: Boolean "Write image thumbnails to disk when generating on the fly" writeImageThumbnails: Boolean "Create Image Clips from Video extensions when Videos are disabled in Library" createImageClipsFromVideos: Boolean "Username" username: String "Password" password: String "Maximum session cookie age" maxSessionAge: Int "Name of the log file" logFile: String "Whether to also output to stderr" logOut: Boolean "Minimum log level" logLevel: String "Whether to log http access" logAccess: Boolean "Maximum log size" logFileMaxSize: Int "True if galleries should be created from folders with images" createGalleriesFromFolders: Boolean "Regex used to identify images as gallery covers" galleryCoverRegex: String "Array of video file extensions" videoExtensions: [String!] "Array of image file extensions" imageExtensions: [String!] "Array of gallery zip file extensions" galleryExtensions: [String!] "Array of file regexp to exclude from Video Scans" excludes: [String!] "Array of file regexp to exclude from Image Scans" imageExcludes: [String!] "Custom Performer Image Location" customPerformerImageLocation: String "Stash-box instances used for tagging" stashBoxes: [StashBoxInput!] "Python path - resolved using path if unset" pythonPath: String "Source of scraper packages" scraperPackageSources: [PackageSourceInput!] "Source of plugin packages" pluginPackageSources: [PackageSourceInput!] "Size of the longest dimension for each sprite in pixels" spriteScreenshotSize: Int "True if sprite generation should use the sprite interval and min/max sprites settings instead of the default" useCustomSpriteInterval: Boolean "Time between two different scrubber sprites in seconds - only used if useCustomSpriteInterval is true" spriteInterval: Float "Minimum number of sprites to be generated - only used if useCustomSpriteInterval is true" minimumSprites: Int "Minimum number of sprites to be generated - only used if useCustomSpriteInterval is true" maximumSprites: Int } type ConfigGeneralResult { "Array of file paths to content" stashes: [StashConfig!]! "Path to the SQLite database" databasePath: String! "Path to backup directory" backupDirectoryPath: String! "Path to trash directory - if set, deleted files will be moved here instead of being permanently deleted" deleteTrashPath: String! "Path to generated files" generatedPath: String! "Path to import/export files" metadataPath: String! "Path to the config file used" configFilePath: String! "Path to scrapers" scrapersPath: String! "Path to plugins" pluginsPath: String! "Path to cache" cachePath: String! "Path to blobs - required for filesystem blob storage" blobsPath: String! "Where to store blobs" blobsStorage: BlobsStorageType! "Path to the ffmpeg binary. If empty, stash will attempt to find it in the path or config directory" ffmpegPath: String! "Path to the ffprobe binary. If empty, stash will attempt to find it in the path or config directory" ffprobePath: String! "Whether to calculate MD5 checksums for scene video files" calculateMD5: Boolean! "Hash algorithm to use for generated file naming" videoFileNamingAlgorithm: HashAlgorithm! "Number of parallel tasks to start during scan/generate" parallelTasks: Int! "Include audio stream in previews" previewAudio: Boolean! "Number of segments in a preview file" previewSegments: Int! "Preview segment duration, in seconds" previewSegmentDuration: Float! "Duration of start of video to exclude when generating previews" previewExcludeStart: String! "Duration of end of video to exclude when generating previews" previewExcludeEnd: String! "Preset when generating preview" previewPreset: PreviewPreset! "Transcode Hardware Acceleration" transcodeHardwareAcceleration: Boolean! "Max generated transcode size" maxTranscodeSize: StreamingResolutionEnum "Max streaming transcode size" maxStreamingTranscodeSize: StreamingResolutionEnum """ ffmpeg transcode input args - injected before input file These are applied to generated transcodes (previews and transcodes) """ transcodeInputArgs: [String!]! """ ffmpeg transcode output args - injected before output file These are applied to generated transcodes (previews and transcodes) """ transcodeOutputArgs: [String!]! """ ffmpeg stream input args - injected before input file These are applied when live transcoding """ liveTranscodeInputArgs: [String!]! """ ffmpeg stream output args - injected before output file These are applied when live transcoding """ liveTranscodeOutputArgs: [String!]! "whether to include range in generated funscript heatmaps" drawFunscriptHeatmapRange: Boolean! "Write image thumbnails to disk when generating on the fly" writeImageThumbnails: Boolean! "Create Image Clips from Video extensions when Videos are disabled in Library" createImageClipsFromVideos: Boolean! "API Key" apiKey: String! "Username" username: String! "Password" password: String! "Maximum session cookie age" maxSessionAge: Int! "Name of the log file" logFile: String "Whether to also output to stderr" logOut: Boolean! "Minimum log level" logLevel: String! "Whether to log http access" logAccess: Boolean! "Maximum log size" logFileMaxSize: Int! "True if sprite generation should use the sprite interval and min/max sprites settings instead of the default" useCustomSpriteInterval: Boolean! "Time between two different scrubber sprites in seconds - only used if useCustomSpriteInterval is true" spriteInterval: Float! "Minimum number of sprites to be generated - only used if useCustomSpriteInterval is true" minimumSprites: Int! "Maximum number of sprites to be generated - only used if useCustomSpriteInterval is true" maximumSprites: Int! "Size of the longest dimension for each sprite in pixels" spriteScreenshotSize: Int! "Array of video file extensions" videoExtensions: [String!]! "Array of image file extensions" imageExtensions: [String!]! "Array of gallery zip file extensions" galleryExtensions: [String!]! "True if galleries should be created from folders with images" createGalleriesFromFolders: Boolean! "Regex used to identify images as gallery covers" galleryCoverRegex: String! "Array of file regexp to exclude from Video Scans" excludes: [String!]! "Array of file regexp to exclude from Image Scans" imageExcludes: [String!]! "Custom Performer Image Location" customPerformerImageLocation: String "Stash-box instances used for tagging" stashBoxes: [StashBox!]! "Python path - resolved using path if unset" pythonPath: String! "Source of scraper packages" scraperPackageSources: [PackageSource!]! "Source of plugin packages" pluginPackageSources: [PackageSource!]! } input ConfigDisableDropdownCreateInput { performer: Boolean tag: Boolean studio: Boolean movie: Boolean gallery: Boolean } enum ImageLightboxDisplayMode { ORIGINAL FIT_XY FIT_X } enum ImageLightboxScrollMode { ZOOM PAN_Y } input ConfigImageLightboxInput { slideshowDelay: Int displayMode: ImageLightboxDisplayMode scaleUp: Boolean resetZoomOnNav: Boolean scrollMode: ImageLightboxScrollMode scrollAttemptsBeforeChange: Int disableAnimation: Boolean } type ConfigImageLightboxResult { slideshowDelay: Int displayMode: ImageLightboxDisplayMode scaleUp: Boolean resetZoomOnNav: Boolean scrollMode: ImageLightboxScrollMode scrollAttemptsBeforeChange: Int! disableAnimation: Boolean } input ConfigInterfaceInput { "True if SFW content mode is enabled" sfwContentMode: Boolean "Ordered list of items that should be shown in the menu" menuItems: [String!] "Enable sound on mouseover previews" soundOnPreview: Boolean "Show title and tags in wall view" wallShowTitle: Boolean "Wall playback type" wallPlayback: String "Show scene scrubber by default" showScrubber: Boolean "Maximum duration (in seconds) in which a scene video will loop in the scene player" maximumLoopDuration: Int "If true, video will autostart on load in the scene player" autostartVideo: Boolean "If true, video will autostart when loading from play random or play selected" autostartVideoOnPlaySelected: Boolean "If true, next scene in playlist will be played at video end by default" continuePlaylistDefault: Boolean "If true, studio overlays will be shown as text instead of logo images" showStudioAsText: Boolean "Custom CSS" css: String cssEnabled: Boolean "Custom Javascript" javascript: String javascriptEnabled: Boolean "Custom Locales" customLocales: String customLocalesEnabled: Boolean "When true, disables all customizations (plugins, CSS, JavaScript, locales) for troubleshooting" disableCustomizations: Boolean "Interface language" language: String imageLightbox: ConfigImageLightboxInput "Set to true to disable creating new objects via the dropdown menus" disableDropdownCreate: ConfigDisableDropdownCreateInput "Handy Connection Key" handyKey: String "Funscript Time Offset" funscriptOffset: Int "Whether to use Stash Hosted Funscript" useStashHostedFunscript: Boolean "True if we should not auto-open a browser window on startup" noBrowser: Boolean "True if we should send notifications to the desktop" notificationsEnabled: Boolean } type ConfigDisableDropdownCreate { performer: Boolean! tag: Boolean! studio: Boolean! movie: Boolean! gallery: Boolean! } type ConfigInterfaceResult { "True if SFW content mode is enabled" sfwContentMode: Boolean! "Ordered list of items that should be shown in the menu" menuItems: [String!] "Enable sound on mouseover previews" soundOnPreview: Boolean "Show title and tags in wall view" wallShowTitle: Boolean "Wall playback type" wallPlayback: String "Show scene scrubber by default" showScrubber: Boolean "Maximum duration (in seconds) in which a scene video will loop in the scene player" maximumLoopDuration: Int "True if we should not auto-open a browser window on startup" noBrowser: Boolean "True if we should send desktop notifications" notificationsEnabled: Boolean "If true, video will autostart on load in the scene player" autostartVideo: Boolean "If true, video will autostart when loading from play random or play selected" autostartVideoOnPlaySelected: Boolean "If true, next scene in playlist will be played at video end by default" continuePlaylistDefault: Boolean "If true, studio overlays will be shown as text instead of logo images" showStudioAsText: Boolean "Custom CSS" css: String cssEnabled: Boolean "Custom Javascript" javascript: String javascriptEnabled: Boolean "Custom Locales" customLocales: String customLocalesEnabled: Boolean "When true, disables all customizations (plugins, CSS, JavaScript, locales) for troubleshooting" disableCustomizations: Boolean "Interface language" language: String imageLightbox: ConfigImageLightboxResult! "Fields are true if creating via dropdown menus are disabled" disableDropdownCreate: ConfigDisableDropdownCreate! "Handy Connection Key" handyKey: String "Funscript Time Offset" funscriptOffset: Int "Whether to use Stash Hosted Funscript" useStashHostedFunscript: Boolean } input ConfigDLNAInput { serverName: String "True if DLNA service should be enabled by default" enabled: Boolean "Defaults to 1338" port: Int "List of IPs whitelisted for DLNA service" whitelistedIPs: [String!] "List of interfaces to run DLNA on. Empty for all" interfaces: [String!] "Order to sort videos" videoSortOrder: String } type ConfigDLNAResult { serverName: String! "True if DLNA service should be enabled by default" enabled: Boolean! "Defaults to 1338" port: Int! "List of IPs whitelisted for DLNA service" whitelistedIPs: [String!]! "List of interfaces to run DLNA on. Empty for all" interfaces: [String!]! "Order to sort videos" videoSortOrder: String! } input ConfigScrapingInput { "Scraper user agent string" scraperUserAgent: String "Scraper CDP path. Path to chrome executable or remote address" scraperCDPPath: String "Whether the scraper should check for invalid certificates" scraperCertCheck: Boolean "Tags blacklist during scraping" excludeTagPatterns: [String!] } type ConfigScrapingResult { "Scraper user agent string" scraperUserAgent: String "Scraper CDP path. Path to chrome executable or remote address" scraperCDPPath: String "Whether the scraper should check for invalid certificates" scraperCertCheck: Boolean! "Tags blacklist during scraping" excludeTagPatterns: [String!]! } type ConfigDefaultSettingsResult { scan: ScanMetadataOptions identify: IdentifyMetadataTaskOptions autoTag: AutoTagMetadataOptions generate: GenerateMetadataOptions "If true, delete file checkbox will be checked by default" deleteFile: Boolean "If true, delete generated supporting files checkbox will be checked by default" deleteGenerated: Boolean } input ConfigDefaultSettingsInput { scan: ScanMetadataInput identify: IdentifyMetadataInput autoTag: AutoTagMetadataInput generate: GenerateMetadataInput "If true, delete file checkbox will be checked by default" deleteFile: Boolean "If true, delete generated files checkbox will be checked by default" deleteGenerated: Boolean } "All configuration settings" type ConfigResult { general: ConfigGeneralResult! interface: ConfigInterfaceResult! dlna: ConfigDLNAResult! scraping: ConfigScrapingResult! defaults: ConfigDefaultSettingsResult! ui: Map! plugins(include: [ID!]): PluginConfigMap! } "Directory structure of a path" type Directory { path: String! parent: String directories: [String!]! } "Stash configuration details" input StashConfigInput { path: String! excludeVideo: Boolean! excludeImage: Boolean! } type StashConfig { path: String! excludeVideo: Boolean! excludeImage: Boolean! } input GenerateAPIKeyInput { clear: Boolean } type StashBoxValidationResult { valid: Boolean! status: String! } ================================================ FILE: graphql/schema/types/dlna.graphql ================================================ type DLNAIP { ipAddress: String! "Time until IP will be no longer allowed/disallowed" until: Time } type DLNAStatus { running: Boolean! "If not currently running, time until it will be started. If running, time until it will be stopped" until: Time recentIPAddresses: [String!]! allowedIPAddresses: [DLNAIP!]! } input EnableDLNAInput { "Duration to enable, in minutes. 0 or null for indefinite." duration: Int } input DisableDLNAInput { "Duration to enable, in minutes. 0 or null for indefinite." duration: Int } input AddTempDLNAIPInput { address: String! "Duration to enable, in minutes. 0 or null for indefinite." duration: Int } input RemoveTempDLNAIPInput { address: String! } ================================================ FILE: graphql/schema/types/file.graphql ================================================ type Fingerprint { type: String! value: String! } type Folder { id: ID! path: String! basename: String! parent_folder_id: ID @deprecated(reason: "Use parent_folder instead") zip_file_id: ID @deprecated(reason: "Use zip_file instead") parent_folder: Folder "Returns all parent folders in order from immediate parent to top-level" parent_folders: [Folder!]! zip_file: BasicFile mod_time: Time! created_at: Time! updated_at: Time! } interface BaseFile { id: ID! path: String! basename: String! parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead") zip_file_id: ID @deprecated(reason: "Use zip_file instead") parent_folder: Folder! zip_file: BasicFile mod_time: Time! size: Int64! fingerprint(type: String!): String fingerprints: [Fingerprint!]! created_at: Time! updated_at: Time! } type BasicFile implements BaseFile { id: ID! path: String! basename: String! parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead") zip_file_id: ID @deprecated(reason: "Use zip_file instead") parent_folder: Folder! zip_file: BasicFile mod_time: Time! size: Int64! fingerprint(type: String!): String fingerprints: [Fingerprint!]! created_at: Time! updated_at: Time! } type VideoFile implements BaseFile { id: ID! path: String! basename: String! parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead") zip_file_id: ID @deprecated(reason: "Use zip_file instead") parent_folder: Folder! zip_file: BasicFile mod_time: Time! size: Int64! fingerprint(type: String!): String fingerprints: [Fingerprint!]! format: String! width: Int! height: Int! duration: Float! video_codec: String! audio_codec: String! frame_rate: Float! bit_rate: Int! created_at: Time! updated_at: Time! } type ImageFile implements BaseFile { id: ID! path: String! basename: String! parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead") zip_file_id: ID @deprecated(reason: "Use zip_file instead") parent_folder: Folder! zip_file: BasicFile mod_time: Time! size: Int64! fingerprint(type: String!): String fingerprints: [Fingerprint!]! format: String! width: Int! height: Int! created_at: Time! updated_at: Time! } union VisualFile = VideoFile | ImageFile type GalleryFile implements BaseFile { id: ID! path: String! basename: String! parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead") zip_file_id: ID @deprecated(reason: "Use zip_file instead") parent_folder: Folder! zip_file: BasicFile mod_time: Time! size: Int64! fingerprint(type: String!): String fingerprints: [Fingerprint!]! created_at: Time! updated_at: Time! } input MoveFilesInput { ids: [ID!]! "valid for single or multiple file ids" destination_folder: String "valid for single or multiple file ids" destination_folder_id: ID "valid only for single file id. If empty, existing basename is used" destination_basename: String } input SetFingerprintsInput { type: String! "a null value will remove the fingerprint" value: String } input FileSetFingerprintsInput { id: ID! "only supplied fingerprint types will be modified" fingerprints: [SetFingerprintsInput!]! } type FindFilesResultType { count: Int! "Total megapixels of any image files" megapixels: Float! "Total duration in seconds of any video files" duration: Float! "Total file size in bytes" size: Int! files: [BaseFile!]! } type FindFoldersResultType { count: Int! folders: [Folder!]! } ================================================ FILE: graphql/schema/types/filters.graphql ================================================ enum SortDirectionEnum { ASC DESC } input FindFilterType { q: String page: Int "use per_page = -1 to indicate all results. Defaults to 25." per_page: Int # TODO - this should be refactored to not use a string sort: String direction: SortDirectionEnum } type SavedFindFilterType { q: String page: Int """ use per_page = -1 to indicate all results. Defaults to 25. """ per_page: Int sort: String direction: SortDirectionEnum } enum ResolutionEnum { "144p" VERY_LOW "240p" LOW "360p" R360P "480p" STANDARD "540p" WEB_HD "720p" STANDARD_HD "1080p" FULL_HD "1440p" QUAD_HD "1920p" VR_HD @deprecated(reason: "Use 4K instead") "4K" FOUR_K "5K" FIVE_K "6K" SIX_K "7K" SEVEN_K "8K" EIGHT_K "8K+" HUGE } input ResolutionCriterionInput { value: ResolutionEnum! modifier: CriterionModifier! } enum OrientationEnum { "Landscape" LANDSCAPE "Portrait" PORTRAIT "Square" SQUARE } input OrientationCriterionInput { value: [OrientationEnum!]! } input DuplicationCriterionInput { duplicated: Boolean @deprecated(reason: "Use phash field instead") "Currently unimplemented. Intended for phash distance matching." distance: Int "Filter by phash duplication" phash: Boolean "Filter by URL duplication" url: Boolean "Filter by Stash ID duplication" stash_id: Boolean "Filter by title duplication" title: Boolean } input FileDuplicationCriterionInput { duplicated: Boolean @deprecated(reason: "Use phash field instead") "Currently unimplemented. Intended for phash distance matching." distance: Int "Filter by phash duplication" phash: Boolean } input StashIDCriterionInput { """ If present, this value is treated as a predicate. That is, it will filter based on stash_id with the matching endpoint """ endpoint: String stash_id: String modifier: CriterionModifier! } input StashIDsCriterionInput { """ If present, this value is treated as a predicate. That is, it will filter based on stash_ids with the matching endpoint """ endpoint: String stash_ids: [String] modifier: CriterionModifier! } input CustomFieldCriterionInput { field: String! value: [Any!] modifier: CriterionModifier! } input PerformerFilterType { AND: PerformerFilterType OR: PerformerFilterType NOT: PerformerFilterType name: StringCriterionInput disambiguation: StringCriterionInput details: StringCriterionInput "Filter by favorite" filter_favorites: Boolean "Filter by birth year" birth_year: IntCriterionInput "Filter by age" age: IntCriterionInput "Filter by ethnicity" ethnicity: StringCriterionInput "Filter by country" country: StringCriterionInput "Filter by eye color" eye_color: StringCriterionInput "Filter by height in cm" height_cm: IntCriterionInput "Filter by measurements" measurements: StringCriterionInput "Filter by fake tits value" fake_tits: StringCriterionInput "Filter by penis length value" penis_length: FloatCriterionInput "Filter by circumcision" circumcised: CircumcisionCriterionInput "Deprecated: use career_start and career_end. This filter is non-functional." career_length: StringCriterionInput @deprecated(reason: "Use career_start and career_end") "Filter by career start" career_start: DateCriterionInput "Filter by career end" career_end: DateCriterionInput "Filter by tattoos" tattoos: StringCriterionInput "Filter by piercings" piercings: StringCriterionInput "Filter by aliases" aliases: StringCriterionInput "Filter by gender" gender: GenderCriterionInput "Filter to only include performers missing this property" is_missing: String "Filter to only include performers with these tags" tags: HierarchicalMultiCriterionInput "Filter by tag count" tag_count: IntCriterionInput "Filter by scene count" scene_count: IntCriterionInput "Filter by marker count (via scene)" marker_count: IntCriterionInput "Filter by image count" image_count: IntCriterionInput "Filter by gallery count" gallery_count: IntCriterionInput "Filter by play count" play_count: IntCriterionInput "Filter by o count" o_counter: IntCriterionInput "Filter by StashID" stash_id_endpoint: StashIDCriterionInput @deprecated(reason: "use stash_ids_endpoint instead") "Filter by StashIDs" stash_ids_endpoint: StashIDsCriterionInput # rating expressed as 1-100 rating100: IntCriterionInput "Filter by url" url: StringCriterionInput "Filter by hair color" hair_color: StringCriterionInput "Filter by weight" weight: IntCriterionInput "Filter by death year" death_year: IntCriterionInput "Filter by studios where performer appears in scene/image/gallery" studios: HierarchicalMultiCriterionInput "Filter by groups where performer appears in scene" groups: HierarchicalMultiCriterionInput "Filter by performers where performer appears with another performer in scene/image/gallery" performers: MultiCriterionInput "Filter by autotag ignore value" ignore_auto_tag: Boolean "Filter by birthdate" birthdate: DateCriterionInput "Filter by death date" death_date: DateCriterionInput "Filter by related scenes that meet this criteria" scenes_filter: SceneFilterType "Filter by related images that meet this criteria" images_filter: ImageFilterType "Filter by related galleries that meet this criteria" galleries_filter: GalleryFilterType "Filter by related tags that meet this criteria" tags_filter: TagFilterType "Filter by related scene markers (via scene) that meet this criteria" markers_filter: SceneMarkerFilterType "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" updated_at: TimestampCriterionInput custom_fields: [CustomFieldCriterionInput!] } input SceneMarkerFilterType { "Filter to only include scene markers with these tags" tags: HierarchicalMultiCriterionInput "Filter to only include scene markers attached to a scene with these tags" scene_tags: HierarchicalMultiCriterionInput "Filter to only include scene markers with these performers" performers: MultiCriterionInput "Filter to only include scene markers from these scenes" scenes: MultiCriterionInput "Filter by duration (in seconds)" duration: FloatCriterionInput "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" updated_at: TimestampCriterionInput "Filter by scene date" scene_date: DateCriterionInput "Filter by scene creation time" scene_created_at: TimestampCriterionInput "Filter by scene last update time" scene_updated_at: TimestampCriterionInput "Filter by related scenes that meet this criteria" scene_filter: SceneFilterType } input SceneFilterType { AND: SceneFilterType OR: SceneFilterType NOT: SceneFilterType id: IntCriterionInput title: StringCriterionInput code: StringCriterionInput details: StringCriterionInput director: StringCriterionInput "Filter by file oshash" oshash: StringCriterionInput "Filter by file checksum" checksum: StringCriterionInput "Filter by file phash" phash: StringCriterionInput @deprecated(reason: "Use phash_distance instead") "Filter by file phash distance" phash_distance: PhashDistanceCriterionInput "Filter by path" path: StringCriterionInput "Filter by file count" file_count: IntCriterionInput # rating expressed as 1-100 rating100: IntCriterionInput "Filter by organized" organized: Boolean "Filter by o-counter" o_counter: IntCriterionInput "Filter Scenes by duplication criteria" duplicated: DuplicationCriterionInput "Filter by resolution" resolution: ResolutionCriterionInput "Filter by orientation" orientation: OrientationCriterionInput "Filter by frame rate" framerate: IntCriterionInput "Filter by bit rate" bitrate: IntCriterionInput "Filter by video codec" video_codec: StringCriterionInput "Filter by audio codec" audio_codec: StringCriterionInput "Filter by duration (in seconds)" duration: IntCriterionInput "Filter to only include scenes which have markers. `true` or `false`" has_markers: String "Filter to only include scenes missing this property" is_missing: String "Filter to only include scenes with this studio" studios: HierarchicalMultiCriterionInput "Filter to only include scenes with this movie" movies: MultiCriterionInput @deprecated(reason: "use groups instead") "Filter to only include scenes with this group" groups: HierarchicalMultiCriterionInput "Filter to only include scenes with this gallery" galleries: MultiCriterionInput "Filter to only include scenes with these tags" tags: HierarchicalMultiCriterionInput "Filter by tag count" tag_count: IntCriterionInput "Filter to only include scenes with performers with these tags" performer_tags: HierarchicalMultiCriterionInput "Filter scenes that have performers that have been favorited" performer_favorite: Boolean "Filter scenes by performer age at time of scene" performer_age: IntCriterionInput "Filter to only include scenes with these performers" performers: MultiCriterionInput "Filter by performer count" performer_count: IntCriterionInput "Filter by StashID" stash_id_endpoint: StashIDCriterionInput @deprecated(reason: "use stash_ids_endpoint instead") "Filter by StashIDs" stash_ids_endpoint: StashIDsCriterionInput "Filter by StashID count" stash_id_count: IntCriterionInput "Filter by url" url: StringCriterionInput "Filter by interactive" interactive: Boolean "Filter by InteractiveSpeed" interactive_speed: IntCriterionInput "Filter by captions" captions: StringCriterionInput "Filter by resume time" resume_time: IntCriterionInput "Filter by play count" play_count: IntCriterionInput "Filter by play duration (in seconds)" play_duration: IntCriterionInput "Filter by scene last played time" last_played_at: TimestampCriterionInput "Filter by date" date: DateCriterionInput "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" updated_at: TimestampCriterionInput "Filter by related galleries that meet this criteria" galleries_filter: GalleryFilterType "Filter by related performers that meet this criteria" performers_filter: PerformerFilterType "Filter by related studios that meet this criteria" studios_filter: StudioFilterType "Filter by related tags that meet this criteria" tags_filter: TagFilterType "Filter by related movies that meet this criteria" movies_filter: MovieFilterType @deprecated(reason: "use groups_filter instead") "Filter by related groups that meet this criteria" groups_filter: GroupFilterType "Filter by related markers that meet this criteria" markers_filter: SceneMarkerFilterType "Filter by related files that meet this criteria" files_filter: FileFilterType custom_fields: [CustomFieldCriterionInput!] } input MovieFilterType { AND: MovieFilterType OR: MovieFilterType NOT: MovieFilterType name: StringCriterionInput director: StringCriterionInput synopsis: StringCriterionInput "Filter by duration (in seconds)" duration: IntCriterionInput # rating expressed as 1-100 rating100: IntCriterionInput "Filter to only include movies with this studio" studios: HierarchicalMultiCriterionInput "Filter to only include movies missing this property" is_missing: String "Filter by url" url: StringCriterionInput "Filter to only include movies where performer appears in a scene" performers: MultiCriterionInput "Filter to only include movies with these tags" tags: HierarchicalMultiCriterionInput "Filter by tag count" tag_count: IntCriterionInput "Filter by date" date: DateCriterionInput "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" updated_at: TimestampCriterionInput "Filter by related scenes that meet this criteria" scenes_filter: SceneFilterType "Filter by related studios that meet this criteria" studios_filter: StudioFilterType } input GroupFilterType { AND: GroupFilterType OR: GroupFilterType NOT: GroupFilterType name: StringCriterionInput director: StringCriterionInput synopsis: StringCriterionInput "Filter by duration (in seconds)" duration: IntCriterionInput # rating expressed as 1-100 rating100: IntCriterionInput "Filter to only include groups with this studio" studios: HierarchicalMultiCriterionInput "Filter to only include groups missing this property" is_missing: String "Filter by url" url: StringCriterionInput "Filter to only include groups where performer appears in a scene" performers: MultiCriterionInput "Filter to only include groups with these tags" tags: HierarchicalMultiCriterionInput "Filter by tag count" tag_count: IntCriterionInput "Filter by date" date: DateCriterionInput "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" updated_at: TimestampCriterionInput "Filter by o-counter" o_counter: IntCriterionInput "Filter by containing groups" containing_groups: HierarchicalMultiCriterionInput "Filter by sub groups" sub_groups: HierarchicalMultiCriterionInput "Filter by number of containing groups the group has" containing_group_count: IntCriterionInput "Filter by number of sub-groups the group has" sub_group_count: IntCriterionInput "Filter by number of scenes the group has" scene_count: IntCriterionInput "Filter by related scenes that meet this criteria" scenes_filter: SceneFilterType "Filter by related studios that meet this criteria" studios_filter: StudioFilterType "Filter by custom fields" custom_fields: [CustomFieldCriterionInput!] } input StudioFilterType { AND: StudioFilterType OR: StudioFilterType NOT: StudioFilterType name: StringCriterionInput details: StringCriterionInput "Filter to only include studios with this parent studio" parents: MultiCriterionInput "Filter by StashID" stash_id_endpoint: StashIDCriterionInput @deprecated(reason: "use stash_ids_endpoint instead") "Filter by StashIDs" stash_ids_endpoint: StashIDsCriterionInput "Filter to only include studios with these tags" tags: HierarchicalMultiCriterionInput "Filter to only include studios missing this property" is_missing: String # rating expressed as 1-100 rating100: IntCriterionInput "Filter by favorite" favorite: Boolean "Filter by scene count" scene_count: IntCriterionInput "Filter by image count" image_count: IntCriterionInput "Filter by gallery count" gallery_count: IntCriterionInput "Filter by group count" group_count: IntCriterionInput "Filter by tag count" tag_count: IntCriterionInput "Filter by url" url: StringCriterionInput "Filter by studio aliases" aliases: StringCriterionInput "Filter by subsidiary studio count" child_count: IntCriterionInput "Filter by autotag ignore value" ignore_auto_tag: Boolean "Filter by organized" organized: Boolean "Filter by related scenes that meet this criteria" scenes_filter: SceneFilterType "Filter by related images that meet this criteria" images_filter: ImageFilterType "Filter by related galleries that meet this criteria" galleries_filter: GalleryFilterType "Filter by related groups that meet this criteria" groups_filter: GroupFilterType "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" updated_at: TimestampCriterionInput custom_fields: [CustomFieldCriterionInput!] } input GalleryFilterType { AND: GalleryFilterType OR: GalleryFilterType NOT: GalleryFilterType id: IntCriterionInput title: StringCriterionInput details: StringCriterionInput "Filter by file checksum" checksum: StringCriterionInput "Filter by path" path: StringCriterionInput "Filter by zip-file count" file_count: IntCriterionInput "Filter to only include galleries missing this property" is_missing: String "Filter to include/exclude galleries that were created from zip" is_zip: Boolean # rating expressed as 1-100 rating100: IntCriterionInput "Filter by organized" organized: Boolean "Filter by average image resolution" average_resolution: ResolutionCriterionInput "Filter to only include galleries that have chapters. `true` or `false`" has_chapters: String "Filter to only include galleries with these scenes" scenes: MultiCriterionInput "Filter to only include galleries with this studio" studios: HierarchicalMultiCriterionInput "Filter to only include galleries with these tags" tags: HierarchicalMultiCriterionInput "Filter by tag count" tag_count: IntCriterionInput "Filter to only include galleries with performers with these tags" performer_tags: HierarchicalMultiCriterionInput "Filter to only include galleries with these performers" performers: MultiCriterionInput "Filter by performer count" performer_count: IntCriterionInput "Filter galleries that have performers that have been favorited" performer_favorite: Boolean "Filter galleries by performer age at time of gallery" performer_age: IntCriterionInput "Filter by number of images in this gallery" image_count: IntCriterionInput "Filter by url" url: StringCriterionInput "Filter by date" date: DateCriterionInput "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" updated_at: TimestampCriterionInput "Filter by studio code" code: StringCriterionInput "Filter by photographer" photographer: StringCriterionInput "Filter by related scenes that meet this criteria" scenes_filter: SceneFilterType "Filter by related images that meet this criteria" images_filter: ImageFilterType "Filter by related performers that meet this criteria" performers_filter: PerformerFilterType "Filter by related studios that meet this criteria" studios_filter: StudioFilterType "Filter by related tags that meet this criteria" tags_filter: TagFilterType "Filter by related files that meet this criteria" files_filter: FileFilterType "Filter by related folders that meet this criteria" folders_filter: FolderFilterType "Filter by parent folder of the zip or folder the gallery is in" parent_folder: HierarchicalMultiCriterionInput custom_fields: [CustomFieldCriterionInput!] } input TagFilterType { AND: TagFilterType OR: TagFilterType NOT: TagFilterType "Filter by tag name" name: StringCriterionInput "Filter by tag sort_name" sort_name: StringCriterionInput "Filter by tag aliases" aliases: StringCriterionInput "Filter by favorite" favorite: Boolean "Filter by tag description" description: StringCriterionInput "Filter to only include tags missing this property" is_missing: String "Filter by number of scenes with this tag" scene_count: IntCriterionInput "Filter by number of images with this tag" image_count: IntCriterionInput "Filter by number of galleries with this tag" gallery_count: IntCriterionInput "Filter by number of performers with this tag" performer_count: IntCriterionInput "Filter by number of studios with this tag" studio_count: IntCriterionInput "Filter by number of movies with this tag" movie_count: IntCriterionInput "Filter by number of group with this tag" group_count: IntCriterionInput "Filter by number of markers with this tag" marker_count: IntCriterionInput "Filter by parent tags" parents: HierarchicalMultiCriterionInput "Filter by child tags" children: HierarchicalMultiCriterionInput "Filter by number of parent tags the tag has" parent_count: IntCriterionInput "Filter by number of child tags the tag has" child_count: IntCriterionInput "Filter by autotag ignore value" ignore_auto_tag: Boolean "Filter by StashID" stash_id_endpoint: StashIDCriterionInput @deprecated(reason: "use stash_ids_endpoint instead") "Filter by StashID" stash_ids_endpoint: StashIDsCriterionInput "Filter by related scenes that meet this criteria" scenes_filter: SceneFilterType "Filter by related images that meet this criteria" images_filter: ImageFilterType "Filter by related galleries that meet this criteria" galleries_filter: GalleryFilterType "Filter by related groups that meet this criteria" groups_filter: GroupFilterType "Filter by related performers that meet this criteria" performers_filter: PerformerFilterType "Filter by related studios that meet this criteria" studios_filter: StudioFilterType "Filter by related scene markers that meet this criteria" markers_filter: SceneMarkerFilterType "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" updated_at: TimestampCriterionInput custom_fields: [CustomFieldCriterionInput!] } input ImageFilterType { AND: ImageFilterType OR: ImageFilterType NOT: ImageFilterType title: StringCriterionInput details: StringCriterionInput " Filter by image id" id: IntCriterionInput "Filter by file checksum" checksum: StringCriterionInput "Filter by file phash distance" phash_distance: PhashDistanceCriterionInput "Filter by path" path: StringCriterionInput "Filter by file count" file_count: IntCriterionInput # rating expressed as 1-100 rating100: IntCriterionInput "Filter by date" date: DateCriterionInput "Filter by url" url: StringCriterionInput "Filter by organized" organized: Boolean "Filter by o-counter" o_counter: IntCriterionInput "Filter by resolution" resolution: ResolutionCriterionInput "Filter by orientation" orientation: OrientationCriterionInput "Filter to only include images missing this property" is_missing: String "Filter to only include images with this studio" studios: HierarchicalMultiCriterionInput "Filter to only include images with these tags" tags: HierarchicalMultiCriterionInput "Filter by tag count" tag_count: IntCriterionInput "Filter to only include images with performers with these tags" performer_tags: HierarchicalMultiCriterionInput "Filter to only include images with these performers" performers: MultiCriterionInput "Filter by performer count" performer_count: IntCriterionInput "Filter images that have performers that have been favorited" performer_favorite: Boolean "Filter images by performer age at time of image" performer_age: IntCriterionInput "Filter to only include images with these galleries" galleries: MultiCriterionInput "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" updated_at: TimestampCriterionInput "Filter by studio code" code: StringCriterionInput "Filter by photographer" photographer: StringCriterionInput "Filter by related galleries that meet this criteria" galleries_filter: GalleryFilterType "Filter by related performers that meet this criteria" performers_filter: PerformerFilterType "Filter by related studios that meet this criteria" studios_filter: StudioFilterType "Filter by related tags that meet this criteria" tags_filter: TagFilterType "Filter by related files that meet this criteria" files_filter: FileFilterType "Filter by custom fields" custom_fields: [CustomFieldCriterionInput!] } input FileFilterType { AND: FileFilterType OR: FileFilterType NOT: FileFilterType path: StringCriterionInput basename: StringCriterionInput dir: StringCriterionInput parent_folder: HierarchicalMultiCriterionInput zip_file: MultiCriterionInput "Filter by modification time" mod_time: TimestampCriterionInput "Filter files by duplication criteria (only phash applies to files)" duplicated: FileDuplicationCriterionInput "find files based on hash" hashes: [FingerprintFilterInput!] video_file_filter: VideoFileFilterInput image_file_filter: ImageFileFilterInput scene_count: IntCriterionInput image_count: IntCriterionInput gallery_count: IntCriterionInput "Filter by related scenes that meet this criteria" scenes_filter: SceneFilterType "Filter by related images that meet this criteria" images_filter: ImageFilterType "Filter by related galleries that meet this criteria" galleries_filter: GalleryFilterType "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" updated_at: TimestampCriterionInput } input FolderFilterType { AND: FolderFilterType OR: FolderFilterType NOT: FolderFilterType path: StringCriterionInput basename: StringCriterionInput parent_folder: HierarchicalMultiCriterionInput zip_file: MultiCriterionInput "Filter by modification time" mod_time: TimestampCriterionInput gallery_count: IntCriterionInput "Filter by files that meet this criteria" files_filter: FileFilterType "Filter by related galleries that meet this criteria" galleries_filter: GalleryFilterType "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" updated_at: TimestampCriterionInput } input VideoFileFilterInput { resolution: ResolutionCriterionInput orientation: OrientationCriterionInput framerate: IntCriterionInput bitrate: IntCriterionInput format: StringCriterionInput video_codec: StringCriterionInput audio_codec: StringCriterionInput "in seconds" duration: IntCriterionInput captions: StringCriterionInput interactive: Boolean interactive_speed: IntCriterionInput } input ImageFileFilterInput { format: StringCriterionInput resolution: ResolutionCriterionInput orientation: OrientationCriterionInput } input FingerprintFilterInput { type: String! value: String! "Hamming distance - defaults to 0" distance: Int } enum CriterionModifier { "=" EQUALS "!=" NOT_EQUALS ">" GREATER_THAN "<" LESS_THAN "IS NULL" IS_NULL "IS NOT NULL" NOT_NULL "INCLUDES ALL" INCLUDES_ALL INCLUDES EXCLUDES "MATCHES REGEX" MATCHES_REGEX "NOT MATCHES REGEX" NOT_MATCHES_REGEX ">= AND <=" BETWEEN "< OR >" NOT_BETWEEN } input StringCriterionInput { value: String! modifier: CriterionModifier! } input IntCriterionInput { value: Int! value2: Int modifier: CriterionModifier! } input FloatCriterionInput { value: Float! value2: Float modifier: CriterionModifier! } input MultiCriterionInput { value: [ID!] modifier: CriterionModifier! excludes: [ID!] } input GenderCriterionInput { value: GenderEnum value_list: [GenderEnum!] modifier: CriterionModifier! } input CircumcisionCriterionInput { value: [CircumcisedEnum!] modifier: CriterionModifier! } input HierarchicalMultiCriterionInput { value: [ID!] modifier: CriterionModifier! depth: Int excludes: [ID!] } input DateCriterionInput { value: String! value2: String modifier: CriterionModifier! } input TimestampCriterionInput { value: String! value2: String modifier: CriterionModifier! } input PhashDistanceCriterionInput { value: String! modifier: CriterionModifier! distance: Int } enum FilterMode { SCENES PERFORMERS STUDIOS GALLERIES SCENE_MARKERS MOVIES GROUPS TAGS IMAGES } type SavedFilter { id: ID! mode: FilterMode! name: String! "JSON-encoded filter string" filter: String! @deprecated(reason: "use find_filter and object_filter instead") find_filter: SavedFindFilterType # maps to any of the AnyFilterInput types # using a generic Map instead of creating and maintaining match types for inputs object_filter: Map # generic map for ui options ui_options: Map } input SaveFilterInput { "provide ID to overwrite existing filter" id: ID mode: FilterMode! name: String! find_filter: FindFilterType object_filter: Map # generic map for ui options ui_options: Map } input DestroyFilterInput { id: ID! } input SetDefaultFilterInput { mode: FilterMode! "null to clear" find_filter: FindFilterType object_filter: Map # generic map for ui options ui_options: Map } ================================================ FILE: graphql/schema/types/gallery-chapter.graphql ================================================ type GalleryChapter { id: ID! gallery: Gallery! title: String! image_index: Int! created_at: Time! updated_at: Time! } input GalleryChapterCreateInput { gallery_id: ID! title: String! image_index: Int! } input GalleryChapterUpdateInput { id: ID! gallery_id: ID title: String image_index: Int } type FindGalleryChaptersResultType { count: Int! chapters: [GalleryChapter!]! } ================================================ FILE: graphql/schema/types/gallery.graphql ================================================ type GalleryPathsType { cover: String! preview: String! # Resolver } "Gallery type" type Gallery { id: ID! title: String code: String url: String @deprecated(reason: "Use urls") urls: [String!]! date: String details: String photographer: String # rating expressed as 1-100 rating100: Int organized: Boolean! created_at: Time! updated_at: Time! files: [GalleryFile!]! folder: Folder chapters: [GalleryChapter!]! scenes: [Scene!]! studio: Studio image_count: Int! tags: [Tag!]! performers: [Performer!]! cover: Image paths: GalleryPathsType! # Resolver custom_fields: Map! image(index: Int!): Image! } input GalleryCreateInput { title: String! code: String url: String @deprecated(reason: "Use urls") urls: [String!] date: String details: String photographer: String # rating expressed as 1-100 rating100: Int organized: Boolean scene_ids: [ID!] studio_id: ID tag_ids: [ID!] performer_ids: [ID!] custom_fields: Map } input GalleryUpdateInput { clientMutationId: String id: ID! title: String code: String url: String @deprecated(reason: "Use urls") urls: [String!] date: String details: String photographer: String # rating expressed as 1-100 rating100: Int organized: Boolean scene_ids: [ID!] studio_id: ID tag_ids: [ID!] performer_ids: [ID!] primary_file_id: ID custom_fields: CustomFieldsInput } input BulkGalleryUpdateInput { clientMutationId: String ids: [ID!] code: String url: String @deprecated(reason: "Use urls") urls: BulkUpdateStrings date: String details: String photographer: String # rating expressed as 1-100 rating100: Int organized: Boolean scene_ids: BulkUpdateIds studio_id: ID tag_ids: BulkUpdateIds performer_ids: BulkUpdateIds custom_fields: CustomFieldsInput } input GalleryDestroyInput { ids: [ID!]! """ If true, then the zip file will be deleted if the gallery is zip-file-based. If gallery is folder-based, then any files not associated with other galleries will be deleted, along with the folder, if it is not empty. """ delete_file: Boolean delete_generated: Boolean "If true, delete the file entry from the database if the file is not assigned to any other objects" destroy_file_entry: Boolean } type FindGalleriesResultType { count: Int! galleries: [Gallery!]! } input GalleryAddInput { gallery_id: ID! image_ids: [ID!]! } input GalleryRemoveInput { gallery_id: ID! image_ids: [ID!]! } input GallerySetCoverInput { gallery_id: ID! cover_image_id: ID! } input GalleryResetCoverInput { gallery_id: ID! } ================================================ FILE: graphql/schema/types/group.graphql ================================================ "GroupDescription represents a relationship to a group with a description of the relationship" type GroupDescription { group: Group! description: String } type Group { id: ID! name: String! aliases: String "Duration in seconds" duration: Int date: String # rating expressed as 1-100 rating100: Int studio: Studio director: String synopsis: String urls: [String!]! tags: [Tag!]! created_at: Time! updated_at: Time! containing_groups: [GroupDescription!]! sub_groups: [GroupDescription!]! front_image_path: String # Resolver back_image_path: String # Resolver scene_count(depth: Int): Int! # Resolver performer_count(depth: Int): Int! # Resolver sub_group_count(depth: Int): Int! # Resolver scenes: [Scene!]! o_counter: Int # Resolver custom_fields: Map! } input GroupDescriptionInput { group_id: ID! description: String } input GroupCreateInput { name: String! aliases: String "Duration in seconds" duration: Int date: String # rating expressed as 1-100 rating100: Int studio_id: ID director: String synopsis: String urls: [String!] tag_ids: [ID!] containing_groups: [GroupDescriptionInput!] sub_groups: [GroupDescriptionInput!] "This should be a URL or a base64 encoded data URL" front_image: String "This should be a URL or a base64 encoded data URL" back_image: String custom_fields: Map } input GroupUpdateInput { id: ID! name: String aliases: String duration: Int date: String # rating expressed as 1-100 rating100: Int studio_id: ID director: String synopsis: String urls: [String!] tag_ids: [ID!] containing_groups: [GroupDescriptionInput!] sub_groups: [GroupDescriptionInput!] "This should be a URL or a base64 encoded data URL" front_image: String "This should be a URL or a base64 encoded data URL" back_image: String custom_fields: CustomFieldsInput } input BulkUpdateGroupDescriptionsInput { groups: [GroupDescriptionInput!]! mode: BulkUpdateIdMode! } input BulkGroupUpdateInput { clientMutationId: String ids: [ID!] # rating expressed as 1-100 rating100: Int date: String synopsis: String studio_id: ID director: String urls: BulkUpdateStrings tag_ids: BulkUpdateIds containing_groups: BulkUpdateGroupDescriptionsInput sub_groups: BulkUpdateGroupDescriptionsInput custom_fields: CustomFieldsInput } input GroupDestroyInput { id: ID! } input ReorderSubGroupsInput { "ID of the group to reorder sub groups for" group_id: ID! """ IDs of the sub groups to reorder. These must be a subset of the current sub groups. Sub groups will be inserted in this order at the insert_index """ sub_group_ids: [ID!]! "The sub-group ID at which to insert the sub groups" insert_at_id: ID! "If true, the sub groups will be inserted after the insert_index, otherwise they will be inserted before" insert_after: Boolean } type FindGroupsResultType { count: Int! groups: [Group!]! } input GroupSubGroupAddInput { containing_group_id: ID! sub_groups: [GroupDescriptionInput!]! "The index at which to insert the sub groups. If not provided, the sub groups will be appended to the end" insert_index: Int } input GroupSubGroupRemoveInput { containing_group_id: ID! sub_group_ids: [ID!]! } ================================================ FILE: graphql/schema/types/image.graphql ================================================ type Image { id: ID! title: String code: String # rating expressed as 1-100 rating100: Int url: String @deprecated(reason: "Use urls") urls: [String!]! date: String details: String photographer: String o_counter: Int organized: Boolean! created_at: Time! updated_at: Time! files: [ImageFile!]! @deprecated(reason: "Use visual_files") visual_files: [VisualFile!]! paths: ImagePathsType! # Resolver galleries: [Gallery!]! studio: Studio tags: [Tag!]! performers: [Performer!]! custom_fields: Map! } type ImageFileType { mod_time: Time! size: Int! width: Int! height: Int! } type ImagePathsType { thumbnail: String # Resolver preview: String # Resolver image: String # Resolver } input ImageUpdateInput { clientMutationId: String id: ID! title: String code: String # rating expressed as 1-100 rating100: Int organized: Boolean url: String @deprecated(reason: "Use urls") urls: [String!] date: String details: String photographer: String studio_id: ID performer_ids: [ID!] tag_ids: [ID!] gallery_ids: [ID!] primary_file_id: ID custom_fields: CustomFieldsInput } input BulkImageUpdateInput { clientMutationId: String ids: [ID!] title: String code: String # rating expressed as 1-100 rating100: Int organized: Boolean url: String @deprecated(reason: "Use urls") urls: BulkUpdateStrings date: String details: String photographer: String studio_id: ID performer_ids: BulkUpdateIds tag_ids: BulkUpdateIds gallery_ids: BulkUpdateIds custom_fields: CustomFieldsInput } input ImageDestroyInput { id: ID! delete_file: Boolean delete_generated: Boolean "If true, delete the file entry from the database if the file is not assigned to any other objects" destroy_file_entry: Boolean } input ImagesDestroyInput { ids: [ID!]! delete_file: Boolean delete_generated: Boolean "If true, delete the file entry from the database if the file is not assigned to any other objects" destroy_file_entry: Boolean } type FindImagesResultType { count: Int! "Total megapixels of the images" megapixels: Float! "Total file size in bytes" filesize: Float! images: [Image!]! } ================================================ FILE: graphql/schema/types/job.graphql ================================================ enum JobStatus { READY RUNNING FINISHED STOPPING CANCELLED FAILED } type Job { id: ID! status: JobStatus! subTasks: [String!] description: String! progress: Float startTime: Time endTime: Time addTime: Time! error: String } input FindJobInput { id: ID! } enum JobStatusUpdateType { ADD REMOVE UPDATE } type JobStatusUpdate { type: JobStatusUpdateType! job: Job! } ================================================ FILE: graphql/schema/types/logging.graphql ================================================ enum LogLevel { Trace Debug Info Progress Warning Error } type LogEntry { time: Time! level: LogLevel! message: String! } ================================================ FILE: graphql/schema/types/metadata.graphql ================================================ input GenerateMetadataInput { covers: Boolean sprites: Boolean previews: Boolean imagePreviews: Boolean previewOptions: GeneratePreviewOptionsInput markers: Boolean markerImagePreviews: Boolean markerScreenshots: Boolean transcodes: Boolean "Generate transcodes even if not required" forceTranscodes: Boolean "Generate video phashes during scan" phashes: Boolean interactiveHeatmapsSpeeds: Boolean "Generate image phashes during scan" imagePhashes: Boolean imageThumbnails: Boolean clipPreviews: Boolean "scene ids to generate for" sceneIDs: [ID!] "marker ids to generate for" markerIDs: [ID!] "image ids to generate for" imageIDs: [ID!] "gallery ids to generate for" galleryIDs: [ID!] "paths to run generate on, in addition to the other ID lists" paths: [String!] "overwrite existing media" overwrite: Boolean } input GeneratePreviewOptionsInput { "Number of segments in a preview file" previewSegments: Int "Preview segment duration, in seconds" previewSegmentDuration: Float "Duration of start of video to exclude when generating previews" previewExcludeStart: String "Duration of end of video to exclude when generating previews" previewExcludeEnd: String "Preset when generating preview" previewPreset: PreviewPreset } type GenerateMetadataOptions { covers: Boolean sprites: Boolean previews: Boolean imagePreviews: Boolean previewOptions: GeneratePreviewOptions markers: Boolean markerImagePreviews: Boolean markerScreenshots: Boolean transcodes: Boolean phashes: Boolean interactiveHeatmapsSpeeds: Boolean imageThumbnails: Boolean clipPreviews: Boolean } type GeneratePreviewOptions { "Number of segments in a preview file" previewSegments: Int "Preview segment duration, in seconds" previewSegmentDuration: Float "Duration of start of video to exclude when generating previews" previewExcludeStart: String "Duration of end of video to exclude when generating previews" previewExcludeEnd: String "Preset when generating preview" previewPreset: PreviewPreset } "Filter options for meta data scannning" input ScanMetaDataFilterInput { "If set, files with a modification time before this time point are ignored by the scan" minModTime: Timestamp } input ScanMetadataInput { paths: [String!] "Forces a rescan on files even if modification time is unchanged" rescan: Boolean "Generate covers during scan" scanGenerateCovers: Boolean "Generate previews during scan" scanGeneratePreviews: Boolean "Generate image previews during scan" scanGenerateImagePreviews: Boolean "Generate sprites during scan" scanGenerateSprites: Boolean "Generate video phashes during scan" scanGeneratePhashes: Boolean "Generate image phashes during scan" scanGenerateImagePhashes: Boolean "Generate image thumbnails during scan" scanGenerateThumbnails: Boolean "Generate image clip previews during scan" scanGenerateClipPreviews: Boolean "Filter options for the scan" filter: ScanMetaDataFilterInput } type ScanMetadataOptions { "Forces a rescan on files even if modification time is unchanged" rescan: Boolean! "Generate covers during scan" scanGenerateCovers: Boolean! "Generate previews during scan" scanGeneratePreviews: Boolean! "Generate image previews during scan" scanGenerateImagePreviews: Boolean! "Generate sprites during scan" scanGenerateSprites: Boolean! "Generate video phashes during scan" scanGeneratePhashes: Boolean! "Generate image phashes during scan" scanGenerateImagePhashes: Boolean "Generate image thumbnails during scan" scanGenerateThumbnails: Boolean! "Generate image clip previews during scan" scanGenerateClipPreviews: Boolean! } input CleanMetadataInput { paths: [String!] """ Don't check zip file contents when determining whether to clean a file. This can significantly speed up the clean process, but will potentially miss removed files within zip files. Where users do not modify zip files contents directly, this should be safe to use. Defaults to false. """ ignoreZipFileContents: Boolean "Do a dry run. Don't delete any files" dryRun: Boolean! } input CleanGeneratedInput { "Clean blob files without blob entries" blobFiles: Boolean "Clean sprite and vtt files without scene entries" sprites: Boolean "Clean preview files without scene entries" screenshots: Boolean "Clean scene transcodes without scene entries" transcodes: Boolean "Clean marker files without marker entries" markers: Boolean "Clean image thumbnails/clips without image entries" imageThumbnails: Boolean "Do a dry run. Don't delete any files" dryRun: Boolean } input AutoTagMetadataInput { "Paths to tag, null for all files" paths: [String!] """ IDs of performers to tag files with, or "*" for all """ performers: [String!] """ IDs of studios to tag files with, or "*" for all """ studios: [String!] """ IDs of tags to tag files with, or "*" for all """ tags: [String!] } type AutoTagMetadataOptions { """ IDs of performers to tag files with, or "*" for all """ performers: [String!] """ IDs of studios to tag files with, or "*" for all """ studios: [String!] """ IDs of tags to tag files with, or "*" for all """ tags: [String!] } enum IdentifyFieldStrategy { "Never sets the field value" IGNORE """ For multi-value fields, merge with existing. For single-value fields, ignore if already set """ MERGE """ Always replaces the value if a value is found. For multi-value fields, any existing values are removed and replaced with the scraped values. """ OVERWRITE } input IdentifyFieldOptionsInput { field: String! strategy: IdentifyFieldStrategy! "creates missing objects if needed - only applicable for performers, tags and studios" createMissing: Boolean } input IdentifyMetadataOptionsInput { "any fields missing from here are defaulted to MERGE and createMissing false" fieldOptions: [IdentifyFieldOptionsInput!] "defaults to true if not provided" setCoverImage: Boolean setOrganized: Boolean "defaults to true if not provided" includeMalePerformers: Boolean @deprecated(reason: "Use performerGenders") "Filter to only include performers with these genders. If not provided, all genders are included." performerGenders: [GenderEnum!] "defaults to true if not provided" skipMultipleMatches: Boolean "tag to tag skipped multiple matches with" skipMultipleMatchTag: String "defaults to true if not provided" skipSingleNamePerformers: Boolean "tag to tag skipped single name performers with" skipSingleNamePerformerTag: String } input IdentifySourceInput { source: ScraperSourceInput! "Options defined for a source override the defaults" options: IdentifyMetadataOptionsInput } input IdentifyMetadataInput { "An ordered list of sources to identify items with. Only the first source that finds a match is used." sources: [IdentifySourceInput!]! "Options defined here override the configured defaults" options: IdentifyMetadataOptionsInput "scene ids to identify" sceneIDs: [ID!] "paths of scenes to identify - ignored if scene ids are set" paths: [String!] } # types for default options type IdentifyFieldOptions { field: String! strategy: IdentifyFieldStrategy! "creates missing objects if needed - only applicable for performers, tags and studios" createMissing: Boolean } type IdentifyMetadataOptions { "any fields missing from here are defaulted to MERGE and createMissing false" fieldOptions: [IdentifyFieldOptions!] "defaults to true if not provided" setCoverImage: Boolean setOrganized: Boolean "defaults to true if not provided" includeMalePerformers: Boolean @deprecated(reason: "Use performerGenders") "Filter to only include performers with these genders. If not provided, all genders are included." performerGenders: [GenderEnum!] "defaults to true if not provided" skipMultipleMatches: Boolean "tag to tag skipped multiple matches with" skipMultipleMatchTag: String "defaults to true if not provided" skipSingleNamePerformers: Boolean "tag to tag skipped single name performers with" skipSingleNamePerformerTag: String } type IdentifySource { source: ScraperSource! "Options defined for a source override the defaults" options: IdentifyMetadataOptions } type IdentifyMetadataTaskOptions { "An ordered list of sources to identify items with. Only the first source that finds a match is used." sources: [IdentifySource!]! "Options defined here override the configured defaults" options: IdentifyMetadataOptions } input ExportObjectTypeInput { ids: [String!] all: Boolean } input ExportObjectsInput { scenes: ExportObjectTypeInput images: ExportObjectTypeInput studios: ExportObjectTypeInput performers: ExportObjectTypeInput tags: ExportObjectTypeInput groups: ExportObjectTypeInput movies: ExportObjectTypeInput @deprecated(reason: "Use groups instead") galleries: ExportObjectTypeInput includeDependencies: Boolean } enum ImportDuplicateEnum { IGNORE OVERWRITE FAIL } enum ImportMissingRefEnum { IGNORE FAIL CREATE } input ImportObjectsInput { file: Upload! duplicateBehaviour: ImportDuplicateEnum! missingRefBehaviour: ImportMissingRefEnum! } input BackupDatabaseInput { download: Boolean "If true, blob files will be included in the backup. This can significantly increase the size of the backup and the time it takes to create it, but allows for a complete backup of the system that can be restored without needing access to the original media files." includeBlobs: Boolean } input AnonymiseDatabaseInput { download: Boolean } enum SystemStatusEnum { SETUP NEEDS_MIGRATION OK } type SystemStatus { databaseSchema: Int databasePath: String configPath: String appSchema: Int! status: SystemStatusEnum! os: String! workingDir: String! homeDir: String! ffmpegPath: String ffprobePath: String } input MigrateInput { backupPath: String! } input CustomFieldsInput { "If populated, the entire custom fields map will be replaced with this value" full: Map "If populated, only the keys in this map will be updated" partial: Map "Remove any keys in this list" remove: [String!] } ================================================ FILE: graphql/schema/types/migration.graphql ================================================ input MigrateSceneScreenshotsInput { # if true, delete screenshot files after migrating deleteFiles: Boolean # if true, overwrite existing covers with the covers from the screenshots directory overwriteExisting: Boolean } input MigrateBlobsInput { # if true, delete blob data from old storage system deleteOld: Boolean } ================================================ FILE: graphql/schema/types/movie.graphql ================================================ type Movie { id: ID! name: String! aliases: String "Duration in seconds" duration: Int date: String # rating expressed as 1-100 rating100: Int studio: Studio director: String synopsis: String url: String @deprecated(reason: "Use urls") urls: [String!]! tags: [Tag!]! created_at: Time! updated_at: Time! front_image_path: String # Resolver back_image_path: String # Resolver scene_count(depth: Int): Int! # Resolver scenes: [Scene!]! } input MovieCreateInput { name: String! aliases: String "Duration in seconds" duration: Int date: String # rating expressed as 1-100 rating100: Int studio_id: ID director: String synopsis: String url: String @deprecated(reason: "Use urls") urls: [String!] tag_ids: [ID!] "This should be a URL or a base64 encoded data URL" front_image: String "This should be a URL or a base64 encoded data URL" back_image: String } input MovieUpdateInput { id: ID! name: String aliases: String duration: Int date: String # rating expressed as 1-100 rating100: Int studio_id: ID director: String synopsis: String url: String @deprecated(reason: "Use urls") urls: [String!] tag_ids: [ID!] "This should be a URL or a base64 encoded data URL" front_image: String "This should be a URL or a base64 encoded data URL" back_image: String } input BulkMovieUpdateInput { clientMutationId: String ids: [ID!] # rating expressed as 1-100 rating100: Int studio_id: ID director: String urls: BulkUpdateStrings tag_ids: BulkUpdateIds } input MovieDestroyInput { id: ID! } type FindMoviesResultType { count: Int! movies: [Movie!]! } ================================================ FILE: graphql/schema/types/package.graphql ================================================ enum PackageType { Scraper Plugin } type Package { package_id: String! name: String! version: String date: Timestamp requires: [Package!]! sourceURL: String! "The version of this package currently available from the remote source" source_package: Package metadata: Map! } input PackageSpecInput { id: String! sourceURL: String! } type PackageSource { name: String url: String! local_path: String } input PackageSourceInput { name: String url: String! local_path: String } ================================================ FILE: graphql/schema/types/performer.graphql ================================================ enum GenderEnum { MALE FEMALE TRANSGENDER_MALE TRANSGENDER_FEMALE INTERSEX NON_BINARY } enum CircumcisedEnum { CUT UNCUT } type Performer { id: ID! name: String! disambiguation: String url: String @deprecated(reason: "Use urls") urls: [String!] gender: GenderEnum twitter: String @deprecated(reason: "Use urls") instagram: String @deprecated(reason: "Use urls") birthdate: String ethnicity: String country: String eye_color: String height_cm: Int measurements: String fake_tits: String penis_length: Float circumcised: CircumcisedEnum career_length: String @deprecated(reason: "Use career_start and career_end") career_start: String career_end: String tattoos: String piercings: String alias_list: [String!]! favorite: Boolean! tags: [Tag!]! ignore_auto_tag: Boolean! image_path: String # Resolver scene_count: Int! # Resolver image_count: Int! # Resolver gallery_count: Int! # Resolver group_count: Int! # Resolver movie_count: Int! @deprecated(reason: "use group_count instead") # Resolver performer_count: Int! # Resolver o_counter: Int # Resolver scenes: [Scene!]! stash_ids: [StashID!]! # rating expressed as 1-100 rating100: Int details: String death_date: String hair_color: String weight: Int created_at: Time! updated_at: Time! groups: [Group!]! movies: [Movie!]! @deprecated(reason: "use groups instead") custom_fields: Map! } input PerformerCreateInput { name: String! disambiguation: String url: String @deprecated(reason: "Use urls") urls: [String!] gender: GenderEnum birthdate: String ethnicity: String country: String eye_color: String height_cm: Int measurements: String fake_tits: String penis_length: Float circumcised: CircumcisedEnum career_length: String @deprecated(reason: "Use career_start and career_end") career_start: String career_end: String tattoos: String piercings: String "Duplicate aliases and those equal to name will be ignored (case-insensitive)" alias_list: [String!] twitter: String @deprecated(reason: "Use urls") instagram: String @deprecated(reason: "Use urls") favorite: Boolean tag_ids: [ID!] "This should be a URL or a base64 encoded data URL" image: String stash_ids: [StashIDInput!] # rating expressed as 1-100 rating100: Int details: String death_date: String hair_color: String weight: Int ignore_auto_tag: Boolean custom_fields: Map } input PerformerUpdateInput { id: ID! name: String disambiguation: String url: String @deprecated(reason: "Use urls") urls: [String!] gender: GenderEnum birthdate: String ethnicity: String country: String eye_color: String height_cm: Int measurements: String fake_tits: String penis_length: Float circumcised: CircumcisedEnum career_length: String @deprecated(reason: "Use career_start and career_end") career_start: String career_end: String tattoos: String piercings: String "Duplicate aliases and those equal to name will be ignored (case-insensitive)" alias_list: [String!] twitter: String @deprecated(reason: "Use urls") instagram: String @deprecated(reason: "Use urls") favorite: Boolean tag_ids: [ID!] "This should be a URL or a base64 encoded data URL" image: String stash_ids: [StashIDInput!] # rating expressed as 1-100 rating100: Int details: String death_date: String hair_color: String weight: Int ignore_auto_tag: Boolean custom_fields: CustomFieldsInput } input BulkUpdateStrings { values: [String!] mode: BulkUpdateIdMode! } input BulkPerformerUpdateInput { clientMutationId: String ids: [ID!] disambiguation: String url: String @deprecated(reason: "Use urls") urls: BulkUpdateStrings gender: GenderEnum birthdate: String ethnicity: String country: String eye_color: String height_cm: Int measurements: String fake_tits: String penis_length: Float circumcised: CircumcisedEnum career_length: String @deprecated(reason: "Use career_start and career_end") career_start: String career_end: String tattoos: String piercings: String "Duplicate aliases and those equal to name will result in an error (case-insensitive)" alias_list: BulkUpdateStrings twitter: String @deprecated(reason: "Use urls") instagram: String @deprecated(reason: "Use urls") favorite: Boolean tag_ids: BulkUpdateIds # rating expressed as 1-100 rating100: Int details: String death_date: String hair_color: String weight: Int ignore_auto_tag: Boolean custom_fields: CustomFieldsInput } input PerformerDestroyInput { id: ID! } type FindPerformersResultType { count: Int! performers: [Performer!]! } input PerformerMergeInput { source: [ID!]! destination: ID! # values defined here will override values in the destination values: PerformerUpdateInput } ================================================ FILE: graphql/schema/types/plugin.graphql ================================================ type PluginPaths { # path to javascript files javascript: [String!] # path to css files css: [String!] } type Plugin { id: ID! name: String! description: String url: String version: String enabled: Boolean! tasks: [PluginTask!] hooks: [PluginHook!] settings: [PluginSetting!] """ Plugin IDs of plugins that this plugin depends on. Applies only for UI plugins to indicate css/javascript load order. """ requires: [ID!] paths: PluginPaths! } type PluginTask { name: String! description: String plugin: Plugin! } type PluginHook { name: String! description: String hooks: [String!] plugin: Plugin! } type PluginResult { error: String result: String } input PluginArgInput { key: String! value: PluginValueInput } input PluginValueInput { str: String i: Int b: Boolean f: Float o: [PluginArgInput!] a: [PluginValueInput!] } enum PluginSettingTypeEnum { STRING NUMBER BOOLEAN } type PluginSetting { name: String! display_name: String description: String type: PluginSettingTypeEnum! } ================================================ FILE: graphql/schema/types/scalars.graphql ================================================ "An RFC3339 timestamp" scalar Time """ Timestamp is a point in time. It is always output as RFC3339-compatible time points. It can be input as a RFC3339 string, or as "<4h" for "4 hours in the past" or ">5m" for "5 minutes in the future" """ scalar Timestamp "A String -> Any map" scalar Map "A String -> Boolean map" scalar BoolMap "A plugin ID -> Map (String -> Any map) map" scalar PluginConfigMap scalar Any scalar Int64 "A multipart file upload" scalar Upload ================================================ FILE: graphql/schema/types/scene-marker-tag.graphql ================================================ type SceneMarkerTag { tag: Tag! scene_markers: [SceneMarker!]! } ================================================ FILE: graphql/schema/types/scene-marker.graphql ================================================ type SceneMarker { id: ID! scene: Scene! title: String! "The required start time of the marker (in seconds). Supports decimals." seconds: Float! "The optional end time of the marker (in seconds). Supports decimals." end_seconds: Float primary_tag: Tag! tags: [Tag!]! created_at: Time! updated_at: Time! "The path to stream this marker" stream: String! # Resolver "The path to the preview image for this marker" preview: String! # Resolver "The path to the screenshot image for this marker" screenshot: String! # Resolver } input SceneMarkerCreateInput { title: String! "The required start time of the marker (in seconds). Supports decimals." seconds: Float! "The optional end time of the marker (in seconds). Supports decimals." end_seconds: Float scene_id: ID! primary_tag_id: ID! tag_ids: [ID!] } input SceneMarkerUpdateInput { id: ID! title: String "The start time of the marker (in seconds). Supports decimals." seconds: Float "The end time of the marker (in seconds). Supports decimals." end_seconds: Float scene_id: ID primary_tag_id: ID tag_ids: [ID!] } input BulkSceneMarkerUpdateInput { ids: [ID!] title: String primary_tag_id: ID tag_ids: BulkUpdateIds } type FindSceneMarkersResultType { count: Int! scene_markers: [SceneMarker!]! } type MarkerStringsResultType { count: Int! id: ID! title: String! } ================================================ FILE: graphql/schema/types/scene.graphql ================================================ type SceneFileType { size: String duration: Float video_codec: String audio_codec: String width: Int height: Int framerate: Float bitrate: Int } type ScenePathsType { screenshot: String # Resolver preview: String # Resolver stream: String # Resolver webp: String # Resolver vtt: String # Resolver sprite: String # Resolver funscript: String # Resolver interactive_heatmap: String # Resolver caption: String # Resolver } type SceneMovie { movie: Movie! scene_index: Int } type SceneGroup { group: Group! scene_index: Int } type VideoCaption { language_code: String! caption_type: String! } type Scene { id: ID! title: String code: String details: String director: String url: String @deprecated(reason: "Use urls") urls: [String!]! date: String # rating expressed as 1-100 rating100: Int organized: Boolean! o_counter: Int interactive: Boolean! interactive_speed: Int captions: [VideoCaption!] created_at: Time! updated_at: Time! "The last time play count was updated" last_played_at: Time "The time index a scene was left at" resume_time: Float "The total time a scene has spent playing" play_duration: Float "The number ot times a scene has been played" play_count: Int "Times a scene was played" play_history: [Time!]! "Times the o counter was incremented" o_history: [Time!]! files: [VideoFile!]! paths: ScenePathsType! # Resolver scene_markers: [SceneMarker!]! galleries: [Gallery!]! studio: Studio groups: [SceneGroup!]! movies: [SceneMovie!]! @deprecated(reason: "Use groups") tags: [Tag!]! performers: [Performer!]! stash_ids: [StashID!]! custom_fields: Map! "Return valid stream paths" sceneStreams: [SceneStreamEndpoint!]! } input SceneMovieInput { movie_id: ID! scene_index: Int } input SceneGroupInput { group_id: ID! scene_index: Int } input SceneCreateInput { title: String code: String details: String director: String url: String @deprecated(reason: "Use urls") urls: [String!] date: String # rating expressed as 1-100 rating100: Int organized: Boolean studio_id: ID gallery_ids: [ID!] performer_ids: [ID!] groups: [SceneGroupInput!] movies: [SceneMovieInput!] @deprecated(reason: "Use groups") tag_ids: [ID!] "This should be a URL or a base64 encoded data URL" cover_image: String stash_ids: [StashIDInput!] """ The first id will be assigned as primary. Files will be reassigned from existing scenes if applicable. Files must not already be primary for another scene. """ file_ids: [ID!] custom_fields: Map } input SceneUpdateInput { clientMutationId: String id: ID! title: String code: String details: String director: String url: String @deprecated(reason: "Use urls") urls: [String!] date: String # rating expressed as 1-100 rating100: Int o_counter: Int @deprecated(reason: "Unsupported - Use sceneIncrementO/sceneDecrementO") organized: Boolean studio_id: ID gallery_ids: [ID!] performer_ids: [ID!] groups: [SceneGroupInput!] movies: [SceneMovieInput!] @deprecated(reason: "Use groups") tag_ids: [ID!] "This should be a URL or a base64 encoded data URL" cover_image: String stash_ids: [StashIDInput!] "The time index a scene was left at" resume_time: Float "The total time a scene has spent playing" play_duration: Float "The number ot times a scene has been played" play_count: Int @deprecated( reason: "Unsupported - Use sceneIncrementPlayCount/sceneDecrementPlayCount" ) primary_file_id: ID custom_fields: CustomFieldsInput } enum BulkUpdateIdMode { SET ADD REMOVE } input BulkUpdateIds { ids: [ID!] mode: BulkUpdateIdMode! } input BulkSceneUpdateInput { clientMutationId: String ids: [ID!] title: String code: String details: String director: String url: String @deprecated(reason: "Use urls") urls: BulkUpdateStrings date: String # rating expressed as 1-100 rating100: Int organized: Boolean studio_id: ID gallery_ids: BulkUpdateIds performer_ids: BulkUpdateIds tag_ids: BulkUpdateIds group_ids: BulkUpdateIds movie_ids: BulkUpdateIds @deprecated(reason: "Use group_ids") custom_fields: CustomFieldsInput } input SceneDestroyInput { id: ID! delete_file: Boolean delete_generated: Boolean "If true, delete the file entry from the database if the file is not assigned to any other objects" destroy_file_entry: Boolean } input ScenesDestroyInput { ids: [ID!]! delete_file: Boolean delete_generated: Boolean "If true, delete the file entry from the database if the file is not assigned to any other objects" destroy_file_entry: Boolean } type FindScenesResultType { count: Int! "Total duration in seconds" duration: Float! "Total file size in bytes" filesize: Float! scenes: [Scene!]! } input SceneParserInput { ignoreWords: [String!] whitespaceCharacters: String capitalizeTitle: Boolean ignoreOrganized: Boolean } type SceneMovieID { movie_id: ID! scene_index: String } type SceneParserResult { scene: Scene! title: String code: String details: String director: String url: String date: String # rating expressed as 1-5 rating: Int @deprecated(reason: "Use 1-100 range with rating100") # rating expressed as 1-100 rating100: Int studio_id: ID gallery_ids: [ID!] performer_ids: [ID!] movies: [SceneMovieID!] tag_ids: [ID!] } type SceneParserResultType { count: Int! results: [SceneParserResult!]! } input SceneHashInput { checksum: String oshash: String } type SceneStreamEndpoint { url: String! mime_type: String label: String } input AssignSceneFileInput { scene_id: ID! file_id: ID! } input SceneMergeInput { """ If destination scene has no files, then the primary file of the first source scene will be assigned as primary """ source: [ID!]! destination: ID! # values defined here will override values in the destination values: SceneUpdateInput # if true, the source history will be combined with the destination play_history: Boolean o_history: Boolean } type HistoryMutationResult { count: Int! history: [Time!]! } ================================================ FILE: graphql/schema/types/scraped-group.graphql ================================================ "A movie from a scraping operation..." type ScrapedMovie { stored_id: ID name: String aliases: String duration: String date: String rating: String director: String url: String @deprecated(reason: "use urls") urls: [String!] synopsis: String studio: ScrapedStudio tags: [ScrapedTag!] "This should be a base64 encoded data URL" front_image: String "This should be a base64 encoded data URL" back_image: String } input ScrapedMovieInput { name: String aliases: String duration: String date: String rating: String director: String url: String @deprecated(reason: "use urls") urls: [String!] synopsis: String # not including tags for the input } "A group from a scraping operation..." type ScrapedGroup { stored_id: ID name: String aliases: String duration: String date: String rating: String director: String urls: [String!] synopsis: String studio: ScrapedStudio tags: [ScrapedTag!] "This should be a base64 encoded data URL" front_image: String "This should be a base64 encoded data URL" back_image: String } input ScrapedGroupInput { name: String aliases: String duration: String date: String rating: String director: String urls: [String!] synopsis: String # not including tags for the input } ================================================ FILE: graphql/schema/types/scraped-performer.graphql ================================================ "A performer from a scraping operation..." type ScrapedPerformer { "Set if performer matched" stored_id: ID name: String disambiguation: String gender: String url: String @deprecated(reason: "use urls") urls: [String!] twitter: String @deprecated(reason: "use urls") instagram: String @deprecated(reason: "use urls") birthdate: String ethnicity: String country: String eye_color: String height: String measurements: String fake_tits: String penis_length: String circumcised: String career_length: String @deprecated(reason: "Use career_start and career_end") career_start: String career_end: String tattoos: String piercings: String # aliases must be comma-delimited to be parsed correctly aliases: String tags: [ScrapedTag!] "This should be a base64 encoded data URL" image: String @deprecated(reason: "use images instead") images: [String!] details: String death_date: String hair_color: String weight: String remote_site_id: String } input ScrapedPerformerInput { "Set if performer matched" stored_id: ID name: String disambiguation: String gender: String url: String @deprecated(reason: "use urls") urls: [String!] twitter: String @deprecated(reason: "use urls") instagram: String @deprecated(reason: "use urls") birthdate: String ethnicity: String country: String eye_color: String height: String measurements: String fake_tits: String penis_length: String circumcised: String career_length: String @deprecated(reason: "Use career_start and career_end") career_start: String career_end: String tattoos: String piercings: String aliases: String # not including tags for the input # not including image for the input details: String death_date: String hair_color: String weight: String remote_site_id: String } ================================================ FILE: graphql/schema/types/scraper.graphql ================================================ enum ScrapeType { "From text query" NAME "From existing object" FRAGMENT "From URL" URL } "Type of the content a scraper generates" enum ScrapeContentType { GALLERY IMAGE MOVIE GROUP PERFORMER SCENE } "Scraped Content is the forming union over the different scrapers" union ScrapedContent = ScrapedStudio | ScrapedTag | ScrapedScene | ScrapedGallery | ScrapedImage | ScrapedMovie | ScrapedGroup | ScrapedPerformer type ScraperSpec { "URLs matching these can be scraped with" urls: [String!] supported_scrapes: [ScrapeType!]! } type Scraper { id: ID! name: String! "Details for performer scraper" performer: ScraperSpec "Details for scene scraper" scene: ScraperSpec "Details for gallery scraper" gallery: ScraperSpec "Details for image scraper" image: ScraperSpec "Details for movie scraper" movie: ScraperSpec @deprecated(reason: "use group") "Details for group scraper" group: ScraperSpec } type ScrapedStudio { "Set if studio matched" stored_id: ID name: String! url: String @deprecated(reason: "use urls") urls: [String!] parent: ScrapedStudio image: String details: String "Aliases must be comma-delimited to be parsed correctly" aliases: String tags: [ScrapedTag!] remote_site_id: String } type ScrapedTag { "Set if tag matched" stored_id: ID name: String! description: String alias_list: [String!] parent: ScrapedTag "Remote site ID, if applicable" remote_site_id: String } type ScrapedScene { title: String code: String details: String director: String url: String @deprecated(reason: "use urls") urls: [String!] date: String "This should be a base64 encoded data URL" image: String file: SceneFileType # Resolver studio: ScrapedStudio tags: [ScrapedTag!] performers: [ScrapedPerformer!] movies: [ScrapedMovie!] @deprecated(reason: "use groups") groups: [ScrapedGroup!] remote_site_id: String duration: Int fingerprints: [StashBoxFingerprint!] } input ScrapedSceneInput { title: String code: String details: String director: String url: String @deprecated(reason: "use urls") urls: [String!] date: String # no image, file, duration or relationships remote_site_id: String } type ScrapedGallery { title: String code: String details: String photographer: String url: String @deprecated(reason: "use urls") urls: [String!] date: String studio: ScrapedStudio tags: [ScrapedTag!] performers: [ScrapedPerformer!] } input ScrapedGalleryInput { title: String code: String details: String photographer: String url: String @deprecated(reason: "use urls") urls: [String!] date: String # no studio, tags or performers } type ScrapedImage { title: String code: String details: String photographer: String urls: [String!] date: String studio: ScrapedStudio tags: [ScrapedTag!] performers: [ScrapedPerformer!] } input ScrapedImageInput { title: String code: String details: String urls: [String!] date: String } input ScraperSourceInput { "Index of the configured stash-box instance to use. Should be unset if scraper_id is set" stash_box_index: Int @deprecated(reason: "use stash_box_endpoint") "Stash-box endpoint" stash_box_endpoint: String "Scraper ID to scrape with. Should be unset if stash_box_endpoint/stash_box_index is set" scraper_id: ID } type ScraperSource { "Index of the configured stash-box instance to use. Should be unset if scraper_id is set" stash_box_index: Int @deprecated(reason: "use stash_box_endpoint") "Stash-box endpoint" stash_box_endpoint: String "Scraper ID to scrape with. Should be unset if stash_box_endpoint/stash_box_index is set" scraper_id: ID } input ScrapeSingleSceneInput { "Instructs to query by string" query: String "Instructs to query by scene fingerprints" scene_id: ID "Instructs to query by scene fragment" scene_input: ScrapedSceneInput } input ScrapeMultiScenesInput { "Instructs to query by scene fingerprints" scene_ids: [ID!] } input ScrapeSingleStudioInput { """ Query can be either a name or a Stash ID """ query: String } input ScrapeSingleTagInput { """ Query can be either a name or a Stash ID """ query: String } input ScrapeSinglePerformerInput { "Instructs to query by string" query: String "Instructs to query by performer id" performer_id: ID "Instructs to query by performer fragment" performer_input: ScrapedPerformerInput } input ScrapeMultiPerformersInput { "Instructs to query by scene fingerprints" performer_ids: [ID!] } input ScrapeSingleGalleryInput { "Instructs to query by string" query: String "Instructs to query by gallery id" gallery_id: ID "Instructs to query by gallery fragment" gallery_input: ScrapedGalleryInput } input ScrapeSingleImageInput { "Instructs to query by string" query: String "Instructs to query by image id" image_id: ID "Instructs to query by image fragment" image_input: ScrapedImageInput } input ScrapeSingleMovieInput { "Instructs to query by string" query: String "Instructs to query by movie id" movie_id: ID "Instructs to query by movie fragment" movie_input: ScrapedMovieInput } input ScrapeSingleGroupInput { "Instructs to query by string" query: String "Instructs to query by group id" group_id: ID "Instructs to query by group fragment" group_input: ScrapedGroupInput } input StashBoxSceneQueryInput { "Index of the configured stash-box instance to use" stash_box_index: Int @deprecated(reason: "use stash_box_endpoint") "Endpoint of the stash-box instance to use" stash_box_endpoint: String "Instructs query by scene fingerprints" scene_ids: [ID!] "Query by query string" q: String } input StashBoxPerformerQueryInput { "Index of the configured stash-box instance to use" stash_box_index: Int @deprecated(reason: "use stash_box_endpoint") "Endpoint of the stash-box instance to use" stash_box_endpoint: String "Instructs query by scene fingerprints" performer_ids: [ID!] "Query by query string" q: String } type StashBoxPerformerQueryResult { query: String! results: [ScrapedPerformer!]! } type StashBoxFingerprint { algorithm: String! hash: String! duration: Int! } """ Accepts either ids, or a combination of names and stash_ids. If none are set, then all existing items will be tagged. """ input StashBoxBatchTagInput { "Stash endpoint to use for the tagging" endpoint: Int @deprecated(reason: "use stash_box_endpoint") "Endpoint of the stash-box instance to use" stash_box_endpoint: String "Fields to exclude when executing the tagging" exclude_fields: [String!] "Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false" refresh: Boolean! "If batch adding studios, should their parent studios also be created?" createParent: Boolean! """ IDs in stash of the items to update. If set, names and stash_ids fields will be ignored. """ ids: [ID!] "Names of the items in the stash-box instance to search for and create" names: [String!] "Stash IDs of the items in the stash-box instance to search for and create" stash_ids: [String!] "IDs in stash of the performers to update" performer_ids: [ID!] @deprecated(reason: "use ids") "Names of the performers in the stash-box instance to search for and create" performer_names: [String!] @deprecated(reason: "use names") } ================================================ FILE: graphql/schema/types/sql.graphql ================================================ type SQLQueryResult { "The column names, in the order they appear in the result set." columns: [String!]! "The returned rows." rows: [[Any]!]! } type SQLExecResult { """ The number of rows affected by the query, usually an UPDATE, INSERT, or DELETE. Not all queries or databases support this feature. """ rows_affected: Int64 """ The integer generated by the database in response to a command. Typically this will be from an "auto increment" column when inserting a new row. Not all databases support this feature, and the syntax of such statements varies. """ last_insert_id: Int64 } ================================================ FILE: graphql/schema/types/stash-box.graphql ================================================ type StashBox { endpoint: String! api_key: String! name: String! max_requests_per_minute: Int! } input StashBoxInput { endpoint: String! api_key: String! name: String! # defaults to 240 max_requests_per_minute: Int } type StashID { endpoint: String! stash_id: String! updated_at: Time! } input StashIDInput { endpoint: String! stash_id: String! updated_at: Time } input StashBoxFingerprintSubmissionInput { scene_ids: [String!]! stash_box_index: Int @deprecated(reason: "use stash_box_endpoint") stash_box_endpoint: String } input StashBoxDraftSubmissionInput { id: String! stash_box_index: Int @deprecated(reason: "use stash_box_endpoint") stash_box_endpoint: String } ================================================ FILE: graphql/schema/types/stats.graphql ================================================ type StatsResultType { scene_count: Int! scenes_size: Float! scenes_duration: Float! image_count: Int! images_size: Float! gallery_count: Int! performer_count: Int! studio_count: Int! group_count: Int! movie_count: Int! @deprecated(reason: "use group_count instead") tag_count: Int! total_o_count: Int! total_play_duration: Float! total_play_count: Int! scenes_played: Int! } ================================================ FILE: graphql/schema/types/studio.graphql ================================================ type Studio { id: ID! name: String! url: String @deprecated(reason: "Use urls") urls: [String!]! parent_studio: Studio child_studios: [Studio!]! aliases: [String!]! tags: [Tag!]! ignore_auto_tag: Boolean! organized: Boolean! image_path: String # Resolver scene_count(depth: Int): Int! # Resolver image_count(depth: Int): Int! # Resolver gallery_count(depth: Int): Int! # Resolver performer_count(depth: Int): Int! # Resolver group_count(depth: Int): Int! # Resolver movie_count(depth: Int): Int! @deprecated(reason: "use group_count instead") # Resolver stash_ids: [StashID!]! # rating expressed as 1-100 rating100: Int favorite: Boolean! details: String created_at: Time! updated_at: Time! groups: [Group!]! movies: [Movie!]! @deprecated(reason: "use groups instead") o_counter: Int custom_fields: Map! } input StudioCreateInput { name: String! url: String @deprecated(reason: "Use urls") urls: [String!] parent_id: ID "This should be a URL or a base64 encoded data URL" image: String stash_ids: [StashIDInput!] # rating expressed as 1-100 rating100: Int favorite: Boolean details: String "Duplicate aliases and those equal to name will be ignored (case-insensitive)" aliases: [String!] tag_ids: [ID!] ignore_auto_tag: Boolean organized: Boolean custom_fields: Map } input StudioUpdateInput { id: ID! name: String url: String @deprecated(reason: "Use urls") urls: [String!] parent_id: ID "This should be a URL or a base64 encoded data URL" image: String stash_ids: [StashIDInput!] # rating expressed as 1-100 rating100: Int favorite: Boolean details: String "Duplicate aliases and those equal to name will be ignored (case-insensitive)" aliases: [String!] tag_ids: [ID!] ignore_auto_tag: Boolean organized: Boolean custom_fields: CustomFieldsInput } input BulkStudioUpdateInput { ids: [ID!]! url: String @deprecated(reason: "Use urls") urls: BulkUpdateStrings parent_id: ID # rating expressed as 1-100 rating100: Int favorite: Boolean details: String tag_ids: BulkUpdateIds ignore_auto_tag: Boolean organized: Boolean } input StudioDestroyInput { id: ID! } type FindStudiosResultType { count: Int! studios: [Studio!]! } ================================================ FILE: graphql/schema/types/tag.graphql ================================================ type Tag { id: ID! name: String! "Value that does not appear in the UI but overrides name for sorting" sort_name: String description: String aliases: [String!]! ignore_auto_tag: Boolean! created_at: Time! updated_at: Time! favorite: Boolean! stash_ids: [StashID!]! image_path: String # Resolver scene_count(depth: Int): Int! # Resolver scene_marker_count(depth: Int): Int! # Resolver image_count(depth: Int): Int! # Resolver gallery_count(depth: Int): Int! # Resolver performer_count(depth: Int): Int! # Resolver studio_count(depth: Int): Int! # Resolver group_count(depth: Int): Int! # Resolver movie_count(depth: Int): Int! @deprecated(reason: "use group_count instead") # Resolver parents: [Tag!]! children: [Tag!]! parent_count: Int! # Resolver child_count: Int! # Resolver custom_fields: Map! } input TagCreateInput { name: String! "Value that does not appear in the UI but overrides name for sorting" sort_name: String description: String "Duplicate aliases and those equal to name will be ignored (case-insensitive)" aliases: [String!] ignore_auto_tag: Boolean favorite: Boolean "This should be a URL or a base64 encoded data URL" image: String stash_ids: [StashIDInput!] parent_ids: [ID!] child_ids: [ID!] custom_fields: Map } input TagUpdateInput { id: ID! name: String "Value that does not appear in the UI but overrides name for sorting" sort_name: String description: String "Duplicate aliases and those equal to name will be ignored (case-insensitive)" aliases: [String!] ignore_auto_tag: Boolean favorite: Boolean "This should be a URL or a base64 encoded data URL" image: String stash_ids: [StashIDInput!] parent_ids: [ID!] child_ids: [ID!] custom_fields: CustomFieldsInput } input TagDestroyInput { id: ID! } type FindTagsResultType { count: Int! tags: [Tag!]! } input TagsMergeInput { source: [ID!]! destination: ID! # values defined here will override values in the destination values: TagUpdateInput } input BulkTagUpdateInput { ids: [ID!] description: String "Duplicate aliases and those equal to name will result in an error (case-insensitive)" aliases: BulkUpdateStrings ignore_auto_tag: Boolean favorite: Boolean parent_ids: BulkUpdateIds child_ids: BulkUpdateIds } ================================================ FILE: graphql/schema/types/version.graphql ================================================ type Version { version: String hash: String! build_time: String! } type LatestVersion { version: String! shorthash: String! release_date: String! url: String! } ================================================ FILE: graphql/stash-box/query.graphql ================================================ fragment URLFragment on URL { url type } fragment ImageFragment on Image { id url width height } fragment StudioFragment on Studio { name id aliases urls { ...URLFragment } parent { name id } images { ...ImageFragment } } fragment TagFragment on Tag { name id description aliases category { id name description } } fragment MeasurementsFragment on Measurements { band_size cup_size waist hip } fragment BodyModificationFragment on BodyModification { location description } fragment PerformerFragment on Performer { id name disambiguation aliases gender merged_ids deleted merged_into_id urls { ...URLFragment } images { ...ImageFragment } birth_date death_date ethnicity country eye_color hair_color height measurements { ...MeasurementsFragment } breast_type career_start_year career_end_year tattoos { ...BodyModificationFragment } piercings { ...BodyModificationFragment } } fragment PerformerAppearanceFragment on PerformerAppearance { as performer { ...PerformerFragment } } fragment FingerprintFragment on Fingerprint { algorithm hash duration } fragment SceneFragment on Scene { id title code details director duration date urls { ...URLFragment } images { ...ImageFragment } studio { ...StudioFragment } tags { ...TagFragment } performers { ...PerformerAppearanceFragment } fingerprints { ...FingerprintFragment } } query FindScenesBySceneFingerprints( $fingerprints: [[FingerprintQueryInput!]!]! ) { findScenesBySceneFingerprints(fingerprints: $fingerprints) { ...SceneFragment } } query SearchScene($term: String!) { searchScene(term: $term) { ...SceneFragment } } query SearchPerformer($term: String!) { searchPerformer(term: $term) { ...PerformerFragment } } query FindPerformerByID($id: ID!) { findPerformer(id: $id) { ...PerformerFragment } } query FindSceneByID($id: ID!) { findScene(id: $id) { ...SceneFragment } } query FindStudio($id: ID, $name: String) { findStudio(id: $id, name: $name) { ...StudioFragment } } query FindTag($id: ID, $name: String) { findTag(id: $id, name: $name) { ...TagFragment } } query QueryTags($input: TagQueryInput!) { queryTags(input: $input) { count tags { ...TagFragment } } } mutation SubmitFingerprint($input: FingerprintSubmission!) { submitFingerprint(input: $input) } query Me { me { name } } mutation SubmitSceneDraft($input: SceneDraftInput!) { submitSceneDraft(input: $input) { id } } mutation SubmitPerformerDraft($input: PerformerDraftInput!) { submitPerformerDraft(input: $input) { id } } ================================================ FILE: internal/api/authentication.go ================================================ package api import ( "errors" "net" "net/http" "net/url" "path" "strings" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/session" ) const ( tripwireActivatedErrMsg = "Stash is exposed to the public internet without authentication, and is not serving any more content to protect your privacy. " + "More information and fixes are available at https://discourse.stashapp.cc/t/-/1658" externalAccessErrMsg = "You have attempted to access Stash over the internet, and authentication is not enabled. " + "This is extremely dangerous! The whole world can see your your stash page and browse your files! " + "Stash is not answering any other requests to protect your privacy. " + "Please read the log entry or visit https://discourse.stashapp.cc/t/-/1658" ) func allowUnauthenticated(r *http.Request) bool { // #2715 - allow access to UI files return strings.HasPrefix(r.URL.Path, loginEndpoint) || r.URL.Path == logoutEndpoint || r.URL.Path == "/css" || strings.HasPrefix(r.URL.Path, "/assets") } func authenticateHandler() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c := config.GetInstance() // error if external access tripwire activated if accessErr := session.CheckExternalAccessTripwire(c); accessErr != nil { http.Error(w, tripwireActivatedErrMsg, http.StatusForbidden) return } r = session.SetLocalRequest(r) userID, err := manager.GetInstance().SessionStore.Authenticate(w, r) if err != nil { if !errors.Is(err, session.ErrUnauthorized) { http.Error(w, err.Error(), http.StatusInternalServerError) return } // unauthorized error w.Header().Add("WWW-Authenticate", "FormBased") w.WriteHeader(http.StatusUnauthorized) return } if err := session.CheckAllowPublicWithoutAuth(c, r); err != nil { var accessErr session.ExternalAccessError if errors.As(err, &accessErr) { session.LogExternalAccessError(accessErr) err := c.ActivatePublicAccessTripwire(net.IP(accessErr).String()) if err != nil { logger.Errorf("Error activating public access tripwire: %v", err) } http.Error(w, externalAccessErrMsg, http.StatusForbidden) } else { logger.Errorf("Error checking external access security: %v", err) w.WriteHeader(http.StatusInternalServerError) } return } ctx := r.Context() if c.HasCredentials() { // authentication is required if userID == "" && !allowUnauthenticated(r) { // if graphql or a non-webpage was requested, we just return a forbidden error ext := path.Ext(r.URL.Path) if r.URL.Path == gqlEndpoint || (ext != "" && ext != ".html") { w.Header().Add("WWW-Authenticate", "FormBased") w.WriteHeader(http.StatusUnauthorized) return } prefix := getProxyPrefix(r) // otherwise redirect to the login page returnURL := url.URL{ Path: prefix + r.URL.Path, RawQuery: r.URL.RawQuery, } q := make(url.Values) q.Set(returnURLParam, returnURL.String()) u := url.URL{ Path: prefix + loginEndpoint, RawQuery: q.Encode(), } http.Redirect(w, r, u.String(), http.StatusFound) return } } ctx = session.SetCurrentUserID(ctx, userID) r = r.WithContext(ctx) next.ServeHTTP(w, r) }) } } ================================================ FILE: internal/api/bool_map.go ================================================ package api import ( "encoding/json" "fmt" "io" "github.com/99designs/gqlgen/graphql" ) func MarshalBoolMap(val map[string]bool) graphql.Marshaler { return graphql.WriterFunc(func(w io.Writer) { err := json.NewEncoder(w).Encode(val) if err != nil { panic(err) } }) } func UnmarshalBoolMap(v interface{}) (map[string]bool, error) { m, ok := v.(map[string]interface{}) if !ok { return nil, fmt.Errorf("%T is not a map", v) } result := make(map[string]bool) for k, v := range m { key := k val, ok := v.(bool) if !ok { return nil, fmt.Errorf("key %s (%T) is not a bool", k, v) } result[key] = val } return result, nil } ================================================ FILE: internal/api/changeset_translator.go ================================================ package api import ( "context" "fmt" "strconv" "strings" "github.com/99designs/gqlgen/graphql" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) const updateInputField = "input" func getArgumentMap(ctx context.Context) map[string]interface{} { rctx := graphql.GetFieldContext(ctx) reqCtx := graphql.GetOperationContext(ctx) return rctx.Field.ArgumentMap(reqCtx.Variables) } func getUpdateInputMap(ctx context.Context) map[string]interface{} { return getNamedUpdateInputMap(ctx, updateInputField) } func getNamedUpdateInputMap(ctx context.Context, field string) map[string]interface{} { args := getArgumentMap(ctx) // field can be qualified fields := strings.Split(field, ".") currArgs := args for _, f := range fields { v, found := currArgs[f] if !found { currArgs = nil break } currArgs, _ = v.(map[string]interface{}) if currArgs == nil { break } } if currArgs != nil { return currArgs } return make(map[string]interface{}) } func getUpdateInputMaps(ctx context.Context) []map[string]interface{} { args := getArgumentMap(ctx) input := args[updateInputField] var ret []map[string]interface{} if input != nil { // convert []interface{} into []map[string]interface{} iSlice, _ := input.([]interface{}) for _, i := range iSlice { m, _ := i.(map[string]interface{}) if m != nil { ret = append(ret, m) } } } return ret } type changesetTranslator struct { inputMap map[string]interface{} } func (t changesetTranslator) hasField(field string) bool { if t.inputMap == nil { return false } _, found := t.inputMap[field] return found } func (t changesetTranslator) getFields() []string { var ret []string for k := range t.inputMap { ret = append(ret, k) } return ret } func (t changesetTranslator) string(value *string) string { if value == nil { return "" } return strings.TrimSpace(*value) } func (t changesetTranslator) optionalString(value *string, field string) models.OptionalString { if !t.hasField(field) { return models.OptionalString{} } if value == nil { return models.NewOptionalStringPtr(nil) } trimmed := strings.TrimSpace(*value) return models.NewOptionalString(trimmed) } func (t changesetTranslator) optionalDate(value *string, field string) (models.OptionalDate, error) { if !t.hasField(field) { return models.OptionalDate{}, nil } if value == nil || *value == "" { return models.OptionalDate{ Set: true, Null: true, }, nil } date, err := models.ParseDate(*value) if err != nil { return models.OptionalDate{}, err } return models.NewOptionalDate(date), nil } func (t changesetTranslator) datePtr(value *string) (*models.Date, error) { if value == nil || *value == "" { return nil, nil } date, err := models.ParseDate(*value) if err != nil { return nil, err } return &date, nil } func (t changesetTranslator) intPtrFromString(value *string) (*int, error) { if value == nil || *value == "" { return nil, nil } vv, err := strconv.Atoi(*value) if err != nil { return nil, fmt.Errorf("converting %v to int: %w", *value, err) } return &vv, nil } func (t changesetTranslator) optionalInt(value *int, field string) models.OptionalInt { if !t.hasField(field) { return models.OptionalInt{} } return models.NewOptionalIntPtr(value) } func (t changesetTranslator) optionalIntFromString(value *string, field string) (models.OptionalInt, error) { if !t.hasField(field) { return models.OptionalInt{}, nil } if value == nil { return models.OptionalInt{ Set: true, Null: true, }, nil } vv, err := strconv.Atoi(*value) if err != nil { return models.OptionalInt{}, fmt.Errorf("converting %v to int: %w", *value, err) } return models.NewOptionalInt(vv), nil } func (t changesetTranslator) bool(value *bool) bool { if value == nil { return false } return *value } func (t changesetTranslator) optionalBool(value *bool, field string) models.OptionalBool { if !t.hasField(field) { return models.OptionalBool{} } return models.NewOptionalBoolPtr(value) } func (t changesetTranslator) optionalFloat64(value *float64, field string) models.OptionalFloat64 { if !t.hasField(field) { return models.OptionalFloat64{} } return models.NewOptionalFloat64Ptr(value) } func (t changesetTranslator) fileIDPtrFromString(value *string) (*models.FileID, error) { if value == nil || *value == "" { return nil, nil } vv, err := strconv.Atoi(*value) if err != nil { return nil, fmt.Errorf("converting %v to int: %w", *value, err) } id := models.FileID(vv) return &id, nil } func (t changesetTranslator) fileIDSliceFromStringSlice(value []string) ([]models.FileID, error) { ints, err := stringslice.StringSliceToIntSlice(value) if err != nil { return nil, err } fileIDs := make([]models.FileID, len(ints)) for i, v := range ints { fileIDs[i] = models.FileID(v) } return fileIDs, nil } func (t changesetTranslator) relatedIds(value []string) (models.RelatedIDs, error) { ids, err := stringslice.StringSliceToIntSlice(value) if err != nil { return models.RelatedIDs{}, err } return models.NewRelatedIDs(ids), nil } func (t changesetTranslator) updateIds(value []string, field string) (*models.UpdateIDs, error) { if !t.hasField(field) { return nil, nil } ids, err := stringslice.StringSliceToIntSlice(value) if err != nil { return nil, err } return &models.UpdateIDs{ IDs: ids, Mode: models.RelationshipUpdateModeSet, }, nil } func (t changesetTranslator) updateIdsBulk(value *BulkUpdateIds, field string) (*models.UpdateIDs, error) { if !t.hasField(field) || value == nil { return nil, nil } ids, err := stringslice.StringSliceToIntSlice(value.Ids) if err != nil { return nil, fmt.Errorf("converting ids [%v]: %w", value.Ids, err) } return &models.UpdateIDs{ IDs: ids, Mode: value.Mode, }, nil } func (t changesetTranslator) optionalURLs(value []string, legacyValue *string) *models.UpdateStrings { const ( legacyField = "url" field = "urls" ) // prefer urls over url if t.hasField(field) { return t.updateStrings(value, field) } else if t.hasField(legacyField) { var valueSlice []string if legacyValue != nil { valueSlice = []string{*legacyValue} } return t.updateStrings(valueSlice, legacyField) } return nil } func (t changesetTranslator) optionalURLsBulk(value *BulkUpdateStrings, legacyValue *string) *models.UpdateStrings { const ( legacyField = "url" field = "urls" ) // prefer urls over url if t.hasField("urls") { return t.updateStringsBulk(value, field) } else if t.hasField(legacyField) { var valueSlice []string if legacyValue != nil { valueSlice = []string{*legacyValue} } return t.updateStrings(valueSlice, legacyField) } return nil } func (t changesetTranslator) updateStrings(value []string, field string) *models.UpdateStrings { if !t.hasField(field) { return nil } // Trim whitespace from each string trimmedValues := make([]string, len(value)) for i, v := range value { trimmedValues[i] = strings.TrimSpace(v) } return &models.UpdateStrings{ Values: trimmedValues, Mode: models.RelationshipUpdateModeSet, } } func (t changesetTranslator) updateStringsBulk(value *BulkUpdateStrings, field string) *models.UpdateStrings { if !t.hasField(field) || value == nil { return nil } // Trim whitespace from each string trimmedValues := make([]string, len(value.Values)) for i, v := range value.Values { trimmedValues[i] = strings.TrimSpace(v) } return &models.UpdateStrings{ Values: trimmedValues, Mode: value.Mode, } } func (t changesetTranslator) updateStashIDs(value models.StashIDInputs, field string) *models.UpdateStashIDs { if !t.hasField(field) { return nil } return &models.UpdateStashIDs{ StashIDs: value.ToStashIDs(), Mode: models.RelationshipUpdateModeSet, } } func (t changesetTranslator) relatedGroupsFromMovies(value []models.SceneMovieInput) (models.RelatedGroups, error) { groupsScenes, err := models.GroupsScenesFromInput(value) if err != nil { return models.RelatedGroups{}, err } return models.NewRelatedGroups(groupsScenes), nil } func groupsScenesFromGroupInput(input []models.SceneGroupInput) ([]models.GroupsScenes, error) { ret := make([]models.GroupsScenes, len(input)) for i, v := range input { mID, err := strconv.Atoi(v.GroupID) if err != nil { return nil, fmt.Errorf("invalid group ID: %s", v.GroupID) } ret[i] = models.GroupsScenes{ GroupID: mID, SceneIndex: v.SceneIndex, } } return ret, nil } func (t changesetTranslator) relatedGroups(value []models.SceneGroupInput) (models.RelatedGroups, error) { groupsScenes, err := groupsScenesFromGroupInput(value) if err != nil { return models.RelatedGroups{}, err } return models.NewRelatedGroups(groupsScenes), nil } func (t changesetTranslator) updateGroupIDsFromMovies(value []models.SceneMovieInput, field string) (*models.UpdateGroupIDs, error) { if !t.hasField(field) { return nil, nil } groupsScenes, err := models.GroupsScenesFromInput(value) if err != nil { return nil, err } return &models.UpdateGroupIDs{ Groups: groupsScenes, Mode: models.RelationshipUpdateModeSet, }, nil } func (t changesetTranslator) updateGroupIDs(value []models.SceneGroupInput, field string) (*models.UpdateGroupIDs, error) { if !t.hasField(field) { return nil, nil } groupsScenes, err := groupsScenesFromGroupInput(value) if err != nil { return nil, err } return &models.UpdateGroupIDs{ Groups: groupsScenes, Mode: models.RelationshipUpdateModeSet, }, nil } func (t changesetTranslator) updateGroupIDsBulk(value *BulkUpdateIds, field string) (*models.UpdateGroupIDs, error) { if !t.hasField(field) || value == nil { return nil, nil } ids, err := stringslice.StringSliceToIntSlice(value.Ids) if err != nil { return nil, fmt.Errorf("converting ids [%v]: %w", value.Ids, err) } groups := make([]models.GroupsScenes, len(ids)) for i, id := range ids { groups[i] = models.GroupsScenes{GroupID: id} } return &models.UpdateGroupIDs{ Groups: groups, Mode: value.Mode, }, nil } func groupsDescriptionsFromGroupInput(input []*GroupDescriptionInput) ([]models.GroupIDDescription, error) { ret := make([]models.GroupIDDescription, len(input)) for i, v := range input { gID, err := strconv.Atoi(v.GroupID) if err != nil { return nil, fmt.Errorf("invalid group ID: %s", v.GroupID) } ret[i] = models.GroupIDDescription{ GroupID: gID, } if v.Description != nil { ret[i].Description = strings.TrimSpace(*v.Description) } } return ret, nil } func (t changesetTranslator) groupIDDescriptions(value []*GroupDescriptionInput) (models.RelatedGroupDescriptions, error) { groupsScenes, err := groupsDescriptionsFromGroupInput(value) if err != nil { return models.RelatedGroupDescriptions{}, err } return models.NewRelatedGroupDescriptions(groupsScenes), nil } func (t changesetTranslator) updateGroupIDDescriptions(value []*GroupDescriptionInput, field string) (*models.UpdateGroupDescriptions, error) { if !t.hasField(field) { return nil, nil } groupsScenes, err := groupsDescriptionsFromGroupInput(value) if err != nil { return nil, err } return &models.UpdateGroupDescriptions{ Groups: groupsScenes, Mode: models.RelationshipUpdateModeSet, }, nil } func (t changesetTranslator) updateGroupIDDescriptionsBulk(value *BulkUpdateGroupDescriptionsInput, field string) (*models.UpdateGroupDescriptions, error) { if !t.hasField(field) || value == nil { return nil, nil } groups, err := groupsDescriptionsFromGroupInput(value.Groups) if err != nil { return nil, err } return &models.UpdateGroupDescriptions{ Groups: groups, Mode: value.Mode, }, nil } ================================================ FILE: internal/api/check_version.go ================================================ package api import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" "regexp" "runtime" "strings" "time" "golang.org/x/sys/cpu" "github.com/stashapp/stash/internal/build" "github.com/stashapp/stash/pkg/logger" ) // we use the github REST V3 API as no login is required const apiReleases string = "https://api.github.com/repos/stashapp/stash/releases" const apiTags string = "https://api.github.com/repos/stashapp/stash/tags" const apiAcceptHeader string = "application/vnd.github.v3+json" const developmentTag string = "latest_develop" const defaultSHLength int = 8 // default length of SHA short hash returned by var stashReleases = func() map[string]string { return map[string]string{ "darwin/amd64": "stash-macos", "darwin/arm64": "stash-macos", "linux/amd64": "stash-linux", "windows/amd64": "stash-win.exe", "linux/arm": "stash-linux-arm32v6", "linux/arm64": "stash-linux-arm64v8", "linux/armv7": "stash-linux-arm32v7", } } // isMacOSBundle checks if the application is running from within a macOS .app bundle func isMacOSBundle() bool { exec, err := os.Executable() return err == nil && strings.Contains(exec, "Stash.app/") } // getWantedRelease determines which release variant to download based on platform and bundle type func getWantedRelease(platform string) string { release := stashReleases()[platform] // On macOS, check if running from .app bundle if runtime.GOOS == "darwin" && isMacOSBundle() { return "Stash.app.zip" } return release } type githubReleasesResponse struct { Url string Assets_url string Upload_url string Html_url string Id int64 Node_id string Tag_name string Target_commitish string Name string Draft bool Author githubAuthor Prerelease bool Created_at string Published_at string Assets []githubAsset Tarball_url string Zipball_url string Body string } type githubAuthor struct { Login string Id int64 Node_id string Avatar_url string Gravatar_id string Url string Html_url string Followers_url string Following_url string Gists_url string Starred_url string Subscriptions_url string Organizations_url string Repos_url string Events_url string Received_events_url string Type string Site_admin bool } type githubAsset struct { Url string Id int64 Node_id string Name string Label string Uploader githubAuthor Content_type string State string Size int64 Download_count int64 Created_at string Updated_at string Browser_download_url string } type githubTagResponse struct { Name string Zipball_url string Tarball_url string Commit struct { Sha string Url string } Node_id string } type LatestRelease struct { Version string Hash string ShortHash string Date string Url string } func makeGithubRequest(ctx context.Context, url string, output interface{}) error { transport := &http.Transport{Proxy: http.ProxyFromEnvironment} client := &http.Client{ Timeout: 3 * time.Second, Transport: transport, } req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req.Header.Add("Accept", apiAcceptHeader) // gh api recommendation , send header with api version logger.Debugf("Github API request: %s", url) response, err := client.Do(req) if err != nil { //lint:ignore ST1005 Github is a proper capitalized noun return fmt.Errorf("Github API request failed: %w", err) } if response.StatusCode != http.StatusOK { //lint:ignore ST1005 Github is a proper capitalized noun return fmt.Errorf("Github API request failed: %s", response.Status) } defer response.Body.Close() data, err := io.ReadAll(response.Body) if err != nil { //lint:ignore ST1005 Github is a proper capitalized noun return fmt.Errorf("Github API read response failed: %w", err) } err = json.Unmarshal(data, output) if err != nil { return fmt.Errorf("unmarshalling Github API response failed: %w", err) } return nil } // GetLatestRelease gets latest release information from github API // If running a build from the "master" branch, then the latest full release // is used, otherwise it uses the release that is tagged with "latest_develop" // which is the latest pre-release build. func GetLatestRelease(ctx context.Context) (*LatestRelease, error) { arch := runtime.GOARCH // https://en.wikipedia.org/wiki/Comparison_of_ARM_cores // armv6 doesn't support any of these features isARMv7 := cpu.ARM.HasNEON || cpu.ARM.HasVFPv3 || cpu.ARM.HasVFPv3D16 || cpu.ARM.HasVFPv4 if arch == "arm" && isARMv7 { arch = "armv7" } platform := fmt.Sprintf("%s/%s", runtime.GOOS, arch) wantedRelease := getWantedRelease(platform) url := apiReleases if build.IsDevelop() { // get the release tagged with the development tag url += "/tags/" + developmentTag } else { // just get the latest full release url += "/latest" } var release githubReleasesResponse err := makeGithubRequest(ctx, url, &release) if err != nil { return nil, err } version := release.Name if release.Prerelease { // find version in prerelease name re := regexp.MustCompile(`v[\w-\.]+-\d+-g[0-9a-f]+`) if match := re.FindString(version); match != "" { version = match } } latestHash, err := getReleaseHash(ctx, release.Tag_name) if err != nil { return nil, err } var releaseDate string if publishedAt, err := time.Parse(time.RFC3339, release.Published_at); err == nil { releaseDate = publishedAt.Format("2006-01-02") } var releaseUrl string if wantedRelease != "" { for _, asset := range release.Assets { if asset.Name == wantedRelease { releaseUrl = asset.Browser_download_url break } } } _, githash, _ := build.Version() shLength := len(githash) if shLength == 0 { shLength = defaultSHLength } return &LatestRelease{ Version: version, Hash: latestHash, ShortHash: latestHash[:shLength], Date: releaseDate, Url: releaseUrl, }, nil } func getReleaseHash(ctx context.Context, tagName string) (string, error) { // Start with a small page size if not searching for latest_develop perPage := 10 if tagName == developmentTag { perPage = 100 } // Limit to 5 pages, ie 500 tags - should be plenty for page := 1; page <= 5; { url := fmt.Sprintf("%s?per_page=%d&page=%d", apiTags, perPage, page) tags := []githubTagResponse{} err := makeGithubRequest(ctx, url, &tags) if err != nil { return "", err } for _, tag := range tags { if tag.Name == tagName { if len(tag.Commit.Sha) != 40 { return "", errors.New("invalid Github API response") } return tag.Commit.Sha, nil } } if len(tags) == 0 { break } // if not found in the first 10, search again on page 1 with the first 100 if perPage == 10 { perPage = 100 } else { page++ } } return "", errors.New("invalid Github API response") } func printLatestVersion(ctx context.Context) { latestRelease, err := GetLatestRelease(ctx) if err != nil { logger.Errorf("Couldn't retrieve latest version: %v", err) } else { _, githash, _ := build.Version() switch { case githash == "": logger.Infof("Latest version: %s (%s)", latestRelease.Version, latestRelease.ShortHash) case githash == latestRelease.ShortHash: logger.Infof("Version %s (%s) is already the latest released", latestRelease.Version, latestRelease.ShortHash) default: logger.Infof("New version available: %s (%s)", latestRelease.Version, latestRelease.ShortHash) } } } ================================================ FILE: internal/api/context_keys.go ================================================ package api // https://stackoverflow.com/questions/40891345/fix-should-not-use-basic-type-string-as-key-in-context-withvalue-golint type key int const ( galleryKey key = 0 performerKey sceneKey studioKey groupKey tagKey downloadKey imageKey pluginKey ) ================================================ FILE: internal/api/custom_fields.go ================================================ package api import "github.com/stashapp/stash/pkg/models" func handleUpdateCustomFields(input models.CustomFieldsInput) models.CustomFieldsInput { ret := input // convert json.Numbers to int/float ret.Full = convertMapJSONNumbers(ret.Full) ret.Partial = convertMapJSONNumbers(ret.Partial) return ret } ================================================ FILE: internal/api/dir_list.go ================================================ package api import ( "io/fs" "os" "path/filepath" "strings" "golang.org/x/text/collate" ) type dirLister []fs.DirEntry func (s dirLister) Len() int { return len(s) } func (s dirLister) Swap(i, j int) { s[j], s[i] = s[i], s[j] } func (s dirLister) Bytes(i int) []byte { return []byte(s[i].Name()) } // listDir will return the contents of a given directory path as a string slice func listDir(col *collate.Collator, path string) ([]string, error) { var dirPaths []string dirPath := path files, err := os.ReadDir(path) if err != nil { dirPath = filepath.Dir(path) dirFiles, err := os.ReadDir(dirPath) if err != nil { return dirPaths, err } // Filter dir contents by last path fragment if the dir isn't an exact match base := strings.ToLower(filepath.Base(path)) if base != "." && base != string(filepath.Separator) { for _, file := range dirFiles { if strings.HasPrefix(strings.ToLower(file.Name()), base) { files = append(files, file) } } } else { files = dirFiles } } if col != nil { col.Sort(dirLister(files)) } for _, file := range files { if !file.IsDir() { continue } dirPaths = append(dirPaths, filepath.Join(dirPath, file.Name())) } return dirPaths, nil } ================================================ FILE: internal/api/doc.go ================================================ // Package api provides the HTTP and Graphql API for the application. package api ================================================ FILE: internal/api/error.go ================================================ package api import ( "context" "encoding/json" "errors" "github.com/99designs/gqlgen/graphql" "github.com/stashapp/stash/pkg/logger" "github.com/vektah/gqlparser/v2/gqlerror" ) func gqlErrorHandler(ctx context.Context, e error) *gqlerror.Error { if !errors.Is(ctx.Err(), context.Canceled) { // log all errors - for now just log the error message // we can potentially add more context later fc := graphql.GetFieldContext(ctx) if fc != nil { logger.Errorf("%s: %v", fc.Path(), e) // log the args in debug level logger.DebugFunc(func() (string, []interface{}) { var args interface{} args = fc.Args s, _ := json.Marshal(args) if len(s) > 0 { args = string(s) } return "%s: %v", []interface{}{ fc.Path(), args, } }) } } // we may also want to transform the error message for the response // for now just return the original error return graphql.DefaultErrorPresenter(ctx, e) } ================================================ FILE: internal/api/fields.go ================================================ package api import ( "context" "github.com/99designs/gqlgen/graphql" ) type queryFields []string func collectQueryFields(ctx context.Context) queryFields { fields := graphql.CollectAllFields(ctx) return queryFields(fields) } func (f queryFields) Has(field string) bool { for _, v := range f { if v == field { return true } } return false } ================================================ FILE: internal/api/images.go ================================================ package api import ( "errors" "fmt" "io" "io/fs" "os" "strings" "github.com/stashapp/stash/internal/static" "github.com/stashapp/stash/pkg/hash" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) type imageBox struct { box fs.FS files []string } var imageBoxExts = []string{ ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp", ".avif", } func newImageBox(box fs.FS) (*imageBox, error) { ret := &imageBox{ box: box, } err := fs.WalkDir(box, ".", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } baseName := strings.ToLower(d.Name()) for _, ext := range imageBoxExts { if strings.HasSuffix(baseName, ext) { ret.files = append(ret.files, path) break } } return nil }) return ret, err } func (box *imageBox) GetRandomImageByName(name string) ([]byte, error) { files := box.files if len(files) == 0 { return nil, errors.New("box is empty") } index := hash.IntFromString(name) % uint64(len(files)) img, err := box.box.Open(files[index]) if err != nil { return nil, err } defer img.Close() return io.ReadAll(img) } var performerBox *imageBox var performerBoxMale *imageBox var performerBoxCustom *imageBox func init() { var err error performerBox, err = newImageBox(static.Sub(static.Performer)) if err != nil { panic(fmt.Sprintf("loading performer images: %v", err)) } performerBoxMale, err = newImageBox(static.Sub(static.PerformerMale)) if err != nil { panic(fmt.Sprintf("loading male performer images: %v", err)) } } func initCustomPerformerImages(customPath string) { if customPath != "" { logger.Debugf("Loading custom performer images from %s", customPath) var err error performerBoxCustom, err = newImageBox(os.DirFS(customPath)) if err != nil { logger.Warnf("error loading custom performer images from %s: %v", customPath, err) } } else { performerBoxCustom = nil } } func getDefaultPerformerImage(name string, gender *models.GenderEnum, sfwMode bool) []byte { // try the custom box first if we have one if performerBoxCustom != nil { ret, err := performerBoxCustom.GetRandomImageByName(name) if err == nil { return ret } logger.Warnf("error loading custom default performer image: %v", err) } if sfwMode { return static.ReadAll(static.DefaultSFWPerformerImage) } var g models.GenderEnum if gender != nil { g = *gender } var box *imageBox switch g { case models.GenderEnumFemale, models.GenderEnumTransgenderFemale: box = performerBox case models.GenderEnumMale, models.GenderEnumTransgenderMale: box = performerBoxMale default: box = performerBox } ret, err := box.GetRandomImageByName(name) if err != nil { logger.Warnf("error loading default performer image: %v", err) } return ret } ================================================ FILE: internal/api/input.go ================================================ package api import ( "fmt" "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) // TODO - apply handleIDs to other resolvers that accept ID lists // handleIDList validates and converts a list of string IDs to integers func handleIDList(idList []string, field string) ([]int, error) { if err := validateIDList(idList); err != nil { return nil, fmt.Errorf("validating %s: %w", field, err) } ids, err := stringslice.StringSliceToIntSlice(idList) if err != nil { return nil, fmt.Errorf("converting %s: %w", field, err) } return ids, nil } // validateIDList returns an error if there are any duplicate ids in the list func validateIDList(ids []string) error { seen := make(map[string]struct{}) for _, id := range ids { if _, exists := seen[id]; exists { return fmt.Errorf("duplicate id found: %s", id) } seen[id] = struct{}{} } return nil } ================================================ FILE: internal/api/json.go ================================================ package api import ( "encoding/json" "strings" "github.com/stashapp/stash/pkg/models" ) // jsonNumberToNumber converts a JSON number to either a float64 or int64. func jsonNumberToNumber(n json.Number) interface{} { if strings.Contains(string(n), ".") { f, _ := n.Float64() return f } ret, _ := n.Int64() return ret } // anyJSONNumberToNumber converts a JSON number using jsonNumberToNumber, otherwise it returns the existing value func anyJSONNumberToNumber(v any) any { if n, ok := v.(json.Number); ok { return jsonNumberToNumber(n) } return v } // ConvertMapJSONNumbers converts all JSON numbers in a map to either float64 or int64. func convertMapJSONNumbers(m map[string]interface{}) (ret map[string]interface{}) { if m == nil { return nil } ret = make(map[string]interface{}) for k, v := range m { if n, ok := v.(json.Number); ok { ret[k] = jsonNumberToNumber(n) } else if mm, ok := v.(map[string]interface{}); ok { ret[k] = convertMapJSONNumbers(mm) } else { ret[k] = v } } return ret } func convertCustomFieldCriterionValues(c models.CustomFieldCriterionInput) models.CustomFieldCriterionInput { nv := make([]any, len(c.Value)) for i, v := range c.Value { nv[i] = anyJSONNumberToNumber(v) } c.Value = nv return c } func convertCustomFieldCriterionInputJSONNumbers(c []models.CustomFieldCriterionInput) []models.CustomFieldCriterionInput { ret := make([]models.CustomFieldCriterionInput, len(c)) for i, v := range c { ret[i] = convertCustomFieldCriterionValues(v) } return ret } ================================================ FILE: internal/api/json_test.go ================================================ package api import ( "encoding/json" "testing" "github.com/stretchr/testify/assert" ) func TestConvertMapJSONNumbers(t *testing.T) { tests := []struct { name string input map[string]interface{} expected map[string]interface{} }{ { name: "Convert JSON numbers to numbers", input: map[string]interface{}{ "int": json.Number("12"), "float": json.Number("12.34"), "string": "foo", }, expected: map[string]interface{}{ "int": int64(12), "float": 12.34, "string": "foo", }, }, { name: "Convert JSON numbers to numbers in nested maps", input: map[string]interface{}{ "foo": map[string]interface{}{ "int": json.Number("56"), "float": json.Number("56.78"), "nested-string": "bar", }, "int": json.Number("12"), "float": json.Number("12.34"), "string": "foo", }, expected: map[string]interface{}{ "foo": map[string]interface{}{ "int": int64(56), "float": 56.78, "nested-string": "bar", }, "int": int64(12), "float": 12.34, "string": "foo", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := convertMapJSONNumbers(tt.input) assert.Equal(t, tt.expected, result) }) } } ================================================ FILE: internal/api/loaders/customfieldsloader_gen.go ================================================ // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. package loaders import ( "sync" "time" "github.com/stashapp/stash/pkg/models" ) // CustomFieldsLoaderConfig captures the config to create a new CustomFieldsLoader type CustomFieldsLoaderConfig struct { // Fetch is a method that provides the data for the loader Fetch func(keys []int) ([]models.CustomFieldMap, []error) // Wait is how long wait before sending a batch Wait time.Duration // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit MaxBatch int } // NewCustomFieldsLoader creates a new CustomFieldsLoader given a fetch, wait, and maxBatch func NewCustomFieldsLoader(config CustomFieldsLoaderConfig) *CustomFieldsLoader { return &CustomFieldsLoader{ fetch: config.Fetch, wait: config.Wait, maxBatch: config.MaxBatch, } } // CustomFieldsLoader batches and caches requests type CustomFieldsLoader struct { // this method provides the data for the loader fetch func(keys []int) ([]models.CustomFieldMap, []error) // how long to done before sending a batch wait time.Duration // this will limit the maximum number of keys to send in one batch, 0 = no limit maxBatch int // INTERNAL // lazily created cache cache map[int]models.CustomFieldMap // the current batch. keys will continue to be collected until timeout is hit, // then everything will be sent to the fetch method and out to the listeners batch *customFieldsLoaderBatch // mutex to prevent races mu sync.Mutex } type customFieldsLoaderBatch struct { keys []int data []models.CustomFieldMap error []error closing bool done chan struct{} } // Load a CustomFieldMap by key, batching and caching will be applied automatically func (l *CustomFieldsLoader) Load(key int) (models.CustomFieldMap, error) { return l.LoadThunk(key)() } // LoadThunk returns a function that when called will block waiting for a CustomFieldMap. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *CustomFieldsLoader) LoadThunk(key int) func() (models.CustomFieldMap, error) { l.mu.Lock() if it, ok := l.cache[key]; ok { l.mu.Unlock() return func() (models.CustomFieldMap, error) { return it, nil } } if l.batch == nil { l.batch = &customFieldsLoaderBatch{done: make(chan struct{})} } batch := l.batch pos := batch.keyIndex(l, key) l.mu.Unlock() return func() (models.CustomFieldMap, error) { <-batch.done var data models.CustomFieldMap if pos < len(batch.data) { data = batch.data[pos] } var err error // its convenient to be able to return a single error for everything if len(batch.error) == 1 { err = batch.error[0] } else if batch.error != nil { err = batch.error[pos] } if err == nil { l.mu.Lock() l.unsafeSet(key, data) l.mu.Unlock() } return data, err } } // LoadAll fetches many keys at once. It will be broken into appropriate sized // sub batches depending on how the loader is configured func (l *CustomFieldsLoader) LoadAll(keys []int) ([]models.CustomFieldMap, []error) { results := make([]func() (models.CustomFieldMap, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } customFieldMaps := make([]models.CustomFieldMap, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { customFieldMaps[i], errors[i] = thunk() } return customFieldMaps, errors } // LoadAllThunk returns a function that when called will block waiting for a CustomFieldMaps. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *CustomFieldsLoader) LoadAllThunk(keys []int) func() ([]models.CustomFieldMap, []error) { results := make([]func() (models.CustomFieldMap, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } return func() ([]models.CustomFieldMap, []error) { customFieldMaps := make([]models.CustomFieldMap, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { customFieldMaps[i], errors[i] = thunk() } return customFieldMaps, errors } } // Prime the cache with the provided key and value. If the key already exists, no change is made // and false is returned. // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) func (l *CustomFieldsLoader) Prime(key int, value models.CustomFieldMap) bool { l.mu.Lock() var found bool if _, found = l.cache[key]; !found { l.unsafeSet(key, value) } l.mu.Unlock() return !found } // Clear the value at key from the cache, if it exists func (l *CustomFieldsLoader) Clear(key int) { l.mu.Lock() delete(l.cache, key) l.mu.Unlock() } func (l *CustomFieldsLoader) unsafeSet(key int, value models.CustomFieldMap) { if l.cache == nil { l.cache = map[int]models.CustomFieldMap{} } l.cache[key] = value } // keyIndex will return the location of the key in the batch, if its not found // it will add the key to the batch func (b *customFieldsLoaderBatch) keyIndex(l *CustomFieldsLoader, key int) int { for i, existingKey := range b.keys { if key == existingKey { return i } } pos := len(b.keys) b.keys = append(b.keys, key) if pos == 0 { go b.startTimer(l) } if l.maxBatch != 0 && pos >= l.maxBatch-1 { if !b.closing { b.closing = true l.batch = nil go b.end(l) } } return pos } func (b *customFieldsLoaderBatch) startTimer(l *CustomFieldsLoader) { time.Sleep(l.wait) l.mu.Lock() // we must have hit a batch limit and are already finalizing this batch if b.closing { l.mu.Unlock() return } l.batch = nil l.mu.Unlock() b.end(l) } func (b *customFieldsLoaderBatch) end(l *CustomFieldsLoader) { b.data, b.error = l.fetch(b.keys) close(b.done) } ================================================ FILE: internal/api/loaders/dataloaders.go ================================================ // Package loaders contains the dataloaders used by the resolver in [api]. // They are generated with `make generate-dataloaders`. // The dataloaders are used to batch requests to the database. //go:generate go run github.com/vektah/dataloaden SceneLoader int *github.com/stashapp/stash/pkg/models.Scene //go:generate go run github.com/vektah/dataloaden GalleryLoader int *github.com/stashapp/stash/pkg/models.Gallery //go:generate go run github.com/vektah/dataloaden ImageLoader int *github.com/stashapp/stash/pkg/models.Image //go:generate go run github.com/vektah/dataloaden PerformerLoader int *github.com/stashapp/stash/pkg/models.Performer //go:generate go run github.com/vektah/dataloaden StudioLoader int *github.com/stashapp/stash/pkg/models.Studio //go:generate go run github.com/vektah/dataloaden TagLoader int *github.com/stashapp/stash/pkg/models.Tag //go:generate go run github.com/vektah/dataloaden GroupLoader int *github.com/stashapp/stash/pkg/models.Group //go:generate go run github.com/vektah/dataloaden FileLoader github.com/stashapp/stash/pkg/models.FileID github.com/stashapp/stash/pkg/models.File //go:generate go run github.com/vektah/dataloaden FolderLoader github.com/stashapp/stash/pkg/models.FolderID *github.com/stashapp/stash/pkg/models.Folder //go:generate go run github.com/vektah/dataloaden FolderParentFolderIDsLoader github.com/stashapp/stash/pkg/models.FolderID []github.com/stashapp/stash/pkg/models.FolderID //go:generate go run github.com/vektah/dataloaden SceneFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID //go:generate go run github.com/vektah/dataloaden ImageFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID //go:generate go run github.com/vektah/dataloaden GalleryFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID //go:generate go run github.com/vektah/dataloaden CustomFieldsLoader int github.com/stashapp/stash/pkg/models.CustomFieldMap //go:generate go run github.com/vektah/dataloaden SceneOCountLoader int int //go:generate go run github.com/vektah/dataloaden ScenePlayCountLoader int int //go:generate go run github.com/vektah/dataloaden SceneOHistoryLoader int []time.Time //go:generate go run github.com/vektah/dataloaden ScenePlayHistoryLoader int []time.Time //go:generate go run github.com/vektah/dataloaden SceneLastPlayedLoader int *time.Time package loaders import ( "context" "net/http" "time" "github.com/stashapp/stash/pkg/models" ) type contextKey struct{ name string } var ( loadersCtxKey = &contextKey{"loaders"} ) const ( wait = 1 * time.Millisecond maxBatch = 100 ) type Loaders struct { SceneByID *SceneLoader SceneFiles *SceneFileIDsLoader ScenePlayCount *ScenePlayCountLoader SceneOCount *SceneOCountLoader ScenePlayHistory *ScenePlayHistoryLoader SceneOHistory *SceneOHistoryLoader SceneLastPlayed *SceneLastPlayedLoader SceneCustomFields *CustomFieldsLoader ImageFiles *ImageFileIDsLoader GalleryFiles *GalleryFileIDsLoader GalleryByID *GalleryLoader GalleryCustomFields *CustomFieldsLoader ImageByID *ImageLoader ImageCustomFields *CustomFieldsLoader PerformerByID *PerformerLoader PerformerCustomFields *CustomFieldsLoader StudioByID *StudioLoader StudioCustomFields *CustomFieldsLoader TagByID *TagLoader TagCustomFields *CustomFieldsLoader GroupByID *GroupLoader GroupCustomFields *CustomFieldsLoader FileByID *FileLoader FolderByID *FolderLoader FolderParentFolderIDs *FolderParentFolderIDsLoader } type Middleware struct { Repository models.Repository } func (m Middleware) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() ldrs := Loaders{ SceneByID: &SceneLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchScenes(ctx), }, GalleryByID: &GalleryLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchGalleries(ctx), }, GalleryCustomFields: &CustomFieldsLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchGalleryCustomFields(ctx), }, ImageByID: &ImageLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchImages(ctx), }, ImageCustomFields: &CustomFieldsLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchImageCustomFields(ctx), }, PerformerByID: &PerformerLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchPerformers(ctx), }, PerformerCustomFields: &CustomFieldsLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchPerformerCustomFields(ctx), }, StudioCustomFields: &CustomFieldsLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchStudioCustomFields(ctx), }, SceneCustomFields: &CustomFieldsLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchSceneCustomFields(ctx), }, StudioByID: &StudioLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchStudios(ctx), }, TagByID: &TagLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchTags(ctx), }, TagCustomFields: &CustomFieldsLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchTagCustomFields(ctx), }, GroupByID: &GroupLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchGroups(ctx), }, GroupCustomFields: &CustomFieldsLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchGroupCustomFields(ctx), }, FileByID: &FileLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchFiles(ctx), }, FolderByID: &FolderLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchFolders(ctx), }, FolderParentFolderIDs: &FolderParentFolderIDsLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchFoldersParentFolderIDs(ctx), }, SceneFiles: &SceneFileIDsLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchScenesFileIDs(ctx), }, ImageFiles: &ImageFileIDsLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchImagesFileIDs(ctx), }, GalleryFiles: &GalleryFileIDsLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchGalleriesFileIDs(ctx), }, ScenePlayCount: &ScenePlayCountLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchScenesPlayCount(ctx), }, SceneOCount: &SceneOCountLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchScenesOCount(ctx), }, ScenePlayHistory: &ScenePlayHistoryLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchScenesPlayHistory(ctx), }, SceneLastPlayed: &SceneLastPlayedLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchScenesLastPlayed(ctx), }, SceneOHistory: &SceneOHistoryLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchScenesOHistory(ctx), }, } newCtx := context.WithValue(r.Context(), loadersCtxKey, ldrs) next.ServeHTTP(w, r.WithContext(newCtx)) }) } func From(ctx context.Context) Loaders { return ctx.Value(loadersCtxKey).(Loaders) } func toErrorSlice(err error) []error { if err != nil { return []error{err} } return nil } func (m Middleware) fetchScenes(ctx context.Context) func(keys []int) ([]*models.Scene, []error) { return func(keys []int) (ret []*models.Scene, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Scene.FindMany(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchSceneCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { return func(keys []int) (ret []models.CustomFieldMap, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Scene.GetCustomFieldsBulk(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchImages(ctx context.Context) func(keys []int) ([]*models.Image, []error) { return func(keys []int) (ret []*models.Image, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Image.FindMany(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchImageCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { return func(keys []int) (ret []models.CustomFieldMap, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Image.GetCustomFieldsBulk(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchGalleries(ctx context.Context) func(keys []int) ([]*models.Gallery, []error) { return func(keys []int) (ret []*models.Gallery, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Gallery.FindMany(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchPerformers(ctx context.Context) func(keys []int) ([]*models.Performer, []error) { return func(keys []int) (ret []*models.Performer, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Performer.FindMany(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchPerformerCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { return func(keys []int) (ret []models.CustomFieldMap, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Performer.GetCustomFieldsBulk(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchStudios(ctx context.Context) func(keys []int) ([]*models.Studio, []error) { return func(keys []int) (ret []*models.Studio, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Studio.FindMany(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchStudioCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { return func(keys []int) (ret []models.CustomFieldMap, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Studio.GetCustomFieldsBulk(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchTags(ctx context.Context) func(keys []int) ([]*models.Tag, []error) { return func(keys []int) (ret []*models.Tag, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Tag.FindMany(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchTagCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { return func(keys []int) (ret []models.CustomFieldMap, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Tag.GetCustomFieldsBulk(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchGroupCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { return func(keys []int) (ret []models.CustomFieldMap, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Group.GetCustomFieldsBulk(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchGalleryCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { return func(keys []int) (ret []models.CustomFieldMap, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Gallery.GetCustomFieldsBulk(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchGroups(ctx context.Context) func(keys []int) ([]*models.Group, []error) { return func(keys []int) (ret []*models.Group, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Group.FindMany(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchFiles(ctx context.Context) func(keys []models.FileID) ([]models.File, []error) { return func(keys []models.FileID) (ret []models.File, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.File.Find(ctx, keys...) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchFolders(ctx context.Context) func(keys []models.FolderID) ([]*models.Folder, []error) { return func(keys []models.FolderID) (ret []*models.Folder, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Folder.FindMany(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchFoldersParentFolderIDs(ctx context.Context) func(keys []models.FolderID) ([][]models.FolderID, []error) { return func(keys []models.FolderID) (ret [][]models.FolderID, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Folder.GetManyParentFolderIDs(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchScenesFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) { return func(keys []int) (ret [][]models.FileID, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Scene.GetManyFileIDs(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchImagesFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) { return func(keys []int) (ret [][]models.FileID, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Image.GetManyFileIDs(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchGalleriesFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) { return func(keys []int) (ret [][]models.FileID, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Gallery.GetManyFileIDs(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchScenesOCount(ctx context.Context) func(keys []int) ([]int, []error) { return func(keys []int) (ret []int, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Scene.GetManyOCount(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchScenesPlayCount(ctx context.Context) func(keys []int) ([]int, []error) { return func(keys []int) (ret []int, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Scene.GetManyViewCount(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchScenesOHistory(ctx context.Context) func(keys []int) ([][]time.Time, []error) { return func(keys []int) (ret [][]time.Time, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Scene.GetManyODates(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchScenesPlayHistory(ctx context.Context) func(keys []int) ([][]time.Time, []error) { return func(keys []int) (ret [][]time.Time, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Scene.GetManyViewDates(ctx, keys) return err }) return ret, toErrorSlice(err) } } func (m Middleware) fetchScenesLastPlayed(ctx context.Context) func(keys []int) ([]*time.Time, []error) { return func(keys []int) (ret []*time.Time, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error ret, err = m.Repository.Scene.GetManyLastViewed(ctx, keys) return err }) return ret, toErrorSlice(err) } } ================================================ FILE: internal/api/loaders/fileloader_gen.go ================================================ // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. package loaders import ( "sync" "time" "github.com/stashapp/stash/pkg/models" ) // FileLoaderConfig captures the config to create a new FileLoader type FileLoaderConfig struct { // Fetch is a method that provides the data for the loader Fetch func(keys []models.FileID) ([]models.File, []error) // Wait is how long wait before sending a batch Wait time.Duration // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit MaxBatch int } // NewFileLoader creates a new FileLoader given a fetch, wait, and maxBatch func NewFileLoader(config FileLoaderConfig) *FileLoader { return &FileLoader{ fetch: config.Fetch, wait: config.Wait, maxBatch: config.MaxBatch, } } // FileLoader batches and caches requests type FileLoader struct { // this method provides the data for the loader fetch func(keys []models.FileID) ([]models.File, []error) // how long to done before sending a batch wait time.Duration // this will limit the maximum number of keys to send in one batch, 0 = no limit maxBatch int // INTERNAL // lazily created cache cache map[models.FileID]models.File // the current batch. keys will continue to be collected until timeout is hit, // then everything will be sent to the fetch method and out to the listeners batch *fileLoaderBatch // mutex to prevent races mu sync.Mutex } type fileLoaderBatch struct { keys []models.FileID data []models.File error []error closing bool done chan struct{} } // Load a File by key, batching and caching will be applied automatically func (l *FileLoader) Load(key models.FileID) (models.File, error) { return l.LoadThunk(key)() } // LoadThunk returns a function that when called will block waiting for a File. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *FileLoader) LoadThunk(key models.FileID) func() (models.File, error) { l.mu.Lock() if it, ok := l.cache[key]; ok { l.mu.Unlock() return func() (models.File, error) { return it, nil } } if l.batch == nil { l.batch = &fileLoaderBatch{done: make(chan struct{})} } batch := l.batch pos := batch.keyIndex(l, key) l.mu.Unlock() return func() (models.File, error) { <-batch.done var data models.File if pos < len(batch.data) { data = batch.data[pos] } var err error // its convenient to be able to return a single error for everything if len(batch.error) == 1 { err = batch.error[0] } else if batch.error != nil { err = batch.error[pos] } if err == nil { l.mu.Lock() l.unsafeSet(key, data) l.mu.Unlock() } return data, err } } // LoadAll fetches many keys at once. It will be broken into appropriate sized // sub batches depending on how the loader is configured func (l *FileLoader) LoadAll(keys []models.FileID) ([]models.File, []error) { results := make([]func() (models.File, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } files := make([]models.File, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { files[i], errors[i] = thunk() } return files, errors } // LoadAllThunk returns a function that when called will block waiting for a Files. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *FileLoader) LoadAllThunk(keys []models.FileID) func() ([]models.File, []error) { results := make([]func() (models.File, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } return func() ([]models.File, []error) { files := make([]models.File, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { files[i], errors[i] = thunk() } return files, errors } } // Prime the cache with the provided key and value. If the key already exists, no change is made // and false is returned. // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) func (l *FileLoader) Prime(key models.FileID, value models.File) bool { l.mu.Lock() var found bool if _, found = l.cache[key]; !found { l.unsafeSet(key, value) } l.mu.Unlock() return !found } // Clear the value at key from the cache, if it exists func (l *FileLoader) Clear(key models.FileID) { l.mu.Lock() delete(l.cache, key) l.mu.Unlock() } func (l *FileLoader) unsafeSet(key models.FileID, value models.File) { if l.cache == nil { l.cache = map[models.FileID]models.File{} } l.cache[key] = value } // keyIndex will return the location of the key in the batch, if its not found // it will add the key to the batch func (b *fileLoaderBatch) keyIndex(l *FileLoader, key models.FileID) int { for i, existingKey := range b.keys { if key == existingKey { return i } } pos := len(b.keys) b.keys = append(b.keys, key) if pos == 0 { go b.startTimer(l) } if l.maxBatch != 0 && pos >= l.maxBatch-1 { if !b.closing { b.closing = true l.batch = nil go b.end(l) } } return pos } func (b *fileLoaderBatch) startTimer(l *FileLoader) { time.Sleep(l.wait) l.mu.Lock() // we must have hit a batch limit and are already finalizing this batch if b.closing { l.mu.Unlock() return } l.batch = nil l.mu.Unlock() b.end(l) } func (b *fileLoaderBatch) end(l *FileLoader) { b.data, b.error = l.fetch(b.keys) close(b.done) } ================================================ FILE: internal/api/loaders/folderloader_gen.go ================================================ // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. package loaders import ( "sync" "time" "github.com/stashapp/stash/pkg/models" ) // FolderLoaderConfig captures the config to create a new FolderLoader type FolderLoaderConfig struct { // Fetch is a method that provides the data for the loader Fetch func(keys []models.FolderID) ([]*models.Folder, []error) // Wait is how long wait before sending a batch Wait time.Duration // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit MaxBatch int } // NewFolderLoader creates a new FolderLoader given a fetch, wait, and maxBatch func NewFolderLoader(config FolderLoaderConfig) *FolderLoader { return &FolderLoader{ fetch: config.Fetch, wait: config.Wait, maxBatch: config.MaxBatch, } } // FolderLoader batches and caches requests type FolderLoader struct { // this method provides the data for the loader fetch func(keys []models.FolderID) ([]*models.Folder, []error) // how long to done before sending a batch wait time.Duration // this will limit the maximum number of keys to send in one batch, 0 = no limit maxBatch int // INTERNAL // lazily created cache cache map[models.FolderID]*models.Folder // the current batch. keys will continue to be collected until timeout is hit, // then everything will be sent to the fetch method and out to the listeners batch *folderLoaderBatch // mutex to prevent races mu sync.Mutex } type folderLoaderBatch struct { keys []models.FolderID data []*models.Folder error []error closing bool done chan struct{} } // Load a Folder by key, batching and caching will be applied automatically func (l *FolderLoader) Load(key models.FolderID) (*models.Folder, error) { return l.LoadThunk(key)() } // LoadThunk returns a function that when called will block waiting for a Folder. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *FolderLoader) LoadThunk(key models.FolderID) func() (*models.Folder, error) { l.mu.Lock() if it, ok := l.cache[key]; ok { l.mu.Unlock() return func() (*models.Folder, error) { return it, nil } } if l.batch == nil { l.batch = &folderLoaderBatch{done: make(chan struct{})} } batch := l.batch pos := batch.keyIndex(l, key) l.mu.Unlock() return func() (*models.Folder, error) { <-batch.done var data *models.Folder if pos < len(batch.data) { data = batch.data[pos] } var err error // its convenient to be able to return a single error for everything if len(batch.error) == 1 { err = batch.error[0] } else if batch.error != nil { err = batch.error[pos] } if err == nil { l.mu.Lock() l.unsafeSet(key, data) l.mu.Unlock() } return data, err } } // LoadAll fetches many keys at once. It will be broken into appropriate sized // sub batches depending on how the loader is configured func (l *FolderLoader) LoadAll(keys []models.FolderID) ([]*models.Folder, []error) { results := make([]func() (*models.Folder, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } folders := make([]*models.Folder, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { folders[i], errors[i] = thunk() } return folders, errors } // LoadAllThunk returns a function that when called will block waiting for a Folders. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *FolderLoader) LoadAllThunk(keys []models.FolderID) func() ([]*models.Folder, []error) { results := make([]func() (*models.Folder, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } return func() ([]*models.Folder, []error) { folders := make([]*models.Folder, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { folders[i], errors[i] = thunk() } return folders, errors } } // Prime the cache with the provided key and value. If the key already exists, no change is made // and false is returned. // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) func (l *FolderLoader) Prime(key models.FolderID, value *models.Folder) bool { l.mu.Lock() var found bool if _, found = l.cache[key]; !found { // make a copy when writing to the cache, its easy to pass a pointer in from a loop var // and end up with the whole cache pointing to the same value. cpy := *value l.unsafeSet(key, &cpy) } l.mu.Unlock() return !found } // Clear the value at key from the cache, if it exists func (l *FolderLoader) Clear(key models.FolderID) { l.mu.Lock() delete(l.cache, key) l.mu.Unlock() } func (l *FolderLoader) unsafeSet(key models.FolderID, value *models.Folder) { if l.cache == nil { l.cache = map[models.FolderID]*models.Folder{} } l.cache[key] = value } // keyIndex will return the location of the key in the batch, if its not found // it will add the key to the batch func (b *folderLoaderBatch) keyIndex(l *FolderLoader, key models.FolderID) int { for i, existingKey := range b.keys { if key == existingKey { return i } } pos := len(b.keys) b.keys = append(b.keys, key) if pos == 0 { go b.startTimer(l) } if l.maxBatch != 0 && pos >= l.maxBatch-1 { if !b.closing { b.closing = true l.batch = nil go b.end(l) } } return pos } func (b *folderLoaderBatch) startTimer(l *FolderLoader) { time.Sleep(l.wait) l.mu.Lock() // we must have hit a batch limit and are already finalizing this batch if b.closing { l.mu.Unlock() return } l.batch = nil l.mu.Unlock() b.end(l) } func (b *folderLoaderBatch) end(l *FolderLoader) { b.data, b.error = l.fetch(b.keys) close(b.done) } ================================================ FILE: internal/api/loaders/folderparentfolderidsloader_gen.go ================================================ // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. package loaders import ( "sync" "time" "github.com/stashapp/stash/pkg/models" ) // FolderParentFolderIDsLoaderConfig captures the config to create a new FolderParentFolderIDsLoader type FolderParentFolderIDsLoaderConfig struct { // Fetch is a method that provides the data for the loader Fetch func(keys []models.FolderID) ([][]models.FolderID, []error) // Wait is how long wait before sending a batch Wait time.Duration // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit MaxBatch int } // NewFolderParentFolderIDsLoader creates a new FolderParentFolderIDsLoader given a fetch, wait, and maxBatch func NewFolderParentFolderIDsLoader(config FolderParentFolderIDsLoaderConfig) *FolderParentFolderIDsLoader { return &FolderParentFolderIDsLoader{ fetch: config.Fetch, wait: config.Wait, maxBatch: config.MaxBatch, } } // FolderParentFolderIDsLoader batches and caches requests type FolderParentFolderIDsLoader struct { // this method provides the data for the loader fetch func(keys []models.FolderID) ([][]models.FolderID, []error) // how long to done before sending a batch wait time.Duration // this will limit the maximum number of keys to send in one batch, 0 = no limit maxBatch int // INTERNAL // lazily created cache cache map[models.FolderID][]models.FolderID // the current batch. keys will continue to be collected until timeout is hit, // then everything will be sent to the fetch method and out to the listeners batch *folderParentFolderIDsLoaderBatch // mutex to prevent races mu sync.Mutex } type folderParentFolderIDsLoaderBatch struct { keys []models.FolderID data [][]models.FolderID error []error closing bool done chan struct{} } // Load a FolderID by key, batching and caching will be applied automatically func (l *FolderParentFolderIDsLoader) Load(key models.FolderID) ([]models.FolderID, error) { return l.LoadThunk(key)() } // LoadThunk returns a function that when called will block waiting for a FolderID. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *FolderParentFolderIDsLoader) LoadThunk(key models.FolderID) func() ([]models.FolderID, error) { l.mu.Lock() if it, ok := l.cache[key]; ok { l.mu.Unlock() return func() ([]models.FolderID, error) { return it, nil } } if l.batch == nil { l.batch = &folderParentFolderIDsLoaderBatch{done: make(chan struct{})} } batch := l.batch pos := batch.keyIndex(l, key) l.mu.Unlock() return func() ([]models.FolderID, error) { <-batch.done var data []models.FolderID if pos < len(batch.data) { data = batch.data[pos] } var err error // its convenient to be able to return a single error for everything if len(batch.error) == 1 { err = batch.error[0] } else if batch.error != nil { err = batch.error[pos] } if err == nil { l.mu.Lock() l.unsafeSet(key, data) l.mu.Unlock() } return data, err } } // LoadAll fetches many keys at once. It will be broken into appropriate sized // sub batches depending on how the loader is configured func (l *FolderParentFolderIDsLoader) LoadAll(keys []models.FolderID) ([][]models.FolderID, []error) { results := make([]func() ([]models.FolderID, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } folderIDs := make([][]models.FolderID, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { folderIDs[i], errors[i] = thunk() } return folderIDs, errors } // LoadAllThunk returns a function that when called will block waiting for a FolderIDs. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *FolderParentFolderIDsLoader) LoadAllThunk(keys []models.FolderID) func() ([][]models.FolderID, []error) { results := make([]func() ([]models.FolderID, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } return func() ([][]models.FolderID, []error) { folderIDs := make([][]models.FolderID, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { folderIDs[i], errors[i] = thunk() } return folderIDs, errors } } // Prime the cache with the provided key and value. If the key already exists, no change is made // and false is returned. // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) func (l *FolderParentFolderIDsLoader) Prime(key models.FolderID, value []models.FolderID) bool { l.mu.Lock() var found bool if _, found = l.cache[key]; !found { // make a copy when writing to the cache, its easy to pass a pointer in from a loop var // and end up with the whole cache pointing to the same value. cpy := make([]models.FolderID, len(value)) copy(cpy, value) l.unsafeSet(key, cpy) } l.mu.Unlock() return !found } // Clear the value at key from the cache, if it exists func (l *FolderParentFolderIDsLoader) Clear(key models.FolderID) { l.mu.Lock() delete(l.cache, key) l.mu.Unlock() } func (l *FolderParentFolderIDsLoader) unsafeSet(key models.FolderID, value []models.FolderID) { if l.cache == nil { l.cache = map[models.FolderID][]models.FolderID{} } l.cache[key] = value } // keyIndex will return the location of the key in the batch, if its not found // it will add the key to the batch func (b *folderParentFolderIDsLoaderBatch) keyIndex(l *FolderParentFolderIDsLoader, key models.FolderID) int { for i, existingKey := range b.keys { if key == existingKey { return i } } pos := len(b.keys) b.keys = append(b.keys, key) if pos == 0 { go b.startTimer(l) } if l.maxBatch != 0 && pos >= l.maxBatch-1 { if !b.closing { b.closing = true l.batch = nil go b.end(l) } } return pos } func (b *folderParentFolderIDsLoaderBatch) startTimer(l *FolderParentFolderIDsLoader) { time.Sleep(l.wait) l.mu.Lock() // we must have hit a batch limit and are already finalizing this batch if b.closing { l.mu.Unlock() return } l.batch = nil l.mu.Unlock() b.end(l) } func (b *folderParentFolderIDsLoaderBatch) end(l *FolderParentFolderIDsLoader) { b.data, b.error = l.fetch(b.keys) close(b.done) } ================================================ FILE: internal/api/loaders/galleryfileidsloader_gen.go ================================================ // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. package loaders import ( "sync" "time" "github.com/stashapp/stash/pkg/models" ) // GalleryFileIDsLoaderConfig captures the config to create a new GalleryFileIDsLoader type GalleryFileIDsLoaderConfig struct { // Fetch is a method that provides the data for the loader Fetch func(keys []int) ([][]models.FileID, []error) // Wait is how long wait before sending a batch Wait time.Duration // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit MaxBatch int } // NewGalleryFileIDsLoader creates a new GalleryFileIDsLoader given a fetch, wait, and maxBatch func NewGalleryFileIDsLoader(config GalleryFileIDsLoaderConfig) *GalleryFileIDsLoader { return &GalleryFileIDsLoader{ fetch: config.Fetch, wait: config.Wait, maxBatch: config.MaxBatch, } } // GalleryFileIDsLoader batches and caches requests type GalleryFileIDsLoader struct { // this method provides the data for the loader fetch func(keys []int) ([][]models.FileID, []error) // how long to done before sending a batch wait time.Duration // this will limit the maximum number of keys to send in one batch, 0 = no limit maxBatch int // INTERNAL // lazily created cache cache map[int][]models.FileID // the current batch. keys will continue to be collected until timeout is hit, // then everything will be sent to the fetch method and out to the listeners batch *galleryFileIDsLoaderBatch // mutex to prevent races mu sync.Mutex } type galleryFileIDsLoaderBatch struct { keys []int data [][]models.FileID error []error closing bool done chan struct{} } // Load a FileID by key, batching and caching will be applied automatically func (l *GalleryFileIDsLoader) Load(key int) ([]models.FileID, error) { return l.LoadThunk(key)() } // LoadThunk returns a function that when called will block waiting for a FileID. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *GalleryFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error) { l.mu.Lock() if it, ok := l.cache[key]; ok { l.mu.Unlock() return func() ([]models.FileID, error) { return it, nil } } if l.batch == nil { l.batch = &galleryFileIDsLoaderBatch{done: make(chan struct{})} } batch := l.batch pos := batch.keyIndex(l, key) l.mu.Unlock() return func() ([]models.FileID, error) { <-batch.done var data []models.FileID if pos < len(batch.data) { data = batch.data[pos] } var err error // its convenient to be able to return a single error for everything if len(batch.error) == 1 { err = batch.error[0] } else if batch.error != nil { err = batch.error[pos] } if err == nil { l.mu.Lock() l.unsafeSet(key, data) l.mu.Unlock() } return data, err } } // LoadAll fetches many keys at once. It will be broken into appropriate sized // sub batches depending on how the loader is configured func (l *GalleryFileIDsLoader) LoadAll(keys []int) ([][]models.FileID, []error) { results := make([]func() ([]models.FileID, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } fileIDs := make([][]models.FileID, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { fileIDs[i], errors[i] = thunk() } return fileIDs, errors } // LoadAllThunk returns a function that when called will block waiting for a FileIDs. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *GalleryFileIDsLoader) LoadAllThunk(keys []int) func() ([][]models.FileID, []error) { results := make([]func() ([]models.FileID, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } return func() ([][]models.FileID, []error) { fileIDs := make([][]models.FileID, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { fileIDs[i], errors[i] = thunk() } return fileIDs, errors } } // Prime the cache with the provided key and value. If the key already exists, no change is made // and false is returned. // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) func (l *GalleryFileIDsLoader) Prime(key int, value []models.FileID) bool { l.mu.Lock() var found bool if _, found = l.cache[key]; !found { // make a copy when writing to the cache, its easy to pass a pointer in from a loop var // and end up with the whole cache pointing to the same value. cpy := make([]models.FileID, len(value)) copy(cpy, value) l.unsafeSet(key, cpy) } l.mu.Unlock() return !found } // Clear the value at key from the cache, if it exists func (l *GalleryFileIDsLoader) Clear(key int) { l.mu.Lock() delete(l.cache, key) l.mu.Unlock() } func (l *GalleryFileIDsLoader) unsafeSet(key int, value []models.FileID) { if l.cache == nil { l.cache = map[int][]models.FileID{} } l.cache[key] = value } // keyIndex will return the location of the key in the batch, if its not found // it will add the key to the batch func (b *galleryFileIDsLoaderBatch) keyIndex(l *GalleryFileIDsLoader, key int) int { for i, existingKey := range b.keys { if key == existingKey { return i } } pos := len(b.keys) b.keys = append(b.keys, key) if pos == 0 { go b.startTimer(l) } if l.maxBatch != 0 && pos >= l.maxBatch-1 { if !b.closing { b.closing = true l.batch = nil go b.end(l) } } return pos } func (b *galleryFileIDsLoaderBatch) startTimer(l *GalleryFileIDsLoader) { time.Sleep(l.wait) l.mu.Lock() // we must have hit a batch limit and are already finalizing this batch if b.closing { l.mu.Unlock() return } l.batch = nil l.mu.Unlock() b.end(l) } func (b *galleryFileIDsLoaderBatch) end(l *GalleryFileIDsLoader) { b.data, b.error = l.fetch(b.keys) close(b.done) } ================================================ FILE: internal/api/loaders/galleryloader_gen.go ================================================ // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. package loaders import ( "sync" "time" "github.com/stashapp/stash/pkg/models" ) // GalleryLoaderConfig captures the config to create a new GalleryLoader type GalleryLoaderConfig struct { // Fetch is a method that provides the data for the loader Fetch func(keys []int) ([]*models.Gallery, []error) // Wait is how long wait before sending a batch Wait time.Duration // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit MaxBatch int } // NewGalleryLoader creates a new GalleryLoader given a fetch, wait, and maxBatch func NewGalleryLoader(config GalleryLoaderConfig) *GalleryLoader { return &GalleryLoader{ fetch: config.Fetch, wait: config.Wait, maxBatch: config.MaxBatch, } } // GalleryLoader batches and caches requests type GalleryLoader struct { // this method provides the data for the loader fetch func(keys []int) ([]*models.Gallery, []error) // how long to done before sending a batch wait time.Duration // this will limit the maximum number of keys to send in one batch, 0 = no limit maxBatch int // INTERNAL // lazily created cache cache map[int]*models.Gallery // the current batch. keys will continue to be collected until timeout is hit, // then everything will be sent to the fetch method and out to the listeners batch *galleryLoaderBatch // mutex to prevent races mu sync.Mutex } type galleryLoaderBatch struct { keys []int data []*models.Gallery error []error closing bool done chan struct{} } // Load a Gallery by key, batching and caching will be applied automatically func (l *GalleryLoader) Load(key int) (*models.Gallery, error) { return l.LoadThunk(key)() } // LoadThunk returns a function that when called will block waiting for a Gallery. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *GalleryLoader) LoadThunk(key int) func() (*models.Gallery, error) { l.mu.Lock() if it, ok := l.cache[key]; ok { l.mu.Unlock() return func() (*models.Gallery, error) { return it, nil } } if l.batch == nil { l.batch = &galleryLoaderBatch{done: make(chan struct{})} } batch := l.batch pos := batch.keyIndex(l, key) l.mu.Unlock() return func() (*models.Gallery, error) { <-batch.done var data *models.Gallery if pos < len(batch.data) { data = batch.data[pos] } var err error // its convenient to be able to return a single error for everything if len(batch.error) == 1 { err = batch.error[0] } else if batch.error != nil { err = batch.error[pos] } if err == nil { l.mu.Lock() l.unsafeSet(key, data) l.mu.Unlock() } return data, err } } // LoadAll fetches many keys at once. It will be broken into appropriate sized // sub batches depending on how the loader is configured func (l *GalleryLoader) LoadAll(keys []int) ([]*models.Gallery, []error) { results := make([]func() (*models.Gallery, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } gallerys := make([]*models.Gallery, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { gallerys[i], errors[i] = thunk() } return gallerys, errors } // LoadAllThunk returns a function that when called will block waiting for a Gallerys. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *GalleryLoader) LoadAllThunk(keys []int) func() ([]*models.Gallery, []error) { results := make([]func() (*models.Gallery, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } return func() ([]*models.Gallery, []error) { gallerys := make([]*models.Gallery, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { gallerys[i], errors[i] = thunk() } return gallerys, errors } } // Prime the cache with the provided key and value. If the key already exists, no change is made // and false is returned. // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) func (l *GalleryLoader) Prime(key int, value *models.Gallery) bool { l.mu.Lock() var found bool if _, found = l.cache[key]; !found { // make a copy when writing to the cache, its easy to pass a pointer in from a loop var // and end up with the whole cache pointing to the same value. cpy := *value l.unsafeSet(key, &cpy) } l.mu.Unlock() return !found } // Clear the value at key from the cache, if it exists func (l *GalleryLoader) Clear(key int) { l.mu.Lock() delete(l.cache, key) l.mu.Unlock() } func (l *GalleryLoader) unsafeSet(key int, value *models.Gallery) { if l.cache == nil { l.cache = map[int]*models.Gallery{} } l.cache[key] = value } // keyIndex will return the location of the key in the batch, if its not found // it will add the key to the batch func (b *galleryLoaderBatch) keyIndex(l *GalleryLoader, key int) int { for i, existingKey := range b.keys { if key == existingKey { return i } } pos := len(b.keys) b.keys = append(b.keys, key) if pos == 0 { go b.startTimer(l) } if l.maxBatch != 0 && pos >= l.maxBatch-1 { if !b.closing { b.closing = true l.batch = nil go b.end(l) } } return pos } func (b *galleryLoaderBatch) startTimer(l *GalleryLoader) { time.Sleep(l.wait) l.mu.Lock() // we must have hit a batch limit and are already finalizing this batch if b.closing { l.mu.Unlock() return } l.batch = nil l.mu.Unlock() b.end(l) } func (b *galleryLoaderBatch) end(l *GalleryLoader) { b.data, b.error = l.fetch(b.keys) close(b.done) } ================================================ FILE: internal/api/loaders/grouploader_gen.go ================================================ // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. package loaders import ( "sync" "time" "github.com/stashapp/stash/pkg/models" ) // GroupLoaderConfig captures the config to create a new GroupLoader type GroupLoaderConfig struct { // Fetch is a method that provides the data for the loader Fetch func(keys []int) ([]*models.Group, []error) // Wait is how long wait before sending a batch Wait time.Duration // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit MaxBatch int } // NewGroupLoader creates a new GroupLoader given a fetch, wait, and maxBatch func NewGroupLoader(config GroupLoaderConfig) *GroupLoader { return &GroupLoader{ fetch: config.Fetch, wait: config.Wait, maxBatch: config.MaxBatch, } } // GroupLoader batches and caches requests type GroupLoader struct { // this method provides the data for the loader fetch func(keys []int) ([]*models.Group, []error) // how long to done before sending a batch wait time.Duration // this will limit the maximum number of keys to send in one batch, 0 = no limit maxBatch int // INTERNAL // lazily created cache cache map[int]*models.Group // the current batch. keys will continue to be collected until timeout is hit, // then everything will be sent to the fetch method and out to the listeners batch *groupLoaderBatch // mutex to prevent races mu sync.Mutex } type groupLoaderBatch struct { keys []int data []*models.Group error []error closing bool done chan struct{} } // Load a Group by key, batching and caching will be applied automatically func (l *GroupLoader) Load(key int) (*models.Group, error) { return l.LoadThunk(key)() } // LoadThunk returns a function that when called will block waiting for a Group. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *GroupLoader) LoadThunk(key int) func() (*models.Group, error) { l.mu.Lock() if it, ok := l.cache[key]; ok { l.mu.Unlock() return func() (*models.Group, error) { return it, nil } } if l.batch == nil { l.batch = &groupLoaderBatch{done: make(chan struct{})} } batch := l.batch pos := batch.keyIndex(l, key) l.mu.Unlock() return func() (*models.Group, error) { <-batch.done var data *models.Group if pos < len(batch.data) { data = batch.data[pos] } var err error // its convenient to be able to return a single error for everything if len(batch.error) == 1 { err = batch.error[0] } else if batch.error != nil { err = batch.error[pos] } if err == nil { l.mu.Lock() l.unsafeSet(key, data) l.mu.Unlock() } return data, err } } // LoadAll fetches many keys at once. It will be broken into appropriate sized // sub batches depending on how the loader is configured func (l *GroupLoader) LoadAll(keys []int) ([]*models.Group, []error) { results := make([]func() (*models.Group, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } groups := make([]*models.Group, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { groups[i], errors[i] = thunk() } return groups, errors } // LoadAllThunk returns a function that when called will block waiting for a Groups. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *GroupLoader) LoadAllThunk(keys []int) func() ([]*models.Group, []error) { results := make([]func() (*models.Group, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } return func() ([]*models.Group, []error) { groups := make([]*models.Group, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { groups[i], errors[i] = thunk() } return groups, errors } } // Prime the cache with the provided key and value. If the key already exists, no change is made // and false is returned. // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) func (l *GroupLoader) Prime(key int, value *models.Group) bool { l.mu.Lock() var found bool if _, found = l.cache[key]; !found { // make a copy when writing to the cache, its easy to pass a pointer in from a loop var // and end up with the whole cache pointing to the same value. cpy := *value l.unsafeSet(key, &cpy) } l.mu.Unlock() return !found } // Clear the value at key from the cache, if it exists func (l *GroupLoader) Clear(key int) { l.mu.Lock() delete(l.cache, key) l.mu.Unlock() } func (l *GroupLoader) unsafeSet(key int, value *models.Group) { if l.cache == nil { l.cache = map[int]*models.Group{} } l.cache[key] = value } // keyIndex will return the location of the key in the batch, if its not found // it will add the key to the batch func (b *groupLoaderBatch) keyIndex(l *GroupLoader, key int) int { for i, existingKey := range b.keys { if key == existingKey { return i } } pos := len(b.keys) b.keys = append(b.keys, key) if pos == 0 { go b.startTimer(l) } if l.maxBatch != 0 && pos >= l.maxBatch-1 { if !b.closing { b.closing = true l.batch = nil go b.end(l) } } return pos } func (b *groupLoaderBatch) startTimer(l *GroupLoader) { time.Sleep(l.wait) l.mu.Lock() // we must have hit a batch limit and are already finalizing this batch if b.closing { l.mu.Unlock() return } l.batch = nil l.mu.Unlock() b.end(l) } func (b *groupLoaderBatch) end(l *GroupLoader) { b.data, b.error = l.fetch(b.keys) close(b.done) } ================================================ FILE: internal/api/loaders/imagefileidsloader_gen.go ================================================ // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. package loaders import ( "sync" "time" "github.com/stashapp/stash/pkg/models" ) // ImageFileIDsLoaderConfig captures the config to create a new ImageFileIDsLoader type ImageFileIDsLoaderConfig struct { // Fetch is a method that provides the data for the loader Fetch func(keys []int) ([][]models.FileID, []error) // Wait is how long wait before sending a batch Wait time.Duration // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit MaxBatch int } // NewImageFileIDsLoader creates a new ImageFileIDsLoader given a fetch, wait, and maxBatch func NewImageFileIDsLoader(config ImageFileIDsLoaderConfig) *ImageFileIDsLoader { return &ImageFileIDsLoader{ fetch: config.Fetch, wait: config.Wait, maxBatch: config.MaxBatch, } } // ImageFileIDsLoader batches and caches requests type ImageFileIDsLoader struct { // this method provides the data for the loader fetch func(keys []int) ([][]models.FileID, []error) // how long to done before sending a batch wait time.Duration // this will limit the maximum number of keys to send in one batch, 0 = no limit maxBatch int // INTERNAL // lazily created cache cache map[int][]models.FileID // the current batch. keys will continue to be collected until timeout is hit, // then everything will be sent to the fetch method and out to the listeners batch *imageFileIDsLoaderBatch // mutex to prevent races mu sync.Mutex } type imageFileIDsLoaderBatch struct { keys []int data [][]models.FileID error []error closing bool done chan struct{} } // Load a FileID by key, batching and caching will be applied automatically func (l *ImageFileIDsLoader) Load(key int) ([]models.FileID, error) { return l.LoadThunk(key)() } // LoadThunk returns a function that when called will block waiting for a FileID. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *ImageFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error) { l.mu.Lock() if it, ok := l.cache[key]; ok { l.mu.Unlock() return func() ([]models.FileID, error) { return it, nil } } if l.batch == nil { l.batch = &imageFileIDsLoaderBatch{done: make(chan struct{})} } batch := l.batch pos := batch.keyIndex(l, key) l.mu.Unlock() return func() ([]models.FileID, error) { <-batch.done var data []models.FileID if pos < len(batch.data) { data = batch.data[pos] } var err error // its convenient to be able to return a single error for everything if len(batch.error) == 1 { err = batch.error[0] } else if batch.error != nil { err = batch.error[pos] } if err == nil { l.mu.Lock() l.unsafeSet(key, data) l.mu.Unlock() } return data, err } } // LoadAll fetches many keys at once. It will be broken into appropriate sized // sub batches depending on how the loader is configured func (l *ImageFileIDsLoader) LoadAll(keys []int) ([][]models.FileID, []error) { results := make([]func() ([]models.FileID, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } fileIDs := make([][]models.FileID, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { fileIDs[i], errors[i] = thunk() } return fileIDs, errors } // LoadAllThunk returns a function that when called will block waiting for a FileIDs. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *ImageFileIDsLoader) LoadAllThunk(keys []int) func() ([][]models.FileID, []error) { results := make([]func() ([]models.FileID, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } return func() ([][]models.FileID, []error) { fileIDs := make([][]models.FileID, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { fileIDs[i], errors[i] = thunk() } return fileIDs, errors } } // Prime the cache with the provided key and value. If the key already exists, no change is made // and false is returned. // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) func (l *ImageFileIDsLoader) Prime(key int, value []models.FileID) bool { l.mu.Lock() var found bool if _, found = l.cache[key]; !found { // make a copy when writing to the cache, its easy to pass a pointer in from a loop var // and end up with the whole cache pointing to the same value. cpy := make([]models.FileID, len(value)) copy(cpy, value) l.unsafeSet(key, cpy) } l.mu.Unlock() return !found } // Clear the value at key from the cache, if it exists func (l *ImageFileIDsLoader) Clear(key int) { l.mu.Lock() delete(l.cache, key) l.mu.Unlock() } func (l *ImageFileIDsLoader) unsafeSet(key int, value []models.FileID) { if l.cache == nil { l.cache = map[int][]models.FileID{} } l.cache[key] = value } // keyIndex will return the location of the key in the batch, if its not found // it will add the key to the batch func (b *imageFileIDsLoaderBatch) keyIndex(l *ImageFileIDsLoader, key int) int { for i, existingKey := range b.keys { if key == existingKey { return i } } pos := len(b.keys) b.keys = append(b.keys, key) if pos == 0 { go b.startTimer(l) } if l.maxBatch != 0 && pos >= l.maxBatch-1 { if !b.closing { b.closing = true l.batch = nil go b.end(l) } } return pos } func (b *imageFileIDsLoaderBatch) startTimer(l *ImageFileIDsLoader) { time.Sleep(l.wait) l.mu.Lock() // we must have hit a batch limit and are already finalizing this batch if b.closing { l.mu.Unlock() return } l.batch = nil l.mu.Unlock() b.end(l) } func (b *imageFileIDsLoaderBatch) end(l *ImageFileIDsLoader) { b.data, b.error = l.fetch(b.keys) close(b.done) } ================================================ FILE: internal/api/loaders/imageloader_gen.go ================================================ // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. package loaders import ( "sync" "time" "github.com/stashapp/stash/pkg/models" ) // ImageLoaderConfig captures the config to create a new ImageLoader type ImageLoaderConfig struct { // Fetch is a method that provides the data for the loader Fetch func(keys []int) ([]*models.Image, []error) // Wait is how long wait before sending a batch Wait time.Duration // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit MaxBatch int } // NewImageLoader creates a new ImageLoader given a fetch, wait, and maxBatch func NewImageLoader(config ImageLoaderConfig) *ImageLoader { return &ImageLoader{ fetch: config.Fetch, wait: config.Wait, maxBatch: config.MaxBatch, } } // ImageLoader batches and caches requests type ImageLoader struct { // this method provides the data for the loader fetch func(keys []int) ([]*models.Image, []error) // how long to done before sending a batch wait time.Duration // this will limit the maximum number of keys to send in one batch, 0 = no limit maxBatch int // INTERNAL // lazily created cache cache map[int]*models.Image // the current batch. keys will continue to be collected until timeout is hit, // then everything will be sent to the fetch method and out to the listeners batch *imageLoaderBatch // mutex to prevent races mu sync.Mutex } type imageLoaderBatch struct { keys []int data []*models.Image error []error closing bool done chan struct{} } // Load a Image by key, batching and caching will be applied automatically func (l *ImageLoader) Load(key int) (*models.Image, error) { return l.LoadThunk(key)() } // LoadThunk returns a function that when called will block waiting for a Image. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *ImageLoader) LoadThunk(key int) func() (*models.Image, error) { l.mu.Lock() if it, ok := l.cache[key]; ok { l.mu.Unlock() return func() (*models.Image, error) { return it, nil } } if l.batch == nil { l.batch = &imageLoaderBatch{done: make(chan struct{})} } batch := l.batch pos := batch.keyIndex(l, key) l.mu.Unlock() return func() (*models.Image, error) { <-batch.done var data *models.Image if pos < len(batch.data) { data = batch.data[pos] } var err error // its convenient to be able to return a single error for everything if len(batch.error) == 1 { err = batch.error[0] } else if batch.error != nil { err = batch.error[pos] } if err == nil { l.mu.Lock() l.unsafeSet(key, data) l.mu.Unlock() } return data, err } } // LoadAll fetches many keys at once. It will be broken into appropriate sized // sub batches depending on how the loader is configured func (l *ImageLoader) LoadAll(keys []int) ([]*models.Image, []error) { results := make([]func() (*models.Image, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } images := make([]*models.Image, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { images[i], errors[i] = thunk() } return images, errors } // LoadAllThunk returns a function that when called will block waiting for a Images. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *ImageLoader) LoadAllThunk(keys []int) func() ([]*models.Image, []error) { results := make([]func() (*models.Image, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } return func() ([]*models.Image, []error) { images := make([]*models.Image, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { images[i], errors[i] = thunk() } return images, errors } } // Prime the cache with the provided key and value. If the key already exists, no change is made // and false is returned. // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) func (l *ImageLoader) Prime(key int, value *models.Image) bool { l.mu.Lock() var found bool if _, found = l.cache[key]; !found { // make a copy when writing to the cache, its easy to pass a pointer in from a loop var // and end up with the whole cache pointing to the same value. cpy := *value l.unsafeSet(key, &cpy) } l.mu.Unlock() return !found } // Clear the value at key from the cache, if it exists func (l *ImageLoader) Clear(key int) { l.mu.Lock() delete(l.cache, key) l.mu.Unlock() } func (l *ImageLoader) unsafeSet(key int, value *models.Image) { if l.cache == nil { l.cache = map[int]*models.Image{} } l.cache[key] = value } // keyIndex will return the location of the key in the batch, if its not found // it will add the key to the batch func (b *imageLoaderBatch) keyIndex(l *ImageLoader, key int) int { for i, existingKey := range b.keys { if key == existingKey { return i } } pos := len(b.keys) b.keys = append(b.keys, key) if pos == 0 { go b.startTimer(l) } if l.maxBatch != 0 && pos >= l.maxBatch-1 { if !b.closing { b.closing = true l.batch = nil go b.end(l) } } return pos } func (b *imageLoaderBatch) startTimer(l *ImageLoader) { time.Sleep(l.wait) l.mu.Lock() // we must have hit a batch limit and are already finalizing this batch if b.closing { l.mu.Unlock() return } l.batch = nil l.mu.Unlock() b.end(l) } func (b *imageLoaderBatch) end(l *ImageLoader) { b.data, b.error = l.fetch(b.keys) close(b.done) } ================================================ FILE: internal/api/loaders/performerloader_gen.go ================================================ // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. package loaders import ( "sync" "time" "github.com/stashapp/stash/pkg/models" ) // PerformerLoaderConfig captures the config to create a new PerformerLoader type PerformerLoaderConfig struct { // Fetch is a method that provides the data for the loader Fetch func(keys []int) ([]*models.Performer, []error) // Wait is how long wait before sending a batch Wait time.Duration // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit MaxBatch int } // NewPerformerLoader creates a new PerformerLoader given a fetch, wait, and maxBatch func NewPerformerLoader(config PerformerLoaderConfig) *PerformerLoader { return &PerformerLoader{ fetch: config.Fetch, wait: config.Wait, maxBatch: config.MaxBatch, } } // PerformerLoader batches and caches requests type PerformerLoader struct { // this method provides the data for the loader fetch func(keys []int) ([]*models.Performer, []error) // how long to done before sending a batch wait time.Duration // this will limit the maximum number of keys to send in one batch, 0 = no limit maxBatch int // INTERNAL // lazily created cache cache map[int]*models.Performer // the current batch. keys will continue to be collected until timeout is hit, // then everything will be sent to the fetch method and out to the listeners batch *performerLoaderBatch // mutex to prevent races mu sync.Mutex } type performerLoaderBatch struct { keys []int data []*models.Performer error []error closing bool done chan struct{} } // Load a Performer by key, batching and caching will be applied automatically func (l *PerformerLoader) Load(key int) (*models.Performer, error) { return l.LoadThunk(key)() } // LoadThunk returns a function that when called will block waiting for a Performer. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *PerformerLoader) LoadThunk(key int) func() (*models.Performer, error) { l.mu.Lock() if it, ok := l.cache[key]; ok { l.mu.Unlock() return func() (*models.Performer, error) { return it, nil } } if l.batch == nil { l.batch = &performerLoaderBatch{done: make(chan struct{})} } batch := l.batch pos := batch.keyIndex(l, key) l.mu.Unlock() return func() (*models.Performer, error) { <-batch.done var data *models.Performer if pos < len(batch.data) { data = batch.data[pos] } var err error // its convenient to be able to return a single error for everything if len(batch.error) == 1 { err = batch.error[0] } else if batch.error != nil { err = batch.error[pos] } if err == nil { l.mu.Lock() l.unsafeSet(key, data) l.mu.Unlock() } return data, err } } // LoadAll fetches many keys at once. It will be broken into appropriate sized // sub batches depending on how the loader is configured func (l *PerformerLoader) LoadAll(keys []int) ([]*models.Performer, []error) { results := make([]func() (*models.Performer, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } performers := make([]*models.Performer, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { performers[i], errors[i] = thunk() } return performers, errors } // LoadAllThunk returns a function that when called will block waiting for a Performers. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *PerformerLoader) LoadAllThunk(keys []int) func() ([]*models.Performer, []error) { results := make([]func() (*models.Performer, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } return func() ([]*models.Performer, []error) { performers := make([]*models.Performer, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { performers[i], errors[i] = thunk() } return performers, errors } } // Prime the cache with the provided key and value. If the key already exists, no change is made // and false is returned. // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) func (l *PerformerLoader) Prime(key int, value *models.Performer) bool { l.mu.Lock() var found bool if _, found = l.cache[key]; !found { // make a copy when writing to the cache, its easy to pass a pointer in from a loop var // and end up with the whole cache pointing to the same value. cpy := *value l.unsafeSet(key, &cpy) } l.mu.Unlock() return !found } // Clear the value at key from the cache, if it exists func (l *PerformerLoader) Clear(key int) { l.mu.Lock() delete(l.cache, key) l.mu.Unlock() } func (l *PerformerLoader) unsafeSet(key int, value *models.Performer) { if l.cache == nil { l.cache = map[int]*models.Performer{} } l.cache[key] = value } // keyIndex will return the location of the key in the batch, if its not found // it will add the key to the batch func (b *performerLoaderBatch) keyIndex(l *PerformerLoader, key int) int { for i, existingKey := range b.keys { if key == existingKey { return i } } pos := len(b.keys) b.keys = append(b.keys, key) if pos == 0 { go b.startTimer(l) } if l.maxBatch != 0 && pos >= l.maxBatch-1 { if !b.closing { b.closing = true l.batch = nil go b.end(l) } } return pos } func (b *performerLoaderBatch) startTimer(l *PerformerLoader) { time.Sleep(l.wait) l.mu.Lock() // we must have hit a batch limit and are already finalizing this batch if b.closing { l.mu.Unlock() return } l.batch = nil l.mu.Unlock() b.end(l) } func (b *performerLoaderBatch) end(l *PerformerLoader) { b.data, b.error = l.fetch(b.keys) close(b.done) } ================================================ FILE: internal/api/loaders/scenefileidsloader_gen.go ================================================ // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. package loaders import ( "sync" "time" "github.com/stashapp/stash/pkg/models" ) // SceneFileIDsLoaderConfig captures the config to create a new SceneFileIDsLoader type SceneFileIDsLoaderConfig struct { // Fetch is a method that provides the data for the loader Fetch func(keys []int) ([][]models.FileID, []error) // Wait is how long wait before sending a batch Wait time.Duration // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit MaxBatch int } // NewSceneFileIDsLoader creates a new SceneFileIDsLoader given a fetch, wait, and maxBatch func NewSceneFileIDsLoader(config SceneFileIDsLoaderConfig) *SceneFileIDsLoader { return &SceneFileIDsLoader{ fetch: config.Fetch, wait: config.Wait, maxBatch: config.MaxBatch, } } // SceneFileIDsLoader batches and caches requests type SceneFileIDsLoader struct { // this method provides the data for the loader fetch func(keys []int) ([][]models.FileID, []error) // how long to done before sending a batch wait time.Duration // this will limit the maximum number of keys to send in one batch, 0 = no limit maxBatch int // INTERNAL // lazily created cache cache map[int][]models.FileID // the current batch. keys will continue to be collected until timeout is hit, // then everything will be sent to the fetch method and out to the listeners batch *sceneFileIDsLoaderBatch // mutex to prevent races mu sync.Mutex } type sceneFileIDsLoaderBatch struct { keys []int data [][]models.FileID error []error closing bool done chan struct{} } // Load a FileID by key, batching and caching will be applied automatically func (l *SceneFileIDsLoader) Load(key int) ([]models.FileID, error) { return l.LoadThunk(key)() } // LoadThunk returns a function that when called will block waiting for a FileID. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *SceneFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error) { l.mu.Lock() if it, ok := l.cache[key]; ok { l.mu.Unlock() return func() ([]models.FileID, error) { return it, nil } } if l.batch == nil { l.batch = &sceneFileIDsLoaderBatch{done: make(chan struct{})} } batch := l.batch pos := batch.keyIndex(l, key) l.mu.Unlock() return func() ([]models.FileID, error) { <-batch.done var data []models.FileID if pos < len(batch.data) { data = batch.data[pos] } var err error // its convenient to be able to return a single error for everything if len(batch.error) == 1 { err = batch.error[0] } else if batch.error != nil { err = batch.error[pos] } if err == nil { l.mu.Lock() l.unsafeSet(key, data) l.mu.Unlock() } return data, err } } // LoadAll fetches many keys at once. It will be broken into appropriate sized // sub batches depending on how the loader is configured func (l *SceneFileIDsLoader) LoadAll(keys []int) ([][]models.FileID, []error) { results := make([]func() ([]models.FileID, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } fileIDs := make([][]models.FileID, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { fileIDs[i], errors[i] = thunk() } return fileIDs, errors } // LoadAllThunk returns a function that when called will block waiting for a FileIDs. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *SceneFileIDsLoader) LoadAllThunk(keys []int) func() ([][]models.FileID, []error) { results := make([]func() ([]models.FileID, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } return func() ([][]models.FileID, []error) { fileIDs := make([][]models.FileID, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { fileIDs[i], errors[i] = thunk() } return fileIDs, errors } } // Prime the cache with the provided key and value. If the key already exists, no change is made // and false is returned. // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) func (l *SceneFileIDsLoader) Prime(key int, value []models.FileID) bool { l.mu.Lock() var found bool if _, found = l.cache[key]; !found { // make a copy when writing to the cache, its easy to pass a pointer in from a loop var // and end up with the whole cache pointing to the same value. cpy := make([]models.FileID, len(value)) copy(cpy, value) l.unsafeSet(key, cpy) } l.mu.Unlock() return !found } // Clear the value at key from the cache, if it exists func (l *SceneFileIDsLoader) Clear(key int) { l.mu.Lock() delete(l.cache, key) l.mu.Unlock() } func (l *SceneFileIDsLoader) unsafeSet(key int, value []models.FileID) { if l.cache == nil { l.cache = map[int][]models.FileID{} } l.cache[key] = value } // keyIndex will return the location of the key in the batch, if its not found // it will add the key to the batch func (b *sceneFileIDsLoaderBatch) keyIndex(l *SceneFileIDsLoader, key int) int { for i, existingKey := range b.keys { if key == existingKey { return i } } pos := len(b.keys) b.keys = append(b.keys, key) if pos == 0 { go b.startTimer(l) } if l.maxBatch != 0 && pos >= l.maxBatch-1 { if !b.closing { b.closing = true l.batch = nil go b.end(l) } } return pos } func (b *sceneFileIDsLoaderBatch) startTimer(l *SceneFileIDsLoader) { time.Sleep(l.wait) l.mu.Lock() // we must have hit a batch limit and are already finalizing this batch if b.closing { l.mu.Unlock() return } l.batch = nil l.mu.Unlock() b.end(l) } func (b *sceneFileIDsLoaderBatch) end(l *SceneFileIDsLoader) { b.data, b.error = l.fetch(b.keys) close(b.done) } ================================================ FILE: internal/api/loaders/scenelastplayedloader_gen.go ================================================ // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. package loaders import ( "sync" "time" ) // SceneLastPlayedLoaderConfig captures the config to create a new SceneLastPlayedLoader type SceneLastPlayedLoaderConfig struct { // Fetch is a method that provides the data for the loader Fetch func(keys []int) ([]*time.Time, []error) // Wait is how long wait before sending a batch Wait time.Duration // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit MaxBatch int } // NewSceneLastPlayedLoader creates a new SceneLastPlayedLoader given a fetch, wait, and maxBatch func NewSceneLastPlayedLoader(config SceneLastPlayedLoaderConfig) *SceneLastPlayedLoader { return &SceneLastPlayedLoader{ fetch: config.Fetch, wait: config.Wait, maxBatch: config.MaxBatch, } } // SceneLastPlayedLoader batches and caches requests type SceneLastPlayedLoader struct { // this method provides the data for the loader fetch func(keys []int) ([]*time.Time, []error) // how long to done before sending a batch wait time.Duration // this will limit the maximum number of keys to send in one batch, 0 = no limit maxBatch int // INTERNAL // lazily created cache cache map[int]*time.Time // the current batch. keys will continue to be collected until timeout is hit, // then everything will be sent to the fetch method and out to the listeners batch *sceneLastPlayedLoaderBatch // mutex to prevent races mu sync.Mutex } type sceneLastPlayedLoaderBatch struct { keys []int data []*time.Time error []error closing bool done chan struct{} } // Load a Time by key, batching and caching will be applied automatically func (l *SceneLastPlayedLoader) Load(key int) (*time.Time, error) { return l.LoadThunk(key)() } // LoadThunk returns a function that when called will block waiting for a Time. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *SceneLastPlayedLoader) LoadThunk(key int) func() (*time.Time, error) { l.mu.Lock() if it, ok := l.cache[key]; ok { l.mu.Unlock() return func() (*time.Time, error) { return it, nil } } if l.batch == nil { l.batch = &sceneLastPlayedLoaderBatch{done: make(chan struct{})} } batch := l.batch pos := batch.keyIndex(l, key) l.mu.Unlock() return func() (*time.Time, error) { <-batch.done var data *time.Time if pos < len(batch.data) { data = batch.data[pos] } var err error // its convenient to be able to return a single error for everything if len(batch.error) == 1 { err = batch.error[0] } else if batch.error != nil { err = batch.error[pos] } if err == nil { l.mu.Lock() l.unsafeSet(key, data) l.mu.Unlock() } return data, err } } // LoadAll fetches many keys at once. It will be broken into appropriate sized // sub batches depending on how the loader is configured func (l *SceneLastPlayedLoader) LoadAll(keys []int) ([]*time.Time, []error) { results := make([]func() (*time.Time, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } times := make([]*time.Time, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { times[i], errors[i] = thunk() } return times, errors } // LoadAllThunk returns a function that when called will block waiting for a Times. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *SceneLastPlayedLoader) LoadAllThunk(keys []int) func() ([]*time.Time, []error) { results := make([]func() (*time.Time, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } return func() ([]*time.Time, []error) { times := make([]*time.Time, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { times[i], errors[i] = thunk() } return times, errors } } // Prime the cache with the provided key and value. If the key already exists, no change is made // and false is returned. // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) func (l *SceneLastPlayedLoader) Prime(key int, value *time.Time) bool { l.mu.Lock() var found bool if _, found = l.cache[key]; !found { // make a copy when writing to the cache, its easy to pass a pointer in from a loop var // and end up with the whole cache pointing to the same value. cpy := *value l.unsafeSet(key, &cpy) } l.mu.Unlock() return !found } // Clear the value at key from the cache, if it exists func (l *SceneLastPlayedLoader) Clear(key int) { l.mu.Lock() delete(l.cache, key) l.mu.Unlock() } func (l *SceneLastPlayedLoader) unsafeSet(key int, value *time.Time) { if l.cache == nil { l.cache = map[int]*time.Time{} } l.cache[key] = value } // keyIndex will return the location of the key in the batch, if its not found // it will add the key to the batch func (b *sceneLastPlayedLoaderBatch) keyIndex(l *SceneLastPlayedLoader, key int) int { for i, existingKey := range b.keys { if key == existingKey { return i } } pos := len(b.keys) b.keys = append(b.keys, key) if pos == 0 { go b.startTimer(l) } if l.maxBatch != 0 && pos >= l.maxBatch-1 { if !b.closing { b.closing = true l.batch = nil go b.end(l) } } return pos } func (b *sceneLastPlayedLoaderBatch) startTimer(l *SceneLastPlayedLoader) { time.Sleep(l.wait) l.mu.Lock() // we must have hit a batch limit and are already finalizing this batch if b.closing { l.mu.Unlock() return } l.batch = nil l.mu.Unlock() b.end(l) } func (b *sceneLastPlayedLoaderBatch) end(l *SceneLastPlayedLoader) { b.data, b.error = l.fetch(b.keys) close(b.done) } ================================================ FILE: internal/api/loaders/sceneloader_gen.go ================================================ // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. package loaders import ( "sync" "time" "github.com/stashapp/stash/pkg/models" ) // SceneLoaderConfig captures the config to create a new SceneLoader type SceneLoaderConfig struct { // Fetch is a method that provides the data for the loader Fetch func(keys []int) ([]*models.Scene, []error) // Wait is how long wait before sending a batch Wait time.Duration // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit MaxBatch int } // NewSceneLoader creates a new SceneLoader given a fetch, wait, and maxBatch func NewSceneLoader(config SceneLoaderConfig) *SceneLoader { return &SceneLoader{ fetch: config.Fetch, wait: config.Wait, maxBatch: config.MaxBatch, } } // SceneLoader batches and caches requests type SceneLoader struct { // this method provides the data for the loader fetch func(keys []int) ([]*models.Scene, []error) // how long to done before sending a batch wait time.Duration // this will limit the maximum number of keys to send in one batch, 0 = no limit maxBatch int // INTERNAL // lazily created cache cache map[int]*models.Scene // the current batch. keys will continue to be collected until timeout is hit, // then everything will be sent to the fetch method and out to the listeners batch *sceneLoaderBatch // mutex to prevent races mu sync.Mutex } type sceneLoaderBatch struct { keys []int data []*models.Scene error []error closing bool done chan struct{} } // Load a Scene by key, batching and caching will be applied automatically func (l *SceneLoader) Load(key int) (*models.Scene, error) { return l.LoadThunk(key)() } // LoadThunk returns a function that when called will block waiting for a Scene. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *SceneLoader) LoadThunk(key int) func() (*models.Scene, error) { l.mu.Lock() if it, ok := l.cache[key]; ok { l.mu.Unlock() return func() (*models.Scene, error) { return it, nil } } if l.batch == nil { l.batch = &sceneLoaderBatch{done: make(chan struct{})} } batch := l.batch pos := batch.keyIndex(l, key) l.mu.Unlock() return func() (*models.Scene, error) { <-batch.done var data *models.Scene if pos < len(batch.data) { data = batch.data[pos] } var err error // its convenient to be able to return a single error for everything if len(batch.error) == 1 { err = batch.error[0] } else if batch.error != nil { err = batch.error[pos] } if err == nil { l.mu.Lock() l.unsafeSet(key, data) l.mu.Unlock() } return data, err } } // LoadAll fetches many keys at once. It will be broken into appropriate sized // sub batches depending on how the loader is configured func (l *SceneLoader) LoadAll(keys []int) ([]*models.Scene, []error) { results := make([]func() (*models.Scene, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } scenes := make([]*models.Scene, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { scenes[i], errors[i] = thunk() } return scenes, errors } // LoadAllThunk returns a function that when called will block waiting for a Scenes. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *SceneLoader) LoadAllThunk(keys []int) func() ([]*models.Scene, []error) { results := make([]func() (*models.Scene, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } return func() ([]*models.Scene, []error) { scenes := make([]*models.Scene, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { scenes[i], errors[i] = thunk() } return scenes, errors } } // Prime the cache with the provided key and value. If the key already exists, no change is made // and false is returned. // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) func (l *SceneLoader) Prime(key int, value *models.Scene) bool { l.mu.Lock() var found bool if _, found = l.cache[key]; !found { // make a copy when writing to the cache, its easy to pass a pointer in from a loop var // and end up with the whole cache pointing to the same value. cpy := *value l.unsafeSet(key, &cpy) } l.mu.Unlock() return !found } // Clear the value at key from the cache, if it exists func (l *SceneLoader) Clear(key int) { l.mu.Lock() delete(l.cache, key) l.mu.Unlock() } func (l *SceneLoader) unsafeSet(key int, value *models.Scene) { if l.cache == nil { l.cache = map[int]*models.Scene{} } l.cache[key] = value } // keyIndex will return the location of the key in the batch, if its not found // it will add the key to the batch func (b *sceneLoaderBatch) keyIndex(l *SceneLoader, key int) int { for i, existingKey := range b.keys { if key == existingKey { return i } } pos := len(b.keys) b.keys = append(b.keys, key) if pos == 0 { go b.startTimer(l) } if l.maxBatch != 0 && pos >= l.maxBatch-1 { if !b.closing { b.closing = true l.batch = nil go b.end(l) } } return pos } func (b *sceneLoaderBatch) startTimer(l *SceneLoader) { time.Sleep(l.wait) l.mu.Lock() // we must have hit a batch limit and are already finalizing this batch if b.closing { l.mu.Unlock() return } l.batch = nil l.mu.Unlock() b.end(l) } func (b *sceneLoaderBatch) end(l *SceneLoader) { b.data, b.error = l.fetch(b.keys) close(b.done) } ================================================ FILE: internal/api/loaders/sceneocountloader_gen.go ================================================ // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. package loaders import ( "sync" "time" ) // SceneOCountLoaderConfig captures the config to create a new SceneOCountLoader type SceneOCountLoaderConfig struct { // Fetch is a method that provides the data for the loader Fetch func(keys []int) ([]int, []error) // Wait is how long wait before sending a batch Wait time.Duration // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit MaxBatch int } // NewSceneOCountLoader creates a new SceneOCountLoader given a fetch, wait, and maxBatch func NewSceneOCountLoader(config SceneOCountLoaderConfig) *SceneOCountLoader { return &SceneOCountLoader{ fetch: config.Fetch, wait: config.Wait, maxBatch: config.MaxBatch, } } // SceneOCountLoader batches and caches requests type SceneOCountLoader struct { // this method provides the data for the loader fetch func(keys []int) ([]int, []error) // how long to done before sending a batch wait time.Duration // this will limit the maximum number of keys to send in one batch, 0 = no limit maxBatch int // INTERNAL // lazily created cache cache map[int]int // the current batch. keys will continue to be collected until timeout is hit, // then everything will be sent to the fetch method and out to the listeners batch *sceneOCountLoaderBatch // mutex to prevent races mu sync.Mutex } type sceneOCountLoaderBatch struct { keys []int data []int error []error closing bool done chan struct{} } // Load a int by key, batching and caching will be applied automatically func (l *SceneOCountLoader) Load(key int) (int, error) { return l.LoadThunk(key)() } // LoadThunk returns a function that when called will block waiting for a int. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *SceneOCountLoader) LoadThunk(key int) func() (int, error) { l.mu.Lock() if it, ok := l.cache[key]; ok { l.mu.Unlock() return func() (int, error) { return it, nil } } if l.batch == nil { l.batch = &sceneOCountLoaderBatch{done: make(chan struct{})} } batch := l.batch pos := batch.keyIndex(l, key) l.mu.Unlock() return func() (int, error) { <-batch.done var data int if pos < len(batch.data) { data = batch.data[pos] } var err error // its convenient to be able to return a single error for everything if len(batch.error) == 1 { err = batch.error[0] } else if batch.error != nil { err = batch.error[pos] } if err == nil { l.mu.Lock() l.unsafeSet(key, data) l.mu.Unlock() } return data, err } } // LoadAll fetches many keys at once. It will be broken into appropriate sized // sub batches depending on how the loader is configured func (l *SceneOCountLoader) LoadAll(keys []int) ([]int, []error) { results := make([]func() (int, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } ints := make([]int, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { ints[i], errors[i] = thunk() } return ints, errors } // LoadAllThunk returns a function that when called will block waiting for a ints. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *SceneOCountLoader) LoadAllThunk(keys []int) func() ([]int, []error) { results := make([]func() (int, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } return func() ([]int, []error) { ints := make([]int, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { ints[i], errors[i] = thunk() } return ints, errors } } // Prime the cache with the provided key and value. If the key already exists, no change is made // and false is returned. // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) func (l *SceneOCountLoader) Prime(key int, value int) bool { l.mu.Lock() var found bool if _, found = l.cache[key]; !found { l.unsafeSet(key, value) } l.mu.Unlock() return !found } // Clear the value at key from the cache, if it exists func (l *SceneOCountLoader) Clear(key int) { l.mu.Lock() delete(l.cache, key) l.mu.Unlock() } func (l *SceneOCountLoader) unsafeSet(key int, value int) { if l.cache == nil { l.cache = map[int]int{} } l.cache[key] = value } // keyIndex will return the location of the key in the batch, if its not found // it will add the key to the batch func (b *sceneOCountLoaderBatch) keyIndex(l *SceneOCountLoader, key int) int { for i, existingKey := range b.keys { if key == existingKey { return i } } pos := len(b.keys) b.keys = append(b.keys, key) if pos == 0 { go b.startTimer(l) } if l.maxBatch != 0 && pos >= l.maxBatch-1 { if !b.closing { b.closing = true l.batch = nil go b.end(l) } } return pos } func (b *sceneOCountLoaderBatch) startTimer(l *SceneOCountLoader) { time.Sleep(l.wait) l.mu.Lock() // we must have hit a batch limit and are already finalizing this batch if b.closing { l.mu.Unlock() return } l.batch = nil l.mu.Unlock() b.end(l) } func (b *sceneOCountLoaderBatch) end(l *SceneOCountLoader) { b.data, b.error = l.fetch(b.keys) close(b.done) } ================================================ FILE: internal/api/loaders/sceneohistoryloader_gen.go ================================================ // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. package loaders import ( "sync" "time" ) // SceneOHistoryLoaderConfig captures the config to create a new SceneOHistoryLoader type SceneOHistoryLoaderConfig struct { // Fetch is a method that provides the data for the loader Fetch func(keys []int) ([][]time.Time, []error) // Wait is how long wait before sending a batch Wait time.Duration // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit MaxBatch int } // NewSceneOHistoryLoader creates a new SceneOHistoryLoader given a fetch, wait, and maxBatch func NewSceneOHistoryLoader(config SceneOHistoryLoaderConfig) *SceneOHistoryLoader { return &SceneOHistoryLoader{ fetch: config.Fetch, wait: config.Wait, maxBatch: config.MaxBatch, } } // SceneOHistoryLoader batches and caches requests type SceneOHistoryLoader struct { // this method provides the data for the loader fetch func(keys []int) ([][]time.Time, []error) // how long to done before sending a batch wait time.Duration // this will limit the maximum number of keys to send in one batch, 0 = no limit maxBatch int // INTERNAL // lazily created cache cache map[int][]time.Time // the current batch. keys will continue to be collected until timeout is hit, // then everything will be sent to the fetch method and out to the listeners batch *sceneOHistoryLoaderBatch // mutex to prevent races mu sync.Mutex } type sceneOHistoryLoaderBatch struct { keys []int data [][]time.Time error []error closing bool done chan struct{} } // Load a Time by key, batching and caching will be applied automatically func (l *SceneOHistoryLoader) Load(key int) ([]time.Time, error) { return l.LoadThunk(key)() } // LoadThunk returns a function that when called will block waiting for a Time. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *SceneOHistoryLoader) LoadThunk(key int) func() ([]time.Time, error) { l.mu.Lock() if it, ok := l.cache[key]; ok { l.mu.Unlock() return func() ([]time.Time, error) { return it, nil } } if l.batch == nil { l.batch = &sceneOHistoryLoaderBatch{done: make(chan struct{})} } batch := l.batch pos := batch.keyIndex(l, key) l.mu.Unlock() return func() ([]time.Time, error) { <-batch.done var data []time.Time if pos < len(batch.data) { data = batch.data[pos] } var err error // its convenient to be able to return a single error for everything if len(batch.error) == 1 { err = batch.error[0] } else if batch.error != nil { err = batch.error[pos] } if err == nil { l.mu.Lock() l.unsafeSet(key, data) l.mu.Unlock() } return data, err } } // LoadAll fetches many keys at once. It will be broken into appropriate sized // sub batches depending on how the loader is configured func (l *SceneOHistoryLoader) LoadAll(keys []int) ([][]time.Time, []error) { results := make([]func() ([]time.Time, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } times := make([][]time.Time, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { times[i], errors[i] = thunk() } return times, errors } // LoadAllThunk returns a function that when called will block waiting for a Times. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *SceneOHistoryLoader) LoadAllThunk(keys []int) func() ([][]time.Time, []error) { results := make([]func() ([]time.Time, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } return func() ([][]time.Time, []error) { times := make([][]time.Time, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { times[i], errors[i] = thunk() } return times, errors } } // Prime the cache with the provided key and value. If the key already exists, no change is made // and false is returned. // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) func (l *SceneOHistoryLoader) Prime(key int, value []time.Time) bool { l.mu.Lock() var found bool if _, found = l.cache[key]; !found { // make a copy when writing to the cache, its easy to pass a pointer in from a loop var // and end up with the whole cache pointing to the same value. cpy := make([]time.Time, len(value)) copy(cpy, value) l.unsafeSet(key, cpy) } l.mu.Unlock() return !found } // Clear the value at key from the cache, if it exists func (l *SceneOHistoryLoader) Clear(key int) { l.mu.Lock() delete(l.cache, key) l.mu.Unlock() } func (l *SceneOHistoryLoader) unsafeSet(key int, value []time.Time) { if l.cache == nil { l.cache = map[int][]time.Time{} } l.cache[key] = value } // keyIndex will return the location of the key in the batch, if its not found // it will add the key to the batch func (b *sceneOHistoryLoaderBatch) keyIndex(l *SceneOHistoryLoader, key int) int { for i, existingKey := range b.keys { if key == existingKey { return i } } pos := len(b.keys) b.keys = append(b.keys, key) if pos == 0 { go b.startTimer(l) } if l.maxBatch != 0 && pos >= l.maxBatch-1 { if !b.closing { b.closing = true l.batch = nil go b.end(l) } } return pos } func (b *sceneOHistoryLoaderBatch) startTimer(l *SceneOHistoryLoader) { time.Sleep(l.wait) l.mu.Lock() // we must have hit a batch limit and are already finalizing this batch if b.closing { l.mu.Unlock() return } l.batch = nil l.mu.Unlock() b.end(l) } func (b *sceneOHistoryLoaderBatch) end(l *SceneOHistoryLoader) { b.data, b.error = l.fetch(b.keys) close(b.done) } ================================================ FILE: internal/api/loaders/sceneplaycountloader_gen.go ================================================ // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. package loaders import ( "sync" "time" ) // ScenePlayCountLoaderConfig captures the config to create a new ScenePlayCountLoader type ScenePlayCountLoaderConfig struct { // Fetch is a method that provides the data for the loader Fetch func(keys []int) ([]int, []error) // Wait is how long wait before sending a batch Wait time.Duration // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit MaxBatch int } // NewScenePlayCountLoader creates a new ScenePlayCountLoader given a fetch, wait, and maxBatch func NewScenePlayCountLoader(config ScenePlayCountLoaderConfig) *ScenePlayCountLoader { return &ScenePlayCountLoader{ fetch: config.Fetch, wait: config.Wait, maxBatch: config.MaxBatch, } } // ScenePlayCountLoader batches and caches requests type ScenePlayCountLoader struct { // this method provides the data for the loader fetch func(keys []int) ([]int, []error) // how long to done before sending a batch wait time.Duration // this will limit the maximum number of keys to send in one batch, 0 = no limit maxBatch int // INTERNAL // lazily created cache cache map[int]int // the current batch. keys will continue to be collected until timeout is hit, // then everything will be sent to the fetch method and out to the listeners batch *scenePlayCountLoaderBatch // mutex to prevent races mu sync.Mutex } type scenePlayCountLoaderBatch struct { keys []int data []int error []error closing bool done chan struct{} } // Load a int by key, batching and caching will be applied automatically func (l *ScenePlayCountLoader) Load(key int) (int, error) { return l.LoadThunk(key)() } // LoadThunk returns a function that when called will block waiting for a int. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *ScenePlayCountLoader) LoadThunk(key int) func() (int, error) { l.mu.Lock() if it, ok := l.cache[key]; ok { l.mu.Unlock() return func() (int, error) { return it, nil } } if l.batch == nil { l.batch = &scenePlayCountLoaderBatch{done: make(chan struct{})} } batch := l.batch pos := batch.keyIndex(l, key) l.mu.Unlock() return func() (int, error) { <-batch.done var data int if pos < len(batch.data) { data = batch.data[pos] } var err error // its convenient to be able to return a single error for everything if len(batch.error) == 1 { err = batch.error[0] } else if batch.error != nil { err = batch.error[pos] } if err == nil { l.mu.Lock() l.unsafeSet(key, data) l.mu.Unlock() } return data, err } } // LoadAll fetches many keys at once. It will be broken into appropriate sized // sub batches depending on how the loader is configured func (l *ScenePlayCountLoader) LoadAll(keys []int) ([]int, []error) { results := make([]func() (int, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } ints := make([]int, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { ints[i], errors[i] = thunk() } return ints, errors } // LoadAllThunk returns a function that when called will block waiting for a ints. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *ScenePlayCountLoader) LoadAllThunk(keys []int) func() ([]int, []error) { results := make([]func() (int, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } return func() ([]int, []error) { ints := make([]int, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { ints[i], errors[i] = thunk() } return ints, errors } } // Prime the cache with the provided key and value. If the key already exists, no change is made // and false is returned. // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) func (l *ScenePlayCountLoader) Prime(key int, value int) bool { l.mu.Lock() var found bool if _, found = l.cache[key]; !found { l.unsafeSet(key, value) } l.mu.Unlock() return !found } // Clear the value at key from the cache, if it exists func (l *ScenePlayCountLoader) Clear(key int) { l.mu.Lock() delete(l.cache, key) l.mu.Unlock() } func (l *ScenePlayCountLoader) unsafeSet(key int, value int) { if l.cache == nil { l.cache = map[int]int{} } l.cache[key] = value } // keyIndex will return the location of the key in the batch, if its not found // it will add the key to the batch func (b *scenePlayCountLoaderBatch) keyIndex(l *ScenePlayCountLoader, key int) int { for i, existingKey := range b.keys { if key == existingKey { return i } } pos := len(b.keys) b.keys = append(b.keys, key) if pos == 0 { go b.startTimer(l) } if l.maxBatch != 0 && pos >= l.maxBatch-1 { if !b.closing { b.closing = true l.batch = nil go b.end(l) } } return pos } func (b *scenePlayCountLoaderBatch) startTimer(l *ScenePlayCountLoader) { time.Sleep(l.wait) l.mu.Lock() // we must have hit a batch limit and are already finalizing this batch if b.closing { l.mu.Unlock() return } l.batch = nil l.mu.Unlock() b.end(l) } func (b *scenePlayCountLoaderBatch) end(l *ScenePlayCountLoader) { b.data, b.error = l.fetch(b.keys) close(b.done) } ================================================ FILE: internal/api/loaders/sceneplayhistoryloader_gen.go ================================================ // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. package loaders import ( "sync" "time" ) // ScenePlayHistoryLoaderConfig captures the config to create a new ScenePlayHistoryLoader type ScenePlayHistoryLoaderConfig struct { // Fetch is a method that provides the data for the loader Fetch func(keys []int) ([][]time.Time, []error) // Wait is how long wait before sending a batch Wait time.Duration // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit MaxBatch int } // NewScenePlayHistoryLoader creates a new ScenePlayHistoryLoader given a fetch, wait, and maxBatch func NewScenePlayHistoryLoader(config ScenePlayHistoryLoaderConfig) *ScenePlayHistoryLoader { return &ScenePlayHistoryLoader{ fetch: config.Fetch, wait: config.Wait, maxBatch: config.MaxBatch, } } // ScenePlayHistoryLoader batches and caches requests type ScenePlayHistoryLoader struct { // this method provides the data for the loader fetch func(keys []int) ([][]time.Time, []error) // how long to done before sending a batch wait time.Duration // this will limit the maximum number of keys to send in one batch, 0 = no limit maxBatch int // INTERNAL // lazily created cache cache map[int][]time.Time // the current batch. keys will continue to be collected until timeout is hit, // then everything will be sent to the fetch method and out to the listeners batch *scenePlayHistoryLoaderBatch // mutex to prevent races mu sync.Mutex } type scenePlayHistoryLoaderBatch struct { keys []int data [][]time.Time error []error closing bool done chan struct{} } // Load a Time by key, batching and caching will be applied automatically func (l *ScenePlayHistoryLoader) Load(key int) ([]time.Time, error) { return l.LoadThunk(key)() } // LoadThunk returns a function that when called will block waiting for a Time. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *ScenePlayHistoryLoader) LoadThunk(key int) func() ([]time.Time, error) { l.mu.Lock() if it, ok := l.cache[key]; ok { l.mu.Unlock() return func() ([]time.Time, error) { return it, nil } } if l.batch == nil { l.batch = &scenePlayHistoryLoaderBatch{done: make(chan struct{})} } batch := l.batch pos := batch.keyIndex(l, key) l.mu.Unlock() return func() ([]time.Time, error) { <-batch.done var data []time.Time if pos < len(batch.data) { data = batch.data[pos] } var err error // its convenient to be able to return a single error for everything if len(batch.error) == 1 { err = batch.error[0] } else if batch.error != nil { err = batch.error[pos] } if err == nil { l.mu.Lock() l.unsafeSet(key, data) l.mu.Unlock() } return data, err } } // LoadAll fetches many keys at once. It will be broken into appropriate sized // sub batches depending on how the loader is configured func (l *ScenePlayHistoryLoader) LoadAll(keys []int) ([][]time.Time, []error) { results := make([]func() ([]time.Time, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } times := make([][]time.Time, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { times[i], errors[i] = thunk() } return times, errors } // LoadAllThunk returns a function that when called will block waiting for a Times. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *ScenePlayHistoryLoader) LoadAllThunk(keys []int) func() ([][]time.Time, []error) { results := make([]func() ([]time.Time, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } return func() ([][]time.Time, []error) { times := make([][]time.Time, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { times[i], errors[i] = thunk() } return times, errors } } // Prime the cache with the provided key and value. If the key already exists, no change is made // and false is returned. // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) func (l *ScenePlayHistoryLoader) Prime(key int, value []time.Time) bool { l.mu.Lock() var found bool if _, found = l.cache[key]; !found { // make a copy when writing to the cache, its easy to pass a pointer in from a loop var // and end up with the whole cache pointing to the same value. cpy := make([]time.Time, len(value)) copy(cpy, value) l.unsafeSet(key, cpy) } l.mu.Unlock() return !found } // Clear the value at key from the cache, if it exists func (l *ScenePlayHistoryLoader) Clear(key int) { l.mu.Lock() delete(l.cache, key) l.mu.Unlock() } func (l *ScenePlayHistoryLoader) unsafeSet(key int, value []time.Time) { if l.cache == nil { l.cache = map[int][]time.Time{} } l.cache[key] = value } // keyIndex will return the location of the key in the batch, if its not found // it will add the key to the batch func (b *scenePlayHistoryLoaderBatch) keyIndex(l *ScenePlayHistoryLoader, key int) int { for i, existingKey := range b.keys { if key == existingKey { return i } } pos := len(b.keys) b.keys = append(b.keys, key) if pos == 0 { go b.startTimer(l) } if l.maxBatch != 0 && pos >= l.maxBatch-1 { if !b.closing { b.closing = true l.batch = nil go b.end(l) } } return pos } func (b *scenePlayHistoryLoaderBatch) startTimer(l *ScenePlayHistoryLoader) { time.Sleep(l.wait) l.mu.Lock() // we must have hit a batch limit and are already finalizing this batch if b.closing { l.mu.Unlock() return } l.batch = nil l.mu.Unlock() b.end(l) } func (b *scenePlayHistoryLoaderBatch) end(l *ScenePlayHistoryLoader) { b.data, b.error = l.fetch(b.keys) close(b.done) } ================================================ FILE: internal/api/loaders/studioloader_gen.go ================================================ // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. package loaders import ( "sync" "time" "github.com/stashapp/stash/pkg/models" ) // StudioLoaderConfig captures the config to create a new StudioLoader type StudioLoaderConfig struct { // Fetch is a method that provides the data for the loader Fetch func(keys []int) ([]*models.Studio, []error) // Wait is how long wait before sending a batch Wait time.Duration // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit MaxBatch int } // NewStudioLoader creates a new StudioLoader given a fetch, wait, and maxBatch func NewStudioLoader(config StudioLoaderConfig) *StudioLoader { return &StudioLoader{ fetch: config.Fetch, wait: config.Wait, maxBatch: config.MaxBatch, } } // StudioLoader batches and caches requests type StudioLoader struct { // this method provides the data for the loader fetch func(keys []int) ([]*models.Studio, []error) // how long to done before sending a batch wait time.Duration // this will limit the maximum number of keys to send in one batch, 0 = no limit maxBatch int // INTERNAL // lazily created cache cache map[int]*models.Studio // the current batch. keys will continue to be collected until timeout is hit, // then everything will be sent to the fetch method and out to the listeners batch *studioLoaderBatch // mutex to prevent races mu sync.Mutex } type studioLoaderBatch struct { keys []int data []*models.Studio error []error closing bool done chan struct{} } // Load a Studio by key, batching and caching will be applied automatically func (l *StudioLoader) Load(key int) (*models.Studio, error) { return l.LoadThunk(key)() } // LoadThunk returns a function that when called will block waiting for a Studio. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *StudioLoader) LoadThunk(key int) func() (*models.Studio, error) { l.mu.Lock() if it, ok := l.cache[key]; ok { l.mu.Unlock() return func() (*models.Studio, error) { return it, nil } } if l.batch == nil { l.batch = &studioLoaderBatch{done: make(chan struct{})} } batch := l.batch pos := batch.keyIndex(l, key) l.mu.Unlock() return func() (*models.Studio, error) { <-batch.done var data *models.Studio if pos < len(batch.data) { data = batch.data[pos] } var err error // its convenient to be able to return a single error for everything if len(batch.error) == 1 { err = batch.error[0] } else if batch.error != nil { err = batch.error[pos] } if err == nil { l.mu.Lock() l.unsafeSet(key, data) l.mu.Unlock() } return data, err } } // LoadAll fetches many keys at once. It will be broken into appropriate sized // sub batches depending on how the loader is configured func (l *StudioLoader) LoadAll(keys []int) ([]*models.Studio, []error) { results := make([]func() (*models.Studio, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } studios := make([]*models.Studio, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { studios[i], errors[i] = thunk() } return studios, errors } // LoadAllThunk returns a function that when called will block waiting for a Studios. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *StudioLoader) LoadAllThunk(keys []int) func() ([]*models.Studio, []error) { results := make([]func() (*models.Studio, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } return func() ([]*models.Studio, []error) { studios := make([]*models.Studio, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { studios[i], errors[i] = thunk() } return studios, errors } } // Prime the cache with the provided key and value. If the key already exists, no change is made // and false is returned. // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) func (l *StudioLoader) Prime(key int, value *models.Studio) bool { l.mu.Lock() var found bool if _, found = l.cache[key]; !found { // make a copy when writing to the cache, its easy to pass a pointer in from a loop var // and end up with the whole cache pointing to the same value. cpy := *value l.unsafeSet(key, &cpy) } l.mu.Unlock() return !found } // Clear the value at key from the cache, if it exists func (l *StudioLoader) Clear(key int) { l.mu.Lock() delete(l.cache, key) l.mu.Unlock() } func (l *StudioLoader) unsafeSet(key int, value *models.Studio) { if l.cache == nil { l.cache = map[int]*models.Studio{} } l.cache[key] = value } // keyIndex will return the location of the key in the batch, if its not found // it will add the key to the batch func (b *studioLoaderBatch) keyIndex(l *StudioLoader, key int) int { for i, existingKey := range b.keys { if key == existingKey { return i } } pos := len(b.keys) b.keys = append(b.keys, key) if pos == 0 { go b.startTimer(l) } if l.maxBatch != 0 && pos >= l.maxBatch-1 { if !b.closing { b.closing = true l.batch = nil go b.end(l) } } return pos } func (b *studioLoaderBatch) startTimer(l *StudioLoader) { time.Sleep(l.wait) l.mu.Lock() // we must have hit a batch limit and are already finalizing this batch if b.closing { l.mu.Unlock() return } l.batch = nil l.mu.Unlock() b.end(l) } func (b *studioLoaderBatch) end(l *StudioLoader) { b.data, b.error = l.fetch(b.keys) close(b.done) } ================================================ FILE: internal/api/loaders/tagloader_gen.go ================================================ // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. package loaders import ( "sync" "time" "github.com/stashapp/stash/pkg/models" ) // TagLoaderConfig captures the config to create a new TagLoader type TagLoaderConfig struct { // Fetch is a method that provides the data for the loader Fetch func(keys []int) ([]*models.Tag, []error) // Wait is how long wait before sending a batch Wait time.Duration // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit MaxBatch int } // NewTagLoader creates a new TagLoader given a fetch, wait, and maxBatch func NewTagLoader(config TagLoaderConfig) *TagLoader { return &TagLoader{ fetch: config.Fetch, wait: config.Wait, maxBatch: config.MaxBatch, } } // TagLoader batches and caches requests type TagLoader struct { // this method provides the data for the loader fetch func(keys []int) ([]*models.Tag, []error) // how long to done before sending a batch wait time.Duration // this will limit the maximum number of keys to send in one batch, 0 = no limit maxBatch int // INTERNAL // lazily created cache cache map[int]*models.Tag // the current batch. keys will continue to be collected until timeout is hit, // then everything will be sent to the fetch method and out to the listeners batch *tagLoaderBatch // mutex to prevent races mu sync.Mutex } type tagLoaderBatch struct { keys []int data []*models.Tag error []error closing bool done chan struct{} } // Load a Tag by key, batching and caching will be applied automatically func (l *TagLoader) Load(key int) (*models.Tag, error) { return l.LoadThunk(key)() } // LoadThunk returns a function that when called will block waiting for a Tag. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *TagLoader) LoadThunk(key int) func() (*models.Tag, error) { l.mu.Lock() if it, ok := l.cache[key]; ok { l.mu.Unlock() return func() (*models.Tag, error) { return it, nil } } if l.batch == nil { l.batch = &tagLoaderBatch{done: make(chan struct{})} } batch := l.batch pos := batch.keyIndex(l, key) l.mu.Unlock() return func() (*models.Tag, error) { <-batch.done var data *models.Tag if pos < len(batch.data) { data = batch.data[pos] } var err error // its convenient to be able to return a single error for everything if len(batch.error) == 1 { err = batch.error[0] } else if batch.error != nil { err = batch.error[pos] } if err == nil { l.mu.Lock() l.unsafeSet(key, data) l.mu.Unlock() } return data, err } } // LoadAll fetches many keys at once. It will be broken into appropriate sized // sub batches depending on how the loader is configured func (l *TagLoader) LoadAll(keys []int) ([]*models.Tag, []error) { results := make([]func() (*models.Tag, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } tags := make([]*models.Tag, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { tags[i], errors[i] = thunk() } return tags, errors } // LoadAllThunk returns a function that when called will block waiting for a Tags. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. func (l *TagLoader) LoadAllThunk(keys []int) func() ([]*models.Tag, []error) { results := make([]func() (*models.Tag, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } return func() ([]*models.Tag, []error) { tags := make([]*models.Tag, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { tags[i], errors[i] = thunk() } return tags, errors } } // Prime the cache with the provided key and value. If the key already exists, no change is made // and false is returned. // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) func (l *TagLoader) Prime(key int, value *models.Tag) bool { l.mu.Lock() var found bool if _, found = l.cache[key]; !found { // make a copy when writing to the cache, its easy to pass a pointer in from a loop var // and end up with the whole cache pointing to the same value. cpy := *value l.unsafeSet(key, &cpy) } l.mu.Unlock() return !found } // Clear the value at key from the cache, if it exists func (l *TagLoader) Clear(key int) { l.mu.Lock() delete(l.cache, key) l.mu.Unlock() } func (l *TagLoader) unsafeSet(key int, value *models.Tag) { if l.cache == nil { l.cache = map[int]*models.Tag{} } l.cache[key] = value } // keyIndex will return the location of the key in the batch, if its not found // it will add the key to the batch func (b *tagLoaderBatch) keyIndex(l *TagLoader, key int) int { for i, existingKey := range b.keys { if key == existingKey { return i } } pos := len(b.keys) b.keys = append(b.keys, key) if pos == 0 { go b.startTimer(l) } if l.maxBatch != 0 && pos >= l.maxBatch-1 { if !b.closing { b.closing = true l.batch = nil go b.end(l) } } return pos } func (b *tagLoaderBatch) startTimer(l *TagLoader) { time.Sleep(l.wait) l.mu.Lock() // we must have hit a batch limit and are already finalizing this batch if b.closing { l.mu.Unlock() return } l.batch = nil l.mu.Unlock() b.end(l) } func (b *tagLoaderBatch) end(l *TagLoader) { b.data, b.error = l.fetch(b.keys) close(b.done) } ================================================ FILE: internal/api/locale.go ================================================ package api import ( "golang.org/x/text/collate" "golang.org/x/text/language" ) // matcher defines a matcher for the languages we support var matcher = language.NewMatcher([]language.Tag{ language.MustParse("en-US"), // The first language is used as fallback. language.MustParse("en-GB"), language.MustParse("en-AU"), language.MustParse("es-ES"), language.MustParse("de-DE"), language.MustParse("it-IT"), language.MustParse("fr-FR"), language.MustParse("fi-FI"), language.MustParse("pt-BR"), language.MustParse("sv-SE"), language.MustParse("zh-CN"), language.MustParse("zh-TW"), language.MustParse("hr-HR"), language.MustParse("nl-NL"), language.MustParse("ru-RU"), language.MustParse("tr-TR"), language.MustParse("da-DK"), language.MustParse("pl-PL"), language.MustParse("ko-KR"), language.MustParse("cs-CZ"), language.MustParse("bn-BD"), language.MustParse("et-EE"), language.MustParse("fa-IR"), language.MustParse("hu-HU"), language.MustParse("ro-RO"), language.MustParse("th-TH"), language.MustParse("uk-UA"), }) // newCollator parses a locale into a collator // Go through the available matches and return a valid match, in practice the first is a fallback // Optionally pass collation options through for creation. // If passed a nil-locale string, return nil func newCollator(locale *string, opts ...collate.Option) *collate.Collator { if locale == nil { return nil } tag, _ := language.MatchStrings(matcher, *locale) return collate.New(tag, opts...) } ================================================ FILE: internal/api/models.go ================================================ package api import ( "fmt" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil" ) type BaseFile interface { IsBaseFile() } type VisualFile interface { IsVisualFile() } func convertVisualFile(f models.File) (VisualFile, error) { switch f := f.(type) { case VisualFile: return f, nil case *models.VideoFile: return &VideoFile{VideoFile: f}, nil case *models.ImageFile: return &ImageFile{ImageFile: f}, nil default: return nil, fmt.Errorf("file %s is not a visual file", f.Base().Path) } } func convertBaseFile(f models.File) BaseFile { if f == nil { return nil } switch f := f.(type) { case BaseFile: return f case *models.VideoFile: return &VideoFile{VideoFile: f} case *models.ImageFile: return &ImageFile{ImageFile: f} case *models.BaseFile: return &BasicFile{BaseFile: f} default: panic("unknown file type") } } func convertBaseFiles(files []models.File) []BaseFile { return sliceutil.Map(files, convertBaseFile) } type GalleryFile struct { *models.BaseFile } func (GalleryFile) IsBaseFile() {} func (GalleryFile) IsVisualFile() {} func (f *GalleryFile) Fingerprints() []models.Fingerprint { return f.BaseFile.Fingerprints } type VideoFile struct { *models.VideoFile } func (VideoFile) IsBaseFile() {} func (VideoFile) IsVisualFile() {} func (f *VideoFile) Fingerprints() []models.Fingerprint { return f.VideoFile.Fingerprints } type ImageFile struct { *models.ImageFile } func (ImageFile) IsBaseFile() {} func (ImageFile) IsVisualFile() {} func (f *ImageFile) Fingerprints() []models.Fingerprint { return f.ImageFile.Fingerprints } type BasicFile struct { *models.BaseFile } func (BasicFile) IsBaseFile() {} func (BasicFile) IsVisualFile() {} func (f *BasicFile) Fingerprints() []models.Fingerprint { return f.BaseFile.Fingerprints } ================================================ FILE: internal/api/plugin_map.go ================================================ package api import ( "encoding/json" "fmt" "io" "github.com/99designs/gqlgen/graphql" ) func MarshalPluginConfigMap(val map[string]map[string]interface{}) graphql.Marshaler { return graphql.WriterFunc(func(w io.Writer) { err := json.NewEncoder(w).Encode(val) if err != nil { panic(err) } }) } func UnmarshalPluginConfigMap(v interface{}) (map[string]map[string]interface{}, error) { m, ok := v.(map[string]interface{}) if !ok { return nil, fmt.Errorf("%T is not a plugin config map", v) } result := make(map[string]map[string]interface{}) for k, v := range m { val, ok := v.(map[string]interface{}) if !ok { return nil, fmt.Errorf("key %s (%T) is not a map", k, v) } result[k] = val } return result, nil } ================================================ FILE: internal/api/resolver.go ================================================ package api import ( "context" "errors" "fmt" "sort" "strconv" "github.com/stashapp/stash/internal/build" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/scraper" ) var ( // ErrNotImplemented is an error which means the given functionality isn't implemented by the API. ErrNotImplemented = errors.New("not implemented") // ErrNotSupported is returned whenever there's a test, which can be used to guard against the error, // but the given parameters aren't supported by the system. ErrNotSupported = errors.New("not supported") // ErrInput signifies errors where the input isn't valid for some reason. And no more specific error exists. ErrInput = errors.New("input error") ) type hookExecutor interface { ExecutePostHooks(ctx context.Context, id int, hookType hook.TriggerEnum, input interface{}, inputFields []string) } type Resolver struct { repository models.Repository sceneService manager.SceneService imageService manager.ImageService galleryService manager.GalleryService groupService manager.GroupService hookExecutor hookExecutor } func (r *Resolver) scraperCache() *scraper.Cache { return manager.GetInstance().ScraperCache } func (r *Resolver) Gallery() GalleryResolver { return &galleryResolver{r} } func (r *Resolver) GalleryChapter() GalleryChapterResolver { return &galleryChapterResolver{r} } func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} } func (r *Resolver) Performer() PerformerResolver { return &performerResolver{r} } func (r *Resolver) Query() QueryResolver { return &queryResolver{r} } func (r *Resolver) Scene() SceneResolver { return &sceneResolver{r} } func (r *Resolver) Image() ImageResolver { return &imageResolver{r} } func (r *Resolver) SceneMarker() SceneMarkerResolver { return &sceneMarkerResolver{r} } func (r *Resolver) Studio() StudioResolver { return &studioResolver{r} } func (r *Resolver) Group() GroupResolver { return &groupResolver{r} } func (r *Resolver) Movie() MovieResolver { return &movieResolver{&groupResolver{r}} } func (r *Resolver) Subscription() SubscriptionResolver { return &subscriptionResolver{r} } func (r *Resolver) Tag() TagResolver { return &tagResolver{r} } func (r *Resolver) GalleryFile() GalleryFileResolver { return &galleryFileResolver{r} } func (r *Resolver) VideoFile() VideoFileResolver { return &videoFileResolver{r} } func (r *Resolver) ImageFile() ImageFileResolver { return &imageFileResolver{r} } func (r *Resolver) BasicFile() BasicFileResolver { return &basicFileResolver{r} } func (r *Resolver) Folder() FolderResolver { return &folderResolver{r} } func (r *Resolver) SavedFilter() SavedFilterResolver { return &savedFilterResolver{r} } func (r *Resolver) Plugin() PluginResolver { return &pluginResolver{r} } func (r *Resolver) ConfigResult() ConfigResultResolver { return &configResultResolver{r} } type mutationResolver struct{ *Resolver } type queryResolver struct{ *Resolver } type subscriptionResolver struct{ *Resolver } type galleryResolver struct{ *Resolver } type galleryChapterResolver struct{ *Resolver } type performerResolver struct{ *Resolver } type sceneResolver struct{ *Resolver } type sceneMarkerResolver struct{ *Resolver } type imageResolver struct{ *Resolver } type studioResolver struct{ *Resolver } // movie is group under the hood type groupResolver struct{ *Resolver } type movieResolver struct{ *groupResolver } type tagResolver struct{ *Resolver } type galleryFileResolver struct{ *Resolver } type videoFileResolver struct{ *Resolver } type imageFileResolver struct{ *Resolver } type basicFileResolver struct{ *Resolver } type folderResolver struct{ *Resolver } type savedFilterResolver struct{ *Resolver } type pluginResolver struct{ *Resolver } type configResultResolver struct{ *Resolver } func (r *Resolver) withTxn(ctx context.Context, fn func(ctx context.Context) error) error { return r.repository.WithTxn(ctx, fn) } func (r *Resolver) withReadTxn(ctx context.Context, fn func(ctx context.Context) error) error { return r.repository.WithReadTxn(ctx, fn) } func (r *queryResolver) MarkerWall(ctx context.Context, q *string) (ret []*models.SceneMarker, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.SceneMarker.Wall(ctx, q) return err }); err != nil { return nil, err } return ret, nil } func (r *queryResolver) SceneWall(ctx context.Context, q *string) (ret []*models.Scene, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Scene.Wall(ctx, q) return err }); err != nil { return nil, err } return ret, nil } func (r *queryResolver) MarkerStrings(ctx context.Context, q *string, sort *string) (ret []*models.MarkerStringsResultType, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.SceneMarker.GetMarkerStrings(ctx, q, sort) return err }); err != nil { return nil, err } return ret, nil } func (r *queryResolver) Stats(ctx context.Context) (*StatsResultType, error) { var ret StatsResultType if err := r.withReadTxn(ctx, func(ctx context.Context) error { repo := r.repository sceneQB := repo.Scene imageQB := repo.Image galleryQB := repo.Gallery studioQB := repo.Studio performerQB := repo.Performer movieQB := repo.Group tagQB := repo.Tag // embrace the error scenesCount, err := sceneQB.Count(ctx) if err != nil { return err } scenesSize, err := sceneQB.Size(ctx) if err != nil { return err } scenesDuration, err := sceneQB.Duration(ctx) if err != nil { return err } imageCount, err := imageQB.Count(ctx) if err != nil { return err } imageSize, err := imageQB.Size(ctx) if err != nil { return err } galleryCount, err := galleryQB.Count(ctx) if err != nil { return err } performersCount, err := performerQB.Count(ctx) if err != nil { return err } studiosCount, err := studioQB.Count(ctx) if err != nil { return err } groupsCount, err := movieQB.Count(ctx) if err != nil { return err } tagsCount, err := tagQB.Count(ctx) if err != nil { return err } scenesTotalOCount, err := sceneQB.GetAllOCount(ctx) if err != nil { return err } imagesTotalOCount, err := imageQB.OCount(ctx) if err != nil { return err } totalOCount := scenesTotalOCount + imagesTotalOCount totalPlayDuration, err := sceneQB.PlayDuration(ctx) if err != nil { return err } totalPlayCount, err := sceneQB.CountAllViews(ctx) if err != nil { return err } uniqueScenePlayCount, err := sceneQB.CountUniqueViews(ctx) if err != nil { return err } ret = StatsResultType{ SceneCount: scenesCount, ScenesSize: scenesSize, ScenesDuration: scenesDuration, ImageCount: imageCount, ImagesSize: imageSize, GalleryCount: galleryCount, PerformerCount: performersCount, StudioCount: studiosCount, GroupCount: groupsCount, MovieCount: groupsCount, TagCount: tagsCount, TotalOCount: totalOCount, TotalPlayDuration: totalPlayDuration, TotalPlayCount: totalPlayCount, ScenesPlayed: uniqueScenePlayCount, } return nil }); err != nil { return nil, err } return &ret, nil } func (r *queryResolver) Version(ctx context.Context) (*Version, error) { version, hash, buildtime := build.Version() return &Version{ Version: &version, Hash: hash, BuildTime: buildtime, }, nil } func (r *queryResolver) Latestversion(ctx context.Context) (*LatestVersion, error) { latestRelease, err := GetLatestRelease(ctx) if err != nil { if !errors.Is(err, context.Canceled) { logger.Errorf("Error while retrieving latest version: %v", err) } return nil, err } logger.Infof("Retrieved latest version: %s (%s)", latestRelease.Version, latestRelease.ShortHash) return &LatestVersion{ Version: latestRelease.Version, Shorthash: latestRelease.ShortHash, ReleaseDate: latestRelease.Date, URL: latestRelease.Url, }, nil } func (r *mutationResolver) ExecSQL(ctx context.Context, sql string, args []interface{}) (*SQLExecResult, error) { var rowsAffected *int64 var lastInsertID *int64 db := manager.GetInstance().Database if err := r.withTxn(ctx, func(ctx context.Context) error { var err error rowsAffected, lastInsertID, err = db.ExecSQL(ctx, sql, args) return err }); err != nil { return nil, err } return &SQLExecResult{ RowsAffected: rowsAffected, LastInsertID: lastInsertID, }, nil } func (r *mutationResolver) QuerySQL(ctx context.Context, sql string, args []interface{}) (*SQLQueryResult, error) { var cols []string var rows [][]interface{} db := manager.GetInstance().Database if err := r.withTxn(ctx, func(ctx context.Context) error { var err error cols, rows, err = db.QuerySQL(ctx, sql, args) return err }); err != nil { return nil, err } return &SQLQueryResult{ Columns: cols, Rows: rows, }, nil } // Get scene marker tags which show up under the video. func (r *queryResolver) SceneMarkerTags(ctx context.Context, scene_id string) ([]*SceneMarkerTag, error) { sceneID, err := strconv.Atoi(scene_id) if err != nil { return nil, err } var keys []int tags := make(map[int]*SceneMarkerTag) if err := r.withReadTxn(ctx, func(ctx context.Context) error { sceneMarkers, err := r.repository.SceneMarker.FindBySceneID(ctx, sceneID) if err != nil { return err } tqb := r.repository.Tag for _, sceneMarker := range sceneMarkers { markerPrimaryTag, err := tqb.Find(ctx, sceneMarker.PrimaryTagID) if err != nil { return err } if markerPrimaryTag == nil { return fmt.Errorf("tag with id %d not found", sceneMarker.PrimaryTagID) } _, hasKey := tags[markerPrimaryTag.ID] if !hasKey { sceneMarkerTag := &SceneMarkerTag{Tag: markerPrimaryTag} tags[markerPrimaryTag.ID] = sceneMarkerTag keys = append(keys, markerPrimaryTag.ID) } tags[markerPrimaryTag.ID].SceneMarkers = append(tags[markerPrimaryTag.ID].SceneMarkers, sceneMarker) } return nil }); err != nil { return nil, err } // Sort so that primary tags that show up earlier in the video are first. sort.Slice(keys, func(i, j int) bool { a := tags[keys[i]] b := tags[keys[j]] return a.SceneMarkers[0].Seconds < b.SceneMarkers[0].Seconds }) var result []*SceneMarkerTag for _, key := range keys { result = append(result, tags[key]) } return result, nil } func firstError(errs []error) error { for _, e := range errs { if e != nil { return e } } return nil } ================================================ FILE: internal/api/resolver_model_config.go ================================================ package api import ( "context" "github.com/stashapp/stash/internal/manager/config" ) func (r *configResultResolver) Plugins(ctx context.Context, obj *ConfigResult, include []string) (map[string]map[string]interface{}, error) { if len(include) == 0 { ret := config.GetInstance().GetAllPluginConfiguration() return ret, nil } ret := make(map[string]map[string]interface{}) for _, plugin := range include { c := config.GetInstance().GetPluginConfiguration(plugin) if len(c) > 0 { ret[plugin] = c } } return ret, nil } ================================================ FILE: internal/api/resolver_model_file.go ================================================ package api import ( "context" "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/pkg/models" ) func fingerprintResolver(fp models.Fingerprints, type_ string) (*string, error) { fingerprint := fp.For(type_) if fingerprint != nil { value := fingerprint.Value() return &value, nil } return nil, nil } func (r *galleryFileResolver) Fingerprint(ctx context.Context, obj *GalleryFile, type_ string) (*string, error) { return fingerprintResolver(obj.BaseFile.Fingerprints, type_) } func (r *imageFileResolver) Fingerprint(ctx context.Context, obj *ImageFile, type_ string) (*string, error) { return fingerprintResolver(obj.ImageFile.Fingerprints, type_) } func (r *videoFileResolver) Fingerprint(ctx context.Context, obj *VideoFile, type_ string) (*string, error) { return fingerprintResolver(obj.VideoFile.Fingerprints, type_) } func (r *basicFileResolver) Fingerprint(ctx context.Context, obj *BasicFile, type_ string) (*string, error) { return fingerprintResolver(obj.BaseFile.Fingerprints, type_) } func (r *galleryFileResolver) ParentFolder(ctx context.Context, obj *GalleryFile) (*models.Folder, error) { return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID) } func (r *imageFileResolver) ParentFolder(ctx context.Context, obj *ImageFile) (*models.Folder, error) { return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID) } func (r *videoFileResolver) ParentFolder(ctx context.Context, obj *VideoFile) (*models.Folder, error) { return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID) } func (r *basicFileResolver) ParentFolder(ctx context.Context, obj *BasicFile) (*models.Folder, error) { return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID) } func zipFileResolver(ctx context.Context, zipFileID *models.FileID) (*BasicFile, error) { if zipFileID == nil { return nil, nil } f, err := loaders.From(ctx).FileByID.Load(*zipFileID) if err != nil { return nil, err } return &BasicFile{ BaseFile: f.Base(), }, nil } func (r *galleryFileResolver) ZipFile(ctx context.Context, obj *GalleryFile) (*BasicFile, error) { return zipFileResolver(ctx, obj.ZipFileID) } func (r *imageFileResolver) ZipFile(ctx context.Context, obj *ImageFile) (*BasicFile, error) { return zipFileResolver(ctx, obj.ZipFileID) } func (r *videoFileResolver) ZipFile(ctx context.Context, obj *VideoFile) (*BasicFile, error) { return zipFileResolver(ctx, obj.ZipFileID) } func (r *basicFileResolver) ZipFile(ctx context.Context, obj *BasicFile) (*BasicFile, error) { return zipFileResolver(ctx, obj.ZipFileID) } ================================================ FILE: internal/api/resolver_model_folder.go ================================================ package api import ( "context" "path/filepath" "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/pkg/models" ) func (r *folderResolver) Basename(ctx context.Context, obj *models.Folder) (string, error) { return filepath.Base(obj.Path), nil } func (r *folderResolver) ParentFolder(ctx context.Context, obj *models.Folder) (*models.Folder, error) { if obj.ParentFolderID == nil { return nil, nil } return loaders.From(ctx).FolderByID.Load(*obj.ParentFolderID) } func (r *folderResolver) ParentFolders(ctx context.Context, obj *models.Folder) ([]*models.Folder, error) { ids, err := loaders.From(ctx).FolderParentFolderIDs.Load(obj.ID) if err != nil { return nil, err } var errs []error ret, errs := loaders.From(ctx).FolderByID.LoadAll(ids) return ret, firstError(errs) } func (r *folderResolver) ZipFile(ctx context.Context, obj *models.Folder) (*BasicFile, error) { return zipFileResolver(ctx, obj.ZipFileID) } ================================================ FILE: internal/api/resolver_model_gallery.go ================================================ package api import ( "context" "fmt" "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" ) func (r *galleryResolver) getFiles(ctx context.Context, obj *models.Gallery) ([]models.File, error) { fileIDs, err := loaders.From(ctx).GalleryFiles.Load(obj.ID) if err != nil { return nil, err } files, errs := loaders.From(ctx).FileByID.LoadAll(fileIDs) return files, firstError(errs) } func (r *galleryResolver) Files(ctx context.Context, obj *models.Gallery) ([]*GalleryFile, error) { files, err := r.getFiles(ctx, obj) if err != nil { return nil, err } ret := make([]*GalleryFile, len(files)) for i, f := range files { ret[i] = &GalleryFile{ BaseFile: f.Base(), } } return ret, nil } func (r *galleryResolver) Folder(ctx context.Context, obj *models.Gallery) (*models.Folder, error) { if obj.FolderID == nil { return nil, nil } var ret *models.Folder if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error ret, err = r.repository.Folder.Find(ctx, *obj.FolderID) if err != nil { return err } return err }); err != nil { return nil, err } if ret == nil { return nil, nil } return ret, nil } func (r *galleryResolver) Cover(ctx context.Context, obj *models.Gallery) (ret *models.Image, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { // Find cover image first ret, err = image.FindGalleryCover(ctx, r.repository.Image, obj.ID, config.GetInstance().GetGalleryCoverRegex()) return err }); err != nil { return nil, err } return ret, nil } func (r *galleryResolver) Date(ctx context.Context, obj *models.Gallery) (*string, error) { if obj.Date != nil { result := obj.Date.String() return &result, nil } return nil, nil } func (r *galleryResolver) Rating100(ctx context.Context, obj *models.Gallery) (*int, error) { return obj.Rating, nil } func (r *galleryResolver) Scenes(ctx context.Context, obj *models.Gallery) (ret []*models.Scene, err error) { if !obj.SceneIDs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadSceneIDs(ctx, r.repository.Gallery) }); err != nil { return nil, err } } var errs []error ret, errs = loaders.From(ctx).SceneByID.LoadAll(obj.SceneIDs.List()) return ret, firstError(errs) } func (r *galleryResolver) Studio(ctx context.Context, obj *models.Gallery) (ret *models.Studio, err error) { if obj.StudioID == nil { return nil, nil } return loaders.From(ctx).StudioByID.Load(*obj.StudioID) } func (r *galleryResolver) Tags(ctx context.Context, obj *models.Gallery) (ret []*models.Tag, err error) { if !obj.TagIDs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadTagIDs(ctx, r.repository.Gallery) }); err != nil { return nil, err } } var errs []error ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List()) return ret, firstError(errs) } func (r *galleryResolver) Performers(ctx context.Context, obj *models.Gallery) (ret []*models.Performer, err error) { if !obj.PerformerIDs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadPerformerIDs(ctx, r.repository.Gallery) }); err != nil { return nil, err } } var errs []error ret, errs = loaders.From(ctx).PerformerByID.LoadAll(obj.PerformerIDs.List()) return ret, firstError(errs) } func (r *galleryResolver) ImageCount(ctx context.Context, obj *models.Gallery) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error ret, err = r.repository.Image.CountByGalleryID(ctx, obj.ID) return err }); err != nil { return 0, err } return ret, nil } func (r *galleryResolver) Chapters(ctx context.Context, obj *models.Gallery) (ret []*models.GalleryChapter, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.GalleryChapter.FindByGalleryID(ctx, obj.ID) return err }); err != nil { return nil, err } return ret, nil } func (r *galleryResolver) URL(ctx context.Context, obj *models.Gallery) (*string, error) { if !obj.URLs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadURLs(ctx, r.repository.Gallery) }); err != nil { return nil, err } } urls := obj.URLs.List() if len(urls) == 0 { return nil, nil } return &urls[0], nil } func (r *galleryResolver) Urls(ctx context.Context, obj *models.Gallery) ([]string, error) { if !obj.URLs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadURLs(ctx, r.repository.Gallery) }); err != nil { return nil, err } } return obj.URLs.List(), nil } func (r *galleryResolver) Paths(ctx context.Context, obj *models.Gallery) (*GalleryPathsType, error) { baseURL, _ := ctx.Value(BaseURLCtxKey).(string) builder := urlbuilders.NewGalleryURLBuilder(baseURL, obj) return &GalleryPathsType{ Cover: builder.GetCoverURL(), Preview: builder.GetPreviewURL(), }, nil } func (r *galleryResolver) Image(ctx context.Context, obj *models.Gallery, index int) (ret *models.Image, err error) { if index < 0 { return nil, fmt.Errorf("index must >= 0") } if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Image.FindByGalleryIDIndex(ctx, obj.ID, uint(index)) return err }); err != nil { return nil, err } return } func (r *galleryResolver) CustomFields(ctx context.Context, obj *models.Gallery) (map[string]interface{}, error) { m, err := loaders.From(ctx).GalleryCustomFields.Load(obj.ID) if err != nil { return nil, err } if m == nil { return make(map[string]interface{}), nil } return m, nil } ================================================ FILE: internal/api/resolver_model_gallery_chapter.go ================================================ package api import ( "context" "github.com/stashapp/stash/pkg/models" ) func (r *galleryChapterResolver) Gallery(ctx context.Context, obj *models.GalleryChapter) (ret *models.Gallery, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Gallery.Find(ctx, obj.GalleryID) return err }); err != nil { return nil, err } return ret, nil } ================================================ FILE: internal/api/resolver_model_image.go ================================================ package api import ( "context" "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/pkg/models" ) func (r *imageResolver) getFiles(ctx context.Context, obj *models.Image) ([]models.File, error) { fileIDs, err := loaders.From(ctx).ImageFiles.Load(obj.ID) if err != nil { return nil, err } files, errs := loaders.From(ctx).FileByID.LoadAll(fileIDs) return files, firstError(errs) } func (r *imageResolver) VisualFiles(ctx context.Context, obj *models.Image) ([]VisualFile, error) { files, err := r.getFiles(ctx, obj) if err != nil { return nil, err } ret := make([]VisualFile, len(files)) for i, f := range files { ret[i], err = convertVisualFile(f) if err != nil { return nil, err } } return ret, nil } func (r *imageResolver) Date(ctx context.Context, obj *models.Image) (*string, error) { if obj.Date != nil { result := obj.Date.String() return &result, nil } return nil, nil } func (r *imageResolver) Files(ctx context.Context, obj *models.Image) ([]*ImageFile, error) { files, err := r.getFiles(ctx, obj) if err != nil { return nil, err } var ret []*ImageFile for _, f := range files { // filter out non-image files imageFile, ok := f.(*models.ImageFile) if !ok { continue } ret = append(ret, &ImageFile{ ImageFile: imageFile, }) } return ret, nil } func (r *imageResolver) Paths(ctx context.Context, obj *models.Image) (*ImagePathsType, error) { baseURL, _ := ctx.Value(BaseURLCtxKey).(string) builder := urlbuilders.NewImageURLBuilder(baseURL, obj) thumbnailPath := builder.GetThumbnailURL() previewPath := builder.GetPreviewURL() imagePath := builder.GetImageURL() return &ImagePathsType{ Image: &imagePath, Thumbnail: &thumbnailPath, Preview: &previewPath, }, nil } func (r *imageResolver) Galleries(ctx context.Context, obj *models.Image) (ret []*models.Gallery, err error) { if !obj.GalleryIDs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadGalleryIDs(ctx, r.repository.Image) }); err != nil { return nil, err } } var errs []error ret, errs = loaders.From(ctx).GalleryByID.LoadAll(obj.GalleryIDs.List()) return ret, firstError(errs) } func (r *imageResolver) Rating100(ctx context.Context, obj *models.Image) (*int, error) { return obj.Rating, nil } func (r *imageResolver) Studio(ctx context.Context, obj *models.Image) (ret *models.Studio, err error) { if obj.StudioID == nil { return nil, nil } return loaders.From(ctx).StudioByID.Load(*obj.StudioID) } func (r *imageResolver) Tags(ctx context.Context, obj *models.Image) (ret []*models.Tag, err error) { if !obj.TagIDs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadTagIDs(ctx, r.repository.Image) }); err != nil { return nil, err } } var errs []error ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List()) return ret, firstError(errs) } func (r *imageResolver) Performers(ctx context.Context, obj *models.Image) (ret []*models.Performer, err error) { if !obj.PerformerIDs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadPerformerIDs(ctx, r.repository.Image) }); err != nil { return nil, err } } var errs []error ret, errs = loaders.From(ctx).PerformerByID.LoadAll(obj.PerformerIDs.List()) return ret, firstError(errs) } func (r *imageResolver) URL(ctx context.Context, obj *models.Image) (*string, error) { if !obj.URLs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadURLs(ctx, r.repository.Image) }); err != nil { return nil, err } } urls := obj.URLs.List() if len(urls) == 0 { return nil, nil } return &urls[0], nil } func (r *imageResolver) Urls(ctx context.Context, obj *models.Image) ([]string, error) { if !obj.URLs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadURLs(ctx, r.repository.Image) }); err != nil { return nil, err } } return obj.URLs.List(), nil } func (r *imageResolver) CustomFields(ctx context.Context, obj *models.Image) (map[string]interface{}, error) { customFields, err := loaders.From(ctx).ImageCustomFields.Load(obj.ID) if err != nil { return nil, err } return customFields, nil } ================================================ FILE: internal/api/resolver_model_movie.go ================================================ package api import ( "context" "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/pkg/group" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/scene" ) func (r *groupResolver) Date(ctx context.Context, obj *models.Group) (*string, error) { if obj.Date != nil { result := obj.Date.String() return &result, nil } return nil, nil } func (r *groupResolver) Rating100(ctx context.Context, obj *models.Group) (*int, error) { return obj.Rating, nil } func (r *groupResolver) URL(ctx context.Context, obj *models.Group) (*string, error) { if !obj.URLs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadURLs(ctx, r.repository.Group) }); err != nil { return nil, err } } urls := obj.URLs.List() if len(urls) == 0 { return nil, nil } return &urls[0], nil } func (r *groupResolver) Urls(ctx context.Context, obj *models.Group) ([]string, error) { if !obj.URLs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadURLs(ctx, r.repository.Group) }); err != nil { return nil, err } } return obj.URLs.List(), nil } func (r *groupResolver) Studio(ctx context.Context, obj *models.Group) (ret *models.Studio, err error) { if obj.StudioID == nil { return nil, nil } return loaders.From(ctx).StudioByID.Load(*obj.StudioID) } func (r groupResolver) Tags(ctx context.Context, obj *models.Group) (ret []*models.Tag, err error) { if !obj.TagIDs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadTagIDs(ctx, r.repository.Group) }); err != nil { return nil, err } } var errs []error ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List()) return ret, firstError(errs) } func (r groupResolver) relatedGroups(ctx context.Context, rgd models.RelatedGroupDescriptions) (ret []*GroupDescription, err error) { // rgd must be loaded gds := rgd.List() ids := make([]int, len(gds)) for i, gd := range gds { ids[i] = gd.GroupID } groups, errs := loaders.From(ctx).GroupByID.LoadAll(ids) err = firstError(errs) if err != nil { return } ret = make([]*GroupDescription, len(groups)) for i, group := range groups { ret[i] = &GroupDescription{Group: group} d := gds[i].Description if d != "" { ret[i].Description = &d } } return ret, firstError(errs) } func (r groupResolver) ContainingGroups(ctx context.Context, obj *models.Group) (ret []*GroupDescription, err error) { if !obj.ContainingGroups.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadContainingGroupIDs(ctx, r.repository.Group) }); err != nil { return nil, err } } return r.relatedGroups(ctx, obj.ContainingGroups) } func (r groupResolver) SubGroups(ctx context.Context, obj *models.Group) (ret []*GroupDescription, err error) { if !obj.SubGroups.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadSubGroupIDs(ctx, r.repository.Group) }); err != nil { return nil, err } } return r.relatedGroups(ctx, obj.SubGroups) } func (r *groupResolver) SubGroupCount(ctx context.Context, obj *models.Group, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = group.CountByContainingGroupID(ctx, r.repository.Group, obj.ID, depth) return err }); err != nil { return 0, err } return ret, nil } func (r *groupResolver) FrontImagePath(ctx context.Context, obj *models.Group) (*string, error) { var hasImage bool if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error hasImage, err = r.repository.Group.HasFrontImage(ctx, obj.ID) return err }); err != nil { return nil, err } baseURL, _ := ctx.Value(BaseURLCtxKey).(string) imagePath := urlbuilders.NewGroupURLBuilder(baseURL, obj).GetGroupFrontImageURL(hasImage) return &imagePath, nil } func (r *groupResolver) BackImagePath(ctx context.Context, obj *models.Group) (*string, error) { var hasImage bool if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error hasImage, err = r.repository.Group.HasBackImage(ctx, obj.ID) return err }); err != nil { return nil, err } // don't return anything if there is no back image if !hasImage { return nil, nil } baseURL, _ := ctx.Value(BaseURLCtxKey).(string) imagePath := urlbuilders.NewGroupURLBuilder(baseURL, obj).GetGroupBackImageURL() return &imagePath, nil } func (r *groupResolver) SceneCount(ctx context.Context, obj *models.Group, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = scene.CountByGroupID(ctx, r.repository.Scene, obj.ID, depth) return err }); err != nil { return 0, err } return ret, nil } func (r *groupResolver) PerformerCount(ctx context.Context, obj *models.Group, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = performer.CountByGroupID(ctx, r.repository.Performer, obj.ID, depth) return err }); err != nil { return 0, err } return ret, nil } func (r *groupResolver) Scenes(ctx context.Context, obj *models.Group) (ret []*models.Scene, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error ret, err = r.repository.Scene.FindByGroupID(ctx, obj.ID) return err }); err != nil { return nil, err } return ret, nil } func (r *groupResolver) OCounter(ctx context.Context, obj *models.Group) (ret *int, err error) { var count int if err := r.withReadTxn(ctx, func(ctx context.Context) error { count, err = r.repository.Scene.OCountByGroupID(ctx, obj.ID) return err }); err != nil { return nil, err } return &count, nil } func (r *groupResolver) CustomFields(ctx context.Context, obj *models.Group) (map[string]interface{}, error) { m, err := loaders.From(ctx).GroupCustomFields.Load(obj.ID) if err != nil { return nil, err } if m == nil { return make(map[string]interface{}), nil } return m, nil } ================================================ FILE: internal/api/resolver_model_performer.go ================================================ package api import ( "context" "strconv" "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/performer" ) func (r *performerResolver) AliasList(ctx context.Context, obj *models.Performer) ([]string, error) { if !obj.Aliases.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadAliases(ctx, r.repository.Performer) }); err != nil { return nil, err } } return obj.Aliases.List(), nil } func (r *performerResolver) URL(ctx context.Context, obj *models.Performer) (*string, error) { if !obj.URLs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadURLs(ctx, r.repository.Performer) }); err != nil { return nil, err } } urls := obj.URLs.List() if len(urls) == 0 { return nil, nil } return &urls[0], nil } func (r *performerResolver) Twitter(ctx context.Context, obj *models.Performer) (*string, error) { if !obj.URLs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadURLs(ctx, r.repository.Performer) }); err != nil { return nil, err } } urls := obj.URLs.List() // find the first twitter url for _, url := range urls { if performer.IsTwitterURL(url) { u := url return &u, nil } } return nil, nil } func (r *performerResolver) Instagram(ctx context.Context, obj *models.Performer) (*string, error) { if !obj.URLs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadURLs(ctx, r.repository.Performer) }); err != nil { return nil, err } } urls := obj.URLs.List() // find the first instagram url for _, url := range urls { if performer.IsInstagramURL(url) { u := url return &u, nil } } return nil, nil } func (r *performerResolver) Urls(ctx context.Context, obj *models.Performer) ([]string, error) { if !obj.URLs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadURLs(ctx, r.repository.Performer) }); err != nil { return nil, err } } return obj.URLs.List(), nil } func (r *performerResolver) Height(ctx context.Context, obj *models.Performer) (*string, error) { if obj.Height != nil { ret := strconv.Itoa(*obj.Height) return &ret, nil } return nil, nil } func (r *performerResolver) HeightCm(ctx context.Context, obj *models.Performer) (*int, error) { return obj.Height, nil } func (r *performerResolver) CareerStart(ctx context.Context, obj *models.Performer) (*string, error) { if obj.CareerStart != nil { ret := obj.CareerStart.String() return &ret, nil } return nil, nil } func (r *performerResolver) CareerEnd(ctx context.Context, obj *models.Performer) (*string, error) { if obj.CareerEnd != nil { ret := obj.CareerEnd.String() return &ret, nil } return nil, nil } func (r *performerResolver) CareerLength(ctx context.Context, obj *models.Performer) (*string, error) { if obj.CareerStart == nil && obj.CareerEnd == nil { return nil, nil } ret := models.FormatYearRange(obj.CareerStart, obj.CareerEnd) return &ret, nil } func (r *performerResolver) Birthdate(ctx context.Context, obj *models.Performer) (*string, error) { if obj.Birthdate != nil { ret := obj.Birthdate.String() return &ret, nil } return nil, nil } func (r *performerResolver) ImagePath(ctx context.Context, obj *models.Performer) (*string, error) { var hasImage bool if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error hasImage, err = r.repository.Performer.HasImage(ctx, obj.ID) return err }); err != nil { return nil, err } baseURL, _ := ctx.Value(BaseURLCtxKey).(string) imagePath := urlbuilders.NewPerformerURLBuilder(baseURL, obj).GetPerformerImageURL(hasImage) return &imagePath, nil } func (r *performerResolver) Tags(ctx context.Context, obj *models.Performer) (ret []*models.Tag, err error) { if !obj.TagIDs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadTagIDs(ctx, r.repository.Performer) }); err != nil { return nil, err } } var errs []error ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List()) return ret, firstError(errs) } func (r *performerResolver) SceneCount(ctx context.Context, obj *models.Performer) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Scene.CountByPerformerID(ctx, obj.ID) return err }); err != nil { return 0, err } return ret, nil } func (r *performerResolver) ImageCount(ctx context.Context, obj *models.Performer) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = image.CountByPerformerID(ctx, r.repository.Image, obj.ID) return err }); err != nil { return 0, err } return ret, nil } func (r *performerResolver) GalleryCount(ctx context.Context, obj *models.Performer) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = gallery.CountByPerformerID(ctx, r.repository.Gallery, obj.ID) return err }); err != nil { return 0, err } return ret, nil } func (r *performerResolver) GroupCount(ctx context.Context, obj *models.Performer) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Group.CountByPerformerID(ctx, obj.ID) return err }); err != nil { return 0, err } return ret, nil } // deprecated func (r *performerResolver) MovieCount(ctx context.Context, obj *models.Performer) (ret int, err error) { return r.GroupCount(ctx, obj) } func (r *performerResolver) PerformerCount(ctx context.Context, obj *models.Performer) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = performer.CountByAppearsWith(ctx, r.repository.Performer, obj.ID) return err }); err != nil { return 0, err } return ret, nil } func (r *performerResolver) OCounter(ctx context.Context, obj *models.Performer) (ret *int, err error) { var res_scene int var res_image int var res int if err := r.withReadTxn(ctx, func(ctx context.Context) error { res_scene, err = r.repository.Scene.OCountByPerformerID(ctx, obj.ID) if err != nil { return err } res_image, err = r.repository.Image.OCountByPerformerID(ctx, obj.ID) return err }); err != nil { return nil, err } res = res_scene + res_image return &res, nil } func (r *performerResolver) Scenes(ctx context.Context, obj *models.Performer) (ret []*models.Scene, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Scene.FindByPerformerID(ctx, obj.ID) return err }); err != nil { return nil, err } return ret, nil } func (r *performerResolver) StashIds(ctx context.Context, obj *models.Performer) ([]*models.StashID, error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadStashIDs(ctx, r.repository.Performer) }); err != nil { return nil, err } return stashIDsSliceToPtrSlice(obj.StashIDs.List()), nil } func (r *performerResolver) Rating100(ctx context.Context, obj *models.Performer) (*int, error) { return obj.Rating, nil } func (r *performerResolver) DeathDate(ctx context.Context, obj *models.Performer) (*string, error) { if obj.DeathDate != nil { ret := obj.DeathDate.String() return &ret, nil } return nil, nil } func (r *performerResolver) Groups(ctx context.Context, obj *models.Performer) (ret []*models.Group, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Group.FindByPerformerID(ctx, obj.ID) return err }); err != nil { return nil, err } return ret, nil } func (r *performerResolver) CustomFields(ctx context.Context, obj *models.Performer) (map[string]interface{}, error) { m, err := loaders.From(ctx).PerformerCustomFields.Load(obj.ID) if err != nil { return nil, err } if m == nil { return make(map[string]interface{}), nil } return m, nil } // deprecated func (r *performerResolver) Movies(ctx context.Context, obj *models.Performer) (ret []*models.Group, err error) { return r.Groups(ctx, obj) } ================================================ FILE: internal/api/resolver_model_plugin.go ================================================ package api import ( "context" "github.com/stashapp/stash/pkg/plugin" ) type pluginURLBuilder struct { BaseURL string Plugin *plugin.Plugin } func (b pluginURLBuilder) javascript() []string { ui := b.Plugin.UI if len(ui.Javascript) == 0 && len(ui.ExternalScript) == 0 { return nil } var ret []string ret = append(ret, ui.ExternalScript...) ret = append(ret, b.BaseURL+"/plugin/"+b.Plugin.ID+"/javascript") return ret } func (b pluginURLBuilder) css() []string { ui := b.Plugin.UI if len(ui.CSS) == 0 && len(ui.ExternalCSS) == 0 { return nil } var ret []string ret = append(ret, b.Plugin.UI.ExternalCSS...) ret = append(ret, b.BaseURL+"/plugin/"+b.Plugin.ID+"/css") return ret } func (b *pluginURLBuilder) paths() *PluginPaths { return &PluginPaths{ Javascript: b.javascript(), CSS: b.css(), } } func (r *pluginResolver) Paths(ctx context.Context, obj *plugin.Plugin) (*PluginPaths, error) { baseURL, _ := ctx.Value(BaseURLCtxKey).(string) b := pluginURLBuilder{ BaseURL: baseURL, Plugin: obj, } return b.paths(), nil } func (r *pluginResolver) Requires(ctx context.Context, obj *plugin.Plugin) ([]string, error) { return obj.UI.Requires, nil } ================================================ FILE: internal/api/resolver_model_saved_filter.go ================================================ package api import ( "context" "github.com/stashapp/stash/pkg/models" ) func (r *savedFilterResolver) Filter(ctx context.Context, obj *models.SavedFilter) (string, error) { return "", nil } ================================================ FILE: internal/api/resolver_model_scene.go ================================================ package api import ( "context" "fmt" "time" "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/models" ) func convertVideoFile(f models.File) (*models.VideoFile, error) { vf, ok := f.(*models.VideoFile) if !ok { return nil, fmt.Errorf("file %T is not a video file", f) } return vf, nil } func (r *sceneResolver) getPrimaryFile(ctx context.Context, obj *models.Scene) (*models.VideoFile, error) { if obj.PrimaryFileID != nil { f, err := loaders.From(ctx).FileByID.Load(*obj.PrimaryFileID) if err != nil { return nil, err } ret, err := convertVideoFile(f) if err != nil { return nil, err } obj.Files.SetPrimary(ret) return ret, nil } else { _ = obj.LoadPrimaryFile(ctx, r.repository.File) } return nil, nil } func (r *sceneResolver) getFiles(ctx context.Context, obj *models.Scene) ([]*models.VideoFile, error) { fileIDs, err := loaders.From(ctx).SceneFiles.Load(obj.ID) if err != nil { return nil, err } files, errs := loaders.From(ctx).FileByID.LoadAll(fileIDs) err = firstError(errs) if err != nil { return nil, err } ret := make([]*models.VideoFile, len(files)) for i, f := range files { ret[i], err = convertVideoFile(f) if err != nil { return nil, err } } obj.Files.Set(ret) return ret, nil } func (r *sceneResolver) Date(ctx context.Context, obj *models.Scene) (*string, error) { if obj.Date != nil { result := obj.Date.String() return &result, nil } return nil, nil } func (r *sceneResolver) Files(ctx context.Context, obj *models.Scene) ([]*VideoFile, error) { files, err := r.getFiles(ctx, obj) if err != nil { return nil, err } ret := make([]*VideoFile, len(files)) for i, f := range files { ret[i] = &VideoFile{ VideoFile: f, } } return ret, nil } func (r *sceneResolver) Rating(ctx context.Context, obj *models.Scene) (*int, error) { if obj.Rating != nil { rating := models.Rating100To5(*obj.Rating) return &rating, nil } return nil, nil } func (r *sceneResolver) Rating100(ctx context.Context, obj *models.Scene) (*int, error) { return obj.Rating, nil } func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*ScenePathsType, error) { baseURL, _ := ctx.Value(BaseURLCtxKey).(string) config := manager.GetInstance().Config builder := urlbuilders.NewSceneURLBuilder(baseURL, obj) screenshotPath := builder.GetScreenshotURL() previewPath := builder.GetStreamPreviewURL() streamPath := builder.GetStreamURL(config.GetAPIKey()).String() webpPath := builder.GetStreamPreviewImageURL() objHash := obj.GetHash(config.GetVideoFileNamingAlgorithm()) vttPath := builder.GetSpriteVTTURL(objHash) spritePath := builder.GetSpriteURL(objHash) funscriptPath := builder.GetFunscriptURL() captionBasePath := builder.GetCaptionURL() interactiveHeatmap := builder.GetInteractiveHeatmapURL() return &ScenePathsType{ Screenshot: &screenshotPath, Preview: &previewPath, Stream: &streamPath, Webp: &webpPath, Vtt: &vttPath, Sprite: &spritePath, Funscript: &funscriptPath, InteractiveHeatmap: &interactiveHeatmap, Caption: &captionBasePath, }, nil } func (r *sceneResolver) SceneMarkers(ctx context.Context, obj *models.Scene) (ret []*models.SceneMarker, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.SceneMarker.FindBySceneID(ctx, obj.ID) return err }); err != nil { return nil, err } return ret, nil } func (r *sceneResolver) Captions(ctx context.Context, obj *models.Scene) (ret []*models.VideoCaption, err error) { primaryFile, err := r.getPrimaryFile(ctx, obj) if err != nil { return nil, err } if primaryFile == nil { return nil, nil } if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.File.GetCaptions(ctx, primaryFile.Base().ID) return err }); err != nil { return nil, err } return ret, err } func (r *sceneResolver) Galleries(ctx context.Context, obj *models.Scene) (ret []*models.Gallery, err error) { if !obj.GalleryIDs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadGalleryIDs(ctx, r.repository.Scene) }); err != nil { return nil, err } } var errs []error ret, errs = loaders.From(ctx).GalleryByID.LoadAll(obj.GalleryIDs.List()) return ret, firstError(errs) } func (r *sceneResolver) Studio(ctx context.Context, obj *models.Scene) (ret *models.Studio, err error) { if obj.StudioID == nil { return nil, nil } return loaders.From(ctx).StudioByID.Load(*obj.StudioID) } func (r *sceneResolver) Movies(ctx context.Context, obj *models.Scene) (ret []*SceneMovie, err error) { if !obj.Groups.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene return obj.LoadGroups(ctx, qb) }); err != nil { return nil, err } } loader := loaders.From(ctx).GroupByID for _, sm := range obj.Groups.List() { movie, err := loader.Load(sm.GroupID) if err != nil { return nil, err } sceneIdx := sm.SceneIndex sceneMovie := &SceneMovie{ Movie: movie, SceneIndex: sceneIdx, } ret = append(ret, sceneMovie) } return ret, nil } func (r *sceneResolver) Groups(ctx context.Context, obj *models.Scene) (ret []*SceneGroup, err error) { if !obj.Groups.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene return obj.LoadGroups(ctx, qb) }); err != nil { return nil, err } } loader := loaders.From(ctx).GroupByID for _, sm := range obj.Groups.List() { group, err := loader.Load(sm.GroupID) if err != nil { return nil, err } sceneIdx := sm.SceneIndex sceneGroup := &SceneGroup{ Group: group, SceneIndex: sceneIdx, } ret = append(ret, sceneGroup) } return ret, nil } func (r *sceneResolver) Tags(ctx context.Context, obj *models.Scene) (ret []*models.Tag, err error) { if !obj.TagIDs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadTagIDs(ctx, r.repository.Scene) }); err != nil { return nil, err } } var errs []error ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List()) return ret, firstError(errs) } func (r *sceneResolver) Performers(ctx context.Context, obj *models.Scene) (ret []*models.Performer, err error) { if !obj.PerformerIDs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadPerformerIDs(ctx, r.repository.Scene) }); err != nil { return nil, err } } var errs []error ret, errs = loaders.From(ctx).PerformerByID.LoadAll(obj.PerformerIDs.List()) return ret, firstError(errs) } func (r *sceneResolver) StashIds(ctx context.Context, obj *models.Scene) (ret []*models.StashID, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadStashIDs(ctx, r.repository.Scene) }); err != nil { return nil, err } return stashIDsSliceToPtrSlice(obj.StashIDs.List()), nil } func (r *sceneResolver) SceneStreams(ctx context.Context, obj *models.Scene) ([]*manager.SceneStreamEndpoint, error) { // load the primary file into the scene _, err := r.getPrimaryFile(ctx, obj) if err != nil { return nil, err } config := manager.GetInstance().Config baseURL, _ := ctx.Value(BaseURLCtxKey).(string) builder := urlbuilders.NewSceneURLBuilder(baseURL, obj) apiKey := config.GetAPIKey() return manager.GetSceneStreamPaths(obj, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize()) } func (r *sceneResolver) Interactive(ctx context.Context, obj *models.Scene) (bool, error) { primaryFile, err := r.getPrimaryFile(ctx, obj) if err != nil { return false, err } if primaryFile == nil { return false, nil } return primaryFile.Interactive, nil } func (r *sceneResolver) InteractiveSpeed(ctx context.Context, obj *models.Scene) (*int, error) { primaryFile, err := r.getPrimaryFile(ctx, obj) if err != nil { return nil, err } if primaryFile == nil { return nil, nil } return primaryFile.InteractiveSpeed, nil } func (r *sceneResolver) URL(ctx context.Context, obj *models.Scene) (*string, error) { if !obj.URLs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadURLs(ctx, r.repository.Scene) }); err != nil { return nil, err } } urls := obj.URLs.List() if len(urls) == 0 { return nil, nil } return &urls[0], nil } func (r *sceneResolver) Urls(ctx context.Context, obj *models.Scene) ([]string, error) { if !obj.URLs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadURLs(ctx, r.repository.Scene) }); err != nil { return nil, err } } return obj.URLs.List(), nil } func (r *sceneResolver) OCounter(ctx context.Context, obj *models.Scene) (*int, error) { ret, err := loaders.From(ctx).SceneOCount.Load(obj.ID) if err != nil { return nil, err } return &ret, nil } func (r *sceneResolver) LastPlayedAt(ctx context.Context, obj *models.Scene) (*time.Time, error) { ret, err := loaders.From(ctx).SceneLastPlayed.Load(obj.ID) if err != nil { return nil, err } return ret, nil } func (r *sceneResolver) PlayCount(ctx context.Context, obj *models.Scene) (*int, error) { ret, err := loaders.From(ctx).ScenePlayCount.Load(obj.ID) if err != nil { return nil, err } return &ret, nil } func (r *sceneResolver) PlayHistory(ctx context.Context, obj *models.Scene) ([]*time.Time, error) { ret, err := loaders.From(ctx).ScenePlayHistory.Load(obj.ID) if err != nil { return nil, err } // convert to pointer slice ptrRet := make([]*time.Time, len(ret)) for i, t := range ret { tt := t ptrRet[i] = &tt } return ptrRet, nil } func (r *sceneResolver) OHistory(ctx context.Context, obj *models.Scene) ([]*time.Time, error) { ret, err := loaders.From(ctx).SceneOHistory.Load(obj.ID) if err != nil { return nil, err } // convert to pointer slice ptrRet := make([]*time.Time, len(ret)) for i, t := range ret { tt := t ptrRet[i] = &tt } return ptrRet, nil } func (r *sceneResolver) CustomFields(ctx context.Context, obj *models.Scene) (map[string]interface{}, error) { m, err := loaders.From(ctx).SceneCustomFields.Load(obj.ID) if err != nil { return nil, err } if m == nil { return make(map[string]interface{}), nil } return m, nil } ================================================ FILE: internal/api/resolver_model_scene_marker.go ================================================ package api import ( "context" "github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/pkg/models" ) func (r *sceneMarkerResolver) Scene(ctx context.Context, obj *models.SceneMarker) (ret *models.Scene, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Scene.Find(ctx, obj.SceneID) return err }); err != nil { return nil, err } return ret, nil } func (r *sceneMarkerResolver) PrimaryTag(ctx context.Context, obj *models.SceneMarker) (ret *models.Tag, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Tag.Find(ctx, obj.PrimaryTagID) return err }); err != nil { return nil, err } return ret, err } func (r *sceneMarkerResolver) Tags(ctx context.Context, obj *models.SceneMarker) (ret []*models.Tag, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Tag.FindBySceneMarkerID(ctx, obj.ID) return err }); err != nil { return nil, err } return ret, err } func (r *sceneMarkerResolver) Stream(ctx context.Context, obj *models.SceneMarker) (string, error) { baseURL, _ := ctx.Value(BaseURLCtxKey).(string) return urlbuilders.NewSceneMarkerURLBuilder(baseURL, obj).GetStreamURL(), nil } func (r *sceneMarkerResolver) Preview(ctx context.Context, obj *models.SceneMarker) (string, error) { baseURL, _ := ctx.Value(BaseURLCtxKey).(string) return urlbuilders.NewSceneMarkerURLBuilder(baseURL, obj).GetPreviewURL(), nil } func (r *sceneMarkerResolver) Screenshot(ctx context.Context, obj *models.SceneMarker) (string, error) { baseURL, _ := ctx.Value(BaseURLCtxKey).(string) return urlbuilders.NewSceneMarkerURLBuilder(baseURL, obj).GetScreenshotURL(), nil } ================================================ FILE: internal/api/resolver_model_studio.go ================================================ package api import ( "context" "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/group" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/scene" ) func (r *studioResolver) ImagePath(ctx context.Context, obj *models.Studio) (*string, error) { var hasImage bool if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error hasImage, err = r.repository.Studio.HasImage(ctx, obj.ID) return err }); err != nil { return nil, err } baseURL, _ := ctx.Value(BaseURLCtxKey).(string) imagePath := urlbuilders.NewStudioURLBuilder(baseURL, obj).GetStudioImageURL(hasImage) return &imagePath, nil } func (r *studioResolver) Aliases(ctx context.Context, obj *models.Studio) ([]string, error) { if !obj.Aliases.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadAliases(ctx, r.repository.Studio) }); err != nil { return nil, err } } return obj.Aliases.List(), nil } func (r *studioResolver) URL(ctx context.Context, obj *models.Studio) (*string, error) { if !obj.URLs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadURLs(ctx, r.repository.Studio) }); err != nil { return nil, err } } urls := obj.URLs.List() if len(urls) == 0 { return nil, nil } return &urls[0], nil } func (r *studioResolver) Urls(ctx context.Context, obj *models.Studio) ([]string, error) { if !obj.URLs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadURLs(ctx, r.repository.Studio) }); err != nil { return nil, err } } return obj.URLs.List(), nil } func (r *studioResolver) Tags(ctx context.Context, obj *models.Studio) (ret []*models.Tag, err error) { if !obj.TagIDs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadTagIDs(ctx, r.repository.Studio) }); err != nil { return nil, err } } var errs []error ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List()) return ret, firstError(errs) } func (r *studioResolver) SceneCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = scene.CountByStudioID(ctx, r.repository.Scene, obj.ID, depth) return err }); err != nil { return 0, err } return ret, nil } func (r *studioResolver) ImageCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = image.CountByStudioID(ctx, r.repository.Image, obj.ID, depth) return err }); err != nil { return 0, err } return ret, nil } func (r *studioResolver) GalleryCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = gallery.CountByStudioID(ctx, r.repository.Gallery, obj.ID, depth) return err }); err != nil { return 0, err } return ret, nil } func (r *studioResolver) PerformerCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = performer.CountByStudioID(ctx, r.repository.Performer, obj.ID, depth) return err }); err != nil { return 0, err } return ret, nil } func (r *studioResolver) GroupCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = group.CountByStudioID(ctx, r.repository.Group, obj.ID, depth) return err }); err != nil { return 0, err } return ret, nil } // deprecated func (r *studioResolver) MovieCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) { return r.GroupCount(ctx, obj, depth) } func (r *studioResolver) OCounter(ctx context.Context, obj *models.Studio) (ret *int, err error) { var res_scene int var res_image int var res int if err := r.withReadTxn(ctx, func(ctx context.Context) error { res_scene, err = r.repository.Scene.OCountByStudioID(ctx, obj.ID) if err != nil { return err } res_image, err = r.repository.Image.OCountByStudioID(ctx, obj.ID) return err }); err != nil { return nil, err } res = res_scene + res_image return &res, nil } func (r *studioResolver) ParentStudio(ctx context.Context, obj *models.Studio) (ret *models.Studio, err error) { if obj.ParentID == nil { return nil, nil } return loaders.From(ctx).StudioByID.Load(*obj.ParentID) } func (r *studioResolver) ChildStudios(ctx context.Context, obj *models.Studio) (ret []*models.Studio, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Studio.FindChildren(ctx, obj.ID) return err }); err != nil { return nil, err } return ret, nil } func (r *studioResolver) StashIds(ctx context.Context, obj *models.Studio) ([]*models.StashID, error) { if !obj.StashIDs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadStashIDs(ctx, r.repository.Studio) }); err != nil { return nil, err } } return stashIDsSliceToPtrSlice(obj.StashIDs.List()), nil } func (r *studioResolver) Rating100(ctx context.Context, obj *models.Studio) (*int, error) { return obj.Rating, nil } func (r *studioResolver) Groups(ctx context.Context, obj *models.Studio) (ret []*models.Group, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Group.FindByStudioID(ctx, obj.ID) return err }); err != nil { return nil, err } return ret, nil } func (r *studioResolver) CustomFields(ctx context.Context, obj *models.Studio) (map[string]interface{}, error) { m, err := loaders.From(ctx).StudioCustomFields.Load(obj.ID) if err != nil { return nil, err } if m == nil { return make(map[string]interface{}), nil } return m, nil } // deprecated func (r *studioResolver) Movies(ctx context.Context, obj *models.Studio) (ret []*models.Group, err error) { return r.Groups(ctx, obj) } ================================================ FILE: internal/api/resolver_model_tag.go ================================================ package api import ( "context" "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/group" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/studio" ) func (r *tagResolver) Parents(ctx context.Context, obj *models.Tag) (ret []*models.Tag, err error) { if !obj.ParentIDs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadParentIDs(ctx, r.repository.Tag) }); err != nil { return nil, err } } var errs []error ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.ParentIDs.List()) return ret, firstError(errs) } func (r *tagResolver) Children(ctx context.Context, obj *models.Tag) (ret []*models.Tag, err error) { if !obj.ChildIDs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadChildIDs(ctx, r.repository.Tag) }); err != nil { return nil, err } } var errs []error ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.ChildIDs.List()) return ret, firstError(errs) } func (r *tagResolver) Aliases(ctx context.Context, obj *models.Tag) (ret []string, err error) { if !obj.Aliases.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadAliases(ctx, r.repository.Tag) }); err != nil { return nil, err } } return obj.Aliases.List(), nil } func (r *tagResolver) StashIds(ctx context.Context, obj *models.Tag) ([]*models.StashID, error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadStashIDs(ctx, r.repository.Tag) }); err != nil { return nil, err } return stashIDsSliceToPtrSlice(obj.StashIDs.List()), nil } func (r *tagResolver) SceneCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = scene.CountByTagID(ctx, r.repository.Scene, obj.ID, depth) return err }); err != nil { return 0, err } return ret, nil } func (r *tagResolver) SceneMarkerCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = scene.MarkerCountByTagID(ctx, r.repository.SceneMarker, obj.ID, depth) return err }); err != nil { return 0, err } return ret, nil } func (r *tagResolver) ImageCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = image.CountByTagID(ctx, r.repository.Image, obj.ID, depth) return err }); err != nil { return 0, err } return ret, nil } func (r *tagResolver) GalleryCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = gallery.CountByTagID(ctx, r.repository.Gallery, obj.ID, depth) return err }); err != nil { return 0, err } return ret, nil } func (r *tagResolver) PerformerCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = performer.CountByTagID(ctx, r.repository.Performer, obj.ID, depth) return err }); err != nil { return 0, err } return ret, nil } func (r *tagResolver) StudioCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = studio.CountByTagID(ctx, r.repository.Studio, obj.ID, depth) return err }); err != nil { return 0, err } return ret, nil } func (r *tagResolver) GroupCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = group.CountByTagID(ctx, r.repository.Group, obj.ID, depth) return err }); err != nil { return 0, err } return ret, nil } func (r *tagResolver) MovieCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { return r.GroupCount(ctx, obj, depth) } func (r *tagResolver) ImagePath(ctx context.Context, obj *models.Tag) (*string, error) { var hasImage bool if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error hasImage, err = r.repository.Tag.HasImage(ctx, obj.ID) return err }); err != nil { return nil, err } baseURL, _ := ctx.Value(BaseURLCtxKey).(string) imagePath := urlbuilders.NewTagURLBuilder(baseURL, obj).GetTagImageURL(hasImage) return &imagePath, nil } func (r *tagResolver) ParentCount(ctx context.Context, obj *models.Tag) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Tag.CountByParentTagID(ctx, obj.ID) return err }); err != nil { return ret, err } return ret, nil } func (r *tagResolver) ChildCount(ctx context.Context, obj *models.Tag) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Tag.CountByChildTagID(ctx, obj.ID) return err }); err != nil { return ret, err } return ret, nil } func (r *tagResolver) CustomFields(ctx context.Context, obj *models.Tag) (map[string]interface{}, error) { m, err := loaders.From(ctx).TagCustomFields.Load(obj.ID) if err != nil { return nil, err } if m == nil { return make(map[string]interface{}), nil } return m, nil } ================================================ FILE: internal/api/resolver_mutation_configure.go ================================================ package api import ( "context" "encoding/json" "errors" "fmt" "io/fs" "path/filepath" "regexp" "strconv" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/internal/manager/task" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) var ErrOverriddenConfig = errors.New("cannot set overridden value") func (r *mutationResolver) Setup(ctx context.Context, input manager.SetupInput) (bool, error) { err := manager.GetInstance().Setup(ctx, input) return err == nil, err } func (r *mutationResolver) DownloadFFMpeg(ctx context.Context) (string, error) { mgr := manager.GetInstance() configDir := mgr.Config.GetConfigPathAbs() // don't run if ffmpeg is already installed ffmpegPath := ffmpeg.FindFFMpeg(configDir) ffprobePath := ffmpeg.FindFFProbe(configDir) if ffmpegPath != "" && ffprobePath != "" { return "", fmt.Errorf("ffmpeg and ffprobe already installed at %s and %s", ffmpegPath, ffprobePath) } t := &task.DownloadFFmpegJob{ ConfigDirectory: configDir, OnComplete: func(ctx context.Context) { // clear the ffmpeg and ffprobe paths logger.Infof("Clearing ffmpeg and ffprobe config paths so they are resolved from the config directory") mgr.Config.SetString(config.FFMpegPath, "") mgr.Config.SetString(config.FFProbePath, "") mgr.RefreshFFMpeg(ctx) mgr.RefreshStreamManager() }, } jobID := mgr.JobManager.Add(ctx, "Downloading ffmpeg...", t) return strconv.Itoa(jobID), nil } func (r *mutationResolver) setConfigString(key string, value *string) { c := config.GetInstance() if value != nil { c.SetString(key, *value) } } func (r *mutationResolver) setConfigBool(key string, value *bool) { c := config.GetInstance() if value != nil { c.SetBool(key, *value) } } func (r *mutationResolver) setConfigInt(key string, value *int) { c := config.GetInstance() if value != nil { c.SetInt(key, *value) } } func (r *mutationResolver) setConfigFloat(key string, value *float64) { c := config.GetInstance() if value != nil { c.SetFloat(key, *value) } } func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGeneralInput) (*ConfigGeneralResult, error) { c := config.GetInstance() // #4709 - allow stash paths even if they do not exist, so that users may configure stash // for disconnected drives or network storage. existingPaths := c.GetStashPaths() if input.Stashes != nil { for _, s := range input.Stashes { // Only validate existence of new paths isNew := true for _, path := range existingPaths { if path.Path == s.Path { isNew = false break } } if isNew { s.Path = filepath.Clean(s.Path) // if it exists, it must be directory exists, err := fsutil.DirExists(s.Path) // allow it to not exist but if it does exist it must be a directory if !exists && !errors.Is(err, fs.ErrNotExist) { return makeConfigGeneralResult(), err } } } c.SetInterface(config.Stash, input.Stashes) } checkConfigOverride := func(key string) error { if c.HasOverride(key) { return fmt.Errorf("%w: %s", ErrOverriddenConfig, key) } return nil } validateDir := func(key string, value string, optional bool) error { if err := checkConfigOverride(key); err != nil { return err } if !optional || value != "" { if err := fsutil.EnsureDir(value); err != nil { return err } } return nil } existingDBPath := c.GetDatabasePath() if input.DatabasePath != nil && existingDBPath != *input.DatabasePath { if err := checkConfigOverride(config.Database); err != nil { return makeConfigGeneralResult(), err } ext := filepath.Ext(*input.DatabasePath) if ext != ".db" && ext != ".sqlite" && ext != ".sqlite3" { return makeConfigGeneralResult(), fmt.Errorf("invalid database path, use extension db, sqlite, or sqlite3") } c.SetString(config.Database, *input.DatabasePath) } existingBackupDirectoryPath := c.GetBackupDirectoryPath() if input.BackupDirectoryPath != nil && existingBackupDirectoryPath != *input.BackupDirectoryPath { if err := validateDir(config.BackupDirectoryPath, *input.BackupDirectoryPath, true); err != nil { return makeConfigGeneralResult(), err } c.SetString(config.BackupDirectoryPath, *input.BackupDirectoryPath) } existingDeleteTrashPath := c.GetDeleteTrashPath() if input.DeleteTrashPath != nil && existingDeleteTrashPath != *input.DeleteTrashPath { if err := validateDir(config.DeleteTrashPath, *input.DeleteTrashPath, true); err != nil { return makeConfigGeneralResult(), err } c.SetString(config.DeleteTrashPath, *input.DeleteTrashPath) } existingGeneratedPath := c.GetGeneratedPath() if input.GeneratedPath != nil && existingGeneratedPath != *input.GeneratedPath { if err := validateDir(config.Generated, *input.GeneratedPath, false); err != nil { return makeConfigGeneralResult(), err } c.SetString(config.Generated, *input.GeneratedPath) } refreshScraperCache := false refreshScraperSource := false existingScrapersPath := c.GetScrapersPath() if input.ScrapersPath != nil && existingScrapersPath != *input.ScrapersPath { if err := validateDir(config.ScrapersPath, *input.ScrapersPath, false); err != nil { return makeConfigGeneralResult(), err } refreshScraperCache = true refreshScraperSource = true c.SetString(config.ScrapersPath, *input.ScrapersPath) } refreshPluginCache := false refreshPluginSource := false existingPluginsPath := c.GetPluginsPath() if input.PluginsPath != nil && existingPluginsPath != *input.PluginsPath { if err := validateDir(config.PluginsPath, *input.PluginsPath, false); err != nil { return makeConfigGeneralResult(), err } refreshPluginCache = true refreshPluginSource = true c.SetString(config.PluginsPath, *input.PluginsPath) } existingMetadataPath := c.GetMetadataPath() if input.MetadataPath != nil && existingMetadataPath != *input.MetadataPath { if err := validateDir(config.Metadata, *input.MetadataPath, true); err != nil { return makeConfigGeneralResult(), err } c.SetString(config.Metadata, *input.MetadataPath) } refreshStreamManager := false existingCachePath := c.GetCachePath() if input.CachePath != nil && existingCachePath != *input.CachePath { if err := validateDir(config.Cache, *input.CachePath, true); err != nil { return makeConfigGeneralResult(), err } c.SetString(config.Cache, *input.CachePath) refreshStreamManager = true } refreshBlobStorage := false existingBlobsPath := c.GetBlobsPath() if input.BlobsPath != nil && existingBlobsPath != *input.BlobsPath { if err := validateDir(config.BlobsPath, *input.BlobsPath, true); err != nil { return makeConfigGeneralResult(), err } c.SetString(config.BlobsPath, *input.BlobsPath) refreshBlobStorage = true } if input.BlobsStorage != nil && *input.BlobsStorage != c.GetBlobsStorage() { if *input.BlobsStorage == config.BlobStorageTypeFilesystem && c.GetBlobsPath() == "" { return makeConfigGeneralResult(), fmt.Errorf("blobs path must be set when using filesystem storage") } c.SetInterface(config.BlobsStorage, *input.BlobsStorage) refreshBlobStorage = true } refreshFfmpeg := false if input.FfmpegPath != nil && *input.FfmpegPath != c.GetFFMpegPath() { if *input.FfmpegPath != "" { if err := ffmpeg.ValidateFFMpeg(*input.FfmpegPath); err != nil { return makeConfigGeneralResult(), fmt.Errorf("invalid ffmpeg path: %w", err) } } c.SetString(config.FFMpegPath, *input.FfmpegPath) refreshFfmpeg = true } if input.FfprobePath != nil && *input.FfprobePath != c.GetFFProbePath() { if *input.FfprobePath != "" { if err := ffmpeg.ValidateFFProbe(*input.FfprobePath); err != nil { return makeConfigGeneralResult(), fmt.Errorf("invalid ffprobe path: %w", err) } } c.SetString(config.FFProbePath, *input.FfprobePath) refreshFfmpeg = true } if input.VideoFileNamingAlgorithm != nil && *input.VideoFileNamingAlgorithm != c.GetVideoFileNamingAlgorithm() { calculateMD5 := c.IsCalculateMD5() if input.CalculateMd5 != nil { calculateMD5 = *input.CalculateMd5 } if !calculateMD5 && *input.VideoFileNamingAlgorithm == models.HashAlgorithmMd5 { return makeConfigGeneralResult(), errors.New("calculateMD5 must be true if using MD5") } // validate changing VideoFileNamingAlgorithm if err := r.withTxn(context.TODO(), func(ctx context.Context) error { return manager.ValidateVideoFileNamingAlgorithm(ctx, r.repository.Scene, *input.VideoFileNamingAlgorithm) }); err != nil { return makeConfigGeneralResult(), err } c.SetInterface(config.VideoFileNamingAlgorithm, *input.VideoFileNamingAlgorithm) } r.setConfigBool(config.CalculateMD5, input.CalculateMd5) r.setConfigInt(config.ParallelTasks, input.ParallelTasks) r.setConfigBool(config.PreviewAudio, input.PreviewAudio) r.setConfigInt(config.PreviewSegments, input.PreviewSegments) r.setConfigFloat(config.PreviewSegmentDuration, input.PreviewSegmentDuration) r.setConfigString(config.PreviewExcludeStart, input.PreviewExcludeStart) r.setConfigString(config.PreviewExcludeEnd, input.PreviewExcludeEnd) if input.PreviewPreset != nil { c.SetString(config.PreviewPreset, input.PreviewPreset.String()) } r.setConfigBool(config.UseCustomSpriteInterval, input.UseCustomSpriteInterval) r.setConfigFloat(config.SpriteInterval, input.SpriteInterval) r.setConfigInt(config.MinimumSprites, input.MinimumSprites) r.setConfigInt(config.MaximumSprites, input.MaximumSprites) r.setConfigInt(config.SpriteScreenshotSize, input.SpriteScreenshotSize) r.setConfigBool(config.TranscodeHardwareAcceleration, input.TranscodeHardwareAcceleration) if input.MaxTranscodeSize != nil { c.SetString(config.MaxTranscodeSize, input.MaxTranscodeSize.String()) } if input.MaxStreamingTranscodeSize != nil { c.SetString(config.MaxStreamingTranscodeSize, input.MaxStreamingTranscodeSize.String()) } r.setConfigBool(config.WriteImageThumbnails, input.WriteImageThumbnails) r.setConfigBool(config.CreateImageClipsFromVideos, input.CreateImageClipsFromVideos) if input.GalleryCoverRegex != nil { _, err := regexp.Compile(*input.GalleryCoverRegex) if err != nil { return makeConfigGeneralResult(), fmt.Errorf("Gallery cover regex '%v' invalid, '%v'", *input.GalleryCoverRegex, err.Error()) } c.SetString(config.GalleryCoverRegex, *input.GalleryCoverRegex) } if input.Username != nil && *input.Username != c.GetUsername() { c.SetString(config.Username, *input.Username) if *input.Password == "" { logger.Info("Username cleared") } else { logger.Info("Username changed") } } if input.Password != nil { // bit of a hack - check if the passed in password is the same as the stored hash // and only set if they are different currentPWHash := c.GetPasswordHash() if *input.Password != currentPWHash { if *input.Password == "" { logger.Info("Password cleared") } else { logger.Info("Password changed") } c.SetPassword(*input.Password) } } r.setConfigInt(config.MaxSessionAge, input.MaxSessionAge) r.setConfigString(config.LogFile, input.LogFile) r.setConfigBool(config.LogOut, input.LogOut) r.setConfigBool(config.LogAccess, input.LogAccess) if input.LogLevel != nil && *input.LogLevel != c.GetLogLevel() { c.SetString(config.LogLevel, *input.LogLevel) logger := manager.GetInstance().Logger logger.SetLogLevel(*input.LogLevel) } if input.LogFileMaxSize != nil && *input.LogFileMaxSize != c.GetLogFileMaxSize() { c.SetInt(config.LogFileMaxSize, *input.LogFileMaxSize) } if input.Excludes != nil { for _, r := range input.Excludes { _, err := regexp.Compile(r) if err != nil { return makeConfigGeneralResult(), fmt.Errorf("video exclusion pattern '%v' invalid: %w", r, err) } } c.SetInterface(config.Exclude, input.Excludes) } if input.ImageExcludes != nil { for _, r := range input.ImageExcludes { _, err := regexp.Compile(r) if err != nil { return makeConfigGeneralResult(), fmt.Errorf("image/gallery exclusion pattern '%v' invalid: %w", r, err) } } c.SetInterface(config.ImageExclude, input.ImageExcludes) } if input.VideoExtensions != nil { c.SetInterface(config.VideoExtensions, input.VideoExtensions) } if input.ImageExtensions != nil { c.SetInterface(config.ImageExtensions, input.ImageExtensions) } if input.GalleryExtensions != nil { c.SetInterface(config.GalleryExtensions, input.GalleryExtensions) } r.setConfigBool(config.CreateGalleriesFromFolders, input.CreateGalleriesFromFolders) if input.CustomPerformerImageLocation != nil { c.SetString(config.CustomPerformerImageLocation, *input.CustomPerformerImageLocation) initCustomPerformerImages(*input.CustomPerformerImageLocation) } if input.StashBoxes != nil { if err := c.ValidateStashBoxes(input.StashBoxes); err != nil { return nil, err } c.SetInterface(config.StashBoxes, input.StashBoxes) } if input.PythonPath != nil { r.setConfigString(config.PythonPath, input.PythonPath) } if input.TranscodeInputArgs != nil { c.SetInterface(config.TranscodeInputArgs, input.TranscodeInputArgs) } if input.TranscodeOutputArgs != nil { c.SetInterface(config.TranscodeOutputArgs, input.TranscodeOutputArgs) } if input.LiveTranscodeInputArgs != nil { c.SetInterface(config.LiveTranscodeInputArgs, input.LiveTranscodeInputArgs) } if input.LiveTranscodeOutputArgs != nil { c.SetInterface(config.LiveTranscodeOutputArgs, input.LiveTranscodeOutputArgs) } r.setConfigBool(config.DrawFunscriptHeatmapRange, input.DrawFunscriptHeatmapRange) if input.ScraperPackageSources != nil { c.SetInterface(config.ScraperPackageSources, input.ScraperPackageSources) refreshScraperSource = true } if input.PluginPackageSources != nil { c.SetInterface(config.PluginPackageSources, input.PluginPackageSources) refreshPluginSource = true } if err := c.Write(); err != nil { return makeConfigGeneralResult(), err } manager.GetInstance().RefreshConfig() if refreshScraperCache { manager.GetInstance().RefreshScraperCache() } if refreshPluginCache { manager.GetInstance().RefreshPluginCache() } if refreshFfmpeg { manager.GetInstance().RefreshFFMpeg(ctx) // refresh stream manager is required since ffmpeg changed refreshStreamManager = true } if refreshStreamManager { manager.GetInstance().RefreshStreamManager() } if refreshBlobStorage { manager.GetInstance().SetBlobStoreOptions() } if refreshScraperSource { manager.GetInstance().RefreshScraperSourceManager() } if refreshPluginSource { manager.GetInstance().RefreshPluginSourceManager() } return makeConfigGeneralResult(), nil } func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigInterfaceInput) (*ConfigInterfaceResult, error) { c := config.GetInstance() r.setConfigBool(config.SFWContentMode, input.SfwContentMode) if input.MenuItems != nil { c.SetInterface(config.MenuItems, input.MenuItems) } r.setConfigBool(config.SoundOnPreview, input.SoundOnPreview) r.setConfigBool(config.WallShowTitle, input.WallShowTitle) r.setConfigBool(config.NoBrowser, input.NoBrowser) r.setConfigBool(config.NotificationsEnabled, input.NotificationsEnabled) r.setConfigBool(config.ShowScrubber, input.ShowScrubber) r.setConfigString(config.WallPlayback, input.WallPlayback) r.setConfigInt(config.MaximumLoopDuration, input.MaximumLoopDuration) r.setConfigBool(config.AutostartVideo, input.AutostartVideo) r.setConfigBool(config.ShowStudioAsText, input.ShowStudioAsText) r.setConfigBool(config.AutostartVideoOnPlaySelected, input.AutostartVideoOnPlaySelected) r.setConfigBool(config.ContinuePlaylistDefault, input.ContinuePlaylistDefault) r.setConfigString(config.Language, input.Language) if input.ImageLightbox != nil { options := input.ImageLightbox r.setConfigInt(config.ImageLightboxSlideshowDelay, options.SlideshowDelay) r.setConfigString(config.ImageLightboxDisplayModeKey, (*string)(options.DisplayMode)) r.setConfigBool(config.ImageLightboxScaleUp, options.ScaleUp) r.setConfigBool(config.ImageLightboxResetZoomOnNav, options.ResetZoomOnNav) r.setConfigString(config.ImageLightboxScrollModeKey, (*string)(options.ScrollMode)) r.setConfigInt(config.ImageLightboxScrollAttemptsBeforeChange, options.ScrollAttemptsBeforeChange) r.setConfigBool(config.ImageLightboxDisableAnimation, options.DisableAnimation) } if input.CSS != nil { c.SetCSS(*input.CSS) } r.setConfigBool(config.CSSEnabled, input.CSSEnabled) if input.Javascript != nil { c.SetJavascript(*input.Javascript) } r.setConfigBool(config.JavascriptEnabled, input.JavascriptEnabled) if input.CustomLocales != nil { c.SetCustomLocales(*input.CustomLocales) } r.setConfigBool(config.CustomLocalesEnabled, input.CustomLocalesEnabled) r.setConfigBool(config.DisableCustomizations, input.DisableCustomizations) if input.DisableDropdownCreate != nil { ddc := input.DisableDropdownCreate r.setConfigBool(config.DisableDropdownCreatePerformer, ddc.Performer) r.setConfigBool(config.DisableDropdownCreateStudio, ddc.Studio) r.setConfigBool(config.DisableDropdownCreateTag, ddc.Tag) r.setConfigBool(config.DisableDropdownCreateMovie, ddc.Movie) r.setConfigBool(config.DisableDropdownCreateGallery, ddc.Gallery) } r.setConfigString(config.HandyKey, input.HandyKey) r.setConfigInt(config.FunscriptOffset, input.FunscriptOffset) r.setConfigBool(config.UseStashHostedFunscript, input.UseStashHostedFunscript) if err := c.Write(); err != nil { return makeConfigInterfaceResult(), err } return makeConfigInterfaceResult(), nil } func (r *mutationResolver) ConfigureDlna(ctx context.Context, input ConfigDLNAInput) (*ConfigDLNAResult, error) { c := config.GetInstance() r.setConfigString(config.DLNAServerName, input.ServerName) if input.WhitelistedIPs != nil { c.SetInterface(config.DLNADefaultIPWhitelist, input.WhitelistedIPs) } r.setConfigString(config.DLNAVideoSortOrder, input.VideoSortOrder) r.setConfigInt(config.DLNAPort, input.Port) refresh := false if input.Enabled != nil { c.SetBool(config.DLNADefaultEnabled, *input.Enabled) refresh = true } if input.Interfaces != nil { c.SetInterface(config.DLNAInterfaces, input.Interfaces) } if err := c.Write(); err != nil { return makeConfigDLNAResult(), err } if refresh { manager.GetInstance().RefreshDLNA() } return makeConfigDLNAResult(), nil } func (r *mutationResolver) ConfigureScraping(ctx context.Context, input ConfigScrapingInput) (*ConfigScrapingResult, error) { c := config.GetInstance() refreshScraperCache := false if input.ScraperUserAgent != nil { c.SetString(config.ScraperUserAgent, *input.ScraperUserAgent) refreshScraperCache = true } if input.ScraperCDPPath != nil { c.SetString(config.ScraperCDPPath, *input.ScraperCDPPath) refreshScraperCache = true } if input.ExcludeTagPatterns != nil { for _, r := range input.ExcludeTagPatterns { _, err := regexp.Compile(r) if err != nil { return makeConfigScrapingResult(), fmt.Errorf("tag exclusion pattern '%v' invalid: %w", r, err) } } c.SetInterface(config.ScraperExcludeTagPatterns, input.ExcludeTagPatterns) } r.setConfigBool(config.ScraperCertCheck, input.ScraperCertCheck) if refreshScraperCache { manager.GetInstance().RefreshScraperCache() } if err := c.Write(); err != nil { return makeConfigScrapingResult(), err } return makeConfigScrapingResult(), nil } func (r *mutationResolver) ConfigureDefaults(ctx context.Context, input ConfigDefaultSettingsInput) (*ConfigDefaultSettingsResult, error) { c := config.GetInstance() if input.Identify != nil { c.SetInterface(config.DefaultIdentifySettings, input.Identify) } if input.Scan != nil { // if input.Scan is used then ScanMetadataOptions is included in the config file // this causes the values to not be read correctly c.SetInterface(config.DefaultScanSettings, input.Scan.ScanMetadataOptions) } if input.AutoTag != nil { c.SetInterface(config.DefaultAutoTagSettings, input.AutoTag) } if input.Generate != nil { c.SetInterface(config.DefaultGenerateSettings, input.Generate) } r.setConfigBool(config.DeleteFileDefault, input.DeleteFile) r.setConfigBool(config.DeleteGeneratedDefault, input.DeleteGenerated) if err := c.Write(); err != nil { return makeConfigDefaultsResult(), err } return makeConfigDefaultsResult(), nil } func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input GenerateAPIKeyInput) (string, error) { c := config.GetInstance() var newAPIKey string if input.Clear == nil || !*input.Clear { username := c.GetUsername() if username != "" { var err error newAPIKey, err = manager.GenerateAPIKey(username) if err != nil { return "", err } } } c.SetString(config.ApiKey, newAPIKey) if err := c.Write(); err != nil { return newAPIKey, err } return newAPIKey, nil } func (r *mutationResolver) ConfigureUI(ctx context.Context, input map[string]interface{}, partial map[string]interface{}) (map[string]interface{}, error) { c := config.GetInstance() if input != nil { // #5483 - convert JSON numbers to float64 or int64 input = convertMapJSONNumbers(input) c.SetUIConfiguration(input) } if partial != nil { // #5483 - convert JSON numbers to float64 or int64 partial = convertMapJSONNumbers(partial) // merge partial into existing config existing := c.GetUIConfiguration() utils.MergeMaps(existing, partial) c.SetUIConfiguration(existing) } if err := c.Write(); err != nil { return c.GetUIConfiguration(), err } return c.GetUIConfiguration(), nil } func (r *mutationResolver) ConfigureUISetting(ctx context.Context, key string, value interface{}) (map[string]interface{}, error) { c := config.GetInstance() cfg := utils.NestedMap(c.GetUIConfiguration()) // #5483 - convert JSON numbers to float64 or int64 if m, ok := value.(map[string]interface{}); ok { value = convertMapJSONNumbers(m) } else if n, ok := value.(json.Number); ok { value = jsonNumberToNumber(n) } cfg.Set(key, value) return r.ConfigureUI(ctx, cfg, nil) } func (r *mutationResolver) ConfigurePlugin(ctx context.Context, pluginID string, input map[string]interface{}) (map[string]interface{}, error) { c := config.GetInstance() // #5483 - convert JSON numbers to float64 or int64 input = convertMapJSONNumbers(input) c.SetPluginConfiguration(pluginID, input) if err := c.Write(); err != nil { return c.GetPluginConfiguration(pluginID), err } return c.GetPluginConfiguration(pluginID), nil } ================================================ FILE: internal/api/resolver_mutation_dlna.go ================================================ package api import ( "context" "time" "github.com/stashapp/stash/internal/manager" ) func (r *mutationResolver) EnableDlna(ctx context.Context, input EnableDLNAInput) (bool, error) { err := manager.GetInstance().DLNAService.Start(parseMinutes(input.Duration)) if err != nil { return false, err } return true, nil } func (r *mutationResolver) DisableDlna(ctx context.Context, input DisableDLNAInput) (bool, error) { manager.GetInstance().DLNAService.Stop(parseMinutes(input.Duration)) return true, nil } func (r *mutationResolver) AddTempDlnaip(ctx context.Context, input AddTempDLNAIPInput) (bool, error) { manager.GetInstance().DLNAService.AddTempDLNAIP(input.Address, parseMinutes(input.Duration)) return true, nil } func (r *mutationResolver) RemoveTempDlnaip(ctx context.Context, input RemoveTempDLNAIPInput) (bool, error) { ret := manager.GetInstance().DLNAService.RemoveTempDLNAIP(input.Address) return ret, nil } func parseMinutes(minutes *int) *time.Duration { var ret *time.Duration if minutes != nil { d := time.Duration(*minutes) * time.Minute ret = &d } return ret } ================================================ FILE: internal/api/resolver_mutation_file.go ================================================ package api import ( "context" "fmt" "strconv" "github.com/stashapp/stash/internal/desktop" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/session" "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput) (bool, error) { if err := r.withTxn(ctx, func(ctx context.Context) error { fileStore := r.repository.File folderStore := r.repository.Folder mover := file.NewMover(fileStore, folderStore, manager.GetInstance().Config.GetStashPaths().Paths()) mover.RegisterHooks(ctx) var ( folder *models.Folder basename string ) fileIDs, err := stringslice.StringSliceToIntSlice(input.Ids) if err != nil { return fmt.Errorf("converting ids: %w", err) } switch { case input.DestinationFolderID != nil: var err error folderID, err := strconv.Atoi(*input.DestinationFolderID) if err != nil { return fmt.Errorf("converting destination folder id: %w", err) } folder, err = folderStore.Find(ctx, models.FolderID(folderID)) if err != nil { return fmt.Errorf("finding destination folder: %w", err) } if folder == nil { return fmt.Errorf("folder with id %d not found", input.DestinationFolderID) } if folder.ZipFileID != nil { return fmt.Errorf("cannot move to %s, is in a zip file", folder.Path) } case input.DestinationFolder != nil: folderPath := *input.DestinationFolder // ensure folder path is within the library stashPaths := manager.GetInstance().Config.GetStashPaths() if err := r.validateFolderPath(stashPaths, folderPath); err != nil { return err } // get or create folder hierarchy var err error folder, err = file.GetOrCreateFolderHierarchy(ctx, folderStore, folderPath, stashPaths.Paths()) if err != nil { return fmt.Errorf("getting or creating folder hierarchy: %w", err) } default: return fmt.Errorf("must specify destination folder or path") } if input.DestinationBasename != nil { // ensure only one file was supplied if len(input.Ids) != 1 { return fmt.Errorf("must specify one file when providing destination path") } basename = *input.DestinationBasename } // create the folder hierarchy in the filesystem if needed if err := mover.CreateFolderHierarchy(folder.Path); err != nil { return fmt.Errorf("creating folder hierarchy %s in filesystem: %w", folder.Path, err) } for _, fileIDInt := range fileIDs { fileID := models.FileID(fileIDInt) f, err := fileStore.Find(ctx, fileID) if err != nil { return fmt.Errorf("finding file %d: %w", fileID, err) } // ensure that the file extension matches the existing file type if basename != "" { if err := r.validateFileExtension(f[0].Base().Basename, basename); err != nil { return err } } if err := mover.Move(ctx, f[0], folder, basename); err != nil { return err } } return nil }); err != nil { return false, err } return true, nil } func (r *mutationResolver) validateFolderPath(paths config.StashConfigs, folderPath string) error { if l := paths.GetStashFromDirPath(folderPath); l == nil { return fmt.Errorf("folder path %s must be within a stash library path", folderPath) } return nil } func (r *mutationResolver) validateFileExtension(oldBasename, newBasename string) error { c := manager.GetInstance().Config if err := r.validateFileExtensionList(c.GetVideoExtensions(), oldBasename, newBasename); err != nil { return err } if err := r.validateFileExtensionList(c.GetImageExtensions(), oldBasename, newBasename); err != nil { return err } if err := r.validateFileExtensionList(c.GetGalleryExtensions(), oldBasename, newBasename); err != nil { return err } return nil } func (r *mutationResolver) validateFileExtensionList(exts []string, oldBasename, newBasename string) error { if fsutil.MatchExtension(oldBasename, exts) && !fsutil.MatchExtension(newBasename, exts) { return fmt.Errorf("file extension for %s is inconsistent with old filename %s", newBasename, oldBasename) } return nil } func (r *mutationResolver) DeleteFiles(ctx context.Context, ids []string) (ret bool, err error) { fileIDs, err := stringslice.StringSliceToIntSlice(ids) if err != nil { return false, fmt.Errorf("converting ids: %w", err) } trashPath := manager.GetInstance().Config.GetDeleteTrashPath() fileDeleter := file.NewDeleterWithTrash(trashPath) destroyer := &file.ZipDestroyer{ FileDestroyer: r.repository.File, FolderDestroyer: r.repository.Folder, } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.File for _, fileIDInt := range fileIDs { fileID := models.FileID(fileIDInt) f, err := qb.Find(ctx, fileID) if err != nil { return err } path := f[0].Base().Path // ensure not a primary file isPrimary, err := qb.IsPrimary(ctx, fileID) if err != nil { return fmt.Errorf("checking if file %s is primary: %w", path, err) } if isPrimary { return fmt.Errorf("cannot delete primary file %s", path) } // destroy files in zip file inZip, err := qb.FindByZipFileID(ctx, fileID) if err != nil { return fmt.Errorf("finding zip file contents for %s: %w", path, err) } for _, ff := range inZip { const deleteFileInZip = false if err := file.Destroy(ctx, qb, ff, fileDeleter, deleteFileInZip); err != nil { return fmt.Errorf("destroying file %s: %w", ff.Base().Path, err) } } const deleteFile = true if err := destroyer.DestroyZip(ctx, f[0], fileDeleter, deleteFile); err != nil { return fmt.Errorf("deleting file %s: %w", path, err) } } return nil }); err != nil { fileDeleter.Rollback() return false, err } // perform the post-commit actions fileDeleter.Commit() return true, nil } func (r *mutationResolver) DestroyFiles(ctx context.Context, ids []string) (ret bool, err error) { fileIDs, err := stringslice.StringSliceToIntSlice(ids) if err != nil { return false, fmt.Errorf("converting ids: %w", err) } destroyer := &file.ZipDestroyer{ FileDestroyer: r.repository.File, FolderDestroyer: r.repository.Folder, } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.File for _, fileIDInt := range fileIDs { fileID := models.FileID(fileIDInt) f, err := qb.Find(ctx, fileID) if err != nil { return err } if len(f) == 0 { return fmt.Errorf("file with id %d not found", fileID) } path := f[0].Base().Path // ensure not a primary file isPrimary, err := qb.IsPrimary(ctx, fileID) if err != nil { return fmt.Errorf("checking if file %s is primary: %w", path, err) } if isPrimary { return fmt.Errorf("cannot destroy primary file entry %s", path) } // destroy DB entries only (no filesystem deletion) const deleteFile = false if err := destroyer.DestroyZip(ctx, f[0], nil, deleteFile); err != nil { return fmt.Errorf("destroying file entry %s: %w", path, err) } } return nil }); err != nil { return false, err } return true, nil } func (r *mutationResolver) FileSetFingerprints(ctx context.Context, input FileSetFingerprintsInput) (bool, error) { fileIDInt, err := strconv.Atoi(input.ID) if err != nil { return false, fmt.Errorf("converting id: %w", err) } fileID := models.FileID(fileIDInt) // determine what we're doing var ( fingerprints []models.Fingerprint toDelete []string ) for _, i := range input.Fingerprints { if i.Type == models.FingerprintTypeMD5 || i.Type == models.FingerprintTypeOshash { return false, fmt.Errorf("cannot modify %s fingerprint", i.Type) } if i.Value == nil { toDelete = append(toDelete, i.Type) } else { // phashes need to be converted from string into uint64 var v interface{} v = *i.Value if i.Type == models.FingerprintTypePhash { vInt, err := strconv.ParseUint(*i.Value, 16, 64) if err != nil { return false, fmt.Errorf("converting phash %s: %w", *i.Value, err) } v = vInt } fingerprints = append(fingerprints, models.Fingerprint{ Type: i.Type, Fingerprint: v, }) } } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.File if len(fingerprints) > 0 { if err := qb.ModifyFingerprints(ctx, fileID, fingerprints); err != nil { return fmt.Errorf("modifying fingerprints: %w", err) } } if len(toDelete) > 0 { if err := qb.DestroyFingerprints(ctx, fileID, toDelete); err != nil { return fmt.Errorf("destroying fingerprints: %w", err) } } return nil }); err != nil { return false, err } return true, nil } func (r *mutationResolver) RevealFileInFileManager(ctx context.Context, id string) (bool, error) { // disallow if request did not come from localhost if !session.IsLocalRequest(ctx) { logger.Warnf("Attempt to reveal file in file manager from non-local request") return false, fmt.Errorf("access denied") } fileIDInt, err := strconv.Atoi(id) if err != nil { return false, fmt.Errorf("converting id: %w", err) } var filePath string if err := r.withReadTxn(ctx, func(ctx context.Context) error { files, err := r.repository.File.Find(ctx, models.FileID(fileIDInt)) if err != nil { return fmt.Errorf("finding file: %w", err) } if len(files) == 0 { return fmt.Errorf("file with id %d not found", fileIDInt) } filePath = files[0].Base().Path return nil }); err != nil { return false, err } if err := desktop.RevealInFileManager(filePath); err != nil { return false, err } return true, nil } func (r *mutationResolver) RevealFolderInFileManager(ctx context.Context, id string) (bool, error) { // disallow if request did not come from localhost if !session.IsLocalRequest(ctx) { logger.Warnf("Attempt to reveal folder in file manager from non-local request") return false, fmt.Errorf("access denied") } folderIDInt, err := strconv.Atoi(id) if err != nil { return false, fmt.Errorf("converting id: %w", err) } var folderPath string if err := r.withReadTxn(ctx, func(ctx context.Context) error { folder, err := r.repository.Folder.Find(ctx, models.FolderID(folderIDInt)) if err != nil { return fmt.Errorf("finding folder: %w", err) } if folder == nil { return fmt.Errorf("folder with id %d not found", folderIDInt) } folderPath = folder.Path return nil }); err != nil { return false, err } if err := desktop.RevealInFileManager(folderPath); err != nil { return false, err } return true, nil } ================================================ FILE: internal/api/resolver_mutation_gallery.go ================================================ package api import ( "context" "errors" "fmt" "os" "strconv" "strings" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/utils" ) // used to refetch gallery after hooks run func (r *mutationResolver) getGallery(ctx context.Context, id int) (ret *models.Gallery, err error) { if err := r.withTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Gallery.Find(ctx, id) return err }); err != nil { return nil, err } return ret, nil } func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreateInput) (*models.Gallery, error) { // name must be provided if input.Title == "" { return nil, errors.New("title must not be empty") } translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate a new gallery from the input newGallery := models.CreateGalleryInput{ Gallery: &models.Gallery{}, } *newGallery.Gallery = models.NewGallery() newGallery.Title = strings.TrimSpace(input.Title) newGallery.Code = translator.string(input.Code) newGallery.Details = translator.string(input.Details) newGallery.Photographer = translator.string(input.Photographer) newGallery.Rating = input.Rating100 newGallery.Organized = translator.bool(input.Organized) var err error newGallery.Date, err = translator.datePtr(input.Date) if err != nil { return nil, fmt.Errorf("converting date: %w", err) } newGallery.StudioID, err = translator.intPtrFromString(input.StudioID) if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) } newGallery.PerformerIDs, err = translator.relatedIds(input.PerformerIds) if err != nil { return nil, fmt.Errorf("converting performer ids: %w", err) } newGallery.TagIDs, err = translator.relatedIds(input.TagIds) if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } newGallery.SceneIDs, err = translator.relatedIds(input.SceneIds) if err != nil { return nil, fmt.Errorf("converting scene ids: %w", err) } if input.Urls != nil { newGallery.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls)) } else if input.URL != nil { newGallery.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)}) } newGallery.CustomFields = convertMapJSONNumbers(input.CustomFields) // Start the transaction and save the gallery if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Gallery if err := qb.Create(ctx, &newGallery); err != nil { return err } return nil }); err != nil { return nil, err } r.hookExecutor.ExecutePostHooks(ctx, newGallery.ID, hook.GalleryCreatePost, input, nil) return r.getGallery(ctx, newGallery.ID) } func (r *mutationResolver) GalleryUpdate(ctx context.Context, input models.GalleryUpdateInput) (ret *models.Gallery, err error) { translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Start the transaction and save the gallery if err := r.withTxn(ctx, func(ctx context.Context) error { ret, err = r.galleryUpdate(ctx, input, translator) return err }); err != nil { return nil, err } // execute post hooks outside txn r.hookExecutor.ExecutePostHooks(ctx, ret.ID, hook.GalleryUpdatePost, input, translator.getFields()) return r.getGallery(ctx, ret.ID) } func (r *mutationResolver) GalleriesUpdate(ctx context.Context, input []*models.GalleryUpdateInput) (ret []*models.Gallery, err error) { inputMaps := getUpdateInputMaps(ctx) // Start the transaction and save the galleries if err := r.withTxn(ctx, func(ctx context.Context) error { for i, gallery := range input { translator := changesetTranslator{ inputMap: inputMaps[i], } thisGallery, err := r.galleryUpdate(ctx, *gallery, translator) if err != nil { return err } ret = append(ret, thisGallery) } return nil }); err != nil { return nil, err } // execute post hooks outside txn var newRet []*models.Gallery for i, gallery := range ret { translator := changesetTranslator{ inputMap: inputMaps[i], } r.hookExecutor.ExecutePostHooks(ctx, gallery.ID, hook.GalleryUpdatePost, input, translator.getFields()) gallery, err = r.getGallery(ctx, gallery.ID) if err != nil { return nil, err } newRet = append(newRet, gallery) } return newRet, nil } func (r *mutationResolver) galleryUpdate(ctx context.Context, input models.GalleryUpdateInput, translator changesetTranslator) (*models.Gallery, error) { galleryID, err := strconv.Atoi(input.ID) if err != nil { return nil, fmt.Errorf("converting id: %w", err) } qb := r.repository.Gallery originalGallery, err := qb.Find(ctx, galleryID) if err != nil { return nil, err } if originalGallery == nil { return nil, fmt.Errorf("gallery with id %d not found", galleryID) } // Populate gallery from the input updatedGallery := models.NewGalleryPartial() if input.Title != nil { // ensure title is not empty if *input.Title == "" && originalGallery.IsUserCreated() { return nil, errors.New("title must not be empty for user-created galleries") } updatedGallery.Title = models.NewOptionalString(*input.Title) } updatedGallery.Code = translator.optionalString(input.Code, "code") updatedGallery.Details = translator.optionalString(input.Details, "details") updatedGallery.Photographer = translator.optionalString(input.Photographer, "photographer") updatedGallery.Rating = translator.optionalInt(input.Rating100, "rating100") updatedGallery.Organized = translator.optionalBool(input.Organized, "organized") updatedGallery.Date, err = translator.optionalDate(input.Date, "date") if err != nil { return nil, fmt.Errorf("converting date: %w", err) } updatedGallery.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) } updatedGallery.URLs = translator.optionalURLs(input.Urls, input.URL) updatedGallery.PrimaryFileID, err = translator.fileIDPtrFromString(input.PrimaryFileID) if err != nil { return nil, fmt.Errorf("converting primary file id: %w", err) } if updatedGallery.PrimaryFileID != nil { primaryFileID := *updatedGallery.PrimaryFileID if err := originalGallery.LoadFiles(ctx, r.repository.Gallery); err != nil { return nil, err } // ensure that new primary file is associated with gallery var f models.File for _, ff := range originalGallery.Files.List() { if ff.Base().ID == primaryFileID { f = ff } } if f == nil { return nil, fmt.Errorf("file with id %d not associated with gallery", primaryFileID) } } updatedGallery.PerformerIDs, err = translator.updateIds(input.PerformerIds, "performer_ids") if err != nil { return nil, fmt.Errorf("converting performer ids: %w", err) } updatedGallery.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids") if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } updatedGallery.SceneIDs, err = translator.updateIds(input.SceneIds, "scene_ids") if err != nil { return nil, fmt.Errorf("converting scene ids: %w", err) } if input.CustomFields != nil { updatedGallery.CustomFields = handleUpdateCustomFields(*input.CustomFields) } // gallery scene is set from the scene only gallery, err := qb.UpdatePartial(ctx, galleryID, updatedGallery) if err != nil { return nil, err } return gallery, nil } func (r *mutationResolver) BulkGalleryUpdate(ctx context.Context, input BulkGalleryUpdateInput) ([]*models.Gallery, error) { galleryIDs, err := stringslice.StringSliceToIntSlice(input.Ids) if err != nil { return nil, fmt.Errorf("converting ids: %w", err) } translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate gallery from the input updatedGallery := models.NewGalleryPartial() updatedGallery.Code = translator.optionalString(input.Code, "code") updatedGallery.Details = translator.optionalString(input.Details, "details") updatedGallery.Photographer = translator.optionalString(input.Photographer, "photographer") updatedGallery.Rating = translator.optionalInt(input.Rating100, "rating100") updatedGallery.Organized = translator.optionalBool(input.Organized, "organized") updatedGallery.URLs = translator.optionalURLsBulk(input.Urls, input.URL) updatedGallery.Date, err = translator.optionalDate(input.Date, "date") if err != nil { return nil, fmt.Errorf("converting date: %w", err) } updatedGallery.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) } updatedGallery.PerformerIDs, err = translator.updateIdsBulk(input.PerformerIds, "performer_ids") if err != nil { return nil, fmt.Errorf("converting performer ids: %w", err) } updatedGallery.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids") if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } updatedGallery.SceneIDs, err = translator.updateIdsBulk(input.SceneIds, "scene_ids") if err != nil { return nil, fmt.Errorf("converting scene ids: %w", err) } if input.CustomFields != nil { updatedGallery.CustomFields = handleUpdateCustomFields(*input.CustomFields) } ret := []*models.Gallery{} // Start the transaction and save the galleries if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Gallery for _, galleryID := range galleryIDs { gallery, err := qb.UpdatePartial(ctx, galleryID, updatedGallery) if err != nil { return err } ret = append(ret, gallery) } return nil }); err != nil { return nil, err } // execute post hooks outside of txn var newRet []*models.Gallery for _, gallery := range ret { r.hookExecutor.ExecutePostHooks(ctx, gallery.ID, hook.GalleryUpdatePost, input, translator.getFields()) gallery, err := r.getGallery(ctx, gallery.ID) if err != nil { return nil, err } newRet = append(newRet, gallery) } return newRet, nil } func (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.GalleryDestroyInput) (bool, error) { galleryIDs, err := stringslice.StringSliceToIntSlice(input.Ids) if err != nil { return false, fmt.Errorf("converting ids: %w", err) } trashPath := manager.GetInstance().Config.GetDeleteTrashPath() var galleries []*models.Gallery var imgsDestroyed []*models.Image fileDeleter := &image.FileDeleter{ Deleter: file.NewDeleterWithTrash(trashPath), Paths: manager.GetInstance().Paths, } deleteGenerated := utils.IsTrue(input.DeleteGenerated) deleteFile := utils.IsTrue(input.DeleteFile) destroyFileEntry := utils.IsTrue(input.DestroyFileEntry) if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Gallery for _, id := range galleryIDs { gallery, err := qb.Find(ctx, id) if err != nil { return err } if gallery == nil { return fmt.Errorf("gallery with id %d not found", id) } if err := gallery.LoadFiles(ctx, qb); err != nil { return fmt.Errorf("loading files for gallery %d", id) } galleries = append(galleries, gallery) imgsDestroyed, err = r.galleryService.Destroy(ctx, gallery, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry) if err != nil { return err } } return nil }); err != nil { fileDeleter.Rollback() return false, err } // perform the post-commit actions fileDeleter.Commit() for _, gallery := range galleries { // don't delete stash library paths path := gallery.Path if deleteFile && path != "" && !isStashPath(path) { // try to remove the folder - it is possible that it is not empty // so swallow the error if present _ = os.Remove(path) } } // call post hook after performing the other actionsa for _, gallery := range galleries { r.hookExecutor.ExecutePostHooks(ctx, gallery.ID, hook.GalleryDestroyPost, plugin.GalleryDestroyInput{ GalleryDestroyInput: input, Checksum: gallery.PrimaryChecksum(), Path: gallery.Path, }, nil) } // call image destroy post hook as well for _, img := range imgsDestroyed { r.hookExecutor.ExecutePostHooks(ctx, img.ID, hook.ImageDestroyPost, plugin.ImageDestroyInput{ Checksum: img.Checksum, Path: img.Path, }, nil) } return true, nil } func isStashPath(path string) bool { stashConfigs := manager.GetInstance().Config.GetStashPaths() for _, config := range stashConfigs { if path == config.Path { return true } } return false } func (r *mutationResolver) AddGalleryImages(ctx context.Context, input GalleryAddInput) (bool, error) { galleryID, err := strconv.Atoi(input.GalleryID) if err != nil { return false, fmt.Errorf("converting gallery id: %w", err) } imageIDs, err := stringslice.StringSliceToIntSlice(input.ImageIds) if err != nil { return false, fmt.Errorf("converting image ids: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Gallery gallery, err := qb.Find(ctx, galleryID) if err != nil { return err } if gallery == nil { return fmt.Errorf("gallery with id %d not found", galleryID) } return r.galleryService.AddImages(ctx, gallery, imageIDs...) }); err != nil { return false, err } return true, nil } func (r *mutationResolver) RemoveGalleryImages(ctx context.Context, input GalleryRemoveInput) (bool, error) { galleryID, err := strconv.Atoi(input.GalleryID) if err != nil { return false, fmt.Errorf("converting gallery id: %w", err) } imageIDs, err := stringslice.StringSliceToIntSlice(input.ImageIds) if err != nil { return false, fmt.Errorf("converting image ids: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Gallery gallery, err := qb.Find(ctx, galleryID) if err != nil { return err } if gallery == nil { return fmt.Errorf("gallery with id %d not found", galleryID) } return r.galleryService.RemoveImages(ctx, gallery, imageIDs...) }); err != nil { return false, err } return true, nil } func (r *mutationResolver) SetGalleryCover(ctx context.Context, input GallerySetCoverInput) (bool, error) { galleryID, err := strconv.Atoi(input.GalleryID) if err != nil { return false, fmt.Errorf("converting gallery id: %w", err) } coverImageID, err := strconv.Atoi(input.CoverImageID) if err != nil { return false, fmt.Errorf("converting cover image id: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Gallery gallery, err := qb.Find(ctx, galleryID) if err != nil { return err } if gallery == nil { return fmt.Errorf("gallery with id %d not found", galleryID) } return r.galleryService.SetCover(ctx, gallery, coverImageID) }); err != nil { return false, err } return true, nil } func (r *mutationResolver) ResetGalleryCover(ctx context.Context, input GalleryResetCoverInput) (bool, error) { galleryID, err := strconv.Atoi(input.GalleryID) if err != nil { return false, fmt.Errorf("converting gallery id: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Gallery gallery, err := qb.Find(ctx, galleryID) if err != nil { return err } if gallery == nil { return fmt.Errorf("gallery with id %d not found", galleryID) } return r.galleryService.ResetCover(ctx, gallery) }); err != nil { return false, err } return true, nil } func (r *mutationResolver) getGalleryChapter(ctx context.Context, id int) (ret *models.GalleryChapter, err error) { if err := r.withTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.GalleryChapter.Find(ctx, id) return err }); err != nil { return nil, err } return ret, nil } func (r *mutationResolver) GalleryChapterCreate(ctx context.Context, input GalleryChapterCreateInput) (*models.GalleryChapter, error) { galleryID, err := strconv.Atoi(input.GalleryID) if err != nil { return nil, fmt.Errorf("converting gallery id: %w", err) } // Populate a new gallery chapter from the input newChapter := models.NewGalleryChapter() newChapter.Title = input.Title newChapter.ImageIndex = input.ImageIndex newChapter.GalleryID = galleryID // Start the transaction and save the gallery chapter if err := r.withTxn(ctx, func(ctx context.Context) error { imageCount, err := r.repository.Image.CountByGalleryID(ctx, galleryID) if err != nil { return err } // Sanity Check of Index if newChapter.ImageIndex > imageCount || newChapter.ImageIndex < 1 { return errors.New("Image # must greater than zero and in range of the gallery images") } return r.repository.GalleryChapter.Create(ctx, &newChapter) }); err != nil { return nil, err } r.hookExecutor.ExecutePostHooks(ctx, newChapter.ID, hook.GalleryChapterCreatePost, input, nil) return r.getGalleryChapter(ctx, newChapter.ID) } func (r *mutationResolver) GalleryChapterUpdate(ctx context.Context, input GalleryChapterUpdateInput) (*models.GalleryChapter, error) { chapterID, err := strconv.Atoi(input.ID) if err != nil { return nil, fmt.Errorf("converting id: %w", err) } translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate gallery chapter from the input updatedChapter := models.NewGalleryChapterPartial() updatedChapter.Title = translator.optionalString(input.Title, "title") updatedChapter.ImageIndex = translator.optionalInt(input.ImageIndex, "image_index") updatedChapter.GalleryID, err = translator.optionalIntFromString(input.GalleryID, "gallery_id") if err != nil { return nil, fmt.Errorf("converting gallery id: %w", err) } // Start the transaction and save the gallery chapter if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.GalleryChapter existingChapter, err := qb.Find(ctx, chapterID) if err != nil { return err } if existingChapter == nil { return fmt.Errorf("gallery chapter with id %d not found", chapterID) } galleryID := existingChapter.GalleryID imageIndex := existingChapter.ImageIndex if updatedChapter.GalleryID.Set { galleryID = updatedChapter.GalleryID.Value } if updatedChapter.ImageIndex.Set { imageIndex = updatedChapter.ImageIndex.Value } imageCount, err := r.repository.Image.CountByGalleryID(ctx, galleryID) if err != nil { return err } // Sanity Check of Index if imageIndex > imageCount || imageIndex < 1 { return errors.New("Image # must greater than zero and in range of the gallery images") } _, err = qb.UpdatePartial(ctx, chapterID, updatedChapter) if err != nil { return err } return nil }); err != nil { return nil, err } r.hookExecutor.ExecutePostHooks(ctx, chapterID, hook.GalleryChapterUpdatePost, input, translator.getFields()) return r.getGalleryChapter(ctx, chapterID) } func (r *mutationResolver) GalleryChapterDestroy(ctx context.Context, id string) (bool, error) { chapterID, err := strconv.Atoi(id) if err != nil { return false, fmt.Errorf("converting id: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.GalleryChapter chapter, err := qb.Find(ctx, chapterID) if err != nil { return err } if chapter == nil { return fmt.Errorf("gallery chapter with id %d not found", chapterID) } return gallery.DestroyChapter(ctx, chapter, qb) }); err != nil { return false, err } r.hookExecutor.ExecutePostHooks(ctx, chapterID, hook.GalleryChapterDestroyPost, id, nil) return true, nil } ================================================ FILE: internal/api/resolver_mutation_group.go ================================================ package api import ( "context" "fmt" "strconv" "strings" "github.com/stashapp/stash/internal/static" "github.com/stashapp/stash/pkg/group" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/utils" ) func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*models.CreateGroupInput, error) { translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate a new group from the input newGroupInput := &models.CreateGroupInput{ Group: &models.Group{}, } *newGroupInput.Group = models.NewGroup() newGroup := newGroupInput.Group newGroup.Name = strings.TrimSpace(input.Name) newGroup.Aliases = translator.string(input.Aliases) newGroup.Duration = input.Duration newGroup.Rating = input.Rating100 newGroup.Director = translator.string(input.Director) newGroup.Synopsis = translator.string(input.Synopsis) var err error newGroup.Date, err = translator.datePtr(input.Date) if err != nil { return nil, fmt.Errorf("converting date: %w", err) } newGroup.StudioID, err = translator.intPtrFromString(input.StudioID) if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) } newGroup.TagIDs, err = translator.relatedIds(input.TagIds) if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } newGroup.ContainingGroups, err = translator.groupIDDescriptions(input.ContainingGroups) if err != nil { return nil, fmt.Errorf("converting containing group ids: %w", err) } newGroup.SubGroups, err = translator.groupIDDescriptions(input.SubGroups) if err != nil { return nil, fmt.Errorf("converting containing group ids: %w", err) } if input.Urls != nil { newGroup.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls)) } newGroupInput.CustomFields = convertMapJSONNumbers(input.CustomFields) // Process the base 64 encoded image string if input.FrontImage != nil { newGroupInput.FrontImageData, err = utils.ProcessImageInput(ctx, *input.FrontImage) if err != nil { return nil, fmt.Errorf("processing front image: %w", err) } } // Process the base 64 encoded image string if input.BackImage != nil { newGroupInput.BackImageData, err = utils.ProcessImageInput(ctx, *input.BackImage) if err != nil { return nil, fmt.Errorf("processing back image: %w", err) } } // HACK: if back image is being set, set the front image to the default. // This is because we can't have a null front image with a non-null back image. if len(newGroupInput.FrontImageData) == 0 && len(newGroupInput.BackImageData) != 0 { newGroupInput.FrontImageData = static.ReadAll(static.DefaultGroupImage) } return newGroupInput, nil } func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInput) (*models.Group, error) { createGroupInput, err := groupFromGroupCreateInput(ctx, input) if err != nil { return nil, err } // Start the transaction and save the group if err := r.withTxn(ctx, func(ctx context.Context) error { if err = r.groupService.Create(ctx, createGroupInput); err != nil { return err } return nil }); err != nil { return nil, err } // for backwards compatibility - run both movie and group hooks r.hookExecutor.ExecutePostHooks(ctx, createGroupInput.Group.ID, hook.GroupCreatePost, input, nil) r.hookExecutor.ExecutePostHooks(ctx, createGroupInput.Group.ID, hook.MovieCreatePost, input, nil) return r.getGroup(ctx, createGroupInput.Group.ID) } func groupPartialFromGroupUpdateInput(translator changesetTranslator, input GroupUpdateInput) (ret models.GroupPartial, err error) { // Populate group from the input updatedGroup := models.NewGroupPartial() updatedGroup.Name = translator.optionalString(input.Name, "name") updatedGroup.Aliases = translator.optionalString(input.Aliases, "aliases") updatedGroup.Duration = translator.optionalInt(input.Duration, "duration") updatedGroup.Rating = translator.optionalInt(input.Rating100, "rating100") updatedGroup.Director = translator.optionalString(input.Director, "director") updatedGroup.Synopsis = translator.optionalString(input.Synopsis, "synopsis") updatedGroup.Date, err = translator.optionalDate(input.Date, "date") if err != nil { err = fmt.Errorf("converting date: %w", err) return } updatedGroup.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") if err != nil { err = fmt.Errorf("converting studio id: %w", err) return } updatedGroup.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids") if err != nil { err = fmt.Errorf("converting tag ids: %w", err) return } updatedGroup.ContainingGroups, err = translator.updateGroupIDDescriptions(input.ContainingGroups, "containing_groups") if err != nil { err = fmt.Errorf("converting containing group ids: %w", err) return } updatedGroup.SubGroups, err = translator.updateGroupIDDescriptions(input.SubGroups, "sub_groups") if err != nil { err = fmt.Errorf("converting containing group ids: %w", err) return } updatedGroup.URLs = translator.updateStrings(input.Urls, "urls") if input.CustomFields != nil { updatedGroup.CustomFields = *input.CustomFields // convert json.Numbers to int/float updatedGroup.CustomFields.Full = convertMapJSONNumbers(updatedGroup.CustomFields.Full) updatedGroup.CustomFields.Partial = convertMapJSONNumbers(updatedGroup.CustomFields.Partial) } return updatedGroup, nil } func (r *mutationResolver) GroupUpdate(ctx context.Context, input GroupUpdateInput) (*models.Group, error) { groupID, err := strconv.Atoi(input.ID) if err != nil { return nil, fmt.Errorf("converting id: %w", err) } translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } updatedGroup, err := groupPartialFromGroupUpdateInput(translator, input) if err != nil { return nil, err } var frontimageData []byte frontImageIncluded := translator.hasField("front_image") if input.FrontImage != nil { frontimageData, err = utils.ProcessImageInput(ctx, *input.FrontImage) if err != nil { return nil, fmt.Errorf("processing front image: %w", err) } } var backimageData []byte backImageIncluded := translator.hasField("back_image") if input.BackImage != nil { backimageData, err = utils.ProcessImageInput(ctx, *input.BackImage) if err != nil { return nil, fmt.Errorf("processing back image: %w", err) } } if err := r.withTxn(ctx, func(ctx context.Context) error { frontImage := group.ImageInput{ Image: frontimageData, Set: frontImageIncluded, } backImage := group.ImageInput{ Image: backimageData, Set: backImageIncluded, } _, err = r.groupService.UpdatePartial(ctx, groupID, updatedGroup, frontImage, backImage) if err != nil { return err } return nil }); err != nil { return nil, err } // for backwards compatibility - run both movie and group hooks r.hookExecutor.ExecutePostHooks(ctx, groupID, hook.GroupUpdatePost, input, translator.getFields()) r.hookExecutor.ExecutePostHooks(ctx, groupID, hook.MovieUpdatePost, input, translator.getFields()) return r.getGroup(ctx, groupID) } func groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input BulkGroupUpdateInput) (ret models.GroupPartial, err error) { updatedGroup := models.NewGroupPartial() updatedGroup.Date, err = translator.optionalDate(input.Date, "date") if err != nil { err = fmt.Errorf("converting date: %w", err) return } updatedGroup.Synopsis = translator.optionalString(input.Synopsis, "synopsis") updatedGroup.Rating = translator.optionalInt(input.Rating100, "rating100") updatedGroup.Director = translator.optionalString(input.Director, "director") updatedGroup.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") if err != nil { err = fmt.Errorf("converting studio id: %w", err) return } updatedGroup.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids") if err != nil { err = fmt.Errorf("converting tag ids: %w", err) return } updatedGroup.ContainingGroups, err = translator.updateGroupIDDescriptionsBulk(input.ContainingGroups, "containing_groups") if err != nil { err = fmt.Errorf("converting containing group ids: %w", err) return } updatedGroup.SubGroups, err = translator.updateGroupIDDescriptionsBulk(input.SubGroups, "sub_groups") if err != nil { err = fmt.Errorf("converting containing group ids: %w", err) return } updatedGroup.URLs = translator.optionalURLsBulk(input.Urls, nil) if input.CustomFields != nil { updatedGroup.CustomFields = *input.CustomFields // convert json.Numbers to int/float updatedGroup.CustomFields.Full = convertMapJSONNumbers(updatedGroup.CustomFields.Full) updatedGroup.CustomFields.Partial = convertMapJSONNumbers(updatedGroup.CustomFields.Partial) } return updatedGroup, nil } func (r *mutationResolver) BulkGroupUpdate(ctx context.Context, input BulkGroupUpdateInput) ([]*models.Group, error) { groupIDs, err := stringslice.StringSliceToIntSlice(input.Ids) if err != nil { return nil, fmt.Errorf("converting ids: %w", err) } translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate group from the input updatedGroup, err := groupPartialFromBulkGroupUpdateInput(translator, input) if err != nil { return nil, err } ret := []*models.Group{} if err := r.withTxn(ctx, func(ctx context.Context) error { for _, groupID := range groupIDs { group, err := r.groupService.UpdatePartial(ctx, groupID, updatedGroup, group.ImageInput{}, group.ImageInput{}) if err != nil { return err } ret = append(ret, group) } return nil }); err != nil { return nil, err } var newRet []*models.Group for _, group := range ret { // for backwards compatibility - run both movie and group hooks r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.GroupUpdatePost, input, translator.getFields()) r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.MovieUpdatePost, input, translator.getFields()) group, err = r.getGroup(ctx, group.ID) if err != nil { return nil, err } newRet = append(newRet, group) } return newRet, nil } func (r *mutationResolver) GroupDestroy(ctx context.Context, input GroupDestroyInput) (bool, error) { id, err := strconv.Atoi(input.ID) if err != nil { return false, fmt.Errorf("converting id: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { return r.repository.Group.Destroy(ctx, id) }); err != nil { return false, err } // for backwards compatibility - run both movie and group hooks r.hookExecutor.ExecutePostHooks(ctx, id, hook.GroupDestroyPost, input, nil) r.hookExecutor.ExecutePostHooks(ctx, id, hook.MovieDestroyPost, input, nil) return true, nil } func (r *mutationResolver) GroupsDestroy(ctx context.Context, groupIDs []string) (bool, error) { ids, err := stringslice.StringSliceToIntSlice(groupIDs) if err != nil { return false, fmt.Errorf("converting ids: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Group for _, id := range ids { if err := qb.Destroy(ctx, id); err != nil { return err } } return nil }); err != nil { return false, err } for _, id := range ids { // for backwards compatibility - run both movie and group hooks r.hookExecutor.ExecutePostHooks(ctx, id, hook.GroupDestroyPost, groupIDs, nil) r.hookExecutor.ExecutePostHooks(ctx, id, hook.MovieDestroyPost, groupIDs, nil) } return true, nil } func (r *mutationResolver) AddGroupSubGroups(ctx context.Context, input GroupSubGroupAddInput) (bool, error) { groupID, err := strconv.Atoi(input.ContainingGroupID) if err != nil { return false, fmt.Errorf("converting group id: %w", err) } subGroups, err := groupsDescriptionsFromGroupInput(input.SubGroups) if err != nil { return false, fmt.Errorf("converting sub group ids: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { return r.groupService.AddSubGroups(ctx, groupID, subGroups, input.InsertIndex) }); err != nil { return false, err } return true, nil } func (r *mutationResolver) RemoveGroupSubGroups(ctx context.Context, input GroupSubGroupRemoveInput) (bool, error) { groupID, err := strconv.Atoi(input.ContainingGroupID) if err != nil { return false, fmt.Errorf("converting group id: %w", err) } subGroupIDs, err := stringslice.StringSliceToIntSlice(input.SubGroupIds) if err != nil { return false, fmt.Errorf("converting sub group ids: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { return r.groupService.RemoveSubGroups(ctx, groupID, subGroupIDs) }); err != nil { return false, err } return true, nil } func (r *mutationResolver) ReorderSubGroups(ctx context.Context, input ReorderSubGroupsInput) (bool, error) { groupID, err := strconv.Atoi(input.GroupID) if err != nil { return false, fmt.Errorf("converting group id: %w", err) } subGroupIDs, err := stringslice.StringSliceToIntSlice(input.SubGroupIds) if err != nil { return false, fmt.Errorf("converting sub group ids: %w", err) } insertPointID, err := strconv.Atoi(input.InsertAtID) if err != nil { return false, fmt.Errorf("converting insert at id: %w", err) } insertAfter := utils.IsTrue(input.InsertAfter) if err := r.withTxn(ctx, func(ctx context.Context) error { return r.groupService.ReorderSubGroups(ctx, groupID, subGroupIDs, insertPointID, insertAfter) }); err != nil { return false, err } return true, nil } ================================================ FILE: internal/api/resolver_mutation_image.go ================================================ package api import ( "context" "fmt" "strconv" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/utils" ) // used to refetch image after hooks run func (r *mutationResolver) getImage(ctx context.Context, id int) (ret *models.Image, err error) { if err := r.withTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Image.Find(ctx, id) return err }); err != nil { return nil, err } return ret, nil } func (r *mutationResolver) ImageUpdate(ctx context.Context, input models.ImageUpdateInput) (ret *models.Image, err error) { translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Start the transaction and save the image if err := r.withTxn(ctx, func(ctx context.Context) error { ret, err = r.imageUpdate(ctx, input, translator) return err }); err != nil { return nil, err } // execute post hooks outside txn r.hookExecutor.ExecutePostHooks(ctx, ret.ID, hook.ImageUpdatePost, input, translator.getFields()) return r.getImage(ctx, ret.ID) } func (r *mutationResolver) ImagesUpdate(ctx context.Context, input []*models.ImageUpdateInput) (ret []*models.Image, err error) { inputMaps := getUpdateInputMaps(ctx) // Start the transaction and save the image if err := r.withTxn(ctx, func(ctx context.Context) error { for i, image := range input { translator := changesetTranslator{ inputMap: inputMaps[i], } thisImage, err := r.imageUpdate(ctx, *image, translator) if err != nil { return err } ret = append(ret, thisImage) } return nil }); err != nil { return nil, err } // execute post hooks outside txn var newRet []*models.Image for i, image := range ret { translator := changesetTranslator{ inputMap: inputMaps[i], } r.hookExecutor.ExecutePostHooks(ctx, image.ID, hook.ImageUpdatePost, input, translator.getFields()) image, err = r.getImage(ctx, image.ID) if err != nil { return nil, err } newRet = append(newRet, image) } return newRet, nil } func (r *mutationResolver) imageUpdate(ctx context.Context, input models.ImageUpdateInput, translator changesetTranslator) (*models.Image, error) { imageID, err := strconv.Atoi(input.ID) if err != nil { return nil, fmt.Errorf("converting id: %w", err) } i, err := r.repository.Image.Find(ctx, imageID) if err != nil { return nil, err } if i == nil { return nil, fmt.Errorf("image with id %d not found", imageID) } // Populate image from the input updatedImage := models.NewImagePartial() updatedImage.Title = translator.optionalString(input.Title, "title") updatedImage.Code = translator.optionalString(input.Code, "code") updatedImage.Details = translator.optionalString(input.Details, "details") updatedImage.Photographer = translator.optionalString(input.Photographer, "photographer") updatedImage.Rating = translator.optionalInt(input.Rating100, "rating100") updatedImage.Organized = translator.optionalBool(input.Organized, "organized") updatedImage.Date, err = translator.optionalDate(input.Date, "date") if err != nil { return nil, fmt.Errorf("converting date: %w", err) } updatedImage.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) } updatedImage.URLs = translator.optionalURLs(input.Urls, input.URL) updatedImage.PrimaryFileID, err = translator.fileIDPtrFromString(input.PrimaryFileID) if err != nil { return nil, fmt.Errorf("converting primary file id: %w", err) } if updatedImage.PrimaryFileID != nil { primaryFileID := *updatedImage.PrimaryFileID if err := i.LoadFiles(ctx, r.repository.Image); err != nil { return nil, err } // ensure that new primary file is associated with image var f models.File for _, ff := range i.Files.List() { if ff.Base().ID == primaryFileID { f = ff } } if f == nil { return nil, fmt.Errorf("file with id %d not associated with image", primaryFileID) } } var updatedGalleryIDs []int updatedImage.GalleryIDs, err = translator.updateIds(input.GalleryIds, "gallery_ids") if err != nil { return nil, fmt.Errorf("converting gallery ids: %w", err) } if updatedImage.GalleryIDs != nil { // ensure gallery IDs are loaded if err := i.LoadGalleryIDs(ctx, r.repository.Image); err != nil { return nil, err } if err := r.galleryService.ValidateImageGalleryChange(ctx, i, *updatedImage.GalleryIDs); err != nil { return nil, err } updatedGalleryIDs = updatedImage.GalleryIDs.ImpactedIDs(i.GalleryIDs.List()) } updatedImage.PerformerIDs, err = translator.updateIds(input.PerformerIds, "performer_ids") if err != nil { return nil, fmt.Errorf("converting performer ids: %w", err) } updatedImage.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids") if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } if input.CustomFields != nil { updatedImage.CustomFields = *input.CustomFields // convert json.Numbers to int/float updatedImage.CustomFields.Full = convertMapJSONNumbers(updatedImage.CustomFields.Full) updatedImage.CustomFields.Partial = convertMapJSONNumbers(updatedImage.CustomFields.Partial) } qb := r.repository.Image image, err := qb.UpdatePartial(ctx, imageID, updatedImage) if err != nil { return nil, err } // #3759 - update all impacted galleries for _, galleryID := range updatedGalleryIDs { if err := r.galleryService.Updated(ctx, galleryID); err != nil { return nil, fmt.Errorf("updating gallery %d: %w", galleryID, err) } } return image, nil } func (r *mutationResolver) BulkImageUpdate(ctx context.Context, input BulkImageUpdateInput) (ret []*models.Image, err error) { imageIDs, err := stringslice.StringSliceToIntSlice(input.Ids) if err != nil { return nil, fmt.Errorf("converting ids: %w", err) } translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate image from the input updatedImage := models.NewImagePartial() updatedImage.Title = translator.optionalString(input.Title, "title") updatedImage.Code = translator.optionalString(input.Code, "code") updatedImage.Details = translator.optionalString(input.Details, "details") updatedImage.Photographer = translator.optionalString(input.Photographer, "photographer") updatedImage.Rating = translator.optionalInt(input.Rating100, "rating100") updatedImage.Organized = translator.optionalBool(input.Organized, "organized") updatedImage.Date, err = translator.optionalDate(input.Date, "date") if err != nil { return nil, fmt.Errorf("converting date: %w", err) } updatedImage.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) } updatedImage.URLs = translator.optionalURLsBulk(input.Urls, input.URL) updatedImage.GalleryIDs, err = translator.updateIdsBulk(input.GalleryIds, "gallery_ids") if err != nil { return nil, fmt.Errorf("converting gallery ids: %w", err) } updatedImage.PerformerIDs, err = translator.updateIdsBulk(input.PerformerIds, "performer_ids") if err != nil { return nil, fmt.Errorf("converting performer ids: %w", err) } updatedImage.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids") if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } if input.CustomFields != nil { updatedImage.CustomFields = *input.CustomFields // convert json.Numbers to int/float updatedImage.CustomFields.Full = convertMapJSONNumbers(updatedImage.CustomFields.Full) updatedImage.CustomFields.Partial = convertMapJSONNumbers(updatedImage.CustomFields.Partial) } // Start the transaction and save the images if err := r.withTxn(ctx, func(ctx context.Context) error { var updatedGalleryIDs []int qb := r.repository.Image for _, imageID := range imageIDs { i, err := r.repository.Image.Find(ctx, imageID) if err != nil { return err } if i == nil { return fmt.Errorf("image with id %d not found", imageID) } if updatedImage.GalleryIDs != nil { // ensure gallery IDs are loaded if err := i.LoadGalleryIDs(ctx, r.repository.Image); err != nil { return err } if err := r.galleryService.ValidateImageGalleryChange(ctx, i, *updatedImage.GalleryIDs); err != nil { return err } thisUpdatedGalleryIDs := updatedImage.GalleryIDs.ImpactedIDs(i.GalleryIDs.List()) updatedGalleryIDs = sliceutil.AppendUniques(updatedGalleryIDs, thisUpdatedGalleryIDs) } image, err := qb.UpdatePartial(ctx, imageID, updatedImage) if err != nil { return err } ret = append(ret, image) } // #3759 - update all impacted galleries for _, galleryID := range updatedGalleryIDs { if err := r.galleryService.Updated(ctx, galleryID); err != nil { return fmt.Errorf("updating gallery %d: %w", galleryID, err) } } return nil }); err != nil { return nil, err } // execute post hooks outside of txn var newRet []*models.Image for _, image := range ret { r.hookExecutor.ExecutePostHooks(ctx, image.ID, hook.ImageUpdatePost, input, translator.getFields()) image, err = r.getImage(ctx, image.ID) if err != nil { return nil, err } newRet = append(newRet, image) } return newRet, nil } func (r *mutationResolver) ImageDestroy(ctx context.Context, input models.ImageDestroyInput) (ret bool, err error) { imageID, err := strconv.Atoi(input.ID) if err != nil { return false, fmt.Errorf("converting id: %w", err) } trashPath := manager.GetInstance().Config.GetDeleteTrashPath() var i *models.Image fileDeleter := &image.FileDeleter{ Deleter: file.NewDeleterWithTrash(trashPath), Paths: manager.GetInstance().Paths, } if err := r.withTxn(ctx, func(ctx context.Context) error { i, err = r.repository.Image.Find(ctx, imageID) if err != nil { return err } if i == nil { return fmt.Errorf("image with id %d not found", imageID) } return r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile), utils.IsTrue(input.DestroyFileEntry)) }); err != nil { fileDeleter.Rollback() return false, err } // perform the post-commit actions fileDeleter.Commit() // call post hook after performing the other actions r.hookExecutor.ExecutePostHooks(ctx, i.ID, hook.ImageDestroyPost, plugin.ImageDestroyInput{ ImageDestroyInput: input, Checksum: i.Checksum, Path: i.Path, }, nil) return true, nil } func (r *mutationResolver) ImagesDestroy(ctx context.Context, input models.ImagesDestroyInput) (ret bool, err error) { imageIDs, err := stringslice.StringSliceToIntSlice(input.Ids) if err != nil { return false, fmt.Errorf("converting ids: %w", err) } trashPath := manager.GetInstance().Config.GetDeleteTrashPath() var images []*models.Image fileDeleter := &image.FileDeleter{ Deleter: file.NewDeleterWithTrash(trashPath), Paths: manager.GetInstance().Paths, } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Image for _, imageID := range imageIDs { i, err := qb.Find(ctx, imageID) if err != nil { return err } if i == nil { return fmt.Errorf("image with id %d not found", imageID) } images = append(images, i) if err := r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile), utils.IsTrue(input.DestroyFileEntry)); err != nil { return err } } return nil }); err != nil { fileDeleter.Rollback() return false, err } // perform the post-commit actions fileDeleter.Commit() for _, image := range images { // call post hook after performing the other actions r.hookExecutor.ExecutePostHooks(ctx, image.ID, hook.ImageDestroyPost, plugin.ImagesDestroyInput{ ImagesDestroyInput: input, Checksum: image.Checksum, Path: image.Path, }, nil) } return true, nil } func (r *mutationResolver) ImageIncrementO(ctx context.Context, id string) (ret int, err error) { imageID, err := strconv.Atoi(id) if err != nil { return 0, fmt.Errorf("converting id: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Image ret, err = qb.IncrementOCounter(ctx, imageID) return err }); err != nil { return 0, err } return ret, nil } func (r *mutationResolver) ImageDecrementO(ctx context.Context, id string) (ret int, err error) { imageID, err := strconv.Atoi(id) if err != nil { return 0, fmt.Errorf("converting id: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Image ret, err = qb.DecrementOCounter(ctx, imageID) return err }); err != nil { return 0, err } return ret, nil } func (r *mutationResolver) ImageResetO(ctx context.Context, id string) (ret int, err error) { imageID, err := strconv.Atoi(id) if err != nil { return 0, fmt.Errorf("converting id: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Image ret, err = qb.ResetOCounter(ctx, imageID) return err }); err != nil { return 0, err } return ret, nil } ================================================ FILE: internal/api/resolver_mutation_job.go ================================================ package api import ( "context" "fmt" "strconv" "github.com/stashapp/stash/internal/manager" ) func (r *mutationResolver) StopJob(ctx context.Context, jobID string) (bool, error) { id, err := strconv.Atoi(jobID) if err != nil { return false, fmt.Errorf("converting id: %w", err) } manager.GetInstance().JobManager.CancelJob(id) return true, nil } func (r *mutationResolver) StopAllJobs(ctx context.Context) (bool, error) { manager.GetInstance().JobManager.CancelAll() return true, nil } ================================================ FILE: internal/api/resolver_mutation_metadata.go ================================================ package api import ( "context" "fmt" "strconv" "sync" "time" "github.com/stashapp/stash/internal/identify" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/internal/manager/task" "github.com/stashapp/stash/pkg/logger" ) func (r *mutationResolver) MetadataScan(ctx context.Context, input manager.ScanMetadataInput) (string, error) { jobID, err := manager.GetInstance().Scan(ctx, input) if err != nil { return "", err } return strconv.Itoa(jobID), nil } func (r *mutationResolver) MetadataImport(ctx context.Context) (string, error) { jobID, err := manager.GetInstance().Import(ctx) if err != nil { return "", err } return strconv.Itoa(jobID), nil } func (r *mutationResolver) ImportObjects(ctx context.Context, input manager.ImportObjectsInput) (string, error) { t, err := manager.CreateImportTask(config.GetInstance().GetVideoFileNamingAlgorithm(), input) if err != nil { return "", err } jobID := manager.GetInstance().RunSingleTask(ctx, t) return strconv.Itoa(jobID), nil } func (r *mutationResolver) MetadataExport(ctx context.Context) (string, error) { jobID, err := manager.GetInstance().Export(ctx) if err != nil { return "", err } return strconv.Itoa(jobID), nil } func (r *mutationResolver) ExportObjects(ctx context.Context, input manager.ExportObjectsInput) (*string, error) { t := manager.CreateExportTask(config.GetInstance().GetVideoFileNamingAlgorithm(), input) var wg sync.WaitGroup wg.Add(1) t.Start(ctx, &wg) if t.DownloadHash != "" { baseURL, _ := ctx.Value(BaseURLCtxKey).(string) // generate timestamp suffix := time.Now().Format("20060102-150405") ret := baseURL + "/downloads/" + t.DownloadHash + "/export" + suffix + ".zip" return &ret, nil } return nil, nil } func (r *mutationResolver) MetadataGenerate(ctx context.Context, input manager.GenerateMetadataInput) (string, error) { jobID, err := manager.GetInstance().Generate(ctx, input) if err != nil { return "", err } return strconv.Itoa(jobID), nil } func (r *mutationResolver) MetadataAutoTag(ctx context.Context, input manager.AutoTagMetadataInput) (string, error) { jobID := manager.GetInstance().AutoTag(ctx, input) return strconv.Itoa(jobID), nil } func (r *mutationResolver) MetadataIdentify(ctx context.Context, input identify.Options) (string, error) { t := manager.CreateIdentifyJob(input) jobID := manager.GetInstance().JobManager.Add(ctx, "Identifying...", t) return strconv.Itoa(jobID), nil } func (r *mutationResolver) MetadataClean(ctx context.Context, input manager.CleanMetadataInput) (string, error) { jobID := manager.GetInstance().Clean(ctx, input) return strconv.Itoa(jobID), nil } func (r *mutationResolver) MetadataCleanGenerated(ctx context.Context, input task.CleanGeneratedOptions) (string, error) { mgr := manager.GetInstance() t := &task.CleanGeneratedJob{ Options: input, Paths: mgr.Paths, BlobsStorageType: mgr.Config.GetBlobsStorage(), VideoFileNamingAlgorithm: mgr.Config.GetVideoFileNamingAlgorithm(), Repository: mgr.Repository, BlobCleaner: mgr.Repository.Blob, } jobID := mgr.JobManager.Add(ctx, "Cleaning generated files...", t) return strconv.Itoa(jobID), nil } func (r *mutationResolver) MigrateHashNaming(ctx context.Context) (string, error) { jobID := manager.GetInstance().MigrateHash(ctx) return strconv.Itoa(jobID), nil } func (r *mutationResolver) BackupDatabase(ctx context.Context, input BackupDatabaseInput) (*string, error) { // if download is true, then backup to temporary file and return a link download := input.Download != nil && *input.Download includeBlobs := input.IncludeBlobs != nil && *input.IncludeBlobs mgr := manager.GetInstance() backupPath, backupName, err := mgr.BackupDatabase(download, includeBlobs) if err != nil { logger.Errorf("Error backing up database: %v", err) return nil, err } if download { downloadHash, err := mgr.DownloadStore.RegisterFile(backupPath, "", false) if err != nil { return nil, fmt.Errorf("error registering file for download: %w", err) } logger.Debugf("Generated backup file %s with hash %s", backupPath, downloadHash) baseURL, _ := ctx.Value(BaseURLCtxKey).(string) ret := baseURL + "/downloads/" + downloadHash + "/" + backupName return &ret, nil } else { logger.Infof("Successfully backed up database to: %s", backupPath) } return nil, nil } func (r *mutationResolver) AnonymiseDatabase(ctx context.Context, input AnonymiseDatabaseInput) (*string, error) { // if download is true, then save to temporary file and return a link download := input.Download != nil && *input.Download mgr := manager.GetInstance() outPath, outName, err := mgr.AnonymiseDatabase(download) if err != nil { logger.Errorf("Error anonymising database: %v", err) return nil, err } if download { downloadHash, err := mgr.DownloadStore.RegisterFile(outPath, "", false) if err != nil { return nil, fmt.Errorf("error registering file for download: %w", err) } logger.Debugf("Generated anonymised file %s with hash %s", outPath, downloadHash) baseURL, _ := ctx.Value(BaseURLCtxKey).(string) ret := baseURL + "/downloads/" + downloadHash + "/" + outName return &ret, nil } else { logger.Infof("Successfully anonymised database to: %s", outPath) } return nil, nil } func (r *mutationResolver) OptimiseDatabase(ctx context.Context) (string, error) { jobID := manager.GetInstance().OptimiseDatabase(ctx) return strconv.Itoa(jobID), nil } ================================================ FILE: internal/api/resolver_mutation_migrate.go ================================================ package api import ( "context" "strconv" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager/task" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/utils" ) func (r *mutationResolver) MigrateSceneScreenshots(ctx context.Context, input MigrateSceneScreenshotsInput) (string, error) { mgr := manager.GetInstance() t := &task.MigrateSceneScreenshotsJob{ ScreenshotsPath: manager.GetInstance().Paths.Generated.Screenshots, Input: scene.MigrateSceneScreenshotsInput{ DeleteFiles: utils.IsTrue(input.DeleteFiles), OverwriteExisting: utils.IsTrue(input.OverwriteExisting), }, SceneRepo: mgr.Repository.Scene, TxnManager: mgr.Repository.TxnManager, } jobID := mgr.JobManager.Add(ctx, "Migrating scene screenshots to blobs...", t) return strconv.Itoa(jobID), nil } func (r *mutationResolver) MigrateBlobs(ctx context.Context, input MigrateBlobsInput) (string, error) { mgr := manager.GetInstance() t := &task.MigrateBlobsJob{ TxnManager: mgr.Database, BlobStore: mgr.Database.Blobs, Vacuumer: mgr.Database, DeleteOld: utils.IsTrue(input.DeleteOld), } jobID := mgr.JobManager.Add(ctx, "Migrating blobs...", t) return strconv.Itoa(jobID), nil } func (r *mutationResolver) Migrate(ctx context.Context, input manager.MigrateInput) (string, error) { mgr := manager.GetInstance() t := &task.MigrateJob{ BackupPath: input.BackupPath, Config: mgr.Config, Database: mgr.Database, } jobID := mgr.JobManager.Add(ctx, "Migrating database...", t) return strconv.Itoa(jobID), nil } ================================================ FILE: internal/api/resolver_mutation_movie.go ================================================ package api import ( "context" "fmt" "strconv" "strings" "github.com/stashapp/stash/internal/static" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/utils" ) // used to refetch group after hooks run func (r *mutationResolver) getGroup(ctx context.Context, id int) (ret *models.Group, err error) { if err := r.withTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Group.Find(ctx, id) return err }); err != nil { return nil, err } return ret, nil } func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInput) (*models.Group, error) { translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate a new group from the input newGroup := models.NewGroup() newGroup.Name = strings.TrimSpace(input.Name) newGroup.Aliases = translator.string(input.Aliases) newGroup.Duration = input.Duration newGroup.Rating = input.Rating100 newGroup.Director = translator.string(input.Director) newGroup.Synopsis = translator.string(input.Synopsis) var err error newGroup.Date, err = translator.datePtr(input.Date) if err != nil { return nil, fmt.Errorf("converting date: %w", err) } newGroup.StudioID, err = translator.intPtrFromString(input.StudioID) if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) } newGroup.TagIDs, err = translator.relatedIds(input.TagIds) if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } if input.Urls != nil { newGroup.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls)) } else if input.URL != nil { newGroup.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)}) } // Process the base 64 encoded image string var frontimageData []byte if input.FrontImage != nil { frontimageData, err = utils.ProcessImageInput(ctx, *input.FrontImage) if err != nil { return nil, fmt.Errorf("processing front image: %w", err) } } // Process the base 64 encoded image string var backimageData []byte if input.BackImage != nil { backimageData, err = utils.ProcessImageInput(ctx, *input.BackImage) if err != nil { return nil, fmt.Errorf("processing back image: %w", err) } } // HACK: if back image is being set, set the front image to the default. // This is because we can't have a null front image with a non-null back image. if len(frontimageData) == 0 && len(backimageData) != 0 { frontimageData = static.ReadAll(static.DefaultGroupImage) } // Start the transaction and save the group if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Group err = qb.Create(ctx, &newGroup) if err != nil { return err } // update image table if len(frontimageData) > 0 { if err := qb.UpdateFrontImage(ctx, newGroup.ID, frontimageData); err != nil { return err } } if len(backimageData) > 0 { if err := qb.UpdateBackImage(ctx, newGroup.ID, backimageData); err != nil { return err } } return nil }); err != nil { return nil, err } // for backwards compatibility - run both movie and group hooks r.hookExecutor.ExecutePostHooks(ctx, newGroup.ID, hook.GroupCreatePost, input, nil) r.hookExecutor.ExecutePostHooks(ctx, newGroup.ID, hook.MovieCreatePost, input, nil) return r.getGroup(ctx, newGroup.ID) } func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInput) (*models.Group, error) { groupID, err := strconv.Atoi(input.ID) if err != nil { return nil, fmt.Errorf("converting id: %w", err) } translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate group from the input updatedGroup := models.NewGroupPartial() updatedGroup.Name = translator.optionalString(input.Name, "name") updatedGroup.Aliases = translator.optionalString(input.Aliases, "aliases") updatedGroup.Duration = translator.optionalInt(input.Duration, "duration") updatedGroup.Rating = translator.optionalInt(input.Rating100, "rating100") updatedGroup.Director = translator.optionalString(input.Director, "director") updatedGroup.Synopsis = translator.optionalString(input.Synopsis, "synopsis") updatedGroup.Date, err = translator.optionalDate(input.Date, "date") if err != nil { return nil, fmt.Errorf("converting date: %w", err) } updatedGroup.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) } updatedGroup.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids") if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } updatedGroup.URLs = translator.optionalURLs(input.Urls, input.URL) var frontimageData []byte frontImageIncluded := translator.hasField("front_image") if input.FrontImage != nil { frontimageData, err = utils.ProcessImageInput(ctx, *input.FrontImage) if err != nil { return nil, fmt.Errorf("processing front image: %w", err) } } var backimageData []byte backImageIncluded := translator.hasField("back_image") if input.BackImage != nil { backimageData, err = utils.ProcessImageInput(ctx, *input.BackImage) if err != nil { return nil, fmt.Errorf("processing back image: %w", err) } } // Start the transaction and save the group var group *models.Group if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Group group, err = qb.UpdatePartial(ctx, groupID, updatedGroup) if err != nil { return err } // update image table if frontImageIncluded { if err := qb.UpdateFrontImage(ctx, group.ID, frontimageData); err != nil { return err } } if backImageIncluded { if err := qb.UpdateBackImage(ctx, group.ID, backimageData); err != nil { return err } } return nil }); err != nil { return nil, err } // for backwards compatibility - run both movie and group hooks r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.GroupUpdatePost, input, translator.getFields()) r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.MovieUpdatePost, input, translator.getFields()) return r.getGroup(ctx, group.ID) } func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieUpdateInput) ([]*models.Group, error) { groupIDs, err := stringslice.StringSliceToIntSlice(input.Ids) if err != nil { return nil, fmt.Errorf("converting ids: %w", err) } translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate group from the input updatedGroup := models.NewGroupPartial() updatedGroup.Rating = translator.optionalInt(input.Rating100, "rating100") updatedGroup.Director = translator.optionalString(input.Director, "director") updatedGroup.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) } updatedGroup.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids") if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } updatedGroup.URLs = translator.optionalURLsBulk(input.Urls, nil) ret := []*models.Group{} if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Group for _, groupID := range groupIDs { group, err := qb.UpdatePartial(ctx, groupID, updatedGroup) if err != nil { return err } ret = append(ret, group) } return nil }); err != nil { return nil, err } var newRet []*models.Group for _, group := range ret { // for backwards compatibility - run both movie and group hooks r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.GroupUpdatePost, input, translator.getFields()) r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.MovieUpdatePost, input, translator.getFields()) group, err = r.getGroup(ctx, group.ID) if err != nil { return nil, err } newRet = append(newRet, group) } return newRet, nil } func (r *mutationResolver) MovieDestroy(ctx context.Context, input MovieDestroyInput) (bool, error) { id, err := strconv.Atoi(input.ID) if err != nil { return false, fmt.Errorf("converting id: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { return r.repository.Group.Destroy(ctx, id) }); err != nil { return false, err } // for backwards compatibility - run both movie and group hooks r.hookExecutor.ExecutePostHooks(ctx, id, hook.GroupDestroyPost, input, nil) r.hookExecutor.ExecutePostHooks(ctx, id, hook.MovieDestroyPost, input, nil) return true, nil } func (r *mutationResolver) MoviesDestroy(ctx context.Context, groupIDs []string) (bool, error) { ids, err := stringslice.StringSliceToIntSlice(groupIDs) if err != nil { return false, fmt.Errorf("converting ids: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Group for _, id := range ids { if err := qb.Destroy(ctx, id); err != nil { return err } } return nil }); err != nil { return false, err } for _, id := range ids { // for backwards compatibility - run both movie and group hooks r.hookExecutor.ExecutePostHooks(ctx, id, hook.GroupDestroyPost, groupIDs, nil) r.hookExecutor.ExecutePostHooks(ctx, id, hook.MovieDestroyPost, groupIDs, nil) } return true, nil } ================================================ FILE: internal/api/resolver_mutation_package.go ================================================ package api import ( "context" "strconv" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager/task" "github.com/stashapp/stash/pkg/models" ) func refreshPackageType(typeArg PackageType) { mgr := manager.GetInstance() if typeArg == PackageTypePlugin { mgr.RefreshPluginCache() } else if typeArg == PackageTypeScraper { mgr.RefreshScraperCache() } } func (r *mutationResolver) InstallPackages(ctx context.Context, typeArg PackageType, packages []*models.PackageSpecInput) (string, error) { pm, err := getPackageManager(typeArg) if err != nil { return "", err } mgr := manager.GetInstance() t := &task.InstallPackagesJob{ PackagesJob: task.PackagesJob{ PackageManager: pm, OnComplete: func() { refreshPackageType(typeArg) }, }, Packages: packages, } jobID := mgr.JobManager.Add(ctx, "Installing packages...", t) return strconv.Itoa(jobID), nil } func (r *mutationResolver) UpdatePackages(ctx context.Context, typeArg PackageType, packages []*models.PackageSpecInput) (string, error) { pm, err := getPackageManager(typeArg) if err != nil { return "", err } mgr := manager.GetInstance() t := &task.UpdatePackagesJob{ PackagesJob: task.PackagesJob{ PackageManager: pm, OnComplete: func() { refreshPackageType(typeArg) }, }, Packages: packages, } jobID := mgr.JobManager.Add(ctx, "Updating packages...", t) return strconv.Itoa(jobID), nil } func (r *mutationResolver) UninstallPackages(ctx context.Context, typeArg PackageType, packages []*models.PackageSpecInput) (string, error) { pm, err := getPackageManager(typeArg) if err != nil { return "", err } mgr := manager.GetInstance() t := &task.UninstallPackagesJob{ PackagesJob: task.PackagesJob{ PackageManager: pm, OnComplete: func() { refreshPackageType(typeArg) }, }, Packages: packages, } jobID := mgr.JobManager.Add(ctx, "Updating packages...", t) return strconv.Itoa(jobID), nil } ================================================ FILE: internal/api/resolver_mutation_performer.go ================================================ package api import ( "context" "errors" "fmt" "slices" "strconv" "strings" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/utils" ) const ( twitterURL = "https://twitter.com" instagramURL = "https://instagram.com" ) // used to refetch performer after hooks run func (r *mutationResolver) getPerformer(ctx context.Context, id int) (ret *models.Performer, err error) { if err := r.withTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Performer.Find(ctx, id) return err }); err != nil { return nil, err } return ret, nil } func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.PerformerCreateInput) (*models.Performer, error) { translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate a new performer from the input newPerformer := models.NewPerformer() newPerformer.Name = strings.TrimSpace(input.Name) newPerformer.Disambiguation = translator.string(input.Disambiguation) newPerformer.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.AliasList), newPerformer.Name)) newPerformer.Gender = input.Gender newPerformer.Ethnicity = translator.string(input.Ethnicity) newPerformer.Country = translator.string(input.Country) newPerformer.EyeColor = translator.string(input.EyeColor) newPerformer.Measurements = translator.string(input.Measurements) newPerformer.FakeTits = translator.string(input.FakeTits) newPerformer.PenisLength = input.PenisLength newPerformer.Circumcised = input.Circumcised newPerformer.Tattoos = translator.string(input.Tattoos) newPerformer.Piercings = translator.string(input.Piercings) newPerformer.Favorite = translator.bool(input.Favorite) newPerformer.Rating = input.Rating100 newPerformer.Details = translator.string(input.Details) newPerformer.HairColor = translator.string(input.HairColor) newPerformer.Height = input.HeightCm newPerformer.Weight = input.Weight newPerformer.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag) newPerformer.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs()) newPerformer.URLs = models.NewRelatedStrings([]string{}) if input.URL != nil { newPerformer.URLs.Add(strings.TrimSpace(*input.URL)) } if input.Twitter != nil { newPerformer.URLs.Add(utils.URLFromHandle(strings.TrimSpace(*input.Twitter), twitterURL)) } if input.Instagram != nil { newPerformer.URLs.Add(utils.URLFromHandle(strings.TrimSpace(*input.Instagram), instagramURL)) } if input.Urls != nil { newPerformer.URLs.Add(stringslice.TrimSpace(input.Urls)...) } var err error newPerformer.Birthdate, err = translator.datePtr(input.Birthdate) if err != nil { return nil, fmt.Errorf("converting birthdate: %w", err) } newPerformer.DeathDate, err = translator.datePtr(input.DeathDate) if err != nil { return nil, fmt.Errorf("converting death date: %w", err) } newPerformer.CareerStart, err = translator.datePtr(input.CareerStart) if err != nil { return nil, fmt.Errorf("converting career start: %w", err) } newPerformer.CareerEnd, err = translator.datePtr(input.CareerEnd) if err != nil { return nil, fmt.Errorf("converting career end: %w", err) } // if career_start/career_end not provided, parse deprecated career_length if newPerformer.CareerStart == nil && newPerformer.CareerEnd == nil && input.CareerLength != nil { start, end, err := models.ParseYearRangeString(*input.CareerLength) if err != nil { return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err) } newPerformer.CareerStart = start newPerformer.CareerEnd = end } newPerformer.TagIDs, err = translator.relatedIds(input.TagIds) if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } // Process the base 64 encoded image string var imageData []byte if input.Image != nil { imageData, err = utils.ProcessImageInput(ctx, *input.Image) if err != nil { return nil, fmt.Errorf("processing image: %w", err) } } // Start the transaction and save the performer if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Performer if err := performer.ValidateCreate(ctx, newPerformer, qb); err != nil { return err } i := &models.CreatePerformerInput{ Performer: &newPerformer, // convert json.Numbers to int/float CustomFields: convertMapJSONNumbers(input.CustomFields), } err = qb.Create(ctx, i) if err != nil { return err } // update image table if len(imageData) > 0 { if err := qb.UpdateImage(ctx, newPerformer.ID, imageData); err != nil { return err } } return nil }); err != nil { return nil, err } r.hookExecutor.ExecutePostHooks(ctx, newPerformer.ID, hook.PerformerCreatePost, input, nil) return r.getPerformer(ctx, newPerformer.ID) } func validateNoLegacyURLs(translator changesetTranslator) error { // ensure url/twitter/instagram are not included in the input if translator.hasField("url") { return fmt.Errorf("url field must not be included if urls is included") } if translator.hasField("twitter") { return fmt.Errorf("twitter field must not be included if urls is included") } if translator.hasField("instagram") { return fmt.Errorf("instagram field must not be included if urls is included") } return nil } func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURLs legacyPerformerURLs, updatedPerformer *models.PerformerPartial) error { qb := r.repository.Performer // we need to be careful with URL/Twitter/Instagram // treat URL as replacing the first non-Twitter/Instagram URL in the list // twitter should replace any existing twitter URL // instagram should replace any existing instagram URL p, err := qb.Find(ctx, performerID) if err != nil { return err } if err := p.LoadURLs(ctx, qb); err != nil { return fmt.Errorf("loading performer URLs: %w", err) } existingURLs := p.URLs.List() // performer partial URLs should be empty if legacyURLs.URL.Set { replaced := false for i, url := range existingURLs { if !performer.IsTwitterURL(url) && !performer.IsInstagramURL(url) { existingURLs[i] = legacyURLs.URL.Value replaced = true break } } if !replaced { existingURLs = append(existingURLs, legacyURLs.URL.Value) } } if legacyURLs.Twitter.Set { value := utils.URLFromHandle(legacyURLs.Twitter.Value, twitterURL) found := false // find and replace the first twitter URL for i, url := range existingURLs { if performer.IsTwitterURL(url) { existingURLs[i] = value found = true break } } if !found { existingURLs = append(existingURLs, value) } } if legacyURLs.Instagram.Set { found := false value := utils.URLFromHandle(legacyURLs.Instagram.Value, instagramURL) // find and replace the first instagram URL for i, url := range existingURLs { if performer.IsInstagramURL(url) { existingURLs[i] = value found = true break } } if !found { existingURLs = append(existingURLs, value) } } updatedPerformer.URLs = &models.UpdateStrings{ Values: existingURLs, Mode: models.RelationshipUpdateModeSet, } return nil } type legacyPerformerURLs struct { URL models.OptionalString Twitter models.OptionalString Instagram models.OptionalString } func (u *legacyPerformerURLs) AnySet() bool { return u.URL.Set || u.Twitter.Set || u.Instagram.Set } func legacyPerformerURLsFromInput(input models.PerformerUpdateInput, translator changesetTranslator) legacyPerformerURLs { return legacyPerformerURLs{ URL: translator.optionalString(input.URL, "url"), Twitter: translator.optionalString(input.Twitter, "twitter"), Instagram: translator.optionalString(input.Instagram, "instagram"), } } func performerPartialFromInput(input models.PerformerUpdateInput, translator changesetTranslator) (*models.PerformerPartial, error) { // Populate performer from the input updatedPerformer := models.NewPerformerPartial() updatedPerformer.Name = translator.optionalString(input.Name, "name") updatedPerformer.Disambiguation = translator.optionalString(input.Disambiguation, "disambiguation") updatedPerformer.Gender = translator.optionalString((*string)(input.Gender), "gender") updatedPerformer.Ethnicity = translator.optionalString(input.Ethnicity, "ethnicity") updatedPerformer.Country = translator.optionalString(input.Country, "country") updatedPerformer.EyeColor = translator.optionalString(input.EyeColor, "eye_color") updatedPerformer.Measurements = translator.optionalString(input.Measurements, "measurements") updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits") updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length") updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised") // prefer career_start/career_end over deprecated career_length if translator.hasField("career_start") || translator.hasField("career_end") { var err error updatedPerformer.CareerStart, err = translator.optionalDate(input.CareerStart, "career_start") if err != nil { return nil, fmt.Errorf("converting career start: %w", err) } updatedPerformer.CareerEnd, err = translator.optionalDate(input.CareerEnd, "career_end") if err != nil { return nil, fmt.Errorf("converting career end: %w", err) } } else if translator.hasField("career_length") && input.CareerLength != nil { start, end, err := models.ParseYearRangeString(*input.CareerLength) if err != nil { return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err) } if start != nil { updatedPerformer.CareerStart = models.NewOptionalDate(*start) } if end != nil { updatedPerformer.CareerEnd = models.NewOptionalDate(*end) } } updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite") updatedPerformer.Rating = translator.optionalInt(input.Rating100, "rating100") updatedPerformer.Details = translator.optionalString(input.Details, "details") updatedPerformer.HairColor = translator.optionalString(input.HairColor, "hair_color") updatedPerformer.Weight = translator.optionalInt(input.Weight, "weight") updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") updatedPerformer.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids") var err error if translator.hasField("urls") { // ensure url/twitter/instagram are not included in the input if err := validateNoLegacyURLs(translator); err != nil { return nil, err } updatedPerformer.URLs = translator.updateStrings(input.Urls, "urls") } updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate") if err != nil { return nil, fmt.Errorf("converting birthdate: %w", err) } updatedPerformer.DeathDate, err = translator.optionalDate(input.DeathDate, "death_date") if err != nil { return nil, fmt.Errorf("converting death date: %w", err) } // prefer height_cm over height if translator.hasField("height_cm") { updatedPerformer.Height = translator.optionalInt(input.HeightCm, "height_cm") } // prefer alias_list over aliases if translator.hasField("alias_list") { updatedPerformer.Aliases = translator.updateStrings(input.AliasList, "alias_list") } updatedPerformer.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids") if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } updatedPerformer.CustomFields = handleUpdateCustomFields(input.CustomFields) return &updatedPerformer, nil } func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) { performerID, err := strconv.Atoi(input.ID) if err != nil { return nil, fmt.Errorf("converting id: %w", err) } translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } updatedPerformer, err := performerPartialFromInput(input, translator) if err != nil { return nil, err } legacyURLs := legacyPerformerURLsFromInput(input, translator) var imageData []byte imageIncluded := translator.hasField("image") if input.Image != nil { imageData, err = utils.ProcessImageInput(ctx, *input.Image) if err != nil { return nil, fmt.Errorf("processing image: %w", err) } } // Start the transaction and save the performer if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Performer if legacyURLs.AnySet() { if err := r.handleLegacyURLs(ctx, performerID, legacyURLs, updatedPerformer); err != nil { return err } } if updatedPerformer.Aliases != nil { p, err := qb.Find(ctx, performerID) if err != nil { return err } if p != nil { if err := p.LoadAliases(ctx, qb); err != nil { return err } effectiveAliases := updatedPerformer.Aliases.Apply(p.Aliases.List()) name := p.Name if updatedPerformer.Name.Set { name = updatedPerformer.Name.Value } sanitized := stringslice.UniqueExcludeFold(effectiveAliases, name) updatedPerformer.Aliases.Values = sanitized updatedPerformer.Aliases.Mode = models.RelationshipUpdateModeSet } } if err := performer.ValidateUpdate(ctx, performerID, *updatedPerformer, qb); err != nil { return err } _, err = qb.UpdatePartial(ctx, performerID, *updatedPerformer) if err != nil { return err } // update image table if imageIncluded { if err := qb.UpdateImage(ctx, performerID, imageData); err != nil { return err } } return nil }); err != nil { return nil, err } r.hookExecutor.ExecutePostHooks(ctx, performerID, hook.PerformerUpdatePost, input, translator.getFields()) return r.getPerformer(ctx, performerID) } func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPerformerUpdateInput) ([]*models.Performer, error) { performerIDs, err := stringslice.StringSliceToIntSlice(input.Ids) if err != nil { return nil, fmt.Errorf("converting ids: %w", err) } translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate performer from the input updatedPerformer := models.NewPerformerPartial() updatedPerformer.Disambiguation = translator.optionalString(input.Disambiguation, "disambiguation") updatedPerformer.Gender = translator.optionalString((*string)(input.Gender), "gender") updatedPerformer.Ethnicity = translator.optionalString(input.Ethnicity, "ethnicity") updatedPerformer.Country = translator.optionalString(input.Country, "country") updatedPerformer.EyeColor = translator.optionalString(input.EyeColor, "eye_color") updatedPerformer.Measurements = translator.optionalString(input.Measurements, "measurements") updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits") updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length") updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised") // prefer career_start/career_end over deprecated career_length if translator.hasField("career_start") || translator.hasField("career_end") { updatedPerformer.CareerStart, err = translator.optionalDate(input.CareerStart, "career_start") if err != nil { return nil, fmt.Errorf("converting career start: %w", err) } updatedPerformer.CareerEnd, err = translator.optionalDate(input.CareerEnd, "career_end") if err != nil { return nil, fmt.Errorf("converting career end: %w", err) } } else if translator.hasField("career_length") && input.CareerLength != nil { start, end, err := models.ParseYearRangeString(*input.CareerLength) if err != nil { return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err) } if start != nil { updatedPerformer.CareerStart = models.NewOptionalDate(*start) } if end != nil { updatedPerformer.CareerEnd = models.NewOptionalDate(*end) } } updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite") updatedPerformer.Rating = translator.optionalInt(input.Rating100, "rating100") updatedPerformer.Details = translator.optionalString(input.Details, "details") updatedPerformer.HairColor = translator.optionalString(input.HairColor, "hair_color") updatedPerformer.Weight = translator.optionalInt(input.Weight, "weight") updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") if translator.hasField("urls") { // ensure url/twitter/instagram are not included in the input if err := validateNoLegacyURLs(translator); err != nil { return nil, err } updatedPerformer.URLs = translator.updateStringsBulk(input.Urls, "urls") } legacyURLs := legacyPerformerURLs{ URL: translator.optionalString(input.URL, "url"), Twitter: translator.optionalString(input.Twitter, "twitter"), Instagram: translator.optionalString(input.Instagram, "instagram"), } updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate") if err != nil { return nil, fmt.Errorf("converting birthdate: %w", err) } updatedPerformer.DeathDate, err = translator.optionalDate(input.DeathDate, "death_date") if err != nil { return nil, fmt.Errorf("converting death date: %w", err) } // prefer height_cm over height if translator.hasField("height_cm") { updatedPerformer.Height = translator.optionalInt(input.HeightCm, "height_cm") } // prefer alias_list over aliases if translator.hasField("alias_list") { updatedPerformer.Aliases = translator.updateStringsBulk(input.AliasList, "alias_list") } updatedPerformer.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids") if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } if input.CustomFields != nil { updatedPerformer.CustomFields = handleUpdateCustomFields(*input.CustomFields) } ret := []*models.Performer{} // Start the transaction and save the performers if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Performer for _, performerID := range performerIDs { if legacyURLs.AnySet() { if err := r.handleLegacyURLs(ctx, performerID, legacyURLs, &updatedPerformer); err != nil { return err } } if err := performer.ValidateUpdate(ctx, performerID, updatedPerformer, qb); err != nil { return err } performer, err := qb.UpdatePartial(ctx, performerID, updatedPerformer) if err != nil { return err } ret = append(ret, performer) } return nil }); err != nil { return nil, err } // execute post hooks outside of txn var newRet []*models.Performer for _, performer := range ret { r.hookExecutor.ExecutePostHooks(ctx, performer.ID, hook.PerformerUpdatePost, input, translator.getFields()) performer, err = r.getPerformer(ctx, performer.ID) if err != nil { return nil, err } newRet = append(newRet, performer) } return newRet, nil } func (r *mutationResolver) PerformerDestroy(ctx context.Context, input PerformerDestroyInput) (bool, error) { id, err := strconv.Atoi(input.ID) if err != nil { return false, fmt.Errorf("converting id: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { return r.repository.Performer.Destroy(ctx, id) }); err != nil { return false, err } r.hookExecutor.ExecutePostHooks(ctx, id, hook.PerformerDestroyPost, input, nil) return true, nil } func (r *mutationResolver) PerformersDestroy(ctx context.Context, performerIDs []string) (bool, error) { ids, err := stringslice.StringSliceToIntSlice(performerIDs) if err != nil { return false, fmt.Errorf("converting ids: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Performer for _, id := range ids { if err := qb.Destroy(ctx, id); err != nil { return err } } return nil }); err != nil { return false, err } for _, id := range ids { r.hookExecutor.ExecutePostHooks(ctx, id, hook.PerformerDestroyPost, performerIDs, nil) } return true, nil } func (r *mutationResolver) PerformerMerge(ctx context.Context, input PerformerMergeInput) (*models.Performer, error) { srcIDs, err := stringslice.StringSliceToIntSlice(input.Source) if err != nil { return nil, fmt.Errorf("converting source ids: %w", err) } // ensure source ids are unique srcIDs = sliceutil.AppendUniques(nil, srcIDs) destID, err := strconv.Atoi(input.Destination) if err != nil { return nil, fmt.Errorf("converting destination id: %w", err) } // ensure destination is not in source list if slices.Contains(srcIDs, destID) { return nil, errors.New("destination performer cannot be in source list") } var values *models.PerformerPartial var imageData []byte if input.Values != nil { translator := changesetTranslator{ inputMap: getNamedUpdateInputMap(ctx, "input.values"), } values, err = performerPartialFromInput(*input.Values, translator) if err != nil { return nil, err } legacyURLs := legacyPerformerURLsFromInput(*input.Values, translator) if legacyURLs.AnySet() { return nil, errors.New("Merging legacy performer URLs is not supported") } if input.Values.Image != nil { var err error imageData, err = utils.ProcessImageInput(ctx, *input.Values.Image) if err != nil { return nil, fmt.Errorf("processing cover image: %w", err) } } } else { v := models.NewPerformerPartial() values = &v } var dest *models.Performer if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Performer dest, err = qb.Find(ctx, destID) if err != nil { return fmt.Errorf("finding destination performer ID %d: %w", destID, err) } // ensure source performers exist if _, err := qb.FindMany(ctx, srcIDs); err != nil { return fmt.Errorf("finding source performers: %w", err) } if _, err := qb.UpdatePartial(ctx, destID, *values); err != nil { return fmt.Errorf("updating performer: %w", err) } if err := qb.Merge(ctx, srcIDs, destID); err != nil { return fmt.Errorf("merging performers: %w", err) } if len(imageData) > 0 { if err := qb.UpdateImage(ctx, destID, imageData); err != nil { return err } } return nil }); err != nil { return nil, err } return dest, nil } ================================================ FILE: internal/api/resolver_mutation_plugin.go ================================================ package api import ( "context" "strconv" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/sliceutil" ) func toPluginArgs(args []*plugin.PluginArgInput) plugin.OperationInput { ret := make(plugin.OperationInput) for _, a := range args { ret[a.Key] = toPluginArgValue(a.Value) } return ret } func toPluginArgValue(arg *plugin.PluginValueInput) interface{} { if arg == nil { return nil } switch { case arg.Str != nil: return *arg.Str case arg.I != nil: return *arg.I case arg.B != nil: return *arg.B case arg.F != nil: return *arg.F case arg.O != nil: return toPluginArgs(arg.O) case arg.A != nil: var ret []interface{} for _, v := range arg.A { ret = append(ret, toPluginArgValue(v)) } return ret } return nil } func (r *mutationResolver) RunPluginTask( ctx context.Context, pluginID string, taskName *string, description *string, args []*plugin.PluginArgInput, argsMap map[string]interface{}, ) (string, error) { if argsMap == nil { // convert args to map // otherwise ignore args in favour of args map argsMap = toPluginArgs(args) } m := manager.GetInstance() jobID := m.RunPluginTask(ctx, pluginID, taskName, description, argsMap) return strconv.Itoa(jobID), nil } func (r *mutationResolver) RunPluginOperation( ctx context.Context, pluginID string, args map[string]interface{}, ) (interface{}, error) { if args == nil { args = make(map[string]interface{}) } m := manager.GetInstance() return m.PluginCache.RunPlugin(ctx, pluginID, args) } func (r *mutationResolver) ReloadPlugins(ctx context.Context) (bool, error) { manager.GetInstance().RefreshPluginCache() return true, nil } func (r *mutationResolver) SetPluginsEnabled(ctx context.Context, enabledMap map[string]bool) (bool, error) { c := config.GetInstance() existingDisabled := c.GetDisabledPlugins() var newDisabled []string // remove plugins that are no longer disabled for _, disabledID := range existingDisabled { if enabled, found := enabledMap[disabledID]; !enabled || !found { newDisabled = append(newDisabled, disabledID) } } // add plugins that are newly disabled for pluginID, enabled := range enabledMap { if !enabled { newDisabled = sliceutil.AppendUnique(newDisabled, pluginID) } } c.SetInterface(config.DisabledPlugins, newDisabled) if err := c.Write(); err != nil { return false, err } return true, nil } ================================================ FILE: internal/api/resolver_mutation_saved_filter.go ================================================ package api import ( "context" "errors" "fmt" "strconv" "strings" "github.com/mitchellh/mapstructure" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) func (r *mutationResolver) SaveFilter(ctx context.Context, input SaveFilterInput) (ret *models.SavedFilter, err error) { if strings.TrimSpace(input.Name) == "" { return nil, errors.New("name must be non-empty") } var id *int if input.ID != nil { idv, err := strconv.Atoi(*input.ID) if err != nil { return nil, fmt.Errorf("converting id: %w", err) } id = &idv } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.SavedFilter f := models.SavedFilter{ Mode: input.Mode, Name: strings.TrimSpace(input.Name), FindFilter: input.FindFilter, ObjectFilter: input.ObjectFilter, UIOptions: input.UIOptions, } if id == nil { err = qb.Create(ctx, &f) ret = &f } else { f.ID = *id err = qb.Update(ctx, &f) ret = &f } return err }); err != nil { return nil, err } return ret, err } func (r *mutationResolver) DestroySavedFilter(ctx context.Context, input DestroyFilterInput) (bool, error) { id, err := strconv.Atoi(input.ID) if err != nil { return false, fmt.Errorf("converting id: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { return r.repository.SavedFilter.Destroy(ctx, id) }); err != nil { return false, err } return true, nil } func (r *mutationResolver) SetDefaultFilter(ctx context.Context, input SetDefaultFilterInput) (bool, error) { // deprecated - write to the config in the meantime config := config.GetInstance() uiConfig := config.GetUIConfiguration() if uiConfig == nil { uiConfig = make(map[string]interface{}) } m := utils.NestedMap(uiConfig) if input.FindFilter == nil && input.ObjectFilter == nil && input.UIOptions == nil { // clearing m.Delete("defaultFilters." + strings.ToLower(input.Mode.String())) config.SetUIConfiguration(m) if err := config.Write(); err != nil { return false, err } return true, nil } subMap := make(map[string]interface{}) d, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ TagName: "json", WeaklyTypedInput: true, Result: &subMap, }) if err != nil { return false, err } if err := d.Decode(input); err != nil { return false, err } m.Set("defaultFilters."+strings.ToLower(input.Mode.String()), subMap) config.SetUIConfiguration(m) if err := config.Write(); err != nil { return false, err } return true, nil } ================================================ FILE: internal/api/resolver_mutation_scene.go ================================================ package api import ( "context" "errors" "fmt" "strconv" "strings" "time" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/utils" ) // used to refetch scene after hooks run func (r *mutationResolver) getScene(ctx context.Context, id int) (ret *models.Scene, err error) { if err := r.withTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Scene.Find(ctx, id) return err }); err != nil { return nil, err } return ret, nil } func (r *mutationResolver) SceneCreate(ctx context.Context, input models.SceneCreateInput) (ret *models.Scene, err error) { translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } fileIDs, err := translator.fileIDSliceFromStringSlice(input.FileIds) if err != nil { return nil, fmt.Errorf("converting file ids: %w", err) } // Populate a new scene from the input newScene := models.NewScene() newScene.Title = translator.string(input.Title) newScene.Code = translator.string(input.Code) newScene.Details = translator.string(input.Details) newScene.Director = translator.string(input.Director) newScene.Rating = input.Rating100 newScene.Organized = translator.bool(input.Organized) newScene.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs()) newScene.Date, err = translator.datePtr(input.Date) if err != nil { return nil, fmt.Errorf("converting date: %w", err) } newScene.StudioID, err = translator.intPtrFromString(input.StudioID) if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) } if input.Urls != nil { newScene.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls)) } else if input.URL != nil { newScene.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)}) } newScene.PerformerIDs, err = translator.relatedIds(input.PerformerIds) if err != nil { return nil, fmt.Errorf("converting performer ids: %w", err) } newScene.TagIDs, err = translator.relatedIds(input.TagIds) if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } newScene.GalleryIDs, err = translator.relatedIds(input.GalleryIds) if err != nil { return nil, fmt.Errorf("converting gallery ids: %w", err) } // prefer groups over movies if len(input.Groups) > 0 { newScene.Groups, err = translator.relatedGroups(input.Groups) if err != nil { return nil, fmt.Errorf("converting groups: %w", err) } } else if len(input.Movies) > 0 { newScene.Groups, err = translator.relatedGroupsFromMovies(input.Movies) if err != nil { return nil, fmt.Errorf("converting movies: %w", err) } } var coverImageData []byte if input.CoverImage != nil { var err error coverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage) if err != nil { return nil, fmt.Errorf("processing cover image: %w", err) } } customFields := convertMapJSONNumbers(input.CustomFields) if err := r.withTxn(ctx, func(ctx context.Context) error { ret, err = r.Resolver.sceneService.Create(ctx, models.CreateSceneInput{ Scene: &newScene, FileIDs: fileIDs, CoverImage: coverImageData, CustomFields: customFields, }) return err }); err != nil { return nil, err } return ret, nil } func (r *mutationResolver) SceneUpdate(ctx context.Context, input models.SceneUpdateInput) (ret *models.Scene, err error) { translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Start the transaction and save the scene if err := r.withTxn(ctx, func(ctx context.Context) error { ret, err = r.sceneUpdate(ctx, input, translator) return err }); err != nil { return nil, err } r.hookExecutor.ExecutePostHooks(ctx, ret.ID, hook.SceneUpdatePost, input, translator.getFields()) return r.getScene(ctx, ret.ID) } func (r *mutationResolver) ScenesUpdate(ctx context.Context, input []*models.SceneUpdateInput) (ret []*models.Scene, err error) { inputMaps := getUpdateInputMaps(ctx) // Start the transaction and save the scenes if err := r.withTxn(ctx, func(ctx context.Context) error { for i, scene := range input { translator := changesetTranslator{ inputMap: inputMaps[i], } thisScene, err := r.sceneUpdate(ctx, *scene, translator) if err != nil { return err } ret = append(ret, thisScene) } return nil }); err != nil { return nil, err } // execute post hooks outside of txn var newRet []*models.Scene for i, scene := range ret { translator := changesetTranslator{ inputMap: inputMaps[i], } r.hookExecutor.ExecutePostHooks(ctx, scene.ID, hook.SceneUpdatePost, input, translator.getFields()) scene, err = r.getScene(ctx, scene.ID) if err != nil { return nil, err } newRet = append(newRet, scene) } return newRet, nil } func scenePartialFromInput(input models.SceneUpdateInput, translator changesetTranslator) (*models.ScenePartial, error) { updatedScene := models.NewScenePartial() updatedScene.Title = translator.optionalString(input.Title, "title") updatedScene.Code = translator.optionalString(input.Code, "code") updatedScene.Details = translator.optionalString(input.Details, "details") updatedScene.Director = translator.optionalString(input.Director, "director") updatedScene.Rating = translator.optionalInt(input.Rating100, "rating100") if input.OCounter != nil { logger.Warnf("o_counter is deprecated and no longer supported, use sceneIncrementO/sceneDecrementO instead") } if input.PlayCount != nil { logger.Warnf("play_count is deprecated and no longer supported, use sceneIncrementPlayCount/sceneDecrementPlayCount instead") } updatedScene.PlayDuration = translator.optionalFloat64(input.PlayDuration, "play_duration") updatedScene.Organized = translator.optionalBool(input.Organized, "organized") updatedScene.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids") var err error updatedScene.Date, err = translator.optionalDate(input.Date, "date") if err != nil { return nil, fmt.Errorf("converting date: %w", err) } updatedScene.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) } updatedScene.URLs = translator.optionalURLs(input.Urls, input.URL) updatedScene.PrimaryFileID, err = translator.fileIDPtrFromString(input.PrimaryFileID) if err != nil { return nil, fmt.Errorf("converting primary file id: %w", err) } updatedScene.PerformerIDs, err = translator.updateIds(input.PerformerIds, "performer_ids") if err != nil { return nil, fmt.Errorf("converting performer ids: %w", err) } updatedScene.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids") if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } updatedScene.GalleryIDs, err = translator.updateIds(input.GalleryIds, "gallery_ids") if err != nil { return nil, fmt.Errorf("converting gallery ids: %w", err) } if translator.hasField("groups") { updatedScene.GroupIDs, err = translator.updateGroupIDs(input.Groups, "groups") if err != nil { return nil, fmt.Errorf("converting groups: %w", err) } } else if translator.hasField("movies") { updatedScene.GroupIDs, err = translator.updateGroupIDsFromMovies(input.Movies, "movies") if err != nil { return nil, fmt.Errorf("converting movies: %w", err) } } return &updatedScene, nil } func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUpdateInput, translator changesetTranslator) (*models.Scene, error) { sceneID, err := strconv.Atoi(input.ID) if err != nil { return nil, fmt.Errorf("converting id: %w", err) } qb := r.repository.Scene originalScene, err := qb.Find(ctx, sceneID) if err != nil { return nil, err } if originalScene == nil { return nil, fmt.Errorf("scene with id %d not found", sceneID) } // Populate scene from the input updatedScene, err := scenePartialFromInput(input, translator) if err != nil { return nil, err } // ensure that title is set where scene has no file if updatedScene.Title.Set && updatedScene.Title.Value == "" { if err := originalScene.LoadFiles(ctx, r.repository.Scene); err != nil { return nil, err } if len(originalScene.Files.List()) == 0 { return nil, errors.New("title must be set if scene has no files") } } if updatedScene.PrimaryFileID != nil { newPrimaryFileID := *updatedScene.PrimaryFileID // if file hash has changed, we should migrate generated files // after commit if err := originalScene.LoadFiles(ctx, r.repository.Scene); err != nil { return nil, err } // ensure that new primary file is associated with scene var f *models.VideoFile for _, ff := range originalScene.Files.List() { if ff.ID == newPrimaryFileID { f = ff } } if f == nil { return nil, fmt.Errorf("file with id %d not associated with scene", newPrimaryFileID) } } var coverImageData []byte coverImageIncluded := translator.hasField("cover_image") if input.CoverImage != nil { var err error coverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage) if err != nil { return nil, fmt.Errorf("processing cover image: %w", err) } } var customFields *models.CustomFieldsInput if input.CustomFields != nil { cfCopy := *input.CustomFields customFields = &cfCopy // convert json.Numbers to int/float customFields.Full = convertMapJSONNumbers(customFields.Full) customFields.Partial = convertMapJSONNumbers(customFields.Partial) } scene, err := qb.UpdatePartial(ctx, sceneID, *updatedScene) if err != nil { return nil, err } if coverImageIncluded { if err := r.sceneUpdateCoverImage(ctx, scene, coverImageData); err != nil { return nil, err } } if customFields != nil { if err := qb.SetCustomFields(ctx, scene.ID, *customFields); err != nil { return nil, err } } return scene, nil } func (r *mutationResolver) sceneUpdateCoverImage(ctx context.Context, s *models.Scene, coverImageData []byte) error { qb := r.repository.Scene // update cover table - empty data will clear the cover if err := qb.UpdateCover(ctx, s.ID, coverImageData); err != nil { return err } return nil } func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneUpdateInput) ([]*models.Scene, error) { sceneIDs, err := stringslice.StringSliceToIntSlice(input.Ids) if err != nil { return nil, fmt.Errorf("converting ids: %w", err) } translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate scene from the input updatedScene := models.NewScenePartial() updatedScene.Title = translator.optionalString(input.Title, "title") updatedScene.Code = translator.optionalString(input.Code, "code") updatedScene.Details = translator.optionalString(input.Details, "details") updatedScene.Director = translator.optionalString(input.Director, "director") updatedScene.Rating = translator.optionalInt(input.Rating100, "rating100") updatedScene.Organized = translator.optionalBool(input.Organized, "organized") updatedScene.Date, err = translator.optionalDate(input.Date, "date") if err != nil { return nil, fmt.Errorf("converting date: %w", err) } updatedScene.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) } updatedScene.URLs = translator.optionalURLsBulk(input.Urls, input.URL) updatedScene.PerformerIDs, err = translator.updateIdsBulk(input.PerformerIds, "performer_ids") if err != nil { return nil, fmt.Errorf("converting performer ids: %w", err) } updatedScene.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids") if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } updatedScene.GalleryIDs, err = translator.updateIdsBulk(input.GalleryIds, "gallery_ids") if err != nil { return nil, fmt.Errorf("converting gallery ids: %w", err) } if translator.hasField("group_ids") { updatedScene.GroupIDs, err = translator.updateGroupIDsBulk(input.GroupIds, "group_ids") if err != nil { return nil, fmt.Errorf("converting group ids: %w", err) } } else if translator.hasField("movie_ids") { updatedScene.GroupIDs, err = translator.updateGroupIDsBulk(input.MovieIds, "movie_ids") if err != nil { return nil, fmt.Errorf("converting movie ids: %w", err) } } var customFields *models.CustomFieldsInput if input.CustomFields != nil { cf := handleUpdateCustomFields(*input.CustomFields) customFields = &cf } ret := []*models.Scene{} // Start the transaction and save the scenes if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene for _, sceneID := range sceneIDs { scene, err := qb.UpdatePartial(ctx, sceneID, updatedScene) if err != nil { return err } if customFields != nil { if err := qb.SetCustomFields(ctx, scene.ID, *customFields); err != nil { return err } } ret = append(ret, scene) } return nil }); err != nil { return nil, err } // execute post hooks outside of txn var newRet []*models.Scene for _, scene := range ret { r.hookExecutor.ExecutePostHooks(ctx, scene.ID, hook.SceneUpdatePost, input, translator.getFields()) scene, err = r.getScene(ctx, scene.ID) if err != nil { return nil, err } newRet = append(newRet, scene) } return newRet, nil } func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneDestroyInput) (bool, error) { sceneID, err := strconv.Atoi(input.ID) if err != nil { return false, fmt.Errorf("converting id: %w", err) } fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm() trashPath := manager.GetInstance().Config.GetDeleteTrashPath() var s *models.Scene fileDeleter := &scene.FileDeleter{ Deleter: file.NewDeleterWithTrash(trashPath), FileNamingAlgo: fileNamingAlgo, Paths: manager.GetInstance().Paths, } deleteGenerated := utils.IsTrue(input.DeleteGenerated) deleteFile := utils.IsTrue(input.DeleteFile) destroyFileEntry := utils.IsTrue(input.DestroyFileEntry) if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene var err error s, err = qb.Find(ctx, sceneID) if err != nil { return err } if s == nil { return fmt.Errorf("scene with id %d not found", sceneID) } // kill any running encoders manager.KillRunningStreams(s, fileNamingAlgo) return r.sceneService.Destroy(ctx, s, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry) }); err != nil { fileDeleter.Rollback() return false, err } // perform the post-commit actions fileDeleter.Commit() // call post hook after performing the other actions r.hookExecutor.ExecutePostHooks(ctx, s.ID, hook.SceneDestroyPost, plugin.SceneDestroyInput{ SceneDestroyInput: input, Checksum: s.Checksum, OSHash: s.OSHash, Path: s.Path, }, nil) return true, nil } func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.ScenesDestroyInput) (bool, error) { sceneIDs, err := stringslice.StringSliceToIntSlice(input.Ids) if err != nil { return false, fmt.Errorf("converting ids: %w", err) } var scenes []*models.Scene fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm() trashPath := manager.GetInstance().Config.GetDeleteTrashPath() fileDeleter := &scene.FileDeleter{ Deleter: file.NewDeleterWithTrash(trashPath), FileNamingAlgo: fileNamingAlgo, Paths: manager.GetInstance().Paths, } deleteGenerated := utils.IsTrue(input.DeleteGenerated) deleteFile := utils.IsTrue(input.DeleteFile) destroyFileEntry := utils.IsTrue(input.DestroyFileEntry) if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene for _, id := range sceneIDs { scene, err := qb.Find(ctx, id) if err != nil { return err } if scene == nil { return fmt.Errorf("scene with id %d not found", id) } scenes = append(scenes, scene) // kill any running encoders manager.KillRunningStreams(scene, fileNamingAlgo) if err := r.sceneService.Destroy(ctx, scene, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil { return err } } return nil }); err != nil { fileDeleter.Rollback() return false, err } // perform the post-commit actions fileDeleter.Commit() for _, scene := range scenes { // call post hook after performing the other actions r.hookExecutor.ExecutePostHooks(ctx, scene.ID, hook.SceneDestroyPost, plugin.ScenesDestroyInput{ ScenesDestroyInput: input, Checksum: scene.Checksum, OSHash: scene.OSHash, Path: scene.Path, }, nil) } return true, nil } func (r *mutationResolver) SceneAssignFile(ctx context.Context, input AssignSceneFileInput) (bool, error) { sceneID, err := strconv.Atoi(input.SceneID) if err != nil { return false, fmt.Errorf("converting scene id: %w", err) } fileID, err := strconv.Atoi(input.FileID) if err != nil { return false, fmt.Errorf("converting file id: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { return r.Resolver.sceneService.AssignFile(ctx, sceneID, models.FileID(fileID)) }); err != nil { return false, fmt.Errorf("assigning file to scene: %w", err) } return true, nil } func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput) (*models.Scene, error) { srcIDs, err := stringslice.StringSliceToIntSlice(input.Source) if err != nil { return nil, fmt.Errorf("converting source ids: %w", err) } destID, err := strconv.Atoi(input.Destination) if err != nil { return nil, fmt.Errorf("converting destination id: %w", err) } var values *models.ScenePartial var coverImageData []byte var customFields *models.CustomFieldsInput if input.Values != nil { translator := changesetTranslator{ inputMap: getNamedUpdateInputMap(ctx, "input.values"), } values, err = scenePartialFromInput(*input.Values, translator) if err != nil { return nil, err } if input.Values.CoverImage != nil { var err error coverImageData, err = utils.ProcessImageInput(ctx, *input.Values.CoverImage) if err != nil { return nil, fmt.Errorf("processing cover image: %w", err) } } if input.Values.CustomFields != nil { cf := handleUpdateCustomFields(*input.Values.CustomFields) customFields = &cf } } else { v := models.NewScenePartial() values = &v } mgr := manager.GetInstance() trashPath := mgr.Config.GetDeleteTrashPath() fileDeleter := &scene.FileDeleter{ Deleter: file.NewDeleterWithTrash(trashPath), FileNamingAlgo: mgr.Config.GetVideoFileNamingAlgorithm(), Paths: mgr.Paths, } var ret *models.Scene if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.Resolver.sceneService.Merge(ctx, srcIDs, destID, fileDeleter, scene.MergeOptions{ ScenePartial: *values, IncludePlayHistory: utils.IsTrue(input.PlayHistory), IncludeOHistory: utils.IsTrue(input.OHistory), }); err != nil { return err } ret, err = r.Resolver.repository.Scene.Find(ctx, destID) if err != nil { return err } if ret == nil { return fmt.Errorf("scene with id %d not found", destID) } // only update cover image if one was provided if len(coverImageData) > 0 { if err := r.sceneUpdateCoverImage(ctx, ret, coverImageData); err != nil { return err } } if customFields != nil { if err := r.Resolver.repository.Scene.SetCustomFields(ctx, ret.ID, *customFields); err != nil { return err } } return nil }); err != nil { return nil, err } return ret, nil } func (r *mutationResolver) getSceneMarker(ctx context.Context, id int) (ret *models.SceneMarker, err error) { if err := r.withTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.SceneMarker.Find(ctx, id) return err }); err != nil { return nil, err } return ret, nil } func (r *mutationResolver) SceneMarkerCreate(ctx context.Context, input SceneMarkerCreateInput) (*models.SceneMarker, error) { sceneID, err := strconv.Atoi(input.SceneID) if err != nil { return nil, fmt.Errorf("converting scene id: %w", err) } primaryTagID, err := strconv.Atoi(input.PrimaryTagID) if err != nil { return nil, fmt.Errorf("converting primary tag id: %w", err) } // Populate a new scene marker from the input newMarker := models.NewSceneMarker() newMarker.Title = strings.TrimSpace(input.Title) newMarker.Seconds = input.Seconds newMarker.PrimaryTagID = primaryTagID newMarker.SceneID = sceneID if input.EndSeconds != nil { if err := validateSceneMarkerEndSeconds(newMarker.Seconds, *input.EndSeconds); err != nil { return nil, err } newMarker.EndSeconds = input.EndSeconds } tagIDs, err := stringslice.StringSliceToIntSlice(input.TagIds) if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.SceneMarker err := qb.Create(ctx, &newMarker) if err != nil { return err } // Save the marker tags // If this tag is the primary tag, then let's not add it. tagIDs = sliceutil.Exclude(tagIDs, []int{newMarker.PrimaryTagID}) return qb.UpdateTags(ctx, newMarker.ID, tagIDs) }); err != nil { return nil, err } r.hookExecutor.ExecutePostHooks(ctx, newMarker.ID, hook.SceneMarkerCreatePost, input, nil) return r.getSceneMarker(ctx, newMarker.ID) } func validateSceneMarkerEndSeconds(seconds, endSeconds float64) error { if endSeconds < seconds { return fmt.Errorf("end_seconds (%f) must be greater than or equal to seconds (%f)", endSeconds, seconds) } return nil } func float64OrZero(f *float64) float64 { if f == nil { return 0 } return *f } func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMarkerUpdateInput) (*models.SceneMarker, error) { markerID, err := strconv.Atoi(input.ID) if err != nil { return nil, fmt.Errorf("converting id: %w", err) } translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate scene marker from the input updatedMarker := models.NewSceneMarkerPartial() updatedMarker.Title = translator.optionalString(input.Title, "title") updatedMarker.Seconds = translator.optionalFloat64(input.Seconds, "seconds") updatedMarker.EndSeconds = translator.optionalFloat64(input.EndSeconds, "end_seconds") updatedMarker.SceneID, err = translator.optionalIntFromString(input.SceneID, "scene_id") if err != nil { return nil, fmt.Errorf("converting scene id: %w", err) } updatedMarker.PrimaryTagID, err = translator.optionalIntFromString(input.PrimaryTagID, "primary_tag_id") if err != nil { return nil, fmt.Errorf("converting primary tag id: %w", err) } var tagIDs []int tagIdsIncluded := translator.hasField("tag_ids") if input.TagIds != nil { tagIDs, err = stringslice.StringSliceToIntSlice(input.TagIds) if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } } mgr := manager.GetInstance() trashPath := mgr.Config.GetDeleteTrashPath() fileDeleter := &scene.FileDeleter{ Deleter: file.NewDeleterWithTrash(trashPath), FileNamingAlgo: mgr.Config.GetVideoFileNamingAlgorithm(), Paths: mgr.Paths, } // Start the transaction and save the scene marker if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.SceneMarker sqb := r.repository.Scene // check to see if timestamp was changed existingMarker, err := qb.Find(ctx, markerID) if err != nil { return err } if existingMarker == nil { return fmt.Errorf("scene marker with id %d not found", markerID) } // Validate end_seconds shouldValidateEndSeconds := (updatedMarker.Seconds.Set || updatedMarker.EndSeconds.Set) && !updatedMarker.EndSeconds.Null if shouldValidateEndSeconds { seconds := existingMarker.Seconds if updatedMarker.Seconds.Set { seconds = updatedMarker.Seconds.Value } endSeconds := existingMarker.EndSeconds if updatedMarker.EndSeconds.Set { endSeconds = &updatedMarker.EndSeconds.Value } if endSeconds != nil { if err := validateSceneMarkerEndSeconds(seconds, *endSeconds); err != nil { return err } } } newMarker, err := qb.UpdatePartial(ctx, markerID, updatedMarker) if err != nil { return err } existingScene, err := sqb.Find(ctx, existingMarker.SceneID) if err != nil { return err } if existingScene == nil { return fmt.Errorf("scene with id %d not found", existingMarker.SceneID) } // remove the marker preview if the scene changed or if the timestamp was changed if existingMarker.SceneID != newMarker.SceneID || existingMarker.Seconds != newMarker.Seconds || float64OrZero(existingMarker.EndSeconds) != float64OrZero(newMarker.EndSeconds) { seconds := int(existingMarker.Seconds) if err := fileDeleter.MarkMarkerFiles(existingScene, seconds); err != nil { return err } } if tagIdsIncluded { // Save the marker tags // If this tag is the primary tag, then let's not add it. tagIDs = sliceutil.Exclude(tagIDs, []int{newMarker.PrimaryTagID}) if err := qb.UpdateTags(ctx, markerID, tagIDs); err != nil { return err } } return nil }); err != nil { fileDeleter.Rollback() return nil, err } // perform the post-commit actions fileDeleter.Commit() r.hookExecutor.ExecutePostHooks(ctx, markerID, hook.SceneMarkerUpdatePost, input, translator.getFields()) return r.getSceneMarker(ctx, markerID) } func (r *mutationResolver) BulkSceneMarkerUpdate(ctx context.Context, input BulkSceneMarkerUpdateInput) ([]*models.SceneMarker, error) { ids, err := stringslice.StringSliceToIntSlice(input.Ids) if err != nil { return nil, fmt.Errorf("converting ids: %w", err) } translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate performer from the input partial := models.NewSceneMarkerPartial() partial.Title = translator.optionalString(input.Title, "title") partial.PrimaryTagID, err = translator.optionalIntFromString(input.PrimaryTagID, "primary_tag_id") if err != nil { return nil, fmt.Errorf("converting primary tag id: %w", err) } partial.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids") if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } ret := []*models.SceneMarker{} // Start the transaction and save the performers if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.SceneMarker for _, id := range ids { l := partial if err := adjustMarkerPartialForTagExclusion(ctx, r.repository.SceneMarker, id, &l); err != nil { return err } updated, err := qb.UpdatePartial(ctx, id, l) if err != nil { return err } ret = append(ret, updated) } return nil }); err != nil { return nil, err } // execute post hooks outside of txn var newRet []*models.SceneMarker for _, m := range ret { r.hookExecutor.ExecutePostHooks(ctx, m.ID, hook.SceneMarkerUpdatePost, input, translator.getFields()) m, err = r.getSceneMarker(ctx, m.ID) if err != nil { return nil, err } newRet = append(newRet, m) } return newRet, nil } // adjustMarkerPartialForTagExclusion adjusts the SceneMarkerPartial to exclude the primary tag from tag updates. func adjustMarkerPartialForTagExclusion(ctx context.Context, r models.SceneMarkerReader, id int, partial *models.SceneMarkerPartial) error { if partial.TagIDs == nil && !partial.PrimaryTagID.Set { return nil } // exclude primary tag from tag updates var primaryTagID int if partial.PrimaryTagID.Set { primaryTagID = partial.PrimaryTagID.Value } else { existing, err := r.Find(ctx, id) if err != nil { return fmt.Errorf("finding existing primary tag id: %w", err) } primaryTagID = existing.PrimaryTagID } existingTagIDs, err := r.GetTagIDs(ctx, id) if err != nil { return fmt.Errorf("getting existing tag ids: %w", err) } tagIDAttr := partial.TagIDs if tagIDAttr == nil { tagIDAttr = &models.UpdateIDs{ IDs: existingTagIDs, Mode: models.RelationshipUpdateModeSet, } } newTagIDs := tagIDAttr.Apply(existingTagIDs) // Remove primary tag from newTagIDs if present newTagIDs = sliceutil.Exclude(newTagIDs, []int{primaryTagID}) if len(existingTagIDs) != len(newTagIDs) { partial.TagIDs = &models.UpdateIDs{ IDs: newTagIDs, Mode: models.RelationshipUpdateModeSet, } } else { // no change to tags required partial.TagIDs = nil } return nil } func (r *mutationResolver) SceneMarkerDestroy(ctx context.Context, id string) (bool, error) { return r.SceneMarkersDestroy(ctx, []string{id}) } func (r *mutationResolver) SceneMarkersDestroy(ctx context.Context, markerIDs []string) (bool, error) { ids, err := stringslice.StringSliceToIntSlice(markerIDs) if err != nil { return false, fmt.Errorf("converting ids: %w", err) } var markers []*models.SceneMarker fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm() trashPath := manager.GetInstance().Config.GetDeleteTrashPath() fileDeleter := &scene.FileDeleter{ Deleter: file.NewDeleterWithTrash(trashPath), FileNamingAlgo: fileNamingAlgo, Paths: manager.GetInstance().Paths, } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.SceneMarker sqb := r.repository.Scene for _, markerID := range ids { marker, err := qb.Find(ctx, markerID) if err != nil { return err } if marker == nil { return fmt.Errorf("scene marker with id %d not found", markerID) } s, err := sqb.Find(ctx, marker.SceneID) if err != nil { return err } if s == nil { return fmt.Errorf("scene with id %d not found", marker.SceneID) } markers = append(markers, marker) if err := scene.DestroyMarker(ctx, s, marker, qb, fileDeleter); err != nil { return err } } return nil }); err != nil { fileDeleter.Rollback() return false, err } fileDeleter.Commit() for _, marker := range markers { r.hookExecutor.ExecutePostHooks(ctx, marker.ID, hook.SceneMarkerDestroyPost, markerIDs, nil) } return true, nil } func (r *mutationResolver) SceneSaveActivity(ctx context.Context, id string, resumeTime *float64, playDuration *float64) (ret bool, err error) { sceneID, err := strconv.Atoi(id) if err != nil { return false, fmt.Errorf("converting id: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene ret, err = qb.SaveActivity(ctx, sceneID, resumeTime, playDuration) return err }); err != nil { return false, err } return ret, nil } func (r *mutationResolver) SceneResetActivity(ctx context.Context, id string, resetResume *bool, resetDuration *bool) (ret bool, err error) { sceneID, err := strconv.Atoi(id) if err != nil { return false, fmt.Errorf("converting id: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene ret, err = qb.ResetActivity(ctx, sceneID, utils.IsTrue(resetResume), utils.IsTrue(resetDuration)) return err }); err != nil { return false, err } return ret, nil } // deprecated func (r *mutationResolver) SceneIncrementPlayCount(ctx context.Context, id string) (ret int, err error) { sceneID, err := strconv.Atoi(id) if err != nil { return 0, fmt.Errorf("converting id: %w", err) } var updatedTimes []time.Time if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene updatedTimes, err = qb.AddViews(ctx, sceneID, nil) return err }); err != nil { return 0, err } return len(updatedTimes), nil } func (r *mutationResolver) SceneAddPlay(ctx context.Context, id string, t []*time.Time) (*HistoryMutationResult, error) { sceneID, err := strconv.Atoi(id) if err != nil { return nil, fmt.Errorf("converting id: %w", err) } var times []time.Time // convert time to local time, so that sorting is consistent for _, tt := range t { times = append(times, tt.Local()) } var updatedTimes []time.Time if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene updatedTimes, err = qb.AddViews(ctx, sceneID, times) return err }); err != nil { return nil, err } return &HistoryMutationResult{ Count: len(updatedTimes), History: sliceutil.ValuesToPtrs(updatedTimes), }, nil } func (r *mutationResolver) SceneDeletePlay(ctx context.Context, id string, t []*time.Time) (*HistoryMutationResult, error) { sceneID, err := strconv.Atoi(id) if err != nil { return nil, err } var times []time.Time for _, tt := range t { times = append(times, *tt) } var updatedTimes []time.Time if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene updatedTimes, err = qb.DeleteViews(ctx, sceneID, times) return err }); err != nil { return nil, err } return &HistoryMutationResult{ Count: len(updatedTimes), History: sliceutil.ValuesToPtrs(updatedTimes), }, nil } func (r *mutationResolver) SceneResetPlayCount(ctx context.Context, id string) (ret int, err error) { sceneID, err := strconv.Atoi(id) if err != nil { return 0, err } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene ret, err = qb.DeleteAllViews(ctx, sceneID) return err }); err != nil { return 0, err } return ret, nil } // deprecated func (r *mutationResolver) SceneIncrementO(ctx context.Context, id string) (ret int, err error) { sceneID, err := strconv.Atoi(id) if err != nil { return 0, fmt.Errorf("converting id: %w", err) } var updatedTimes []time.Time if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene updatedTimes, err = qb.AddO(ctx, sceneID, nil) return err }); err != nil { return 0, err } return len(updatedTimes), nil } // deprecated func (r *mutationResolver) SceneDecrementO(ctx context.Context, id string) (ret int, err error) { sceneID, err := strconv.Atoi(id) if err != nil { return 0, fmt.Errorf("converting id: %w", err) } var updatedTimes []time.Time if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene updatedTimes, err = qb.DeleteO(ctx, sceneID, nil) return err }); err != nil { return 0, err } return len(updatedTimes), nil } func (r *mutationResolver) SceneResetO(ctx context.Context, id string) (ret int, err error) { sceneID, err := strconv.Atoi(id) if err != nil { return 0, fmt.Errorf("converting id: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene ret, err = qb.ResetO(ctx, sceneID) return err }); err != nil { return 0, err } return ret, nil } func (r *mutationResolver) SceneAddO(ctx context.Context, id string, t []*time.Time) (*HistoryMutationResult, error) { sceneID, err := strconv.Atoi(id) if err != nil { return nil, fmt.Errorf("converting id: %w", err) } var times []time.Time // convert time to local time, so that sorting is consistent for _, tt := range t { times = append(times, tt.Local()) } var updatedTimes []time.Time if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene updatedTimes, err = qb.AddO(ctx, sceneID, times) return err }); err != nil { return nil, err } return &HistoryMutationResult{ Count: len(updatedTimes), History: sliceutil.ValuesToPtrs(updatedTimes), }, nil } func (r *mutationResolver) SceneDeleteO(ctx context.Context, id string, t []*time.Time) (*HistoryMutationResult, error) { sceneID, err := strconv.Atoi(id) if err != nil { return nil, fmt.Errorf("converting id: %w", err) } var times []time.Time for _, tt := range t { times = append(times, *tt) } var updatedTimes []time.Time if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene updatedTimes, err = qb.DeleteO(ctx, sceneID, times) return err }); err != nil { return nil, err } return &HistoryMutationResult{ Count: len(updatedTimes), History: sliceutil.ValuesToPtrs(updatedTimes), }, nil } func (r *mutationResolver) SceneGenerateScreenshot(ctx context.Context, id string, at *float64) (string, error) { if at != nil { manager.GetInstance().GenerateScreenshot(ctx, id, *at) } else { manager.GetInstance().GenerateDefaultScreenshot(ctx, id) } return "todo", nil } ================================================ FILE: internal/api/resolver_mutation_scraper.go ================================================ package api import ( "context" "github.com/stashapp/stash/internal/manager" ) func (r *mutationResolver) ReloadScrapers(ctx context.Context) (bool, error) { manager.GetInstance().RefreshScraperCache() return true, nil } ================================================ FILE: internal/api/resolver_mutation_stash_box.go ================================================ package api import ( "context" "fmt" "strconv" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/stashbox" ) func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input StashBoxFingerprintSubmissionInput) (bool, error) { b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint) if err != nil { return false, err } ids, err := stringslice.StringSliceToIntSlice(input.SceneIds) if err != nil { return false, err } client := r.newStashBoxClient(*b) var scenes []*models.Scene if err := r.withReadTxn(ctx, func(ctx context.Context) error { scenes, err = r.sceneService.FindByIDs(ctx, ids, scene.LoadStashIDs, scene.LoadFiles) return err }); err != nil { return false, err } return client.SubmitFingerprints(ctx, scenes) } func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) { b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck if err != nil { return "", err } jobID := manager.GetInstance().StashBoxBatchPerformerTag(ctx, b, input) return strconv.Itoa(jobID), nil } func (r *mutationResolver) StashBoxBatchStudioTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) { b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck if err != nil { return "", err } jobID := manager.GetInstance().StashBoxBatchStudioTag(ctx, b, input) return strconv.Itoa(jobID), nil } func (r *mutationResolver) StashBoxBatchTagTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) { b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck if err != nil { return "", err } jobID := manager.GetInstance().StashBoxBatchTagTag(ctx, b, input) return strconv.Itoa(jobID), nil } func (r *mutationResolver) SubmitStashBoxSceneDraft(ctx context.Context, input StashBoxDraftSubmissionInput) (*string, error) { b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint) if err != nil { return nil, err } client := r.newStashBoxClient(*b) id, err := strconv.Atoi(input.ID) if err != nil { return nil, fmt.Errorf("converting id: %w", err) } var res *string err = r.withReadTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene scene, err := qb.Find(ctx, id) if err != nil { return err } if scene == nil { return fmt.Errorf("scene with id %d not found", id) } cover, err := qb.GetCover(ctx, id) if err != nil { logger.Errorf("Error getting scene cover: %v", err) } draft, err := r.makeSceneDraft(ctx, scene, cover) if err != nil { return err } res, err = client.SubmitSceneDraft(ctx, *draft) return err }) return res, err } func (r *mutationResolver) makeSceneDraft(ctx context.Context, s *models.Scene, cover []byte) (*stashbox.SceneDraft, error) { if err := s.LoadURLs(ctx, r.repository.Scene); err != nil { return nil, fmt.Errorf("loading scene URLs: %w", err) } if err := s.LoadStashIDs(ctx, r.repository.Scene); err != nil { return nil, err } draft := &stashbox.SceneDraft{ Scene: s, } pqb := r.repository.Performer sqb := r.repository.Studio if s.StudioID != nil { var err error draft.Studio, err = sqb.Find(ctx, *s.StudioID) if err != nil { return nil, err } if draft.Studio == nil { return nil, fmt.Errorf("studio with id %d not found", *s.StudioID) } if err := draft.Studio.LoadStashIDs(ctx, r.repository.Studio); err != nil { return nil, err } } // submit all file fingerprints if err := s.LoadFiles(ctx, r.repository.Scene); err != nil { return nil, err } scenePerformers, err := pqb.FindBySceneID(ctx, s.ID) if err != nil { return nil, err } for _, p := range scenePerformers { if err := p.LoadStashIDs(ctx, pqb); err != nil { return nil, err } } draft.Performers = scenePerformers draft.Tags, err = r.repository.Tag.FindBySceneID(ctx, s.ID) if err != nil { return nil, err } // Load StashIDs for tags tqb := r.repository.Tag for _, t := range draft.Tags { if err := t.LoadStashIDs(ctx, tqb); err != nil { return nil, err } } draft.Cover = cover return draft, nil } func (r *mutationResolver) SubmitStashBoxPerformerDraft(ctx context.Context, input StashBoxDraftSubmissionInput) (*string, error) { b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint) if err != nil { return nil, err } client := r.newStashBoxClient(*b) id, err := strconv.Atoi(input.ID) if err != nil { return nil, fmt.Errorf("converting id: %w", err) } var res *string err = r.withReadTxn(ctx, func(ctx context.Context) error { qb := r.repository.Performer performer, err := qb.Find(ctx, id) if err != nil { return err } if performer == nil { return fmt.Errorf("performer with id %d not found", id) } pqb := r.repository.Performer if err := performer.LoadAliases(ctx, pqb); err != nil { return err } if err := performer.LoadURLs(ctx, pqb); err != nil { return err } if err := performer.LoadStashIDs(ctx, pqb); err != nil { return err } img, _ := pqb.GetImage(ctx, performer.ID) res, err = client.SubmitPerformerDraft(ctx, performer, img) return err }) return res, err } ================================================ FILE: internal/api/resolver_mutation_studio.go ================================================ package api import ( "context" "fmt" "strconv" "strings" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/studio" "github.com/stashapp/stash/pkg/utils" ) // used to refetch studio after hooks run func (r *mutationResolver) getStudio(ctx context.Context, id int) (ret *models.Studio, err error) { if err := r.withTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Studio.Find(ctx, id) return err }); err != nil { return nil, err } return ret, nil } func (r *mutationResolver) StudioCreate(ctx context.Context, input models.StudioCreateInput) (*models.Studio, error) { translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate a new studio from the input newStudio := models.NewCreateStudioInput() newStudio.Name = strings.TrimSpace(input.Name) newStudio.Rating = input.Rating100 newStudio.Favorite = translator.bool(input.Favorite) newStudio.Details = translator.string(input.Details) newStudio.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag) newStudio.Organized = translator.bool(input.Organized) newStudio.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.Aliases), newStudio.Name)) newStudio.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs()) var err error newStudio.URLs = models.NewRelatedStrings([]string{}) if input.URL != nil { newStudio.URLs.Add(strings.TrimSpace(*input.URL)) } if input.Urls != nil { newStudio.URLs.Add(stringslice.TrimSpace(input.Urls)...) } newStudio.ParentID, err = translator.intPtrFromString(input.ParentID) if err != nil { return nil, fmt.Errorf("converting parent id: %w", err) } newStudio.TagIDs, err = translator.relatedIds(input.TagIds) if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } newStudio.CustomFields = convertMapJSONNumbers(input.CustomFields) // Process the base 64 encoded image string var imageData []byte if input.Image != nil { var err error imageData, err = utils.ProcessImageInput(ctx, *input.Image) if err != nil { return nil, fmt.Errorf("processing image: %w", err) } } // Start the transaction and save the studio if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Studio if err := studio.ValidateCreate(ctx, newStudio, qb); err != nil { return err } err = qb.Create(ctx, &newStudio) if err != nil { return err } if len(imageData) > 0 { if err := qb.UpdateImage(ctx, newStudio.ID, imageData); err != nil { return err } } return nil }); err != nil { return nil, err } r.hookExecutor.ExecutePostHooks(ctx, newStudio.ID, hook.StudioCreatePost, input, nil) return r.getStudio(ctx, newStudio.ID) } func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.StudioUpdateInput) (*models.Studio, error) { studioID, err := strconv.Atoi(input.ID) if err != nil { return nil, fmt.Errorf("converting id: %w", err) } translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate studio from the input updatedStudio := models.NewStudioPartial() updatedStudio.ID = studioID updatedStudio.Name = translator.optionalString(input.Name, "name") updatedStudio.Details = translator.optionalString(input.Details, "details") updatedStudio.Rating = translator.optionalInt(input.Rating100, "rating100") updatedStudio.Favorite = translator.optionalBool(input.Favorite, "favorite") updatedStudio.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") updatedStudio.Organized = translator.optionalBool(input.Organized, "organized") updatedStudio.Aliases = translator.updateStrings(input.Aliases, "aliases") updatedStudio.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids") updatedStudio.ParentID, err = translator.optionalIntFromString(input.ParentID, "parent_id") if err != nil { return nil, fmt.Errorf("converting parent id: %w", err) } updatedStudio.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids") if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } if translator.hasField("urls") { // ensure url not included in the input if err := validateNoLegacyURLs(translator); err != nil { return nil, err } updatedStudio.URLs = translator.updateStrings(input.Urls, "urls") } else if translator.hasField("url") { // handle legacy url field legacyURLs := []string{} if input.URL != nil { legacyURLs = append(legacyURLs, *input.URL) } updatedStudio.URLs = &models.UpdateStrings{ Mode: models.RelationshipUpdateModeSet, Values: legacyURLs, } } updatedStudio.CustomFields = input.CustomFields // convert json.Numbers to int/float updatedStudio.CustomFields.Full = convertMapJSONNumbers(updatedStudio.CustomFields.Full) updatedStudio.CustomFields.Partial = convertMapJSONNumbers(updatedStudio.CustomFields.Partial) // Process the base 64 encoded image string var imageData []byte imageIncluded := translator.hasField("image") if input.Image != nil { var err error imageData, err = utils.ProcessImageInput(ctx, *input.Image) if err != nil { return nil, fmt.Errorf("processing image: %w", err) } } // Start the transaction and update the studio if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Studio if updatedStudio.Aliases != nil { s, err := qb.Find(ctx, studioID) if err != nil { return err } if s != nil { if err := s.LoadAliases(ctx, qb); err != nil { return err } effectiveAliases := updatedStudio.Aliases.Apply(s.Aliases.List()) name := s.Name if updatedStudio.Name.Set { name = updatedStudio.Name.Value } sanitized := stringslice.UniqueExcludeFold(effectiveAliases, name) updatedStudio.Aliases.Values = sanitized updatedStudio.Aliases.Mode = models.RelationshipUpdateModeSet } } if err := studio.ValidateModify(ctx, updatedStudio, qb); err != nil { return err } _, err = qb.UpdatePartial(ctx, updatedStudio) if err != nil { return err } if imageIncluded { if err := qb.UpdateImage(ctx, studioID, imageData); err != nil { return err } } return nil }); err != nil { return nil, err } r.hookExecutor.ExecutePostHooks(ctx, studioID, hook.StudioUpdatePost, input, translator.getFields()) return r.getStudio(ctx, studioID) } func (r *mutationResolver) BulkStudioUpdate(ctx context.Context, input BulkStudioUpdateInput) ([]*models.Studio, error) { ids, err := stringslice.StringSliceToIntSlice(input.Ids) if err != nil { return nil, fmt.Errorf("converting ids: %w", err) } translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate performer from the input partial := models.NewStudioPartial() partial.ParentID, err = translator.optionalIntFromString(input.ParentID, "parent_id") if err != nil { return nil, fmt.Errorf("converting parent id: %w", err) } if translator.hasField("urls") { // ensure url/twitter/instagram are not included in the input if err := validateNoLegacyURLs(translator); err != nil { return nil, err } partial.URLs = translator.updateStringsBulk(input.Urls, "urls") } else if translator.hasField("url") { // handle legacy url field legacyURLs := []string{} if input.URL != nil { legacyURLs = append(legacyURLs, *input.URL) } partial.URLs = &models.UpdateStrings{ Mode: models.RelationshipUpdateModeSet, Values: legacyURLs, } } partial.Favorite = translator.optionalBool(input.Favorite, "favorite") partial.Rating = translator.optionalInt(input.Rating100, "rating100") partial.Details = translator.optionalString(input.Details, "details") partial.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") partial.Organized = translator.optionalBool(input.Organized, "organized") partial.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids") if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } ret := []*models.Studio{} // Start the transaction and save the performers if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Studio for _, id := range ids { local := partial local.ID = id if err := studio.ValidateModify(ctx, local, qb); err != nil { return err } updated, err := qb.UpdatePartial(ctx, local) if err != nil { return err } ret = append(ret, updated) } return nil }); err != nil { return nil, err } // execute post hooks outside of txn var newRet []*models.Studio for _, studio := range ret { r.hookExecutor.ExecutePostHooks(ctx, studio.ID, hook.StudioUpdatePost, input, translator.getFields()) studio, err = r.getStudio(ctx, studio.ID) if err != nil { return nil, err } newRet = append(newRet, studio) } return newRet, nil } func (r *mutationResolver) StudioDestroy(ctx context.Context, input StudioDestroyInput) (bool, error) { id, err := strconv.Atoi(input.ID) if err != nil { return false, fmt.Errorf("converting id: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { return r.repository.Studio.Destroy(ctx, id) }); err != nil { return false, err } r.hookExecutor.ExecutePostHooks(ctx, id, hook.StudioDestroyPost, input, nil) return true, nil } func (r *mutationResolver) StudiosDestroy(ctx context.Context, studioIDs []string) (bool, error) { ids, err := stringslice.StringSliceToIntSlice(studioIDs) if err != nil { return false, fmt.Errorf("converting ids: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Studio for _, id := range ids { if err := qb.Destroy(ctx, id); err != nil { return err } } return nil }); err != nil { return false, err } for _, id := range ids { r.hookExecutor.ExecutePostHooks(ctx, id, hook.StudioDestroyPost, studioIDs, nil) } return true, nil } ================================================ FILE: internal/api/resolver_mutation_tag.go ================================================ package api import ( "context" "fmt" "strconv" "strings" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/tag" "github.com/stashapp/stash/pkg/utils" ) func (r *mutationResolver) getTag(ctx context.Context, id int) (ret *models.Tag, err error) { if err := r.withTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Tag.Find(ctx, id) return err }); err != nil { return nil, err } return ret, nil } func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput) (*models.Tag, error) { translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate a new tag from the input newTag := models.CreateTagInput{ Tag: &models.Tag{}, } *newTag.Tag = models.NewTag() newTag.Name = strings.TrimSpace(input.Name) newTag.SortName = translator.string(input.SortName) newTag.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.Aliases), newTag.Name)) newTag.Favorite = translator.bool(input.Favorite) newTag.Description = translator.string(input.Description) newTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag) var stashIDInputs models.StashIDInputs for _, sid := range input.StashIds { if sid != nil { stashIDInputs = append(stashIDInputs, *sid) } } newTag.StashIDs = models.NewRelatedStashIDs(stashIDInputs.ToStashIDs()) var err error newTag.ParentIDs, err = translator.relatedIds(input.ParentIds) if err != nil { return nil, fmt.Errorf("converting parent tag ids: %w", err) } newTag.ChildIDs, err = translator.relatedIds(input.ChildIds) if err != nil { return nil, fmt.Errorf("converting child tag ids: %w", err) } newTag.CustomFields = convertMapJSONNumbers(input.CustomFields) // Process the base 64 encoded image string var imageData []byte if input.Image != nil { imageData, err = utils.ProcessImageInput(ctx, *input.Image) if err != nil { return nil, fmt.Errorf("processing image: %w", err) } } // Start the transaction and save the tag if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Tag if err := tag.ValidateCreate(ctx, *newTag.Tag, qb); err != nil { return err } err = qb.Create(ctx, &newTag) if err != nil { return err } // update image table if len(imageData) > 0 { if err := qb.UpdateImage(ctx, newTag.ID, imageData); err != nil { return err } } return nil }); err != nil { return nil, err } r.hookExecutor.ExecutePostHooks(ctx, newTag.ID, hook.TagCreatePost, input, nil) return r.getTag(ctx, newTag.ID) } func tagPartialFromInput(input TagUpdateInput, translator changesetTranslator) (*models.TagPartial, error) { updatedTag := models.NewTagPartial() updatedTag.Name = translator.optionalString(input.Name, "name") updatedTag.SortName = translator.optionalString(input.SortName, "sort_name") updatedTag.Favorite = translator.optionalBool(input.Favorite, "favorite") updatedTag.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") updatedTag.Description = translator.optionalString(input.Description, "description") updatedTag.Aliases = translator.updateStrings(input.Aliases, "aliases") var updateStashIDInputs models.StashIDInputs for _, sid := range input.StashIds { if sid != nil { updateStashIDInputs = append(updateStashIDInputs, *sid) } } updatedTag.StashIDs = translator.updateStashIDs(updateStashIDInputs, "stash_ids") var err error updatedTag.ParentIDs, err = translator.updateIds(input.ParentIds, "parent_ids") if err != nil { return nil, fmt.Errorf("converting parent tag ids: %w", err) } updatedTag.ChildIDs, err = translator.updateIds(input.ChildIds, "child_ids") if err != nil { return nil, fmt.Errorf("converting child tag ids: %w", err) } if input.CustomFields != nil { updatedTag.CustomFields = *input.CustomFields // convert json.Numbers to int/float updatedTag.CustomFields.Full = convertMapJSONNumbers(updatedTag.CustomFields.Full) updatedTag.CustomFields.Partial = convertMapJSONNumbers(updatedTag.CustomFields.Partial) } return &updatedTag, nil } func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) (*models.Tag, error) { tagID, err := strconv.Atoi(input.ID) if err != nil { return nil, fmt.Errorf("converting id: %w", err) } translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate tag from the input updatedTag, err := tagPartialFromInput(input, translator) if err != nil { return nil, err } var imageData []byte imageIncluded := translator.hasField("image") if input.Image != nil { imageData, err = utils.ProcessImageInput(ctx, *input.Image) if err != nil { return nil, fmt.Errorf("processing image: %w", err) } } // Start the transaction and save the tag var t *models.Tag if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Tag if updatedTag.Aliases != nil { t, err := qb.Find(ctx, tagID) if err != nil { return err } if t != nil { if err := t.LoadAliases(ctx, qb); err != nil { return err } newAliases := updatedTag.Aliases.Apply(t.Aliases.List()) name := t.Name if updatedTag.Name.Set { name = updatedTag.Name.Value } sanitized := stringslice.UniqueExcludeFold(newAliases, name) updatedTag.Aliases.Values = sanitized updatedTag.Aliases.Mode = models.RelationshipUpdateModeSet } } if err := tag.ValidateUpdate(ctx, tagID, *updatedTag, qb); err != nil { return err } t, err = qb.UpdatePartial(ctx, tagID, *updatedTag) if err != nil { return err } // update image table if imageIncluded { if err := qb.UpdateImage(ctx, tagID, imageData); err != nil { return err } } return nil }); err != nil { return nil, err } r.hookExecutor.ExecutePostHooks(ctx, t.ID, hook.TagUpdatePost, input, translator.getFields()) return r.getTag(ctx, t.ID) } func (r *mutationResolver) BulkTagUpdate(ctx context.Context, input BulkTagUpdateInput) ([]*models.Tag, error) { tagIDs, err := stringslice.StringSliceToIntSlice(input.Ids) if err != nil { return nil, fmt.Errorf("converting ids: %w", err) } translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate scene from the input updatedTag := models.NewTagPartial() updatedTag.Description = translator.optionalString(input.Description, "description") updatedTag.Favorite = translator.optionalBool(input.Favorite, "favorite") updatedTag.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") updatedTag.Aliases = translator.updateStringsBulk(input.Aliases, "aliases") updatedTag.ParentIDs, err = translator.updateIdsBulk(input.ParentIds, "parent_ids") if err != nil { return nil, fmt.Errorf("converting parent tag ids: %w", err) } updatedTag.ChildIDs, err = translator.updateIdsBulk(input.ChildIds, "child_ids") if err != nil { return nil, fmt.Errorf("converting child tag ids: %w", err) } ret := []*models.Tag{} // Start the transaction and save the scenes if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Tag for _, tagID := range tagIDs { if err := tag.ValidateUpdate(ctx, tagID, updatedTag, qb); err != nil { return err } tag, err := qb.UpdatePartial(ctx, tagID, updatedTag) if err != nil { return err } ret = append(ret, tag) } return nil }); err != nil { return nil, err } // execute post hooks outside of txn var newRet []*models.Tag for _, tag := range ret { r.hookExecutor.ExecutePostHooks(ctx, tag.ID, hook.TagUpdatePost, input, translator.getFields()) tag, err = r.getTag(ctx, tag.ID) if err != nil { return nil, err } newRet = append(newRet, tag) } return newRet, nil } func (r *mutationResolver) TagDestroy(ctx context.Context, input TagDestroyInput) (bool, error) { tagID, err := strconv.Atoi(input.ID) if err != nil { return false, fmt.Errorf("converting id: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { return r.repository.Tag.Destroy(ctx, tagID) }); err != nil { return false, err } r.hookExecutor.ExecutePostHooks(ctx, tagID, hook.TagDestroyPost, input, nil) return true, nil } func (r *mutationResolver) TagsDestroy(ctx context.Context, tagIDs []string) (bool, error) { ids, err := stringslice.StringSliceToIntSlice(tagIDs) if err != nil { return false, fmt.Errorf("converting ids: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Tag for _, id := range ids { if err := qb.Destroy(ctx, id); err != nil { return err } } return nil }); err != nil { return false, err } for _, id := range ids { r.hookExecutor.ExecutePostHooks(ctx, id, hook.TagDestroyPost, tagIDs, nil) } return true, nil } func (r *mutationResolver) TagsMerge(ctx context.Context, input TagsMergeInput) (*models.Tag, error) { source, err := stringslice.StringSliceToIntSlice(input.Source) if err != nil { return nil, fmt.Errorf("converting source ids: %w", err) } destination, err := strconv.Atoi(input.Destination) if err != nil { return nil, fmt.Errorf("converting destination id: %w", err) } if len(source) == 0 { return nil, nil } var values *models.TagPartial var imageData []byte if input.Values != nil { translator := changesetTranslator{ inputMap: getNamedUpdateInputMap(ctx, "input.values"), } values, err = tagPartialFromInput(*input.Values, translator) if err != nil { return nil, err } if input.Values.Image != nil { var err error imageData, err = utils.ProcessImageInput(ctx, *input.Values.Image) if err != nil { return nil, fmt.Errorf("processing cover image: %w", err) } } } else { v := models.NewTagPartial() values = &v } var t *models.Tag if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Tag var err error t, err = qb.Find(ctx, destination) if err != nil { return err } if t == nil { return fmt.Errorf("tag with id %d not found", destination) } if err = qb.Merge(ctx, source, destination); err != nil { return err } if err := tag.ValidateUpdate(ctx, destination, *values, qb); err != nil { return err } if _, err := qb.UpdatePartial(ctx, destination, *values); err != nil { return fmt.Errorf("updating tag: %w", err) } if len(imageData) > 0 { if err := qb.UpdateImage(ctx, destination, imageData); err != nil { return err } } return nil }); err != nil { return nil, err } r.hookExecutor.ExecutePostHooks(ctx, t.ID, hook.TagMergePost, input, nil) return t, nil } ================================================ FILE: internal/api/resolver_mutation_tag_test.go ================================================ package api import ( "context" "errors" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stashapp/stash/pkg/plugin/hook" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) // TODO - move this into a common area func newResolver(db *mocks.Database) *Resolver { return &Resolver{ repository: db.Repository(), hookExecutor: &mockHookExecutor{}, } } const ( tagName = "tagName" errTagName = "errTagName" existingTagID = 1 existingTagName = "existingTagName" newTagID = 2 ) var testCtx = context.Background() type mockHookExecutor struct{} func (*mockHookExecutor) ExecutePostHooks(ctx context.Context, id int, hookType hook.TriggerEnum, input interface{}, inputFields []string) { } func TestTagCreate(t *testing.T) { db := mocks.NewDatabase() r := newResolver(db) pp := 1 findFilter := &models.FindFilterType{ PerPage: &pp, } tagFilterForName := func(n string) *models.TagFilterType { return &models.TagFilterType{ Name: &models.StringCriterionInput{ Value: n, Modifier: models.CriterionModifierEquals, }, } } tagFilterForAlias := func(n string) *models.TagFilterType { return &models.TagFilterType{ Aliases: &models.StringCriterionInput{ Value: n, Modifier: models.CriterionModifierEquals, }, } } db.Tag.On("Query", mock.Anything, tagFilterForName(existingTagName), findFilter).Return([]*models.Tag{ { ID: existingTagID, Name: existingTagName, }, }, 1, nil).Once() db.Tag.On("Query", mock.Anything, tagFilterForName(errTagName), findFilter).Return(nil, 0, nil).Once() db.Tag.On("Query", mock.Anything, tagFilterForAlias(errTagName), findFilter).Return(nil, 0, nil).Once() expectedErr := errors.New("TagCreate error") db.Tag.On("Create", mock.Anything, mock.AnythingOfType("*models.Tag")).Return(expectedErr) // fails here because testCtx is empty // TODO: Fix this if 1 != 0 { return } _, err := r.Mutation().TagCreate(testCtx, TagCreateInput{ Name: existingTagName, }) assert.NotNil(t, err) _, err = r.Mutation().TagCreate(testCtx, TagCreateInput{ Name: errTagName, }) assert.Equal(t, expectedErr, err) db.AssertExpectations(t) db = mocks.NewDatabase() r = newResolver(db) db.Tag.On("Query", mock.Anything, tagFilterForName(tagName), findFilter).Return(nil, 0, nil).Once() db.Tag.On("Query", mock.Anything, tagFilterForAlias(tagName), findFilter).Return(nil, 0, nil).Once() newTag := &models.Tag{ ID: newTagID, Name: tagName, } db.Tag.On("Create", mock.Anything, mock.AnythingOfType("*models.Tag")).Run(func(args mock.Arguments) { arg := args.Get(1).(*models.Tag) arg.ID = newTagID }).Return(nil) db.Tag.On("Find", mock.Anything, newTagID).Return(newTag, nil) tag, err := r.Mutation().TagCreate(testCtx, TagCreateInput{ Name: tagName, }) assert.Nil(t, err) assert.NotNil(t, tag) db.AssertExpectations(t) } ================================================ FILE: internal/api/resolver_query_configuration.go ================================================ package api import ( "context" "fmt" "path/filepath" "strings" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/models" "golang.org/x/text/collate" ) func (r *queryResolver) Configuration(ctx context.Context) (*ConfigResult, error) { return makeConfigResult(), nil } func (r *queryResolver) Directory(ctx context.Context, path, locale *string) (*Directory, error) { directory := &Directory{} var err error col := newCollator(locale, collate.IgnoreCase, collate.Numeric) var dirPath = "" if path != nil { dirPath = *path } currentDir := getDir(dirPath) directories, err := listDir(col, currentDir) if err != nil { return directory, err } directory.Path = currentDir directory.Parent = getParent(currentDir) directory.Directories = directories return directory, err } func getDir(path string) string { if path == "" { path = fsutil.GetHomeDirectory() } return path } func getParent(path string) *string { isRoot := path == "/" if isRoot { return nil } else { parentPath := filepath.Clean(path + "/..") return &parentPath } } func makeConfigResult() *ConfigResult { return &ConfigResult{ General: makeConfigGeneralResult(), Interface: makeConfigInterfaceResult(), Dlna: makeConfigDLNAResult(), Scraping: makeConfigScrapingResult(), Defaults: makeConfigDefaultsResult(), UI: makeConfigUIResult(), } } func makeConfigGeneralResult() *ConfigGeneralResult { config := config.GetInstance() logFile := config.GetLogFile() maxTranscodeSize := config.GetMaxTranscodeSize() maxStreamingTranscodeSize := config.GetMaxStreamingTranscodeSize() customPerformerImageLocation := config.GetCustomPerformerImageLocation() return &ConfigGeneralResult{ Stashes: config.GetStashPaths(), DatabasePath: config.GetDatabasePath(), BackupDirectoryPath: config.GetBackupDirectoryPath(), DeleteTrashPath: config.GetDeleteTrashPath(), GeneratedPath: config.GetGeneratedPath(), MetadataPath: config.GetMetadataPath(), ConfigFilePath: config.GetConfigFile(), ScrapersPath: config.GetScrapersPath(), PluginsPath: config.GetPluginsPath(), CachePath: config.GetCachePath(), BlobsPath: config.GetBlobsPath(), BlobsStorage: config.GetBlobsStorage(), FfmpegPath: config.GetFFMpegPath(), FfprobePath: config.GetFFProbePath(), CalculateMd5: config.IsCalculateMD5(), VideoFileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(), ParallelTasks: config.GetParallelTasks(), UseCustomSpriteInterval: config.GetUseCustomSpriteInterval(), SpriteInterval: config.GetSpriteInterval(), SpriteScreenshotSize: config.GetSpriteScreenshotSize(), MinimumSprites: config.GetMinimumSprites(), MaximumSprites: config.GetMaximumSprites(), PreviewAudio: config.GetPreviewAudio(), PreviewSegments: config.GetPreviewSegments(), PreviewSegmentDuration: config.GetPreviewSegmentDuration(), PreviewExcludeStart: config.GetPreviewExcludeStart(), PreviewExcludeEnd: config.GetPreviewExcludeEnd(), PreviewPreset: config.GetPreviewPreset(), TranscodeHardwareAcceleration: config.GetTranscodeHardwareAcceleration(), MaxTranscodeSize: &maxTranscodeSize, MaxStreamingTranscodeSize: &maxStreamingTranscodeSize, WriteImageThumbnails: config.IsWriteImageThumbnails(), CreateImageClipsFromVideos: config.IsCreateImageClipsFromVideos(), GalleryCoverRegex: config.GetGalleryCoverRegex(), APIKey: config.GetAPIKey(), Username: config.GetUsername(), Password: config.GetPasswordHash(), MaxSessionAge: config.GetMaxSessionAge(), LogFile: &logFile, LogOut: config.GetLogOut(), LogLevel: config.GetLogLevel(), LogAccess: config.GetLogAccess(), LogFileMaxSize: config.GetLogFileMaxSize(), VideoExtensions: config.GetVideoExtensions(), ImageExtensions: config.GetImageExtensions(), GalleryExtensions: config.GetGalleryExtensions(), CreateGalleriesFromFolders: config.GetCreateGalleriesFromFolders(), Excludes: config.GetExcludes(), ImageExcludes: config.GetImageExcludes(), CustomPerformerImageLocation: &customPerformerImageLocation, StashBoxes: config.GetStashBoxes(), PythonPath: config.GetPythonPath(), TranscodeInputArgs: config.GetTranscodeInputArgs(), TranscodeOutputArgs: config.GetTranscodeOutputArgs(), LiveTranscodeInputArgs: config.GetLiveTranscodeInputArgs(), LiveTranscodeOutputArgs: config.GetLiveTranscodeOutputArgs(), DrawFunscriptHeatmapRange: config.GetDrawFunscriptHeatmapRange(), ScraperPackageSources: config.GetScraperPackageSources(), PluginPackageSources: config.GetPluginPackageSources(), } } func makeConfigInterfaceResult() *ConfigInterfaceResult { config := config.GetInstance() menuItems := config.GetMenuItems() soundOnPreview := config.GetSoundOnPreview() wallShowTitle := config.GetWallShowTitle() showScrubber := config.GetShowScrubber() wallPlayback := config.GetWallPlayback() noBrowser := config.GetNoBrowser() notificationsEnabled := config.GetNotificationsEnabled() maximumLoopDuration := config.GetMaximumLoopDuration() autostartVideo := config.GetAutostartVideo() autostartVideoOnPlaySelected := config.GetAutostartVideoOnPlaySelected() continuePlaylistDefault := config.GetContinuePlaylistDefault() showStudioAsText := config.GetShowStudioAsText() css := config.GetCSS() cssEnabled := config.GetCSSEnabled() javascript := config.GetJavascript() javascriptEnabled := config.GetJavascriptEnabled() customLocales := config.GetCustomLocales() customLocalesEnabled := config.GetCustomLocalesEnabled() disableCustomizations := config.GetDisableCustomizations() language := config.GetLanguage() handyKey := config.GetHandyKey() scriptOffset := config.GetFunscriptOffset() useStashHostedFunscript := config.GetUseStashHostedFunscript() imageLightboxOptions := config.GetImageLightboxOptions() disableDropdownCreate := config.GetDisableDropdownCreate() return &ConfigInterfaceResult{ SfwContentMode: config.GetSFWContentMode(), MenuItems: menuItems, SoundOnPreview: &soundOnPreview, WallShowTitle: &wallShowTitle, WallPlayback: &wallPlayback, ShowScrubber: &showScrubber, MaximumLoopDuration: &maximumLoopDuration, NoBrowser: &noBrowser, NotificationsEnabled: ¬ificationsEnabled, AutostartVideo: &autostartVideo, ShowStudioAsText: &showStudioAsText, AutostartVideoOnPlaySelected: &autostartVideoOnPlaySelected, ContinuePlaylistDefault: &continuePlaylistDefault, CSS: &css, CSSEnabled: &cssEnabled, Javascript: &javascript, JavascriptEnabled: &javascriptEnabled, CustomLocales: &customLocales, CustomLocalesEnabled: &customLocalesEnabled, DisableCustomizations: &disableCustomizations, Language: &language, ImageLightbox: &imageLightboxOptions, DisableDropdownCreate: disableDropdownCreate, HandyKey: &handyKey, FunscriptOffset: &scriptOffset, UseStashHostedFunscript: &useStashHostedFunscript, } } func makeConfigDLNAResult() *ConfigDLNAResult { config := config.GetInstance() return &ConfigDLNAResult{ ServerName: config.GetDLNAServerName(), Enabled: config.GetDLNADefaultEnabled(), Port: config.GetDLNAPort(), WhitelistedIPs: config.GetDLNADefaultIPWhitelist(), Interfaces: config.GetDLNAInterfaces(), VideoSortOrder: config.GetVideoSortOrder(), } } func makeConfigScrapingResult() *ConfigScrapingResult { config := config.GetInstance() scraperUserAgent := config.GetScraperUserAgent() scraperCDPPath := config.GetScraperCDPPath() return &ConfigScrapingResult{ ScraperUserAgent: &scraperUserAgent, ScraperCertCheck: config.GetScraperCertCheck(), ScraperCDPPath: &scraperCDPPath, ExcludeTagPatterns: config.GetScraperExcludeTagPatterns(), } } func makeConfigDefaultsResult() *ConfigDefaultSettingsResult { config := config.GetInstance() deleteFileDefault := config.GetDeleteFileDefault() deleteGeneratedDefault := config.GetDeleteGeneratedDefault() return &ConfigDefaultSettingsResult{ Identify: config.GetDefaultIdentifySettings(), Scan: config.GetDefaultScanSettings(), AutoTag: config.GetDefaultAutoTagSettings(), Generate: config.GetDefaultGenerateSettings(), DeleteFile: &deleteFileDefault, DeleteGenerated: &deleteGeneratedDefault, } } func makeConfigUIResult() map[string]interface{} { return config.GetInstance().GetUIConfiguration() } func (r *queryResolver) ValidateStashBoxCredentials(ctx context.Context, input config.StashBoxInput) (*StashBoxValidationResult, error) { box := models.StashBox{Endpoint: input.Endpoint, APIKey: input.APIKey} client := r.newStashBoxClient(box) user, err := client.GetUser(ctx) valid := user != nil && user.Me != nil var status string if valid { status = fmt.Sprintf("Successfully authenticated as %s", user.Me.Name) } else { errorStr := strings.ToLower(err.Error()) switch { case strings.Contains(errorStr, "doctype"): // Index file returned rather than graphql status = "Invalid endpoint" case strings.Contains(errorStr, "request failed"): status = "No response from server" case strings.Contains(errorStr, "invalid character") || strings.Contains(errorStr, "illegal base64 data") || strings.Contains(errorStr, "unexpected end of json input") || strings.Contains(errorStr, "token contains an invalid number of segments"): status = "Malformed API key." case strings.Contains(errorStr, "signature is invalid"): status = "Invalid or expired API key." default: status = fmt.Sprintf("Unknown error: %s", err) } } result := StashBoxValidationResult{ Valid: valid, Status: status, } return &result, nil } ================================================ FILE: internal/api/resolver_query_dlna.go ================================================ package api import ( "context" "github.com/stashapp/stash/internal/dlna" "github.com/stashapp/stash/internal/manager" ) func (r *queryResolver) DlnaStatus(ctx context.Context) (*dlna.Status, error) { return manager.GetInstance().DLNAService.Status(), nil } ================================================ FILE: internal/api/resolver_query_find_file.go ================================================ package api import ( "context" "errors" "strconv" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) func (r *queryResolver) FindFile(ctx context.Context, id *string, path *string) (BaseFile, error) { var ret models.File if err := r.withReadTxn(ctx, func(ctx context.Context) error { qb := r.repository.File var err error switch { case id != nil: idInt, err := strconv.Atoi(*id) if err != nil { return err } var files []models.File files, err = qb.Find(ctx, models.FileID(idInt)) if err != nil { return err } if len(files) > 0 { ret = files[0] } case path != nil: ret, err = qb.FindByPath(ctx, *path, true) if err == nil && ret == nil { return errors.New("file not found") } default: return errors.New("either id or path must be provided") } return err }); err != nil { return nil, err } return convertBaseFile(ret), nil } func (r *queryResolver) FindFiles( ctx context.Context, fileFilter *models.FileFilterType, filter *models.FindFilterType, ids []string, ) (ret *FindFilesResultType, err error) { var fileIDs []models.FileID if len(ids) > 0 { fileIDsInt, err := stringslice.StringSliceToIntSlice(ids) if err != nil { return nil, err } fileIDs = models.FileIDsFromInts(fileIDsInt) } if err := r.withReadTxn(ctx, func(ctx context.Context) error { var files []models.File var err error fields := collectQueryFields(ctx) result := &models.FileQueryResult{} if len(fileIDs) > 0 { files, err = r.repository.File.Find(ctx, fileIDs...) if err == nil { result.Count = len(files) for _, f := range files { if asVideo, ok := f.(*models.VideoFile); ok { result.TotalDuration += asVideo.Duration } if asImage, ok := f.(*models.ImageFile); ok { result.Megapixels += asImage.Megapixels() } result.TotalSize += f.Base().Size } } } else { result, err = r.repository.File.Query(ctx, models.FileQueryOptions{ QueryOptions: models.QueryOptions{ FindFilter: filter, Count: fields.Has("count"), }, FileFilter: fileFilter, TotalDuration: fields.Has("duration"), Megapixels: fields.Has("megapixels"), TotalSize: fields.Has("size"), }) if err == nil { files, err = result.Resolve(ctx) } } if err != nil { return err } ret = &FindFilesResultType{ Count: result.Count, Files: convertBaseFiles(files), Duration: result.TotalDuration, Megapixels: result.Megapixels, Size: int(result.TotalSize), } return nil }); err != nil { return nil, err } return ret, nil } ================================================ FILE: internal/api/resolver_query_find_folder.go ================================================ package api import ( "context" "errors" "strconv" "github.com/stashapp/stash/pkg/models" ) func (r *queryResolver) FindFolder(ctx context.Context, id *string, path *string) (*models.Folder, error) { var ret *models.Folder if err := r.withReadTxn(ctx, func(ctx context.Context) error { qb := r.repository.Folder var err error switch { case id != nil: idInt, err := strconv.Atoi(*id) if err != nil { return err } ret, err = qb.Find(ctx, models.FolderID(idInt)) if err != nil { return err } case path != nil: ret, err = qb.FindByPath(ctx, *path, true) if err == nil && ret == nil { return errors.New("folder not found") } default: return errors.New("either id or path must be provided") } return err }); err != nil { return nil, err } return ret, nil } func (r *queryResolver) FindFolders( ctx context.Context, folderFilter *models.FolderFilterType, filter *models.FindFilterType, ids []string, ) (ret *FindFoldersResultType, err error) { var folderIDs []models.FolderID if len(ids) > 0 { folderIDsInt, err := handleIDList(ids, "ids") if err != nil { return nil, err } folderIDs = models.FolderIDsFromInts(folderIDsInt) } if err := r.withReadTxn(ctx, func(ctx context.Context) error { var folders []*models.Folder var err error fields := collectQueryFields(ctx) result := &models.FolderQueryResult{} if len(folderIDs) > 0 { folders, err = r.repository.Folder.FindMany(ctx, folderIDs) if err == nil { result.Count = len(folders) } } else { result, err = r.repository.Folder.Query(ctx, models.FolderQueryOptions{ QueryOptions: models.QueryOptions{ FindFilter: filter, Count: fields.Has("count"), }, FolderFilter: folderFilter, }) if err == nil { folders, err = result.Resolve(ctx) } } if err != nil { return err } ret = &FindFoldersResultType{ Count: result.Count, Folders: folders, } return nil }); err != nil { return nil, err } return ret, nil } ================================================ FILE: internal/api/resolver_query_find_gallery.go ================================================ package api import ( "context" "strconv" "github.com/stashapp/stash/pkg/models" ) func (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models.Gallery, err error) { idInt, err := strconv.Atoi(id) if err != nil { return nil, err } if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Gallery.Find(ctx, idInt) return err }); err != nil { return nil, err } return ret, nil } func (r *queryResolver) FindGalleries(ctx context.Context, galleryFilter *models.GalleryFilterType, filter *models.FindFilterType, ids []string) (ret *FindGalleriesResultType, err error) { idInts, err := handleIDList(ids, "ids") if err != nil { return nil, err } if err := r.withReadTxn(ctx, func(ctx context.Context) error { var galleries []*models.Gallery var err error var total int if len(idInts) > 0 { galleries, err = r.repository.Gallery.FindMany(ctx, idInts) total = len(galleries) } else { galleries, total, err = r.repository.Gallery.Query(ctx, galleryFilter, filter) } if err != nil { return err } ret = &FindGalleriesResultType{ Count: total, Galleries: galleries, } return nil }); err != nil { return nil, err } return ret, nil } func (r *queryResolver) AllGalleries(ctx context.Context) (ret []*models.Gallery, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Gallery.All(ctx) return err }); err != nil { return nil, err } return ret, nil } ================================================ FILE: internal/api/resolver_query_find_group.go ================================================ package api import ( "context" "strconv" "github.com/stashapp/stash/pkg/models" ) func (r *queryResolver) FindGroup(ctx context.Context, id string) (ret *models.Group, err error) { idInt, err := strconv.Atoi(id) if err != nil { return nil, err } if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Group.Find(ctx, idInt) return err }); err != nil { return nil, err } return ret, nil } func (r *queryResolver) FindGroups(ctx context.Context, groupFilter *models.GroupFilterType, filter *models.FindFilterType, ids []string) (ret *FindGroupsResultType, err error) { idInts, err := handleIDList(ids, "ids") if err != nil { return nil, err } if err := r.withReadTxn(ctx, func(ctx context.Context) error { var groups []*models.Group var err error var total int if len(idInts) > 0 { groups, err = r.repository.Group.FindMany(ctx, idInts) total = len(groups) } else { groups, total, err = r.repository.Group.Query(ctx, groupFilter, filter) } if err != nil { return err } ret = &FindGroupsResultType{ Count: total, Groups: groups, } return nil }); err != nil { return nil, err } return ret, nil } ================================================ FILE: internal/api/resolver_query_find_image.go ================================================ package api import ( "context" "slices" "strconv" "github.com/99designs/gqlgen/graphql" "github.com/stashapp/stash/pkg/models" ) func (r *queryResolver) FindImage(ctx context.Context, id *string, checksum *string) (*models.Image, error) { var image *models.Image if err := r.withReadTxn(ctx, func(ctx context.Context) error { qb := r.repository.Image var err error if id != nil { idInt, err := strconv.Atoi(*id) if err != nil { return err } image, err = qb.Find(ctx, idInt) if err != nil { return err } } else if checksum != nil { var images []*models.Image images, err = qb.FindByChecksum(ctx, *checksum) if err != nil { return err } if len(images) > 0 { image = images[0] } } return err }); err != nil { return nil, err } return image, nil } func (r *queryResolver) FindImages( ctx context.Context, imageFilter *models.ImageFilterType, imageIds []int, ids []string, filter *models.FindFilterType, ) (ret *FindImagesResultType, err error) { if len(ids) > 0 { imageIds, err = handleIDList(ids, "ids") if err != nil { return nil, err } } if err := r.withReadTxn(ctx, func(ctx context.Context) error { qb := r.repository.Image var images []*models.Image fields := graphql.CollectAllFields(ctx) result := &models.ImageQueryResult{} if len(imageIds) > 0 { images, err = r.repository.Image.FindMany(ctx, imageIds) if err == nil { result.Count = len(images) for _, s := range images { if err = s.LoadPrimaryFile(ctx, r.repository.File); err != nil { break } f := s.Files.Primary() if f == nil { continue } imageFile, ok := f.(*models.ImageFile) if !ok { continue } result.Megapixels += float64(imageFile.Width*imageFile.Height) / float64(1000000) result.TotalSize += float64(f.Base().Size) } } } else { result, err = qb.Query(ctx, models.ImageQueryOptions{ QueryOptions: models.QueryOptions{ FindFilter: filter, Count: slices.Contains(fields, "count"), }, ImageFilter: imageFilter, Megapixels: slices.Contains(fields, "megapixels"), TotalSize: slices.Contains(fields, "filesize"), }) if err == nil { images, err = result.Resolve(ctx) } } if err != nil { return err } ret = &FindImagesResultType{ Count: result.Count, Images: images, Megapixels: result.Megapixels, Filesize: result.TotalSize, } return nil }); err != nil { return nil, err } return ret, nil } func (r *queryResolver) AllImages(ctx context.Context) (ret []*models.Image, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Image.All(ctx) return err }); err != nil { return nil, err } return ret, nil } ================================================ FILE: internal/api/resolver_query_find_movie.go ================================================ package api import ( "context" "strconv" "github.com/stashapp/stash/pkg/models" ) func (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.Group, err error) { idInt, err := strconv.Atoi(id) if err != nil { return nil, err } if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Group.Find(ctx, idInt) return err }); err != nil { return nil, err } return ret, nil } func (r *queryResolver) FindMovies(ctx context.Context, movieFilter *models.GroupFilterType, filter *models.FindFilterType, ids []string) (ret *FindMoviesResultType, err error) { idInts, err := handleIDList(ids, "ids") if err != nil { return nil, err } if err := r.withReadTxn(ctx, func(ctx context.Context) error { var groups []*models.Group var err error var total int if len(idInts) > 0 { groups, err = r.repository.Group.FindMany(ctx, idInts) total = len(groups) } else { groups, total, err = r.repository.Group.Query(ctx, movieFilter, filter) } if err != nil { return err } ret = &FindMoviesResultType{ Count: total, Movies: groups, } return nil }); err != nil { return nil, err } return ret, nil } func (r *queryResolver) AllMovies(ctx context.Context) (ret []*models.Group, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Group.All(ctx) return err }); err != nil { return nil, err } return ret, nil } ================================================ FILE: internal/api/resolver_query_find_performer.go ================================================ package api import ( "context" "strconv" "github.com/stashapp/stash/pkg/models" ) func (r *queryResolver) FindPerformer(ctx context.Context, id string) (ret *models.Performer, err error) { idInt, err := strconv.Atoi(id) if err != nil { return nil, err } if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Performer.Find(ctx, idInt) return err }); err != nil { return nil, err } return ret, nil } func (r *queryResolver) FindPerformers(ctx context.Context, performerFilter *models.PerformerFilterType, filter *models.FindFilterType, performerIDs []int, ids []string) (ret *FindPerformersResultType, err error) { if len(ids) > 0 { performerIDs, err = handleIDList(ids, "ids") if err != nil { return nil, err } } // #5682 - convert JSON numbers to float64 or int64 if performerFilter != nil { performerFilter.CustomFields = convertCustomFieldCriterionInputJSONNumbers(performerFilter.CustomFields) } if err := r.withReadTxn(ctx, func(ctx context.Context) error { var performers []*models.Performer var err error var total int if len(performerIDs) > 0 { performers, err = r.repository.Performer.FindMany(ctx, performerIDs) total = len(performers) } else { performers, total, err = r.repository.Performer.Query(ctx, performerFilter, filter) } if err != nil { return err } ret = &FindPerformersResultType{ Count: total, Performers: performers, } return nil }); err != nil { return nil, err } return ret, nil } func (r *queryResolver) AllPerformers(ctx context.Context) (ret []*models.Performer, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Performer.All(ctx) return err }); err != nil { return nil, err } return ret, nil } ================================================ FILE: internal/api/resolver_query_find_saved_filter.go ================================================ package api import ( "context" "strconv" "strings" "github.com/mitchellh/mapstructure" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) func (r *queryResolver) FindSavedFilter(ctx context.Context, id string) (ret *models.SavedFilter, err error) { idInt, err := strconv.Atoi(id) if err != nil { return nil, err } if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.SavedFilter.Find(ctx, idInt) return err }); err != nil { return nil, err } return ret, err } func (r *queryResolver) FindSavedFilters(ctx context.Context, mode *models.FilterMode) (ret []*models.SavedFilter, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { if mode != nil { ret, err = r.repository.SavedFilter.FindByMode(ctx, *mode) } else { ret, err = r.repository.SavedFilter.All(ctx) } return err }); err != nil { return nil, err } return ret, err } func (r *queryResolver) FindDefaultFilter(ctx context.Context, mode models.FilterMode) (ret *models.SavedFilter, err error) { // deprecated - read from the config in the meantime config := config.GetInstance() uiConfig := config.GetUIConfiguration() if uiConfig == nil { return nil, nil } m := utils.NestedMap(uiConfig) filterRaw, _ := m.Get("defaultFilters." + strings.ToLower(mode.String())) if filterRaw == nil { return nil, nil } ret = &models.SavedFilter{} d, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ TagName: "json", WeaklyTypedInput: true, Result: ret, }) if err != nil { return nil, err } if err := d.Decode(filterRaw); err != nil { return nil, err } return ret, nil } ================================================ FILE: internal/api/resolver_query_find_scene.go ================================================ package api import ( "context" "slices" "strconv" "github.com/99designs/gqlgen/graphql" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" ) func (r *queryResolver) FindScene(ctx context.Context, id *string, checksum *string) (*models.Scene, error) { var scene *models.Scene if err := r.withReadTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene var err error if id != nil { idInt, err := strconv.Atoi(*id) if err != nil { return err } scene, err = qb.Find(ctx, idInt) if err != nil { return err } } else if checksum != nil { var scenes []*models.Scene scenes, err = qb.FindByChecksum(ctx, *checksum) if len(scenes) > 0 { scene = scenes[0] } } return err }); err != nil { return nil, err } return scene, nil } func (r *queryResolver) FindSceneByHash(ctx context.Context, input SceneHashInput) (*models.Scene, error) { var scene *models.Scene if err := r.withReadTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene if input.Checksum != nil { scenes, err := qb.FindByChecksum(ctx, *input.Checksum) if err != nil { return err } if len(scenes) > 0 { scene = scenes[0] } } if scene == nil && input.Oshash != nil { scenes, err := qb.FindByOSHash(ctx, *input.Oshash) if err != nil { return err } if len(scenes) > 0 { scene = scenes[0] } } return nil }); err != nil { return nil, err } return scene, nil } func (r *queryResolver) FindScenes( ctx context.Context, sceneFilter *models.SceneFilterType, sceneIDs []int, ids []string, filter *models.FindFilterType, ) (ret *FindScenesResultType, err error) { if len(ids) > 0 { sceneIDs, err = handleIDList(ids, "ids") if err != nil { return nil, err } } if err := r.withReadTxn(ctx, func(ctx context.Context) error { var scenes []*models.Scene var err error fields := graphql.CollectAllFields(ctx) result := &models.SceneQueryResult{} if len(sceneIDs) > 0 { scenes, err = r.repository.Scene.FindMany(ctx, sceneIDs) if err == nil { result.Count = len(scenes) for _, s := range scenes { if err = s.LoadPrimaryFile(ctx, r.repository.File); err != nil { break } f := s.Files.Primary() if f == nil { continue } result.TotalDuration += f.Duration result.TotalSize += float64(f.Size) } } } else { result, err = r.repository.Scene.Query(ctx, models.SceneQueryOptions{ QueryOptions: models.QueryOptions{ FindFilter: filter, Count: slices.Contains(fields, "count"), }, SceneFilter: sceneFilter, TotalDuration: slices.Contains(fields, "duration"), TotalSize: slices.Contains(fields, "filesize"), }) if err == nil { scenes, err = result.Resolve(ctx) } } if err != nil { return err } ret = &FindScenesResultType{ Count: result.Count, Scenes: scenes, Duration: result.TotalDuration, Filesize: result.TotalSize, } return nil }); err != nil { return nil, err } return ret, nil } func (r *queryResolver) FindScenesByPathRegex(ctx context.Context, filter *models.FindFilterType) (ret *FindScenesResultType, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { sceneFilter := &models.SceneFilterType{} if filter != nil && filter.Q != nil { sceneFilter.Path = &models.StringCriterionInput{ Modifier: models.CriterionModifierMatchesRegex, Value: "(?i)" + *filter.Q, } } // make a copy of the filter if provided, nilling out Q var queryFilter *models.FindFilterType if filter != nil { f := *filter queryFilter = &f queryFilter.Q = nil } fields := graphql.CollectAllFields(ctx) result, err := r.repository.Scene.Query(ctx, models.SceneQueryOptions{ QueryOptions: models.QueryOptions{ FindFilter: queryFilter, Count: slices.Contains(fields, "count"), }, SceneFilter: sceneFilter, TotalDuration: slices.Contains(fields, "duration"), TotalSize: slices.Contains(fields, "filesize"), }) if err != nil { return err } scenes, err := result.Resolve(ctx) if err != nil { return err } ret = &FindScenesResultType{ Count: result.Count, Scenes: scenes, Duration: result.TotalDuration, Filesize: result.TotalSize, } return nil }); err != nil { return nil, err } return ret, nil } func (r *queryResolver) ParseSceneFilenames(ctx context.Context, filter *models.FindFilterType, config models.SceneParserInput) (ret *SceneParserResultType, err error) { repo := scene.NewFilenameParserRepository(r.repository) parser := scene.NewFilenameParser(filter, config, repo) if err := r.withReadTxn(ctx, func(ctx context.Context) error { result, count, err := parser.Parse(ctx) if err != nil { return err } ret = &SceneParserResultType{ Count: count, Results: result, } return nil }); err != nil { return nil, err } return ret, nil } func (r *queryResolver) FindDuplicateScenes(ctx context.Context, distance *int, durationDiff *float64) (ret [][]*models.Scene, err error) { dist := 0 durDiff := -1. if distance != nil { dist = *distance } if durationDiff != nil { durDiff = *durationDiff } if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Scene.FindDuplicates(ctx, dist, durDiff) return err }); err != nil { return nil, err } return ret, nil } func (r *queryResolver) AllScenes(ctx context.Context) (ret []*models.Scene, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Scene.All(ctx) return err }); err != nil { return nil, err } return ret, nil } ================================================ FILE: internal/api/resolver_query_find_scene_marker.go ================================================ package api import ( "context" "github.com/stashapp/stash/pkg/models" ) func (r *queryResolver) FindSceneMarkers(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, filter *models.FindFilterType, ids []string) (ret *FindSceneMarkersResultType, err error) { idInts, err := handleIDList(ids, "ids") if err != nil { return nil, err } if err := r.withReadTxn(ctx, func(ctx context.Context) error { var sceneMarkers []*models.SceneMarker var err error var total int if len(idInts) > 0 { sceneMarkers, err = r.repository.SceneMarker.FindMany(ctx, idInts) total = len(sceneMarkers) } else { sceneMarkers, total, err = r.repository.SceneMarker.Query(ctx, sceneMarkerFilter, filter) } if err != nil { return err } ret = &FindSceneMarkersResultType{ Count: total, SceneMarkers: sceneMarkers, } return nil }); err != nil { return nil, err } return ret, nil } func (r *queryResolver) AllSceneMarkers(ctx context.Context) (ret []*models.SceneMarker, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.SceneMarker.All(ctx) return err }); err != nil { return nil, err } return ret, nil } ================================================ FILE: internal/api/resolver_query_find_studio.go ================================================ package api import ( "context" "strconv" "github.com/stashapp/stash/pkg/models" ) func (r *queryResolver) FindStudio(ctx context.Context, id string) (ret *models.Studio, err error) { idInt, err := strconv.Atoi(id) if err != nil { return nil, err } if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error ret, err = r.repository.Studio.Find(ctx, idInt) return err }); err != nil { return nil, err } return ret, nil } func (r *queryResolver) FindStudios(ctx context.Context, studioFilter *models.StudioFilterType, filter *models.FindFilterType, ids []string) (ret *FindStudiosResultType, err error) { idInts, err := handleIDList(ids, "ids") if err != nil { return nil, err } if err := r.withReadTxn(ctx, func(ctx context.Context) error { var studios []*models.Studio var err error var total int if len(idInts) > 0 { studios, err = r.repository.Studio.FindMany(ctx, idInts) total = len(studios) } else { studios, total, err = r.repository.Studio.Query(ctx, studioFilter, filter) } if err != nil { return err } ret = &FindStudiosResultType{ Count: total, Studios: studios, } return nil }); err != nil { return nil, err } return ret, nil } func (r *queryResolver) AllStudios(ctx context.Context) (ret []*models.Studio, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Studio.All(ctx) return err }); err != nil { return nil, err } return ret, nil } ================================================ FILE: internal/api/resolver_query_find_tag.go ================================================ package api import ( "context" "strconv" "github.com/stashapp/stash/pkg/models" ) func (r *queryResolver) FindTag(ctx context.Context, id string) (ret *models.Tag, err error) { idInt, err := strconv.Atoi(id) if err != nil { return nil, err } if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Tag.Find(ctx, idInt) return err }); err != nil { return nil, err } return ret, nil } func (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilterType, filter *models.FindFilterType, ids []string) (ret *FindTagsResultType, err error) { idInts, err := handleIDList(ids, "ids") if err != nil { return nil, err } if err := r.withReadTxn(ctx, func(ctx context.Context) error { var tags []*models.Tag var err error var total int if len(idInts) > 0 { tags, err = r.repository.Tag.FindMany(ctx, idInts) total = len(tags) } else { tags, total, err = r.repository.Tag.Query(ctx, tagFilter, filter) } if err != nil { return err } ret = &FindTagsResultType{ Count: total, Tags: tags, } return nil }); err != nil { return nil, err } return ret, nil } func (r *queryResolver) AllTags(ctx context.Context) (ret []*models.Tag, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Tag.All(ctx) if err != nil { return err } return nil }); err != nil { return nil, err } return ret, nil } ================================================ FILE: internal/api/resolver_query_job.go ================================================ package api import ( "context" "strconv" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/job" ) func (r *queryResolver) JobQueue(ctx context.Context) ([]*Job, error) { queue := manager.GetInstance().JobManager.GetQueue() var ret []*Job for _, j := range queue { ret = append(ret, jobToJobModel(j)) } return ret, nil } func (r *queryResolver) FindJob(ctx context.Context, input FindJobInput) (*Job, error) { jobID, err := strconv.Atoi(input.ID) if err != nil { return nil, err } j := manager.GetInstance().JobManager.GetJob(jobID) if j == nil { return nil, nil } return jobToJobModel(*j), nil } func jobToJobModel(j job.Job) *Job { ret := &Job{ ID: strconv.Itoa(j.ID), Status: JobStatus(j.Status), Description: j.Description, SubTasks: j.Details, StartTime: j.StartTime, EndTime: j.EndTime, AddTime: j.AddTime, Error: j.Error, } if j.Progress != -1 { ret.Progress = &j.Progress } return ret } ================================================ FILE: internal/api/resolver_query_logs.go ================================================ package api import ( "context" "github.com/stashapp/stash/internal/manager" ) func (r *queryResolver) Logs(ctx context.Context) ([]*LogEntry, error) { logger := manager.GetInstance().Logger logCache := logger.GetLogCache() ret := make([]*LogEntry, len(logCache)) for i, entry := range logCache { ret[i] = &LogEntry{ Time: entry.Time, Level: getLogLevel(entry.Type), Message: entry.Message, } } return ret, nil } ================================================ FILE: internal/api/resolver_query_metadata.go ================================================ package api import ( "context" "github.com/stashapp/stash/internal/manager" ) func (r *queryResolver) SystemStatus(ctx context.Context) (*manager.SystemStatus, error) { return manager.GetInstance().GetSystemStatus(), nil } ================================================ FILE: internal/api/resolver_query_package.go ================================================ package api import ( "context" "errors" "fmt" "slices" "sort" "strings" "github.com/99designs/gqlgen/graphql" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/pkg" ) var ErrInvalidPackageType = errors.New("invalid package type") func getPackageManager(typeArg PackageType) (*pkg.Manager, error) { var pm *pkg.Manager switch typeArg { case PackageTypeScraper: pm = manager.GetInstance().ScraperPackageManager case PackageTypePlugin: pm = manager.GetInstance().PluginPackageManager default: return nil, ErrInvalidPackageType } if pm == nil { return nil, fmt.Errorf("%s package manager not initialized", typeArg) } return pm, nil } func manifestToPackage(p pkg.Manifest) *Package { ret := &Package{ PackageID: p.ID, Name: p.Name, SourceURL: p.RepositoryURL, } if len(p.Version) > 0 { ret.Version = &p.Version } if !p.Date.IsZero() { ret.Date = &p.Date.Time } ret.Metadata = p.Metadata if ret.Metadata == nil { ret.Metadata = make(map[string]interface{}) } return ret } func remotePackageToPackage(p pkg.RemotePackage, index pkg.RemotePackageIndex) *Package { ret := &Package{ PackageID: p.ID, Name: p.Name, } if len(p.Version) > 0 { ret.Version = &p.Version } if !p.Date.IsZero() { ret.Date = &p.Date.Time } ret.Metadata = p.Metadata if ret.Metadata == nil { ret.Metadata = make(map[string]interface{}) } ret.SourceURL = p.Repository.Path() for _, r := range p.Requires { // required packages must come from the same source spec := models.PackageSpecInput{ ID: r, SourceURL: p.Repository.Path(), } req, found := index[spec] if !found { // shouldn't happen, but we'll ignore it continue } ret.Requires = append(ret.Requires, remotePackageToPackage(req, index)) } return ret } func sortedPackageSpecKeys[V any](m map[models.PackageSpecInput]V) []models.PackageSpecInput { // sort keys var keys []models.PackageSpecInput for k := range m { keys = append(keys, k) } sort.Slice(keys, func(i, j int) bool { a := keys[i] b := keys[j] aID := a.ID bID := b.ID if aID == bID { return a.SourceURL < b.SourceURL } aIDL := strings.ToLower(aID) bIDL := strings.ToLower(bID) if aIDL == bIDL { return aID < bID } return aIDL < bIDL }) return keys } func (r *queryResolver) getInstalledPackagesWithUpgrades(ctx context.Context, pm *pkg.Manager) ([]*Package, error) { // get all installed packages installed, err := pm.ListInstalled(ctx) if err != nil { return nil, err } // get remotes for all installed packages allRemoteList, err := pm.ListInstalledRemotes(ctx, installed) if err != nil { return nil, err } packageStatusIndex := pkg.MakePackageStatusIndex(installed, allRemoteList) ret := make([]*Package, len(packageStatusIndex)) i := 0 for _, k := range sortedPackageSpecKeys(packageStatusIndex) { v := packageStatusIndex[k] p := manifestToPackage(*v.Local) if v.Remote != nil { pp := remotePackageToPackage(*v.Remote, allRemoteList) p.SourcePackage = pp } ret[i] = p i++ } return ret, nil } func (r *queryResolver) InstalledPackages(ctx context.Context, typeArg PackageType) ([]*Package, error) { pm, err := getPackageManager(typeArg) if err != nil { return nil, err } var ret []*Package if slices.Contains(graphql.CollectAllFields(ctx), "source_package") { ret, err = r.getInstalledPackagesWithUpgrades(ctx, pm) if err != nil { return nil, err } } else { installed, err := pm.ListInstalled(ctx) if err != nil { return nil, err } ret = make([]*Package, len(installed)) i := 0 for _, k := range sortedPackageSpecKeys(installed) { ret[i] = manifestToPackage(installed[k]) i++ } } return ret, nil } func (r *queryResolver) AvailablePackages(ctx context.Context, typeArg PackageType, source string) ([]*Package, error) { pm, err := getPackageManager(typeArg) if err != nil { return nil, err } available, err := pm.ListRemote(ctx, source) if err != nil { return nil, err } ret := make([]*Package, len(available)) i := 0 for _, k := range sortedPackageSpecKeys(available) { p := available[k] ret[i] = remotePackageToPackage(p, available) i++ } return ret, nil } ================================================ FILE: internal/api/resolver_query_plugin.go ================================================ package api import ( "context" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/plugin" ) func (r *queryResolver) Plugins(ctx context.Context) ([]*plugin.Plugin, error) { return manager.GetInstance().PluginCache.ListPlugins(), nil } func (r *queryResolver) PluginTasks(ctx context.Context) ([]*plugin.PluginTask, error) { return manager.GetInstance().PluginCache.ListPluginTasks(), nil } ================================================ FILE: internal/api/resolver_query_scene.go ================================================ package api import ( "context" "fmt" "strconv" "github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/models" ) func (r *queryResolver) SceneStreams(ctx context.Context, id *string) ([]*manager.SceneStreamEndpoint, error) { sceneID, err := strconv.Atoi(*id) if err != nil { return nil, err } // find the scene var scene *models.Scene if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error scene, err = r.repository.Scene.Find(ctx, sceneID) if scene != nil { err = scene.LoadPrimaryFile(ctx, r.repository.File) } return err }); err != nil { return nil, err } if scene == nil { return nil, fmt.Errorf("scene with id %d not found", sceneID) } config := manager.GetInstance().Config baseURL, _ := ctx.Value(BaseURLCtxKey).(string) builder := urlbuilders.NewSceneURLBuilder(baseURL, scene) apiKey := config.GetAPIKey() return manager.GetSceneStreamPaths(scene, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize()) } ================================================ FILE: internal/api/resolver_query_scraper.go ================================================ package api import ( "context" "errors" "fmt" "slices" "strconv" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scraper" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) func (r *queryResolver) ScrapeURL(ctx context.Context, url string, ty scraper.ScrapeContentType) (scraper.ScrapedContent, error) { return r.scraperCache().ScrapeURL(ctx, url, ty) } func (r *queryResolver) ListScrapers(ctx context.Context, types []scraper.ScrapeContentType) ([]*scraper.Scraper, error) { return r.scraperCache().ListScrapers(types), nil } func (r *queryResolver) ScrapePerformerURL(ctx context.Context, url string) (*models.ScrapedPerformer, error) { content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypePerformer) if err != nil { return nil, err } return marshalScrapedPerformer(content) } func (r *queryResolver) ScrapeSceneQuery(ctx context.Context, scraperID string, query string) ([]*models.ScrapedScene, error) { if query == "" { return nil, nil } content, err := r.scraperCache().ScrapeName(ctx, scraperID, query, scraper.ScrapeContentTypeScene) if err != nil { return nil, err } ret, err := marshalScrapedScenes(content) if err != nil { return nil, err } return ret, nil } func (r *queryResolver) ScrapeSceneURL(ctx context.Context, url string) (*models.ScrapedScene, error) { content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeScene) if err != nil { return nil, err } ret, err := marshalScrapedScene(content) if err != nil { return nil, err } return ret, nil } func (r *queryResolver) ScrapeGalleryURL(ctx context.Context, url string) (*models.ScrapedGallery, error) { content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeGallery) if err != nil { return nil, err } ret, err := marshalScrapedGallery(content) if err != nil { return nil, err } return ret, nil } func (r *queryResolver) ScrapeImageURL(ctx context.Context, url string) (*models.ScrapedImage, error) { content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeImage) if err != nil { return nil, err } return marshalScrapedImage(content) } func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models.ScrapedMovie, error) { content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeMovie) if err != nil { return nil, err } ret, err := marshalScrapedMovie(content) if err != nil { return nil, err } return ret, nil } func (r *queryResolver) ScrapeGroupURL(ctx context.Context, url string) (*models.ScrapedGroup, error) { content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeGroup) if err != nil { return nil, err } ret, err := marshalScrapedGroup(content) if err != nil { return nil, err } // convert to scraped group group := &models.ScrapedGroup{ StoredID: ret.StoredID, Name: ret.Name, Aliases: ret.Aliases, Duration: ret.Duration, Date: ret.Date, Rating: ret.Rating, Director: ret.Director, URLs: ret.URLs, Synopsis: ret.Synopsis, Studio: ret.Studio, Tags: ret.Tags, FrontImage: ret.FrontImage, BackImage: ret.BackImage, } return group, nil } func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.Source, input ScrapeSingleSceneInput) ([]*models.ScrapedScene, error) { var ret []*models.ScrapedScene var sceneID int if input.SceneID != nil { var err error sceneID, err = strconv.Atoi(*input.SceneID) if err != nil { return nil, fmt.Errorf("%w: sceneID is not an integer: '%s'", ErrInput, *input.SceneID) } } switch { case source.ScraperID != nil: var err error var c scraper.ScrapedContent var content []scraper.ScrapedContent switch { case input.SceneID != nil: c, err = r.scraperCache().ScrapeID(ctx, *source.ScraperID, sceneID, scraper.ScrapeContentTypeScene) if c != nil { content = []scraper.ScrapedContent{c} } case input.SceneInput != nil: c, err = r.scraperCache().ScrapeFragment(ctx, *source.ScraperID, scraper.Input{Scene: input.SceneInput}) if c != nil { content = []scraper.ScrapedContent{c} } case input.Query != nil: content, err = r.scraperCache().ScrapeName(ctx, *source.ScraperID, *input.Query, scraper.ScrapeContentTypeScene) default: err = fmt.Errorf("%w: scene_id, scene_input, or query must be set", ErrInput) } if err != nil { return nil, err } ret, err = marshalScrapedScenes(content) if err != nil { return nil, err } case source.StashBoxIndex != nil || source.StashBoxEndpoint != nil: b, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint) if err != nil { return nil, err } client := r.newStashBoxClient(*b) switch { case input.SceneID != nil: var fps []models.Fingerprints fps, err = r.getScenesFingerprints(ctx, []int{sceneID}) if err != nil { return nil, err } ret, err = client.FindSceneByFingerprints(ctx, fps[0]) case input.Query != nil: ret, err = client.QueryScene(ctx, *input.Query) default: return nil, fmt.Errorf("%w: scene_id or query must be set", ErrInput) } if err != nil { return nil, err } // TODO - this should happen after any scene is scraped if err := r.matchScenesRelationships(ctx, ret, b.Endpoint); err != nil { return nil, err } default: return nil, fmt.Errorf("%w: scraper_id or stash_box_index must be set", ErrInput) } for i := range ret { slices.SortFunc(ret[i].Tags, models.ScrapedTagSortFunction) } return ret, nil } func (r *queryResolver) ScrapeMultiScenes(ctx context.Context, source scraper.Source, input ScrapeMultiScenesInput) ([][]*models.ScrapedScene, error) { if source.ScraperID != nil { return nil, ErrNotImplemented } else if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil { b, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint) if err != nil { return nil, err } client := r.newStashBoxClient(*b) sceneIDs, err := stringslice.StringSliceToIntSlice(input.SceneIds) if err != nil { return nil, err } fps, err := r.getScenesFingerprints(ctx, sceneIDs) if err != nil { return nil, err } ret, err := client.FindScenesByFingerprints(ctx, fps) if err != nil { return nil, err } // match relationships - this mutates the existing scenes so we can // just flatten the slice and pass it in flat := sliceutil.Flatten(ret) if err := r.matchScenesRelationships(ctx, flat, b.Endpoint); err != nil { return nil, err } return ret, nil } return nil, errors.New("scraper_id or stash_box_index must be set") } func (r *queryResolver) getScenesFingerprints(ctx context.Context, ids []int) ([]models.Fingerprints, error) { fingerprints := make([]models.Fingerprints, len(ids)) if err := r.withReadTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene for i, sceneID := range ids { scene, err := qb.Find(ctx, sceneID) if err != nil { return err } if scene == nil { return fmt.Errorf("scene with id %d not found", sceneID) } if err := scene.LoadFiles(ctx, qb); err != nil { return err } var sceneFPs models.Fingerprints for _, f := range scene.Files.List() { sceneFPs = append(sceneFPs, f.Fingerprints...) } fingerprints[i] = sceneFPs } return nil }); err != nil { return nil, err } return fingerprints, nil } // matchSceneRelationships accepts scraped scenes and attempts to match its relationships to existing stash models. func (r *queryResolver) matchScenesRelationships(ctx context.Context, ss []*models.ScrapedScene, endpoint string) error { if err := r.withReadTxn(ctx, func(ctx context.Context) error { matcher := match.SceneRelationships{ PerformerFinder: r.repository.Performer, TagFinder: r.repository.Tag, StudioFinder: r.repository.Studio, } for _, s := range ss { if err := matcher.MatchRelationships(ctx, s, endpoint); err != nil { return err } } return nil }); err != nil { return err } return nil } func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.Source, input ScrapeSingleStudioInput) ([]*models.ScrapedStudio, error) { if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil { b, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint) if err != nil { return nil, err } client := r.newStashBoxClient(*b) var ret []*models.ScrapedStudio out, err := client.FindStudio(ctx, *input.Query) if err != nil { return nil, err } else if out != nil { ret = append(ret, out) } if len(ret) > 0 { if err := r.withReadTxn(ctx, func(ctx context.Context) error { for _, studio := range ret { if err := match.ScrapedStudioHierarchy(ctx, r.repository.Studio, studio, b.Endpoint); err != nil { return err } } return nil }); err != nil { return nil, err } return ret, nil } return nil, nil } return nil, errors.New("stash_box_endpoint must be set") } func (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Source, input ScrapeSingleTagInput) ([]*models.ScrapedTag, error) { if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil { b, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint) if err != nil { return nil, err } client := r.newStashBoxClient(*b) var ret []*models.ScrapedTag out, err := client.QueryTag(ctx, *input.Query) if err != nil { return nil, err } else if out != nil { ret = append(ret, out...) } if len(ret) > 0 { if err := r.withReadTxn(ctx, func(ctx context.Context) error { for _, tag := range ret { if err := match.ScrapedTag(ctx, r.repository.Tag, tag, b.Endpoint); err != nil { return err } } return nil }); err != nil { return nil, err } return ret, nil } return nil, nil } return nil, errors.New("stash_box_endpoint must be set") } func (r *queryResolver) ScrapeSinglePerformer(ctx context.Context, source scraper.Source, input ScrapeSinglePerformerInput) ([]*models.ScrapedPerformer, error) { var ret []*models.ScrapedPerformer switch { case source.ScraperID != nil: switch { case input.PerformerInput != nil: performer, err := r.scraperCache().ScrapeFragment(ctx, *source.ScraperID, scraper.Input{Performer: input.PerformerInput}) if err != nil { return nil, err } ret, err = marshalScrapedPerformers([]scraper.ScrapedContent{performer}) if err != nil { return nil, err } case input.Query != nil: content, err := r.scraperCache().ScrapeName(ctx, *source.ScraperID, *input.Query, scraper.ScrapeContentTypePerformer) if err != nil { return nil, err } ret, err = marshalScrapedPerformers(content) if err != nil { return nil, err } default: return nil, ErrNotImplemented } case source.StashBoxIndex != nil || source.StashBoxEndpoint != nil: b, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint) if err != nil { return nil, err } client := r.newStashBoxClient(*b) var query string switch { case input.PerformerID != nil: names, err := r.findPerformerNames(ctx, []string{*input.PerformerID}) if err != nil { return nil, err } query = names[0] case input.Query != nil: query = *input.Query default: return nil, ErrNotImplemented } if query == "" { return nil, nil } ret, err = client.QueryPerformer(ctx, query) if err != nil { return nil, err } default: return nil, errors.New("scraper_id or stash_box_index must be set") } return ret, nil } func (r *queryResolver) ScrapeMultiPerformers(ctx context.Context, source scraper.Source, input ScrapeMultiPerformersInput) ([][]*models.ScrapedPerformer, error) { if source.ScraperID != nil { return nil, ErrNotImplemented } else if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil { names, err := r.findPerformerNames(ctx, input.PerformerIds) if err != nil { return nil, err } b, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint) if err != nil { return nil, err } client := r.newStashBoxClient(*b) return client.QueryPerformers(ctx, names) } return nil, errors.New("scraper_id or stash_box_index must be set") } func (r *queryResolver) findPerformerNames(ctx context.Context, performerIDs []string) ([]string, error) { ids, err := stringslice.StringSliceToIntSlice(performerIDs) if err != nil { return nil, err } names := make([]string, len(ids)) if err := r.withReadTxn(ctx, func(ctx context.Context) error { p, err := r.repository.Performer.FindMany(ctx, ids) if err != nil { return err } for i, pp := range p { names[i] = pp.Name } return nil }); err != nil { return nil, err } return names, nil } func (r *queryResolver) ScrapeSingleGallery(ctx context.Context, source scraper.Source, input ScrapeSingleGalleryInput) ([]*models.ScrapedGallery, error) { var ret []*models.ScrapedGallery if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil { return nil, ErrNotSupported } if source.ScraperID == nil { return nil, fmt.Errorf("%w: scraper_id must be set", ErrInput) } var c scraper.ScrapedContent switch { case input.GalleryID != nil: galleryID, err := strconv.Atoi(*input.GalleryID) if err != nil { return nil, fmt.Errorf("%w: gallery id is not an integer: '%s'", ErrInput, *input.GalleryID) } c, err = r.scraperCache().ScrapeID(ctx, *source.ScraperID, galleryID, scraper.ScrapeContentTypeGallery) if err != nil { return nil, err } ret, err = marshalScrapedGalleries([]scraper.ScrapedContent{c}) if err != nil { return nil, err } case input.GalleryInput != nil: c, err := r.scraperCache().ScrapeFragment(ctx, *source.ScraperID, scraper.Input{Gallery: input.GalleryInput}) if err != nil { return nil, err } ret, err = marshalScrapedGalleries([]scraper.ScrapedContent{c}) if err != nil { return nil, err } default: return nil, ErrNotImplemented } return ret, nil } func (r *queryResolver) ScrapeSingleImage(ctx context.Context, source scraper.Source, input ScrapeSingleImageInput) ([]*models.ScrapedImage, error) { if source.StashBoxIndex != nil { return nil, ErrNotSupported } if source.ScraperID == nil { return nil, fmt.Errorf("%w: scraper_id must be set", ErrInput) } var c scraper.ScrapedContent switch { case input.ImageID != nil: imageID, err := strconv.Atoi(*input.ImageID) if err != nil { return nil, fmt.Errorf("%w: image id is not an integer: '%s'", ErrInput, *input.ImageID) } c, err = r.scraperCache().ScrapeID(ctx, *source.ScraperID, imageID, scraper.ScrapeContentTypeImage) if err != nil { return nil, err } return marshalScrapedImages([]scraper.ScrapedContent{c}) case input.ImageInput != nil: c, err := r.scraperCache().ScrapeFragment(ctx, *source.ScraperID, scraper.Input{Image: input.ImageInput}) if err != nil { return nil, err } return marshalScrapedImages([]scraper.ScrapedContent{c}) default: return nil, ErrNotImplemented } } func (r *queryResolver) ScrapeSingleMovie(ctx context.Context, source scraper.Source, input ScrapeSingleMovieInput) ([]*models.ScrapedMovie, error) { return nil, ErrNotSupported } func (r *queryResolver) ScrapeSingleGroup(ctx context.Context, source scraper.Source, input ScrapeSingleGroupInput) ([]*models.ScrapedGroup, error) { return nil, ErrNotSupported } ================================================ FILE: internal/api/resolver_subscription_job.go ================================================ package api import ( "context" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/job" ) func makeJobStatusUpdate(t JobStatusUpdateType, j job.Job) *JobStatusUpdate { return &JobStatusUpdate{ Type: t, Job: jobToJobModel(j), } } func (r *subscriptionResolver) JobsSubscribe(ctx context.Context) (<-chan *JobStatusUpdate, error) { msg := make(chan *JobStatusUpdate, 100) subscription := manager.GetInstance().JobManager.Subscribe(ctx) go func() { for { select { case j := <-subscription.NewJob: msg <- makeJobStatusUpdate(JobStatusUpdateTypeAdd, j) case j := <-subscription.RemovedJob: msg <- makeJobStatusUpdate(JobStatusUpdateTypeRemove, j) case j := <-subscription.UpdatedJob: msg <- makeJobStatusUpdate(JobStatusUpdateTypeUpdate, j) case <-ctx.Done(): close(msg) return } } }() return msg, nil } func (r *subscriptionResolver) ScanCompleteSubscribe(ctx context.Context) (<-chan bool, error) { return manager.GetInstance().ScanSubscribe(ctx), nil } ================================================ FILE: internal/api/resolver_subscription_logging.go ================================================ package api import ( "context" "github.com/stashapp/stash/internal/log" "github.com/stashapp/stash/internal/manager" ) func getLogLevel(logType string) LogLevel { switch logType { case "progress": return LogLevelProgress case "trace": return LogLevelTrace case "debug": return LogLevelDebug case "info": return LogLevelInfo case "warn": return LogLevelWarning case "error": return LogLevelError default: return LogLevelDebug } } func logEntriesFromLogItems(logItems []log.LogItem) []*LogEntry { ret := make([]*LogEntry, len(logItems)) for i, entry := range logItems { ret[i] = &LogEntry{ Time: entry.Time, Level: getLogLevel(entry.Type), Message: entry.Message, } } return ret } func (r *subscriptionResolver) LoggingSubscribe(ctx context.Context) (<-chan []*LogEntry, error) { ret := make(chan []*LogEntry, 100) stop := make(chan int, 1) logger := manager.GetInstance().Logger logSub := logger.SubscribeToLog(stop) go func() { for { select { case logEntries := <-logSub: ret <- logEntriesFromLogItems(logEntries) case <-ctx.Done(): stop <- 0 close(ret) return } } }() return ret, nil } ================================================ FILE: internal/api/routes.go ================================================ package api import ( "net/http" "github.com/stashapp/stash/pkg/txn" ) type routes struct { txnManager txn.Manager } func (rs routes) withReadTxn(r *http.Request, fn txn.TxnFunc) error { return txn.WithReadTxn(r.Context(), rs.txnManager, fn) } ================================================ FILE: internal/api/routes_custom.go ================================================ package api import ( "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/stashapp/stash/pkg/utils" ) type customRoutes struct { servedFolders utils.URLMap } func getCustomRoutes(servedFolders utils.URLMap) chi.Router { return customRoutes{servedFolders: servedFolders}.Routes() } func (rs customRoutes) Routes() chi.Router { r := chi.NewRouter() r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { r.URL.Path = strings.Replace(r.URL.Path, "/custom", "", 1) // http.FileServer redirects to / if the path ends with index.html r.URL.Path = strings.TrimSuffix(r.URL.Path, "/index.html") // map the path to the applicable filesystem location var dir string r.URL.Path, dir = rs.servedFolders.GetFilesystemLocation(r.URL.Path) if dir != "" { http.FileServer(http.Dir(dir)).ServeHTTP(w, r) } else { http.NotFound(w, r) } }) return r } ================================================ FILE: internal/api/routes_downloads.go ================================================ package api import ( "context" "net/http" "github.com/go-chi/chi/v5" "github.com/stashapp/stash/internal/manager" ) type downloadsRoutes struct{} func (rs downloadsRoutes) Routes() chi.Router { r := chi.NewRouter() r.Route("/{downloadHash}", func(r chi.Router) { r.Use(downloadCtx) r.Get("/{filename}", rs.file) }) return r } func (rs downloadsRoutes) file(w http.ResponseWriter, r *http.Request) { hash := r.Context().Value(downloadKey).(string) if hash == "" { http.Error(w, http.StatusText(404), 404) return } manager.GetInstance().DownloadStore.Serve(hash, w, r) } func downloadCtx(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { downloadHash := chi.URLParam(r, "downloadHash") ctx := context.WithValue(r.Context(), downloadKey, downloadHash) next.ServeHTTP(w, r.WithContext(ctx)) }) } ================================================ FILE: internal/api/routes_gallery.go ================================================ package api import ( "context" "errors" "net/http" "strconv" "github.com/go-chi/chi/v5" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/internal/static" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) type GalleryFinder interface { models.GalleryGetter FindByChecksum(ctx context.Context, checksum string) ([]*models.Gallery, error) } type GalleryImageFinder interface { FindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*models.Image, error) image.Queryer image.CoverQueryer } type galleryRoutes struct { routes imageRoutes imageRoutes galleryFinder GalleryFinder imageFinder GalleryImageFinder fileGetter models.FileGetter } func (rs galleryRoutes) Routes() chi.Router { r := chi.NewRouter() r.Route("/{galleryId}", func(r chi.Router) { r.Use(rs.GalleryCtx) r.Get("/cover", rs.Cover) r.Get("/preview/{imageIndex}", rs.Preview) }) return r } func (rs galleryRoutes) Cover(w http.ResponseWriter, r *http.Request) { g := r.Context().Value(galleryKey).(*models.Gallery) var i *models.Image _ = rs.withReadTxn(r, func(ctx context.Context) error { // Find cover image first i, _ = image.FindGalleryCover(ctx, rs.imageFinder, g.ID, config.GetInstance().GetGalleryCoverRegex()) if i == nil { return nil } // serveThumbnail needs files populated if err := i.LoadPrimaryFile(ctx, rs.fileGetter); err != nil { if !errors.Is(err, context.Canceled) { logger.Errorf("error loading primary file for image %d: %v", i.ID, err) } // set image to nil so that it doesn't try to use the primary file i = nil } return nil }) if i == nil { // fallback to default image image := static.ReadAll(static.DefaultGalleryImage) utils.ServeImage(w, r, image) return } rs.imageRoutes.serveThumbnail(w, r, i, &g.UpdatedAt) } func (rs galleryRoutes) Preview(w http.ResponseWriter, r *http.Request) { g := r.Context().Value(galleryKey).(*models.Gallery) indexQueryParam := chi.URLParam(r, "imageIndex") var i *models.Image index, err := strconv.Atoi(indexQueryParam) if err != nil || index < 0 { http.Error(w, "bad index", 400) return } _ = rs.withReadTxn(r, func(ctx context.Context) error { qb := rs.imageFinder i, _ = qb.FindByGalleryIDIndex(ctx, g.ID, uint(index)) if i == nil { return nil } // TODO - handle errors? // serveThumbnail needs files populated if err := i.LoadPrimaryFile(ctx, rs.fileGetter); err != nil { if !errors.Is(err, context.Canceled) { logger.Errorf("error loading primary file for image %d: %v", i.ID, err) } // set image to nil so that it doesn't try to use the primary file i = nil } return nil }) if i == nil { http.Error(w, http.StatusText(404), 404) return } rs.imageRoutes.serveThumbnail(w, r, i, nil) } func (rs galleryRoutes) GalleryCtx(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { galleryIdentifierQueryParam := chi.URLParam(r, "galleryId") galleryID, _ := strconv.Atoi(galleryIdentifierQueryParam) var gallery *models.Gallery _ = rs.withReadTxn(r, func(ctx context.Context) error { qb := rs.galleryFinder if galleryID == 0 { galleries, _ := qb.FindByChecksum(ctx, galleryIdentifierQueryParam) if len(galleries) > 0 { gallery = galleries[0] } } else { gallery, _ = qb.Find(ctx, galleryID) } if gallery != nil { if err := gallery.LoadPrimaryFile(ctx, rs.fileGetter); err != nil { if !errors.Is(err, context.Canceled) { logger.Errorf("error loading primary file for gallery %d: %v", galleryID, err) } // set image to nil so that it doesn't try to use the primary file gallery = nil } } return nil }) if gallery == nil { http.Error(w, http.StatusText(404), 404) return } ctx := context.WithValue(r.Context(), galleryKey, gallery) next.ServeHTTP(w, r.WithContext(ctx)) }) } ================================================ FILE: internal/api/routes_group.go ================================================ package api import ( "context" "errors" "net/http" "strconv" "github.com/go-chi/chi/v5" "github.com/stashapp/stash/internal/static" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) type GroupFinder interface { models.GroupGetter GetFrontImage(ctx context.Context, groupID int) ([]byte, error) GetBackImage(ctx context.Context, groupID int) ([]byte, error) } type groupRoutes struct { routes groupFinder GroupFinder } func (rs groupRoutes) Routes() chi.Router { r := chi.NewRouter() r.Route("/{groupId}", func(r chi.Router) { r.Use(rs.GroupCtx) r.Get("/frontimage", rs.FrontImage) r.Get("/backimage", rs.BackImage) }) return r } func (rs groupRoutes) FrontImage(w http.ResponseWriter, r *http.Request) { group := r.Context().Value(groupKey).(*models.Group) defaultParam := r.URL.Query().Get("default") var image []byte if defaultParam != "true" { readTxnErr := rs.withReadTxn(r, func(ctx context.Context) error { var err error image, err = rs.groupFinder.GetFrontImage(ctx, group.ID) return err }) if errors.Is(readTxnErr, context.Canceled) { return } if readTxnErr != nil { logger.Warnf("read transaction error on fetch group front image: %v", readTxnErr) } } // fallback to default image if len(image) == 0 { image = static.ReadAll(static.DefaultGroupImage) } utils.ServeImage(w, r, image) } func (rs groupRoutes) BackImage(w http.ResponseWriter, r *http.Request) { group := r.Context().Value(groupKey).(*models.Group) defaultParam := r.URL.Query().Get("default") var image []byte if defaultParam != "true" { readTxnErr := rs.withReadTxn(r, func(ctx context.Context) error { var err error image, err = rs.groupFinder.GetBackImage(ctx, group.ID) return err }) if errors.Is(readTxnErr, context.Canceled) { return } if readTxnErr != nil { logger.Warnf("read transaction error on fetch group back image: %v", readTxnErr) } } // fallback to default image if len(image) == 0 { image = static.ReadAll(static.DefaultGroupImage) } utils.ServeImage(w, r, image) } func (rs groupRoutes) GroupCtx(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { groupID, err := strconv.Atoi(chi.URLParam(r, "groupId")) if err != nil { http.Error(w, http.StatusText(404), 404) return } var group *models.Group _ = rs.withReadTxn(r, func(ctx context.Context) error { group, _ = rs.groupFinder.Find(ctx, groupID) return nil }) if group == nil { http.Error(w, http.StatusText(404), 404) return } ctx := context.WithValue(r.Context(), groupKey, group) next.ServeHTTP(w, r.WithContext(ctx)) }) } ================================================ FILE: internal/api/routes_image.go ================================================ package api import ( "context" "errors" "io/fs" "net/http" "os/exec" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/static" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) type ImageFinder interface { models.ImageGetter FindByChecksum(ctx context.Context, checksum string) ([]*models.Image, error) } type imageRoutes struct { routes imageFinder ImageFinder fileGetter models.FileGetter } func (rs imageRoutes) Routes() chi.Router { r := chi.NewRouter() r.Route("/{imageId}", func(r chi.Router) { r.Use(rs.ImageCtx) r.Get("/image", rs.Image) r.Get("/thumbnail", rs.Thumbnail) r.Get("/preview", rs.Preview) }) return r } func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) { img := r.Context().Value(imageKey).(*models.Image) rs.serveThumbnail(w, r, img, nil) } func (rs imageRoutes) serveThumbnail(w http.ResponseWriter, r *http.Request, img *models.Image, modTime *time.Time) { mgr := manager.GetInstance() filepath := mgr.Paths.Generated.GetThumbnailPath(img.Checksum, models.DefaultGthumbWidth) // if the thumbnail doesn't exist, encode on the fly exists, _ := fsutil.FileExists(filepath) if exists { if modTime == nil { utils.ServeStaticFile(w, r, filepath) } else { utils.ServeStaticFileModTime(w, r, filepath, *modTime) } } else { const useDefault = true f := img.Files.Primary() if f == nil { rs.serveImage(w, r, img, useDefault) return } // use the image thumbnail generate wait group to limit the number of concurrent thumbnail generation tasks wg := &mgr.ImageThumbnailGenerateWaitGroup wg.Add() defer wg.Done() clipPreviewOptions := image.ClipPreviewOptions{ InputArgs: manager.GetInstance().Config.GetTranscodeInputArgs(), OutputArgs: manager.GetInstance().Config.GetTranscodeOutputArgs(), Preset: manager.GetInstance().Config.GetPreviewPreset().String(), } encoder := image.NewThumbnailEncoder(manager.GetInstance().FFMpeg, manager.GetInstance().FFProbe, clipPreviewOptions) data, err := encoder.GetThumbnail(f, models.DefaultGthumbWidth) if err != nil { // don't log for unsupported image format // don't log for file not found - can optionally be logged in serveImage if !errors.Is(err, image.ErrNotSupportedForThumbnail) && !errors.Is(err, fs.ErrNotExist) { logger.Errorf("error generating thumbnail for %s: %v", f.Base().Path, err) var exitErr *exec.ExitError if errors.As(err, &exitErr) { logger.Errorf("stderr: %s", string(exitErr.Stderr)) } } // backwards compatibility - fallback to original image instead rs.serveImage(w, r, img, useDefault) return } // write the generated thumbnail to disk if enabled if manager.GetInstance().Config.IsWriteImageThumbnails() { logger.Debugf("writing thumbnail to disk: %s", img.Path) if err := fsutil.WriteFile(filepath, data); err == nil { utils.ServeStaticFile(w, r, filepath) return } logger.Errorf("error writing thumbnail for image %s: %v", img.Path, err) } utils.ServeStaticContent(w, r, data) } } func (rs imageRoutes) Preview(w http.ResponseWriter, r *http.Request) { img := r.Context().Value(imageKey).(*models.Image) filepath := manager.GetInstance().Paths.Generated.GetClipPreviewPath(img.Checksum, models.DefaultGthumbWidth) // don't check if the preview exists - we'll just return a 404 if it doesn't utils.ServeStaticFile(w, r, filepath) } func (rs imageRoutes) Image(w http.ResponseWriter, r *http.Request) { i := r.Context().Value(imageKey).(*models.Image) const useDefault = false rs.serveImage(w, r, i, useDefault) } func (rs imageRoutes) serveImage(w http.ResponseWriter, r *http.Request, i *models.Image, useDefault bool) { if i.Files.Primary() != nil { err := i.Files.Primary().Base().Serve(&file.OsFS{}, w, r) if err == nil { return } if !useDefault { http.Error(w, err.Error(), http.StatusInternalServerError) return } // only log in debug since it can get noisy logger.Debugf("Error serving %s: %v", i.DisplayName(), err) } if !useDefault { http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) return } // fallback to default image image := static.ReadAll(static.DefaultImageImage) utils.ServeImage(w, r, image) } func (rs imageRoutes) ImageCtx(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { imageIdentifierQueryParam := chi.URLParam(r, "imageId") imageID, _ := strconv.Atoi(imageIdentifierQueryParam) var image *models.Image _ = rs.withReadTxn(r, func(ctx context.Context) error { qb := rs.imageFinder if imageID == 0 { images, _ := qb.FindByChecksum(ctx, imageIdentifierQueryParam) if len(images) > 0 { image = images[0] } } else { image, _ = qb.Find(ctx, imageID) } if image != nil { if err := image.LoadPrimaryFile(ctx, rs.fileGetter); err != nil { if !errors.Is(err, context.Canceled) { logger.Errorf("error loading primary file for image %d: %v", imageID, err) } // set image to nil so that it doesn't try to use the primary file image = nil } } return nil }) if image == nil { http.Error(w, http.StatusText(404), 404) return } ctx := context.WithValue(r.Context(), imageKey, image) next.ServeHTTP(w, r.WithContext(ctx)) }) } ================================================ FILE: internal/api/routes_performer.go ================================================ package api import ( "context" "errors" "net/http" "strconv" "github.com/go-chi/chi/v5" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) type PerformerFinder interface { models.PerformerGetter GetImage(ctx context.Context, performerID int) ([]byte, error) } type sfwConfig interface { GetSFWContentMode() bool } type performerRoutes struct { routes performerFinder PerformerFinder sfwConfig sfwConfig } func (rs performerRoutes) Routes() chi.Router { r := chi.NewRouter() r.Route("/{performerId}", func(r chi.Router) { r.Use(rs.PerformerCtx) r.Get("/image", rs.Image) }) return r } func (rs performerRoutes) Image(w http.ResponseWriter, r *http.Request) { performer := r.Context().Value(performerKey).(*models.Performer) defaultParam := r.URL.Query().Get("default") var image []byte if defaultParam != "true" { readTxnErr := rs.withReadTxn(r, func(ctx context.Context) error { var err error image, err = rs.performerFinder.GetImage(ctx, performer.ID) return err }) if errors.Is(readTxnErr, context.Canceled) { return } if readTxnErr != nil { logger.Warnf("read transaction error on fetch performer image: %v", readTxnErr) } } if len(image) == 0 { image = getDefaultPerformerImage(performer.Name, performer.Gender, rs.sfwConfig.GetSFWContentMode()) } utils.ServeImage(w, r, image) } func (rs performerRoutes) PerformerCtx(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { performerID, err := strconv.Atoi(chi.URLParam(r, "performerId")) if err != nil { http.Error(w, http.StatusText(404), 404) return } var performer *models.Performer _ = rs.withReadTxn(r, func(ctx context.Context) error { var err error performer, err = rs.performerFinder.Find(ctx, performerID) return err }) if performer == nil { http.Error(w, http.StatusText(404), 404) return } ctx := context.WithValue(r.Context(), performerKey, performer) next.ServeHTTP(w, r.WithContext(ctx)) }) } ================================================ FILE: internal/api/routes_plugin.go ================================================ package api import ( "context" "net/http" "path/filepath" "strings" "github.com/go-chi/chi/v5" "github.com/stashapp/stash/pkg/plugin" ) type pluginRoutes struct { pluginCache *plugin.Cache } func (rs pluginRoutes) Routes() chi.Router { r := chi.NewRouter() r.Route("/{pluginId}", func(r chi.Router) { r.Use(rs.PluginCtx) r.Get("/assets", rs.Assets) r.Get("/assets/*", rs.Assets) r.Get("/javascript", rs.Javascript) r.Get("/css", rs.CSS) }) return r } func (rs pluginRoutes) Assets(w http.ResponseWriter, r *http.Request) { p := r.Context().Value(pluginKey).(*plugin.Plugin) if !p.Enabled { http.Error(w, "plugin disabled", http.StatusBadRequest) return } prefix := "/plugin/" + chi.URLParam(r, "pluginId") + "/assets" r.URL.Path = strings.Replace(r.URL.Path, prefix, "", 1) // http.FileServer redirects to / if the path ends with index.html r.URL.Path = strings.TrimSuffix(r.URL.Path, "/index.html") pluginDir := filepath.Dir(p.ConfigPath) // map the path to the applicable filesystem location var dir string r.URL.Path, dir = p.UI.Assets.GetFilesystemLocation(r.URL.Path) if dir == "" { http.NotFound(w, r) return } dir = filepath.Join(pluginDir, filepath.FromSlash(dir)) // ensure directory is still within the plugin directory if !strings.HasPrefix(dir, pluginDir) { http.NotFound(w, r) return } http.FileServer(http.Dir(dir)).ServeHTTP(w, r) } func (rs pluginRoutes) Javascript(w http.ResponseWriter, r *http.Request) { p := r.Context().Value(pluginKey).(*plugin.Plugin) if !p.Enabled { http.Error(w, "plugin disabled", http.StatusBadRequest) return } w.Header().Set("Content-Type", "text/javascript") serveFiles(w, r, p.UI.Javascript) } func (rs pluginRoutes) CSS(w http.ResponseWriter, r *http.Request) { p := r.Context().Value(pluginKey).(*plugin.Plugin) if !p.Enabled { http.Error(w, "plugin disabled", http.StatusBadRequest) return } w.Header().Set("Content-Type", "text/css") serveFiles(w, r, p.UI.CSS) } func (rs pluginRoutes) PluginCtx(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := rs.pluginCache.GetPlugin(chi.URLParam(r, "pluginId")) if p == nil { http.Error(w, http.StatusText(404), 404) return } ctx := context.WithValue(r.Context(), pluginKey, p) next.ServeHTTP(w, r.WithContext(ctx)) }) } ================================================ FILE: internal/api/routes_scene.go ================================================ package api import ( "bytes" "context" "errors" "net/http" "strconv" "strings" "github.com/go-chi/chi/v5" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/internal/static" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/file/video" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) type SceneFinder interface { models.SceneGetter FindByChecksum(ctx context.Context, checksum string) ([]*models.Scene, error) FindByOSHash(ctx context.Context, oshash string) ([]*models.Scene, error) GetCover(ctx context.Context, sceneID int) ([]byte, error) } type SceneMarkerFinder interface { models.SceneMarkerGetter FindBySceneID(ctx context.Context, sceneID int) ([]*models.SceneMarker, error) } type SceneMarkerTagFinder interface { models.TagGetter FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*models.Tag, error) } type CaptionFinder interface { GetCaptions(ctx context.Context, fileID models.FileID) ([]*models.VideoCaption, error) } type sceneRoutes struct { routes sceneFinder SceneFinder fileGetter models.FileGetter captionFinder CaptionFinder sceneMarkerFinder SceneMarkerFinder tagFinder SceneMarkerTagFinder } func (rs sceneRoutes) Routes() chi.Router { r := chi.NewRouter() r.Route("/{sceneId}", func(r chi.Router) { r.Use(rs.SceneCtx) // streaming endpoints r.Get("/stream", rs.StreamDirect) r.Get("/stream.mp4", rs.StreamMp4) r.Get("/stream.webm", rs.StreamWebM) r.Get("/stream.mkv", rs.StreamMKV) r.Get("/stream.m3u8", rs.StreamHLS) r.Get("/stream.m3u8/{segment}.ts", rs.StreamHLSSegment) r.Get("/stream.mpd", rs.StreamDASH) r.Get("/stream.mpd/{segment}_v.webm", rs.StreamDASHVideoSegment) r.Get("/stream.mpd/{segment}_a.webm", rs.StreamDASHAudioSegment) r.Get("/screenshot", rs.Screenshot) r.Get("/preview", rs.Preview) r.Get("/webp", rs.Webp) r.Get("/vtt/chapter", rs.VttChapter) r.Get("/vtt/thumbs", rs.VttThumbs) r.Get("/vtt/sprite", rs.VttSprite) r.Get("/funscript", rs.Funscript) r.Get("/interactive_csv", rs.InteractiveCSV) r.Get("/interactive_heatmap", rs.InteractiveHeatmap) r.Get("/caption", rs.CaptionLang) r.Get("/scene_marker/{sceneMarkerId}/stream", rs.SceneMarkerStream) r.Get("/scene_marker/{sceneMarkerId}/preview", rs.SceneMarkerPreview) r.Get("/scene_marker/{sceneMarkerId}/screenshot", rs.SceneMarkerScreenshot) }) r.Get("/{sceneHash}_thumbs.vtt", rs.VttThumbs) r.Get("/{sceneHash}_sprite.jpg", rs.VttSprite) return r } func (rs sceneRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) ss := manager.SceneServer{ TxnManager: rs.txnManager, SceneCoverGetter: rs.sceneFinder, } ss.StreamSceneDirect(scene, w, r) } func (rs sceneRoutes) StreamMp4(w http.ResponseWriter, r *http.Request) { rs.streamTranscode(w, r, ffmpeg.StreamTypeMP4) } func (rs sceneRoutes) StreamWebM(w http.ResponseWriter, r *http.Request) { rs.streamTranscode(w, r, ffmpeg.StreamTypeWEBM) } func (rs sceneRoutes) StreamMKV(w http.ResponseWriter, r *http.Request) { // only allow mkv streaming if the scene container is an mkv already scene := r.Context().Value(sceneKey).(*models.Scene) pf := scene.Files.Primary() if pf == nil { return } container, err := manager.GetVideoFileContainer(pf) if err != nil { logger.Errorf("[transcode] error getting container: %v", err) } if container != ffmpeg.Matroska { w.WriteHeader(http.StatusBadRequest) if _, err := w.Write([]byte("not an mkv file")); err != nil { logger.Warnf("[stream] error writing to stream: %v", err) } return } rs.streamTranscode(w, r, ffmpeg.StreamTypeMKV) } func (rs sceneRoutes) streamTranscode(w http.ResponseWriter, r *http.Request, streamType ffmpeg.StreamFormat) { scene := r.Context().Value(sceneKey).(*models.Scene) streamManager := manager.GetInstance().StreamManager if streamManager == nil { http.Error(w, "Live transcoding disabled", http.StatusServiceUnavailable) return } f := scene.Files.Primary() if f == nil { return } if err := r.ParseForm(); err != nil { logger.Warnf("[transcode] error parsing query form: %v", err) } startTime := r.Form.Get("start") ss, _ := strconv.ParseFloat(startTime, 64) resolution := r.Form.Get("resolution") options := ffmpeg.TranscodeOptions{ StreamType: streamType, VideoFile: f, Resolution: resolution, StartTime: ss, } logger.Debugf("[transcode] streaming scene %d as %s", scene.ID, streamType.MimeType) streamManager.ServeTranscode(w, r, options) } func (rs sceneRoutes) StreamHLS(w http.ResponseWriter, r *http.Request) { rs.streamManifest(w, r, ffmpeg.StreamTypeHLS, "HLS") } func (rs sceneRoutes) StreamDASH(w http.ResponseWriter, r *http.Request) { rs.streamManifest(w, r, ffmpeg.StreamTypeDASHVideo, "DASH") } func (rs sceneRoutes) streamManifest(w http.ResponseWriter, r *http.Request, streamType *ffmpeg.StreamType, logName string) { scene := r.Context().Value(sceneKey).(*models.Scene) streamManager := manager.GetInstance().StreamManager if streamManager == nil { http.Error(w, "Live transcoding disabled", http.StatusServiceUnavailable) return } f := scene.Files.Primary() if f == nil { return } if err := r.ParseForm(); err != nil { logger.Warnf("[transcode] error parsing query form: %v", err) } resolution := r.Form.Get("resolution") logger.Debugf("[transcode] returning %s manifest for scene %d", logName, scene.ID) streamManager.ServeManifest(w, r, streamType, f, resolution) } func (rs sceneRoutes) StreamHLSSegment(w http.ResponseWriter, r *http.Request) { rs.streamSegment(w, r, ffmpeg.StreamTypeHLS) } func (rs sceneRoutes) StreamDASHVideoSegment(w http.ResponseWriter, r *http.Request) { rs.streamSegment(w, r, ffmpeg.StreamTypeDASHVideo) } func (rs sceneRoutes) StreamDASHAudioSegment(w http.ResponseWriter, r *http.Request) { rs.streamSegment(w, r, ffmpeg.StreamTypeDASHAudio) } func (rs sceneRoutes) streamSegment(w http.ResponseWriter, r *http.Request, streamType *ffmpeg.StreamType) { scene := r.Context().Value(sceneKey).(*models.Scene) streamManager := manager.GetInstance().StreamManager if streamManager == nil { http.Error(w, "Live transcoding disabled", http.StatusServiceUnavailable) return } f := scene.Files.Primary() if f == nil { return } if err := r.ParseForm(); err != nil { logger.Warnf("[transcode] error parsing query form: %v", err) } sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()) segment := chi.URLParam(r, "segment") resolution := r.Form.Get("resolution") options := ffmpeg.StreamOptions{ StreamType: streamType, VideoFile: f, Resolution: resolution, Hash: sceneHash, Segment: segment, } streamManager.ServeSegment(w, r, options) } func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) { // if default flag is set, return the default image if r.URL.Query().Get("default") == "true" { utils.ServeImage(w, r, static.ReadAll(static.DefaultSceneImage)) return } scene := r.Context().Value(sceneKey).(*models.Scene) ss := manager.SceneServer{ TxnManager: rs.txnManager, SceneCoverGetter: rs.sceneFinder, } ss.ServeScreenshot(scene, w, r) } func (rs sceneRoutes) Preview(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()) filepath := manager.GetInstance().Paths.Scene.GetVideoPreviewPath(sceneHash) utils.ServeStaticFile(w, r, filepath) } func (rs sceneRoutes) Webp(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()) filepath := manager.GetInstance().Paths.Scene.GetWebpPreviewPath(sceneHash) utils.ServeStaticFile(w, r, filepath) } func (rs sceneRoutes) getChapterVttTitle(r *http.Request, marker *models.SceneMarker) (*string, error) { if marker.Title != "" { return &marker.Title, nil } var title string if err := rs.withReadTxn(r, func(ctx context.Context) error { qb := rs.tagFinder primaryTag, err := qb.Find(ctx, marker.PrimaryTagID) if err != nil { return err } title = primaryTag.Name tags, err := qb.FindBySceneMarkerID(ctx, marker.ID) if err != nil { return err } for _, t := range tags { title += ", " + t.Name } return nil }); err != nil { return nil, err } return &title, nil } func (rs sceneRoutes) VttChapter(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) var sceneMarkers []*models.SceneMarker readTxnErr := rs.withReadTxn(r, func(ctx context.Context) error { var err error sceneMarkers, err = rs.sceneMarkerFinder.FindBySceneID(ctx, scene.ID) return err }) if errors.Is(readTxnErr, context.Canceled) { return } if readTxnErr != nil { logger.Warnf("read transaction error on fetch scene markers: %v", readTxnErr) http.Error(w, readTxnErr.Error(), http.StatusInternalServerError) return } vttLines := []string{"WEBVTT", ""} for i, marker := range sceneMarkers { vttLines = append(vttLines, strconv.Itoa(i+1)) time := utils.GetVTTTime(marker.Seconds) vttLines = append(vttLines, time+" --> "+time) vttTitle, err := rs.getChapterVttTitle(r, marker) if errors.Is(err, context.Canceled) { return } if err != nil { logger.Warnf("read transaction error on fetch scene marker title: %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } vttLines = append(vttLines, *vttTitle) vttLines = append(vttLines, "") } vtt := strings.Join(vttLines, "\n") w.Header().Set("Content-Type", "text/vtt") utils.ServeStaticContent(w, r, []byte(vtt)) } func (rs sceneRoutes) VttThumbs(w http.ResponseWriter, r *http.Request) { scene, ok := r.Context().Value(sceneKey).(*models.Scene) var sceneHash string if ok && scene != nil { sceneHash = scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()) } else { sceneHash = chi.URLParam(r, "sceneHash") } filepath := manager.GetInstance().Paths.Scene.GetSpriteVttFilePath(sceneHash) w.Header().Set("Content-Type", "text/vtt") utils.ServeStaticFile(w, r, filepath) } func (rs sceneRoutes) VttSprite(w http.ResponseWriter, r *http.Request) { scene, ok := r.Context().Value(sceneKey).(*models.Scene) var sceneHash string if ok && scene != nil { sceneHash = scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()) } else { sceneHash = chi.URLParam(r, "sceneHash") } filepath := manager.GetInstance().Paths.Scene.GetSpriteImageFilePath(sceneHash) utils.ServeStaticFile(w, r, filepath) } func (rs sceneRoutes) Funscript(w http.ResponseWriter, r *http.Request) { s := r.Context().Value(sceneKey).(*models.Scene) filepath := video.GetFunscriptPath(s.Path) utils.ServeStaticFile(w, r, filepath) } func (rs sceneRoutes) InteractiveCSV(w http.ResponseWriter, r *http.Request) { s := r.Context().Value(sceneKey).(*models.Scene) filepath := video.GetFunscriptPath(s.Path) // TheHandy directly only accepts interactive CSVs csvBytes, err := manager.ConvertFunscriptToCSV(filepath) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } utils.ServeStaticContent(w, r, csvBytes) } func (rs sceneRoutes) InteractiveHeatmap(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()) filepath := manager.GetInstance().Paths.Scene.GetInteractiveHeatmapPath(sceneHash) utils.ServeStaticFile(w, r, filepath) } func (rs sceneRoutes) Caption(w http.ResponseWriter, r *http.Request, lang string, ext string) { s := r.Context().Value(sceneKey).(*models.Scene) var captions []*models.VideoCaption readTxnErr := rs.withReadTxn(r, func(ctx context.Context) error { var err error primaryFile := s.Files.Primary() if primaryFile == nil { return nil } captions, err = rs.captionFinder.GetCaptions(ctx, primaryFile.Base().ID) return err }) if errors.Is(readTxnErr, context.Canceled) { return } if readTxnErr != nil { logger.Warnf("read transaction error on fetch scene captions: %v", readTxnErr) http.Error(w, readTxnErr.Error(), http.StatusInternalServerError) return } for _, caption := range captions { if lang != caption.LanguageCode || ext != caption.CaptionType { continue } sub, err := video.ReadSubs(caption.Path(s.Path)) if err != nil { logger.Warnf("error while reading subs: %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } var buf bytes.Buffer err = sub.WriteToWebVTT(&buf) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/vtt") utils.ServeStaticContent(w, r, buf.Bytes()) return } } func (rs sceneRoutes) CaptionLang(w http.ResponseWriter, r *http.Request) { // serve caption based on lang query param, if provided if err := r.ParseForm(); err != nil { logger.Warnf("[caption] error parsing query form: %v", err) } l := r.Form.Get("lang") ext := r.Form.Get("type") rs.Caption(w, r, l, ext) } func (rs sceneRoutes) SceneMarkerStream(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()) sceneMarkerID, _ := strconv.Atoi(chi.URLParam(r, "sceneMarkerId")) var sceneMarker *models.SceneMarker readTxnErr := rs.withReadTxn(r, func(ctx context.Context) error { var err error sceneMarker, err = rs.sceneMarkerFinder.Find(ctx, sceneMarkerID) return err }) if errors.Is(readTxnErr, context.Canceled) { return } if readTxnErr != nil { logger.Warnf("read transaction error on fetch scene marker: %v", readTxnErr) http.Error(w, readTxnErr.Error(), http.StatusInternalServerError) return } if sceneMarker == nil { http.Error(w, http.StatusText(404), 404) return } filepath := manager.GetInstance().Paths.SceneMarkers.GetVideoPreviewPath(sceneHash, int(sceneMarker.Seconds)) utils.ServeStaticFile(w, r, filepath) } func (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()) sceneMarkerID, _ := strconv.Atoi(chi.URLParam(r, "sceneMarkerId")) var sceneMarker *models.SceneMarker readTxnErr := rs.withReadTxn(r, func(ctx context.Context) error { var err error sceneMarker, err = rs.sceneMarkerFinder.Find(ctx, sceneMarkerID) return err }) if errors.Is(readTxnErr, context.Canceled) { return } if readTxnErr != nil { logger.Warnf("read transaction error on fetch scene marker preview: %v", readTxnErr) http.Error(w, readTxnErr.Error(), http.StatusInternalServerError) return } if sceneMarker == nil { http.Error(w, http.StatusText(404), 404) return } filepath := manager.GetInstance().Paths.SceneMarkers.GetWebpPreviewPath(sceneHash, int(sceneMarker.Seconds)) // If the image doesn't exist, send the placeholder exists, _ := fsutil.FileExists(filepath) if !exists { w.Header().Set("Content-Type", "image/png") utils.ServeStaticContent(w, r, utils.PendingGenerateResource) } else { utils.ServeStaticFile(w, r, filepath) } } func (rs sceneRoutes) SceneMarkerScreenshot(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()) sceneMarkerID, _ := strconv.Atoi(chi.URLParam(r, "sceneMarkerId")) var sceneMarker *models.SceneMarker readTxnErr := rs.withReadTxn(r, func(ctx context.Context) error { var err error sceneMarker, err = rs.sceneMarkerFinder.Find(ctx, sceneMarkerID) return err }) if errors.Is(readTxnErr, context.Canceled) { return } if readTxnErr != nil { logger.Warnf("read transaction error on fetch scene marker screenshot: %v", readTxnErr) http.Error(w, readTxnErr.Error(), http.StatusInternalServerError) return } if sceneMarker == nil { http.Error(w, http.StatusText(404), 404) return } filepath := manager.GetInstance().Paths.SceneMarkers.GetScreenshotPath(sceneHash, int(sceneMarker.Seconds)) // If the image doesn't exist, send the placeholder exists, _ := fsutil.FileExists(filepath) if !exists { w.Header().Set("Content-Type", "image/png") utils.ServeStaticContent(w, r, utils.PendingGenerateResource) } else { utils.ServeStaticFile(w, r, filepath) } } func (rs sceneRoutes) SceneCtx(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sceneID, err := strconv.Atoi(chi.URLParam(r, "sceneId")) if err != nil { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } var scene *models.Scene _ = rs.withReadTxn(r, func(ctx context.Context) error { qb := rs.sceneFinder scene, _ = qb.Find(ctx, sceneID) if scene != nil { if err := scene.LoadPrimaryFile(ctx, rs.fileGetter); err != nil { if !errors.Is(err, context.Canceled) { logger.Errorf("error loading primary file for scene %d: %v", sceneID, err) } // set scene to nil so that it doesn't try to use the primary file scene = nil } } return nil }) if scene == nil { http.Error(w, http.StatusText(404), 404) return } ctx := context.WithValue(r.Context(), sceneKey, scene) next.ServeHTTP(w, r.WithContext(ctx)) }) } ================================================ FILE: internal/api/routes_studio.go ================================================ package api import ( "context" "errors" "net/http" "strconv" "github.com/go-chi/chi/v5" "github.com/stashapp/stash/internal/static" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) type StudioFinder interface { models.StudioGetter GetImage(ctx context.Context, studioID int) ([]byte, error) } type studioRoutes struct { routes studioFinder StudioFinder } func (rs studioRoutes) Routes() chi.Router { r := chi.NewRouter() r.Route("/{studioId}", func(r chi.Router) { r.Use(rs.StudioCtx) r.Get("/image", rs.Image) }) return r } func (rs studioRoutes) Image(w http.ResponseWriter, r *http.Request) { studio := r.Context().Value(studioKey).(*models.Studio) defaultParam := r.URL.Query().Get("default") var image []byte if defaultParam != "true" { readTxnErr := rs.withReadTxn(r, func(ctx context.Context) error { var err error image, err = rs.studioFinder.GetImage(ctx, studio.ID) return err }) if errors.Is(readTxnErr, context.Canceled) { return } if readTxnErr != nil { logger.Warnf("read transaction error on fetch studio image: %v", readTxnErr) } } // fallback to default image if len(image) == 0 { image = static.ReadAll(static.DefaultStudioImage) } utils.ServeImage(w, r, image) } func (rs studioRoutes) StudioCtx(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { studioID, err := strconv.Atoi(chi.URLParam(r, "studioId")) if err != nil { http.Error(w, http.StatusText(404), 404) return } var studio *models.Studio _ = rs.withReadTxn(r, func(ctx context.Context) error { var err error studio, err = rs.studioFinder.Find(ctx, studioID) return err }) if studio == nil { http.Error(w, http.StatusText(404), 404) return } ctx := context.WithValue(r.Context(), studioKey, studio) next.ServeHTTP(w, r.WithContext(ctx)) }) } ================================================ FILE: internal/api/routes_tag.go ================================================ package api import ( "context" "errors" "net/http" "strconv" "github.com/go-chi/chi/v5" "github.com/stashapp/stash/internal/static" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) type TagFinder interface { models.TagGetter GetImage(ctx context.Context, tagID int) ([]byte, error) } type tagRoutes struct { routes tagFinder TagFinder } func (rs tagRoutes) Routes() chi.Router { r := chi.NewRouter() r.Route("/{tagId}", func(r chi.Router) { r.Use(rs.TagCtx) r.Get("/image", rs.Image) }) return r } func (rs tagRoutes) Image(w http.ResponseWriter, r *http.Request) { tag := r.Context().Value(tagKey).(*models.Tag) defaultParam := r.URL.Query().Get("default") var image []byte if defaultParam != "true" { readTxnErr := rs.withReadTxn(r, func(ctx context.Context) error { var err error image, err = rs.tagFinder.GetImage(ctx, tag.ID) return err }) if errors.Is(readTxnErr, context.Canceled) { return } if readTxnErr != nil { logger.Warnf("read transaction error on fetch tag image: %v", readTxnErr) } } // fallback to default image if len(image) == 0 { image = static.ReadAll(static.DefaultTagImage) } utils.ServeImage(w, r, image) } func (rs tagRoutes) TagCtx(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tagID, err := strconv.Atoi(chi.URLParam(r, "tagId")) if err != nil { http.Error(w, http.StatusText(404), 404) return } var tag *models.Tag _ = rs.withReadTxn(r, func(ctx context.Context) error { var err error tag, err = rs.tagFinder.Find(ctx, tagID) return err }) if tag == nil { http.Error(w, http.StatusText(404), 404) return } ctx := context.WithValue(r.Context(), tagKey, tag) next.ServeHTTP(w, r.WithContext(ctx)) }) } ================================================ FILE: internal/api/scraped_content.go ================================================ package api import ( "fmt" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scraper" ) // marshalScrapedScenes converts ScrapedContent into ScrapedScene. If conversion fails, an // error is returned to the caller. func marshalScrapedScenes(content []scraper.ScrapedContent) ([]*models.ScrapedScene, error) { var ret []*models.ScrapedScene for _, c := range content { if c == nil { // graphql schema requires scenes to be non-nil continue } switch s := c.(type) { case *models.ScrapedScene: ret = append(ret, s) case models.ScrapedScene: ret = append(ret, &s) default: return nil, fmt.Errorf("%w: cannot turn ScrapedContent into ScrapedScene", models.ErrConversion) } } return ret, nil } // marshalScrapedPerformers converts ScrapedContent into ScrapedPerformer. If conversion // fails, an error is returned to the caller. func marshalScrapedPerformers(content []scraper.ScrapedContent) ([]*models.ScrapedPerformer, error) { var ret []*models.ScrapedPerformer for _, c := range content { if c == nil { // graphql schema requires performers to be non-nil continue } switch p := c.(type) { case *models.ScrapedPerformer: ret = append(ret, p) case models.ScrapedPerformer: ret = append(ret, &p) default: return nil, fmt.Errorf("%w: cannot turn ScrapedContent into ScrapedPerformer", models.ErrConversion) } } return ret, nil } // marshalScrapedGalleries converts ScrapedContent into ScrapedGallery. If // conversion fails, an error is returned. func marshalScrapedGalleries(content []scraper.ScrapedContent) ([]*models.ScrapedGallery, error) { var ret []*models.ScrapedGallery for _, c := range content { if c == nil { // graphql schema requires galleries to be non-nil continue } switch g := c.(type) { case *models.ScrapedGallery: ret = append(ret, g) case models.ScrapedGallery: ret = append(ret, &g) default: return nil, fmt.Errorf("%w: cannot turn ScrapedContent into ScrapedGallery", models.ErrConversion) } } return ret, nil } func marshalScrapedImages(content []scraper.ScrapedContent) ([]*models.ScrapedImage, error) { var ret []*models.ScrapedImage for _, c := range content { if c == nil { // graphql schema requires images to be non-nil continue } switch g := c.(type) { case *models.ScrapedImage: ret = append(ret, g) case models.ScrapedImage: ret = append(ret, &g) default: return nil, fmt.Errorf("%w: cannot turn ScrapedContent into ScrapedImage", models.ErrConversion) } } return ret, nil } // marshalScrapedMovies converts ScrapedContent into ScrapedMovie. If conversion // fails, an error is returned. func marshalScrapedMovies(content []scraper.ScrapedContent) ([]*models.ScrapedMovie, error) { var ret []*models.ScrapedMovie for _, c := range content { if c == nil { // graphql schema requires movies to be non-nil continue } switch m := c.(type) { case *models.ScrapedMovie: ret = append(ret, m) case models.ScrapedMovie: ret = append(ret, &m) default: return nil, fmt.Errorf("%w: cannot turn ScrapedContent into ScrapedMovie", models.ErrConversion) } } return ret, nil } // marshalScrapedMovies converts ScrapedContent into ScrapedMovie. If conversion // fails, an error is returned. func marshalScrapedGroups(content []scraper.ScrapedContent) ([]*models.ScrapedGroup, error) { var ret []*models.ScrapedGroup for _, c := range content { if c == nil { // graphql schema requires groups to be non-nil continue } switch m := c.(type) { case *models.ScrapedGroup: ret = append(ret, m) case models.ScrapedGroup: ret = append(ret, &m) // it's possible that a scraper returns models.ScrapedMovie case *models.ScrapedMovie: g := m.ScrapedGroup() ret = append(ret, &g) case models.ScrapedMovie: g := m.ScrapedGroup() ret = append(ret, &g) default: return nil, fmt.Errorf("%w: cannot turn ScrapedContent into ScrapedGroup", models.ErrConversion) } } return ret, nil } // marshalScrapedPerformer will marshal a single performer func marshalScrapedPerformer(content scraper.ScrapedContent) (*models.ScrapedPerformer, error) { p, err := marshalScrapedPerformers([]scraper.ScrapedContent{content}) if err != nil { return nil, err } return p[0], nil } // marshalScrapedScene will marshal a single scraped scene func marshalScrapedScene(content scraper.ScrapedContent) (*models.ScrapedScene, error) { s, err := marshalScrapedScenes([]scraper.ScrapedContent{content}) if err != nil { return nil, err } return s[0], nil } // marshalScrapedGallery will marshal a single scraped gallery func marshalScrapedGallery(content scraper.ScrapedContent) (*models.ScrapedGallery, error) { g, err := marshalScrapedGalleries([]scraper.ScrapedContent{content}) if err != nil { return nil, err } return g[0], nil } // marshalScrapedImage will marshal a single scraped image func marshalScrapedImage(content scraper.ScrapedContent) (*models.ScrapedImage, error) { g, err := marshalScrapedImages([]scraper.ScrapedContent{content}) if err != nil { return nil, err } return g[0], nil } // marshalScrapedMovie will marshal a single scraped movie func marshalScrapedMovie(content scraper.ScrapedContent) (*models.ScrapedMovie, error) { m, err := marshalScrapedMovies([]scraper.ScrapedContent{content}) if err != nil { return nil, err } return m[0], nil } // marshalScrapedMovie will marshal a single scraped movie func marshalScrapedGroup(content scraper.ScrapedContent) (*models.ScrapedGroup, error) { m, err := marshalScrapedGroups([]scraper.ScrapedContent{content}) if err != nil { return nil, err } return m[0], nil } ================================================ FILE: internal/api/server.go ================================================ package api import ( "bytes" "context" "crypto/tls" "errors" "fmt" "io" "io/fs" "net/http" "os" "path" "path/filepath" "runtime/debug" "strconv" "strings" "time" gqlHandler "github.com/99designs/gqlgen/graphql/handler" gqlExtension "github.com/99designs/gqlgen/graphql/handler/extension" gqlLru "github.com/99designs/gqlgen/graphql/handler/lru" gqlTransport "github.com/99designs/gqlgen/graphql/handler/transport" gqlPlayground "github.com/99designs/gqlgen/graphql/playground" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/go-chi/httplog" "github.com/gorilla/websocket" "github.com/vearutop/statigz" "github.com/vektah/gqlparser/v2/ast" "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/internal/build" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/utils" "github.com/stashapp/stash/ui" ) const ( loginEndpoint = "/login" loginLocaleEndpoint = loginEndpoint + "/locale" logoutEndpoint = "/logout" gqlEndpoint = "/graphql" playgroundEndpoint = "/playground" ) type Server struct { http.Server displayAddress string manager *manager.Manager } // TODO - os.DirFS doesn't implement ReadDir, so re-implement it here // This can be removed when we upgrade go type osFS string func (dir osFS) ReadDir(name string) ([]os.DirEntry, error) { fullname := string(dir) + "/" + name entries, err := os.ReadDir(fullname) if err != nil { var e *os.PathError if errors.As(err, &e) { // See comment in dirFS.Open. e.Path = name } return nil, err } return entries, nil } func (dir osFS) Open(name string) (fs.File, error) { return os.DirFS(string(dir)).Open(name) } // Initialize creates a new [Server] instance. // It assumes that the [manager.Manager] instance has been initialised. func Initialize() (*Server, error) { mgr := manager.GetInstance() cfg := mgr.Config initCustomPerformerImages(cfg.GetCustomPerformerImageLocation()) displayHost := cfg.GetHost() if displayHost == "0.0.0.0" { displayHost = "localhost" } displayAddress := displayHost + ":" + strconv.Itoa(cfg.GetPort()) address := cfg.GetHost() + ":" + strconv.Itoa(cfg.GetPort()) tlsConfig, err := makeTLSConfig(cfg) if err != nil { // assume we don't want to start with a broken TLS configuration return nil, fmt.Errorf("error loading TLS config: %v", err) } if tlsConfig != nil { displayAddress = "https://" + displayAddress + "/" } else { displayAddress = "http://" + displayAddress + "/" } r := chi.NewRouter() server := &Server{ Server: http.Server{ Addr: address, Handler: r, TLSConfig: tlsConfig, // disable http/2 support by default // when http/2 is enabled, we are unable to hijack and close // the connection/request. This is necessary to stop running // streams when deleting a scene file. TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), }, displayAddress: displayAddress, manager: mgr, } r.Use(middleware.Heartbeat("/healthz")) r.Use(cors.AllowAll().Handler) r.Use(authenticateHandler()) visitedPluginHandler := mgr.SessionStore.VisitedPluginHandler() r.Use(visitedPluginHandler) r.Use(middleware.Recoverer) if cfg.GetLogAccess() { httpLogger := httplog.NewLogger("Stash", httplog.Options{ Concise: true, }) r.Use(httplog.RequestLogger(httpLogger)) } r.Use(SecurityHeadersMiddleware) r.Use(middleware.Compress(4)) r.Use(middleware.StripSlashes) r.Use(BaseURLMiddleware) recoverFunc := func(ctx context.Context, err interface{}) error { logger.Error(err) debug.PrintStack() message := fmt.Sprintf("Internal system error. Error <%v>", err) return errors.New(message) } repo := mgr.Repository dataloaders := loaders.Middleware{ Repository: repo, } r.Use(dataloaders.Middleware) pluginCache := mgr.PluginCache sceneService := mgr.SceneService imageService := mgr.ImageService galleryService := mgr.GalleryService groupService := mgr.GroupService resolver := &Resolver{ repository: repo, sceneService: sceneService, imageService: imageService, galleryService: galleryService, groupService: groupService, hookExecutor: pluginCache, } gqlSrv := gqlHandler.New(NewExecutableSchema(Config{Resolvers: resolver})) gqlSrv.SetRecoverFunc(recoverFunc) gqlSrv.AddTransport(gqlTransport.Websocket{ Upgrader: websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, }, KeepAlivePingInterval: 10 * time.Second, }) gqlSrv.AddTransport(gqlTransport.Options{}) gqlSrv.AddTransport(gqlTransport.GET{}) gqlSrv.AddTransport(gqlTransport.POST{}) gqlSrv.AddTransport(gqlTransport.MultipartForm{ MaxUploadSize: cfg.GetMaxUploadSize(), }) gqlSrv.SetQueryCache(gqlLru.New[*ast.QueryDocument](1000)) gqlSrv.Use(gqlExtension.Introspection{}) gqlSrv.SetErrorPresenter(gqlErrorHandler) gqlHandlerFunc := func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") gqlSrv.ServeHTTP(w, r) } // register GQL handler with plugin cache // chain the visited plugin handler // also requires the dataloader middleware gqlHandler := visitedPluginHandler(dataloaders.Middleware(http.HandlerFunc(gqlHandlerFunc))) pluginCache.RegisterGQLHandler(gqlHandler) r.HandleFunc(gqlEndpoint, gqlHandlerFunc) r.HandleFunc(playgroundEndpoint, func(w http.ResponseWriter, r *http.Request) { setPageSecurityHeaders(w, r, pluginCache.ListPlugins()) endpoint := getProxyPrefix(r) + gqlEndpoint gqlPlayground.Handler("GraphQL playground", endpoint, gqlPlayground.WithGraphiqlEnablePluginExplorer(true))(w, r) }) r.Mount("/performer", server.getPerformerRoutes()) r.Mount("/scene", server.getSceneRoutes()) r.Mount("/gallery", server.getGalleryRoutes()) r.Mount("/image", server.getImageRoutes()) r.Mount("/studio", server.getStudioRoutes()) r.Mount("/group", server.getGroupRoutes()) r.Mount("/tag", server.getTagRoutes()) r.Mount("/downloads", server.getDownloadsRoutes()) r.Mount("/plugin", server.getPluginRoutes()) r.HandleFunc("/css", cssHandler(cfg)) r.HandleFunc("/javascript", javascriptHandler(cfg)) r.HandleFunc("/customlocales", customLocalesHandler(cfg)) staticLoginUI := statigz.FileServer(ui.LoginUIBox.(fs.ReadDirFS)) r.Get(loginEndpoint, handleLogin()) r.Post(loginEndpoint, handleLoginPost()) r.Get(logoutEndpoint, handleLogout()) r.Get(loginLocaleEndpoint, handleLoginLocale(cfg)) r.HandleFunc(loginEndpoint+"/*", func(w http.ResponseWriter, r *http.Request) { r.URL.Path = strings.TrimPrefix(r.URL.Path, loginEndpoint) w.Header().Set("Cache-Control", "no-cache") staticLoginUI.ServeHTTP(w, r) }) // Serve static folders customServedFolders := cfg.GetCustomServedFolders() if customServedFolders != nil { r.Mount("/custom", getCustomRoutes(customServedFolders)) } var uiFS fs.FS var staticUI *statigz.Server customUILocation := cfg.GetUILocation() if customUILocation != "" { logger.Debugf("Serving UI from %s", customUILocation) uiFS = osFS(customUILocation) staticUI = statigz.FileServer(uiFS.(fs.ReadDirFS)) } else { logger.Debug("Serving embedded UI") uiFS = ui.UIBox staticUI = statigz.FileServer(ui.UIBox.(fs.ReadDirFS)) } // handle favicon override r.HandleFunc("/favicon.ico", handleFavicon(staticUI)) // Serve the web app r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { ext := path.Ext(r.URL.Path) if ext == ".html" || ext == "" { w.Header().Set("Content-Type", "text/html") setPageSecurityHeaders(w, r, pluginCache.ListPlugins()) } if ext == "" || r.URL.Path == "/" || r.URL.Path == "/index.html" { themeColor := cfg.GetThemeColor() data, err := fs.ReadFile(uiFS, "index.html") if err != nil { panic(err) } indexHtml := string(data) prefix := getProxyPrefix(r) indexHtml = strings.ReplaceAll(indexHtml, "%COLOR%", themeColor) indexHtml = strings.Replace(indexHtml, `= len(boxes) { return nil, fmt.Errorf("invalid %s %d", indexField, index) } return boxes[*index], nil } return nil, fmt.Errorf("%s not provided", endpointField) } } var ( resolveStashBox = resolveStashBoxFn("stash_box_index", "stash_box_endpoint") resolveStashBoxBatchTagInput = resolveStashBoxFn("endpoint", "stash_box_endpoint") ) ================================================ FILE: internal/api/timestamp.go ================================================ package api import ( "errors" "fmt" "io" "strconv" "time" "github.com/99designs/gqlgen/graphql" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/utils" ) var ErrTimestamp = errors.New("cannot parse Timestamp") func MarshalTimestamp(t time.Time) graphql.Marshaler { if t.IsZero() { return graphql.Null } return graphql.WriterFunc(func(w io.Writer) { _, err := io.WriteString(w, strconv.Quote(t.Format(time.RFC3339Nano))) if err != nil { logger.Warnf("could not marshal timestamp: %v", err) } }) } func UnmarshalTimestamp(v interface{}) (time.Time, error) { if tmpStr, ok := v.(string); ok { if len(tmpStr) == 0 { return time.Time{}, fmt.Errorf("%w: empty string", ErrTimestamp) } switch tmpStr[0] { case '>', '<': d, err := time.ParseDuration(tmpStr[1:]) if err != nil { return time.Time{}, fmt.Errorf("%w: cannot parse %v-duration: %v", ErrTimestamp, tmpStr[0], err) } t := time.Now() // Compute point in time: if tmpStr[0] == '<' { t = t.Add(-d) } else { t = t.Add(d) } return t, nil } return utils.ParseDateStringAsTime(tmpStr) } return time.Time{}, fmt.Errorf("%w: not a string", ErrTimestamp) } ================================================ FILE: internal/api/timestamp_test.go ================================================ package api import ( "bytes" "strconv" "testing" "time" ) func TestTimestampSymmetry(t *testing.T) { n := time.Now() buf := bytes.NewBuffer([]byte{}) MarshalTimestamp(n).MarshalGQL(buf) str, err := strconv.Unquote(buf.String()) if err != nil { t.Fatal("could not unquote string") } got, err := UnmarshalTimestamp(str) if err != nil { t.Fatalf("could not unmarshal time: %v", err) } if !n.Equal(got) { t.Fatalf("have %v, want %v", got, n) } } func TestTimestamp(t *testing.T) { n := time.Now().In(time.UTC) testCases := []struct { name string have string want string }{ {"reflexivity", n.Format(time.RFC3339Nano), n.Format(time.RFC3339Nano)}, {"rfc3339", "2021-11-04T01:02:03Z", "2021-11-04T01:02:03Z"}, {"date", "2021-04-05", "2021-04-05T00:00:00Z"}, {"datetime", "2021-04-05 14:45:36", "2021-04-05T14:45:36Z"}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { p, err := UnmarshalTimestamp(tc.have) if err != nil { t.Fatalf("could not unmarshal time: %v", err) } buf := bytes.NewBuffer([]byte{}) MarshalTimestamp(p).MarshalGQL(buf) got, err := strconv.Unquote(buf.String()) if err != nil { t.Fatalf("count not unquote string") } if got != tc.want { t.Errorf("got %s; want %s", got, tc.want) } }) } } const epsilon = 10 * time.Second func TestTimestampRelative(t *testing.T) { n := time.Now() testCases := []struct { name string have string want time.Time }{ {"past", "<4h", n.Add(-4 * time.Hour)}, {"future", ">5m", n.Add(5 * time.Minute)}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { got, err := UnmarshalTimestamp(tc.have) if err != nil { t.Fatalf("could not unmarshal time: %v", err) } if got.Sub(tc.want) > epsilon { t.Errorf("not within bound of %v; got %s; want %s", epsilon, got, tc.want) } }) } } ================================================ FILE: internal/api/types.go ================================================ package api import ( "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil" ) func stashIDsSliceToPtrSlice(v []models.StashID) []*models.StashID { return sliceutil.ValuesToPtrs(v) } ================================================ FILE: internal/api/urlbuilders/doc.go ================================================ // Package urlbuilders provides the builders used to build URLs to pass to clients. package urlbuilders ================================================ FILE: internal/api/urlbuilders/gallery.go ================================================ package urlbuilders import ( "strconv" "github.com/stashapp/stash/pkg/models" ) type GalleryURLBuilder struct { BaseURL string GalleryID string UpdatedAt string } func NewGalleryURLBuilder(baseURL string, gallery *models.Gallery) GalleryURLBuilder { return GalleryURLBuilder{ BaseURL: baseURL, GalleryID: strconv.Itoa(gallery.ID), UpdatedAt: strconv.FormatInt(gallery.UpdatedAt.Unix(), 10), } } func (b GalleryURLBuilder) GetPreviewURL() string { return b.BaseURL + "/gallery/" + b.GalleryID + "/preview" } func (b GalleryURLBuilder) GetCoverURL() string { return b.BaseURL + "/gallery/" + b.GalleryID + "/cover?t=" + b.UpdatedAt } ================================================ FILE: internal/api/urlbuilders/group.go ================================================ package urlbuilders import ( "strconv" "github.com/stashapp/stash/pkg/models" ) type GroupURLBuilder struct { BaseURL string GroupID string UpdatedAt string } func NewGroupURLBuilder(baseURL string, group *models.Group) GroupURLBuilder { return GroupURLBuilder{ BaseURL: baseURL, GroupID: strconv.Itoa(group.ID), UpdatedAt: strconv.FormatInt(group.UpdatedAt.Unix(), 10), } } func (b GroupURLBuilder) GetGroupFrontImageURL(hasImage bool) string { url := b.BaseURL + "/group/" + b.GroupID + "/frontimage?t=" + b.UpdatedAt if !hasImage { url += "&default=true" } return url } func (b GroupURLBuilder) GetGroupBackImageURL() string { return b.BaseURL + "/group/" + b.GroupID + "/backimage?t=" + b.UpdatedAt } ================================================ FILE: internal/api/urlbuilders/image.go ================================================ package urlbuilders import ( "strconv" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/models" ) type ImageURLBuilder struct { BaseURL string ImageID string Checksum string UpdatedAt string } func NewImageURLBuilder(baseURL string, image *models.Image) ImageURLBuilder { return ImageURLBuilder{ BaseURL: baseURL, ImageID: strconv.Itoa(image.ID), Checksum: image.Checksum, UpdatedAt: strconv.FormatInt(image.UpdatedAt.Unix(), 10), } } func (b ImageURLBuilder) GetImageURL() string { return b.BaseURL + "/image/" + b.ImageID + "/image?t=" + b.UpdatedAt } func (b ImageURLBuilder) GetThumbnailURL() string { return b.BaseURL + "/image/" + b.ImageID + "/thumbnail?t=" + b.UpdatedAt } func (b ImageURLBuilder) GetPreviewURL() string { if exists, err := fsutil.FileExists(manager.GetInstance().Paths.Generated.GetClipPreviewPath(b.Checksum, models.DefaultGthumbWidth)); exists && err == nil { return b.BaseURL + "/image/" + b.ImageID + "/preview?" + b.UpdatedAt } else { return "" } } ================================================ FILE: internal/api/urlbuilders/performer.go ================================================ package urlbuilders import ( "strconv" "github.com/stashapp/stash/pkg/models" ) type PerformerURLBuilder struct { BaseURL string PerformerID string UpdatedAt string } func NewPerformerURLBuilder(baseURL string, performer *models.Performer) PerformerURLBuilder { return PerformerURLBuilder{ BaseURL: baseURL, PerformerID: strconv.Itoa(performer.ID), UpdatedAt: strconv.FormatInt(performer.UpdatedAt.Unix(), 10), } } func (b PerformerURLBuilder) GetPerformerImageURL(hasImage bool) string { url := b.BaseURL + "/performer/" + b.PerformerID + "/image?t=" + b.UpdatedAt if !hasImage { url += "&default=true" } return url } ================================================ FILE: internal/api/urlbuilders/scene.go ================================================ package urlbuilders import ( "fmt" "net/url" "strconv" "github.com/stashapp/stash/pkg/models" ) type SceneURLBuilder struct { BaseURL string SceneID string UpdatedAt string } func NewSceneURLBuilder(baseURL string, scene *models.Scene) SceneURLBuilder { return SceneURLBuilder{ BaseURL: baseURL, SceneID: strconv.Itoa(scene.ID), UpdatedAt: strconv.FormatInt(scene.UpdatedAt.Unix(), 10), } } func (b SceneURLBuilder) GetStreamURL(apiKey string) *url.URL { u, err := url.Parse(fmt.Sprintf("%s/scene/%s/stream", b.BaseURL, b.SceneID)) if err != nil { // shouldn't happen panic(err) } if apiKey != "" { v := u.Query() v.Set("apikey", apiKey) u.RawQuery = v.Encode() } return u } func (b SceneURLBuilder) GetStreamPreviewURL() string { return b.BaseURL + "/scene/" + b.SceneID + "/preview" } func (b SceneURLBuilder) GetStreamPreviewImageURL() string { return b.BaseURL + "/scene/" + b.SceneID + "/webp" } func (b SceneURLBuilder) GetSpriteVTTURL(checksum string) string { return b.BaseURL + "/scene/" + checksum + "_thumbs.vtt" } func (b SceneURLBuilder) GetSpriteURL(checksum string) string { return b.BaseURL + "/scene/" + checksum + "_sprite.jpg" } func (b SceneURLBuilder) GetScreenshotURL() string { return b.BaseURL + "/scene/" + b.SceneID + "/screenshot?t=" + b.UpdatedAt } func (b SceneURLBuilder) GetFunscriptURL() string { return b.BaseURL + "/scene/" + b.SceneID + "/funscript" } func (b SceneURLBuilder) GetCaptionURL() string { return b.BaseURL + "/scene/" + b.SceneID + "/caption" } func (b SceneURLBuilder) GetInteractiveHeatmapURL() string { return b.BaseURL + "/scene/" + b.SceneID + "/interactive_heatmap" } ================================================ FILE: internal/api/urlbuilders/scene_markers.go ================================================ package urlbuilders import ( "strconv" "github.com/stashapp/stash/pkg/models" ) type SceneMarkerURLBuilder struct { BaseURL string SceneID string MarkerID string } func NewSceneMarkerURLBuilder(baseURL string, sceneMarker *models.SceneMarker) SceneMarkerURLBuilder { return SceneMarkerURLBuilder{ BaseURL: baseURL, SceneID: strconv.Itoa(sceneMarker.SceneID), MarkerID: strconv.Itoa(sceneMarker.ID), } } func (b SceneMarkerURLBuilder) GetStreamURL() string { return b.BaseURL + "/scene/" + b.SceneID + "/scene_marker/" + b.MarkerID + "/stream" } func (b SceneMarkerURLBuilder) GetPreviewURL() string { return b.BaseURL + "/scene/" + b.SceneID + "/scene_marker/" + b.MarkerID + "/preview" } func (b SceneMarkerURLBuilder) GetScreenshotURL() string { return b.BaseURL + "/scene/" + b.SceneID + "/scene_marker/" + b.MarkerID + "/screenshot" } ================================================ FILE: internal/api/urlbuilders/studio.go ================================================ package urlbuilders import ( "strconv" "github.com/stashapp/stash/pkg/models" ) type StudioURLBuilder struct { BaseURL string StudioID string UpdatedAt string } func NewStudioURLBuilder(baseURL string, studio *models.Studio) StudioURLBuilder { return StudioURLBuilder{ BaseURL: baseURL, StudioID: strconv.Itoa(studio.ID), UpdatedAt: strconv.FormatInt(studio.UpdatedAt.Unix(), 10), } } func (b StudioURLBuilder) GetStudioImageURL(hasImage bool) string { url := b.BaseURL + "/studio/" + b.StudioID + "/image?t=" + b.UpdatedAt if !hasImage { url += "&default=true" } return url } ================================================ FILE: internal/api/urlbuilders/tag.go ================================================ package urlbuilders import ( "github.com/stashapp/stash/pkg/models" "strconv" ) type TagURLBuilder struct { BaseURL string TagID string UpdatedAt string } func NewTagURLBuilder(baseURL string, tag *models.Tag) TagURLBuilder { return TagURLBuilder{ BaseURL: baseURL, TagID: strconv.Itoa(tag.ID), UpdatedAt: strconv.FormatInt(tag.UpdatedAt.Unix(), 10), } } func (b TagURLBuilder) GetTagImageURL(hasImage bool) string { url := b.BaseURL + "/tag/" + b.TagID + "/image?t=" + b.UpdatedAt if !hasImage { url += "&default=true" } return url } ================================================ FILE: internal/autotag/doc.go ================================================ // Package autotag provides the autotagging functionality for the application. // // The autotag functionality sets media metadata based on the media's path. // The functions in this package are in the form of {ObjectType}{TagTypes}, // where the ObjectType is the single object instance to run on, and TagTypes // are the related types. // For example, PerformerScenes finds and tags scenes with a provided performer, // whereas ScenePerformers tags a single scene with any Performers that match. package autotag ================================================ FILE: internal/autotag/gallery.go ================================================ package autotag import ( "context" "slices" "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" ) type GalleryFinderUpdater interface { models.GalleryQueryer models.GalleryUpdater } type GalleryPerformerUpdater interface { models.PerformerIDLoader models.GalleryUpdater } type GalleryTagUpdater interface { models.TagIDLoader models.GalleryUpdater } func getGalleryFileTagger(s *models.Gallery, cache *match.Cache) tagger { var path string if s.Path != "" { path = s.Path } // only trim the extension if gallery is file-based trimExt := s.PrimaryFileID != nil return tagger{ ID: s.ID, Type: "gallery", Name: s.DisplayName(), Path: path, trimExt: trimExt, cache: cache, } } // GalleryPerformers tags the provided gallery with performers whose name matches the gallery's path. func GalleryPerformers(ctx context.Context, s *models.Gallery, rw GalleryPerformerUpdater, performerReader models.PerformerAutoTagQueryer, cache *match.Cache) error { t := getGalleryFileTagger(s, cache) return t.tagPerformers(ctx, performerReader, func(subjectID, otherID int) (bool, error) { if err := s.LoadPerformerIDs(ctx, rw); err != nil { return false, err } existing := s.PerformerIDs.List() if slices.Contains(existing, otherID) { return false, nil } if err := gallery.AddPerformer(ctx, rw, s, otherID); err != nil { return false, err } return true, nil }) } // GalleryStudios tags the provided gallery with the first studio whose name matches the gallery's path. // // Gallerys will not be tagged if studio is already set. func GalleryStudios(ctx context.Context, s *models.Gallery, rw GalleryFinderUpdater, studioReader models.StudioAutoTagQueryer, cache *match.Cache) error { if s.StudioID != nil { // don't modify return nil } t := getGalleryFileTagger(s, cache) return t.tagStudios(ctx, studioReader, func(subjectID, otherID int) (bool, error) { return addGalleryStudio(ctx, rw, s, otherID) }) } // GalleryTags tags the provided gallery with tags whose name matches the gallery's path. func GalleryTags(ctx context.Context, s *models.Gallery, rw GalleryTagUpdater, tagReader models.TagAutoTagQueryer, cache *match.Cache) error { t := getGalleryFileTagger(s, cache) return t.tagTags(ctx, tagReader, func(subjectID, otherID int) (bool, error) { if err := s.LoadTagIDs(ctx, rw); err != nil { return false, err } existing := s.TagIDs.List() if slices.Contains(existing, otherID) { return false, nil } if err := gallery.AddTag(ctx, rw, s, otherID); err != nil { return false, err } return true, nil }) } ================================================ FILE: internal/autotag/gallery_test.go ================================================ package autotag import ( "context" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) const galleryExt = "zip" var testCtx = context.Background() // returns got == expected // ignores expected.UpdatedAt, but ensures that got.UpdatedAt is set and not null func galleryPartialsEqual(got, expected models.GalleryPartial) bool { // updated at should be set and not null if !got.UpdatedAt.Set || got.UpdatedAt.Null { return false } // else ignore the exact value got.UpdatedAt = models.OptionalTime{} return assert.ObjectsAreEqual(got, expected) } func TestGalleryPerformers(t *testing.T) { t.Parallel() const galleryID = 1 const performerName = "performer name" const performerID = 2 performer := models.Performer{ ID: performerID, Name: performerName, Aliases: models.NewRelatedStrings([]string{}), } const reversedPerformerName = "name performer" const reversedPerformerID = 3 reversedPerformer := models.Performer{ ID: reversedPerformerID, Name: reversedPerformerName, Aliases: models.NewRelatedStrings([]string{}), } testTables := generateTestTable(performerName, galleryExt) assert := assert.New(t) for _, test := range testTables { db := mocks.NewDatabase() db.Performer.On("Query", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil) db.Performer.On("QueryForAutoTag", testCtx, mock.Anything).Return([]*models.Performer{&performer, &reversedPerformer}, nil).Once() if test.Matches { matchPartial := mock.MatchedBy(func(got models.GalleryPartial) bool { expected := models.GalleryPartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerID}, Mode: models.RelationshipUpdateModeAdd, }, } return galleryPartialsEqual(got, expected) }) db.Gallery.On("UpdatePartial", testCtx, galleryID, matchPartial).Return(nil, nil).Once() } gallery := models.Gallery{ ID: galleryID, Path: test.Path, PerformerIDs: models.NewRelatedIDs([]int{}), } err := GalleryPerformers(testCtx, &gallery, db.Gallery, db.Performer, nil) assert.Nil(err) db.AssertExpectations(t) } } func TestGalleryStudios(t *testing.T) { t.Parallel() const galleryID = 1 const studioName = "studio name" var studioID = 2 studio := models.Studio{ ID: studioID, Name: studioName, } const reversedStudioName = "name studio" const reversedStudioID = 3 reversedStudio := models.Studio{ ID: reversedStudioID, Name: reversedStudioName, } testTables := generateTestTable(studioName, galleryExt) assert := assert.New(t) doTest := func(db *mocks.Database, test pathTestTable) { if test.Matches { matchPartial := mock.MatchedBy(func(got models.GalleryPartial) bool { expected := models.GalleryPartial{ StudioID: models.NewOptionalInt(studioID), } return galleryPartialsEqual(got, expected) }) db.Gallery.On("UpdatePartial", testCtx, galleryID, matchPartial).Return(nil, nil).Once() } gallery := models.Gallery{ ID: galleryID, Path: test.Path, } err := GalleryStudios(testCtx, &gallery, db.Gallery, db.Studio, nil) assert.Nil(err) db.AssertExpectations(t) } for _, test := range testTables { db := mocks.NewDatabase() db.Studio.On("Query", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil) db.Studio.On("QueryForAutoTag", testCtx, mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once() db.Studio.On("GetAliases", testCtx, mock.Anything).Return([]string{}, nil).Maybe() doTest(db, test) } // test against aliases const unmatchedName = "unmatched" studio.Name = unmatchedName for _, test := range testTables { db := mocks.NewDatabase() db.Studio.On("Query", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil) db.Studio.On("QueryForAutoTag", testCtx, mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once() db.Studio.On("GetAliases", testCtx, studioID).Return([]string{ studioName, }, nil).Once() db.Studio.On("GetAliases", testCtx, reversedStudioID).Return([]string{}, nil).Once() doTest(db, test) } } func TestGalleryTags(t *testing.T) { t.Parallel() const galleryID = 1 const tagName = "tag name" const tagID = 2 tag := models.Tag{ ID: tagID, Name: tagName, } const reversedTagName = "name tag" const reversedTagID = 3 reversedTag := models.Tag{ ID: reversedTagID, Name: reversedTagName, } testTables := generateTestTable(tagName, galleryExt) assert := assert.New(t) doTest := func(db *mocks.Database, test pathTestTable) { if test.Matches { matchPartial := mock.MatchedBy(func(got models.GalleryPartial) bool { expected := models.GalleryPartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagID}, Mode: models.RelationshipUpdateModeAdd, }, } return galleryPartialsEqual(got, expected) }) db.Gallery.On("UpdatePartial", testCtx, galleryID, matchPartial).Return(nil, nil).Once() } gallery := models.Gallery{ ID: galleryID, Path: test.Path, TagIDs: models.NewRelatedIDs([]int{}), } err := GalleryTags(testCtx, &gallery, db.Gallery, db.Tag, nil) assert.Nil(err) db.AssertExpectations(t) } for _, test := range testTables { db := mocks.NewDatabase() db.Tag.On("Query", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil) db.Tag.On("QueryForAutoTag", testCtx, mock.Anything).Return([]*models.Tag{&tag, &reversedTag}, nil).Once() db.Tag.On("GetAliases", testCtx, mock.Anything).Return([]string{}, nil).Maybe() doTest(db, test) } const unmatchedName = "unmatched" tag.Name = unmatchedName for _, test := range testTables { db := mocks.NewDatabase() db.Tag.On("Query", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil) db.Tag.On("QueryForAutoTag", testCtx, mock.Anything).Return([]*models.Tag{&tag, &reversedTag}, nil).Once() db.Tag.On("GetAliases", testCtx, tagID).Return([]string{ tagName, }, nil).Once() db.Tag.On("GetAliases", testCtx, reversedTagID).Return([]string{}, nil).Once() doTest(db, test) } } ================================================ FILE: internal/autotag/image.go ================================================ package autotag import ( "context" "slices" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" ) type ImageFinderUpdater interface { models.ImageQueryer models.ImageUpdater } type ImagePerformerUpdater interface { models.PerformerIDLoader models.ImageUpdater } type ImageTagUpdater interface { models.TagIDLoader models.ImageUpdater } func getImageFileTagger(s *models.Image, cache *match.Cache) tagger { return tagger{ ID: s.ID, Type: "image", Name: s.DisplayName(), Path: s.Path, cache: cache, } } // ImagePerformers tags the provided image with performers whose name matches the image's path. func ImagePerformers(ctx context.Context, s *models.Image, rw ImagePerformerUpdater, performerReader models.PerformerAutoTagQueryer, cache *match.Cache) error { t := getImageFileTagger(s, cache) return t.tagPerformers(ctx, performerReader, func(subjectID, otherID int) (bool, error) { if err := s.LoadPerformerIDs(ctx, rw); err != nil { return false, err } existing := s.PerformerIDs.List() if slices.Contains(existing, otherID) { return false, nil } if err := image.AddPerformer(ctx, rw, s, otherID); err != nil { return false, err } return true, nil }) } // ImageStudios tags the provided image with the first studio whose name matches the image's path. // // Images will not be tagged if studio is already set. func ImageStudios(ctx context.Context, s *models.Image, rw ImageFinderUpdater, studioReader models.StudioAutoTagQueryer, cache *match.Cache) error { if s.StudioID != nil { // don't modify return nil } t := getImageFileTagger(s, cache) return t.tagStudios(ctx, studioReader, func(subjectID, otherID int) (bool, error) { return addImageStudio(ctx, rw, s, otherID) }) } // ImageTags tags the provided image with tags whose name matches the image's path. func ImageTags(ctx context.Context, s *models.Image, rw ImageTagUpdater, tagReader models.TagAutoTagQueryer, cache *match.Cache) error { t := getImageFileTagger(s, cache) return t.tagTags(ctx, tagReader, func(subjectID, otherID int) (bool, error) { if err := s.LoadTagIDs(ctx, rw); err != nil { return false, err } existing := s.TagIDs.List() if slices.Contains(existing, otherID) { return false, nil } if err := image.AddTag(ctx, rw, s, otherID); err != nil { return false, err } return true, nil }) } ================================================ FILE: internal/autotag/image_test.go ================================================ package autotag import ( "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) const imageExt = "jpg" // returns got == expected // ignores expected.UpdatedAt, but ensures that got.UpdatedAt is set and not null func imagePartialsEqual(got, expected models.ImagePartial) bool { // updated at should be set and not null if !got.UpdatedAt.Set || got.UpdatedAt.Null { return false } // else ignore the exact value got.UpdatedAt = models.OptionalTime{} return assert.ObjectsAreEqual(got, expected) } func TestImagePerformers(t *testing.T) { t.Parallel() const imageID = 1 const performerName = "performer name" const performerID = 2 performer := models.Performer{ ID: performerID, Name: performerName, Aliases: models.NewRelatedStrings([]string{}), } const reversedPerformerName = "name performer" const reversedPerformerID = 3 reversedPerformer := models.Performer{ ID: reversedPerformerID, Name: reversedPerformerName, Aliases: models.NewRelatedStrings([]string{}), } testTables := generateTestTable(performerName, imageExt) assert := assert.New(t) for _, test := range testTables { db := mocks.NewDatabase() db.Performer.On("Query", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil) db.Performer.On("QueryForAutoTag", testCtx, mock.Anything).Return([]*models.Performer{&performer, &reversedPerformer}, nil).Once() if test.Matches { matchPartial := mock.MatchedBy(func(got models.ImagePartial) bool { expected := models.ImagePartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerID}, Mode: models.RelationshipUpdateModeAdd, }, } return imagePartialsEqual(got, expected) }) db.Image.On("UpdatePartial", testCtx, imageID, matchPartial).Return(nil, nil).Once() } image := models.Image{ ID: imageID, Path: test.Path, PerformerIDs: models.NewRelatedIDs([]int{}), } err := ImagePerformers(testCtx, &image, db.Image, db.Performer, nil) assert.Nil(err) db.AssertExpectations(t) } } func TestImageStudios(t *testing.T) { t.Parallel() const imageID = 1 const studioName = "studio name" var studioID = 2 studio := models.Studio{ ID: studioID, Name: studioName, } const reversedStudioName = "name studio" const reversedStudioID = 3 reversedStudio := models.Studio{ ID: reversedStudioID, Name: reversedStudioName, } testTables := generateTestTable(studioName, imageExt) assert := assert.New(t) doTest := func(db *mocks.Database, test pathTestTable) { if test.Matches { matchPartial := mock.MatchedBy(func(got models.ImagePartial) bool { expected := models.ImagePartial{ StudioID: models.NewOptionalInt(studioID), } return imagePartialsEqual(got, expected) }) db.Image.On("UpdatePartial", testCtx, imageID, matchPartial).Return(nil, nil).Once() } image := models.Image{ ID: imageID, Path: test.Path, } err := ImageStudios(testCtx, &image, db.Image, db.Studio, nil) assert.Nil(err) db.AssertExpectations(t) } for _, test := range testTables { db := mocks.NewDatabase() db.Studio.On("Query", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil) db.Studio.On("QueryForAutoTag", testCtx, mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once() db.Studio.On("GetAliases", testCtx, mock.Anything).Return([]string{}, nil).Maybe() doTest(db, test) } // test against aliases const unmatchedName = "unmatched" studio.Name = unmatchedName for _, test := range testTables { db := mocks.NewDatabase() db.Studio.On("Query", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil) db.Studio.On("QueryForAutoTag", testCtx, mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once() db.Studio.On("GetAliases", testCtx, studioID).Return([]string{ studioName, }, nil).Once() db.Studio.On("GetAliases", testCtx, reversedStudioID).Return([]string{}, nil).Once() doTest(db, test) } } func TestImageTags(t *testing.T) { t.Parallel() const imageID = 1 const tagName = "tag name" const tagID = 2 tag := models.Tag{ ID: tagID, Name: tagName, } const reversedTagName = "name tag" const reversedTagID = 3 reversedTag := models.Tag{ ID: reversedTagID, Name: reversedTagName, } testTables := generateTestTable(tagName, imageExt) assert := assert.New(t) doTest := func(db *mocks.Database, test pathTestTable) { if test.Matches { matchPartial := mock.MatchedBy(func(got models.ImagePartial) bool { expected := models.ImagePartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagID}, Mode: models.RelationshipUpdateModeAdd, }, } return imagePartialsEqual(got, expected) }) db.Image.On("UpdatePartial", testCtx, imageID, matchPartial).Return(nil, nil).Once() } image := models.Image{ ID: imageID, Path: test.Path, TagIDs: models.NewRelatedIDs([]int{}), } err := ImageTags(testCtx, &image, db.Image, db.Tag, nil) assert.Nil(err) db.AssertExpectations(t) } for _, test := range testTables { db := mocks.NewDatabase() db.Tag.On("Query", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil) db.Tag.On("QueryForAutoTag", testCtx, mock.Anything).Return([]*models.Tag{&tag, &reversedTag}, nil).Once() db.Tag.On("GetAliases", testCtx, mock.Anything).Return([]string{}, nil).Maybe() doTest(db, test) } // test against aliases const unmatchedName = "unmatched" tag.Name = unmatchedName for _, test := range testTables { db := mocks.NewDatabase() db.Tag.On("Query", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil) db.Tag.On("QueryForAutoTag", testCtx, mock.Anything).Return([]*models.Tag{&tag, &reversedTag}, nil).Once() db.Tag.On("GetAliases", testCtx, tagID).Return([]string{ tagName, }, nil).Once() db.Tag.On("GetAliases", testCtx, reversedTagID).Return([]string{}, nil).Once() doTest(db, test) } } ================================================ FILE: internal/autotag/integration_test.go ================================================ //go:build integration // +build integration package autotag import ( "context" "fmt" "os" "path/filepath" "testing" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sqlite" "github.com/stashapp/stash/pkg/txn" _ "github.com/golang-migrate/migrate/v4/database/sqlite3" _ "github.com/golang-migrate/migrate/v4/source/file" // necessary to register custom migrations _ "github.com/stashapp/stash/pkg/sqlite/migrations" ) const testName = "Foo's Bar" const existingStudioName = "ExistingStudio" const existingStudioSceneName = testName + ".dontChangeStudio.mp4" const existingStudioImageName = testName + ".dontChangeStudio.png" const existingStudioGalleryName = testName + ".dontChangeStudio.zip" var existingStudioID int const expectedMatchTitle = "expected match" var db *sqlite.Database var r models.Repository func testTeardown(databaseFile string) { err := db.Close() if err != nil { panic(err) } err = os.Remove(databaseFile) if err != nil { panic(err) } } func runTests(m *testing.M) int { // create the database file f, err := os.CreateTemp("", "*.sqlite") if err != nil { panic(fmt.Sprintf("Could not create temporary file: %s", err.Error())) } f.Close() databaseFile := f.Name() db = sqlite.NewDatabase() if err := db.Open(databaseFile); err != nil { panic(fmt.Sprintf("Could not initialize database: %s", err.Error())) } r = db.Repository() // defer close and delete the database defer testTeardown(databaseFile) err = populateDB() if err != nil { panic(fmt.Sprintf("Could not populate database: %s", err.Error())) } else { // run the tests return m.Run() } } func TestMain(m *testing.M) { // initialise empty config - needed by some db migrations _ = config.InitializeEmpty() ret := runTests(m) os.Exit(ret) } func createPerformer(ctx context.Context, pqb models.PerformerWriter) error { // create the performer performer := models.Performer{ Name: testName, } err := pqb.Create(ctx, &models.CreatePerformerInput{Performer: &performer}) if err != nil { return err } return nil } func createStudio(ctx context.Context, qb models.StudioWriter, name string) (*models.Studio, error) { // create the studio studio := models.NewCreateStudioInput() studio.Name = name err := qb.Create(ctx, &studio) if err != nil { return nil, err } return studio.Studio, nil } func createTag(ctx context.Context, qb models.TagWriter) error { // create the studio tag := models.Tag{ Name: testName, } err := qb.Create(ctx, &models.CreateTagInput{Tag: &tag}) if err != nil { return err } return nil } func createScenes(ctx context.Context, sqb models.SceneReaderWriter, folderStore models.FolderFinderCreator, fileCreator models.FileCreator) error { // create the scenes scenePatterns, falseScenePatterns := generateTestPaths(testName, sceneExt) for _, fn := range scenePatterns { f, err := createSceneFile(ctx, fn, folderStore, fileCreator) if err != nil { return err } const expectedResult = true if err := createScene(ctx, sqb, makeScene(expectedResult), f); err != nil { return err } } for _, fn := range falseScenePatterns { f, err := createSceneFile(ctx, fn, folderStore, fileCreator) if err != nil { return err } const expectedResult = false if err := createScene(ctx, sqb, makeScene(expectedResult), f); err != nil { return err } } // add organized scenes for _, fn := range scenePatterns { f, err := createSceneFile(ctx, "organized"+fn, folderStore, fileCreator) if err != nil { return err } const expectedResult = false s := makeScene(expectedResult) s.Organized = true if err := createScene(ctx, sqb, s, f); err != nil { return err } } // create scene with existing studio io f, err := createSceneFile(ctx, existingStudioSceneName, folderStore, fileCreator) if err != nil { return err } s := &models.Scene{ Title: expectedMatchTitle, Code: existingStudioSceneName, StudioID: &existingStudioID, } if err := createScene(ctx, sqb, s, f); err != nil { return err } return nil } func makeScene(expectedResult bool) *models.Scene { s := &models.Scene{} // if expectedResult is true then we expect it to match, set the title accordingly if expectedResult { s.Title = expectedMatchTitle } return s } func createSceneFile(ctx context.Context, name string, folderStore models.FolderFinderCreator, fileCreator models.FileCreator) (*models.VideoFile, error) { folderPath := filepath.Dir(name) basename := filepath.Base(name) folder, err := getOrCreateFolder(ctx, folderStore, folderPath) if err != nil { return nil, err } folderID := folder.ID f := &models.VideoFile{ BaseFile: &models.BaseFile{ Basename: basename, ParentFolderID: folderID, }, } if err := fileCreator.Create(ctx, f); err != nil { return nil, fmt.Errorf("creating scene file %q: %w", name, err) } return f, nil } func getOrCreateFolder(ctx context.Context, folderStore models.FolderFinderCreator, folderPath string) (*models.Folder, error) { f, err := folderStore.FindByPath(ctx, folderPath, true) if err != nil { return nil, fmt.Errorf("getting folder by path: %w", err) } if f != nil { return f, nil } var parentID models.FolderID dir := filepath.Dir(folderPath) if dir != "." { parent, err := getOrCreateFolder(ctx, folderStore, dir) if err != nil { return nil, err } parentID = parent.ID } f = &models.Folder{ Path: folderPath, } if parentID != 0 { f.ParentFolderID = &parentID } if err := folderStore.Create(ctx, f); err != nil { return nil, fmt.Errorf("creating folder: %w", err) } return f, nil } func createScene(ctx context.Context, sqb models.SceneWriter, s *models.Scene, f *models.VideoFile) error { err := sqb.Create(ctx, s, []models.FileID{f.ID}) if err != nil { return fmt.Errorf("Failed to create scene with path '%s': %s", f.Path, err.Error()) } return nil } func createImages(ctx context.Context, w models.ImageReaderWriter, folderStore models.FolderFinderCreator, fileCreator models.FileCreator) error { // create the images imagePatterns, falseImagePatterns := generateTestPaths(testName, imageExt) for _, fn := range imagePatterns { f, err := createImageFile(ctx, fn, folderStore, fileCreator) if err != nil { return err } const expectedResult = true if err := createImage(ctx, w, makeImage(expectedResult), f); err != nil { return err } } for _, fn := range falseImagePatterns { f, err := createImageFile(ctx, fn, folderStore, fileCreator) if err != nil { return err } const expectedResult = false if err := createImage(ctx, w, makeImage(expectedResult), f); err != nil { return err } } // add organized images for _, fn := range imagePatterns { f, err := createImageFile(ctx, "organized"+fn, folderStore, fileCreator) if err != nil { return err } const expectedResult = false s := makeImage(expectedResult) s.Organized = true if err := createImage(ctx, w, s, f); err != nil { return err } } // create image with existing studio io f, err := createImageFile(ctx, existingStudioImageName, folderStore, fileCreator) if err != nil { return err } s := &models.Image{ Title: existingStudioImageName, StudioID: &existingStudioID, } if err := createImage(ctx, w, s, f); err != nil { return err } return nil } func createImageFile(ctx context.Context, name string, folderStore models.FolderFinderCreator, fileCreator models.FileCreator) (*models.ImageFile, error) { folderPath := filepath.Dir(name) basename := filepath.Base(name) folder, err := getOrCreateFolder(ctx, folderStore, folderPath) if err != nil { return nil, err } folderID := folder.ID f := &models.ImageFile{ BaseFile: &models.BaseFile{ Basename: basename, ParentFolderID: folderID, }, } if err := fileCreator.Create(ctx, f); err != nil { return nil, err } return f, nil } func makeImage(expectedResult bool) *models.Image { o := &models.Image{} // if expectedResult is true then we expect it to match, set the title accordingly if expectedResult { o.Title = expectedMatchTitle } return o } func createImage(ctx context.Context, w models.ImageWriter, o *models.Image, f *models.ImageFile) error { err := w.Create(ctx, &models.CreateImageInput{ Image: o, FileIDs: []models.FileID{f.ID}, }) if err != nil { return fmt.Errorf("Failed to create image with path '%s': %s", f.Path, err.Error()) } return nil } func createGalleries(ctx context.Context, w models.GalleryReaderWriter, folderStore models.FolderFinderCreator, fileCreator models.FileCreator) error { // create the galleries galleryPatterns, falseGalleryPatterns := generateTestPaths(testName, galleryExt) for _, fn := range galleryPatterns { f, err := createGalleryFile(ctx, fn, folderStore, fileCreator) if err != nil { return err } const expectedResult = true if err := createGallery(ctx, w, makeGallery(expectedResult), f); err != nil { return err } } for _, fn := range falseGalleryPatterns { f, err := createGalleryFile(ctx, fn, folderStore, fileCreator) if err != nil { return err } const expectedResult = false if err := createGallery(ctx, w, makeGallery(expectedResult), f); err != nil { return err } } // add organized galleries for _, fn := range galleryPatterns { f, err := createGalleryFile(ctx, "organized"+fn, folderStore, fileCreator) if err != nil { return err } const expectedResult = false s := makeGallery(expectedResult) s.Organized = true if err := createGallery(ctx, w, s, f); err != nil { return err } } // create gallery with existing studio io f, err := createGalleryFile(ctx, existingStudioGalleryName, folderStore, fileCreator) if err != nil { return err } s := &models.Gallery{ Title: existingStudioGalleryName, StudioID: &existingStudioID, } if err := createGallery(ctx, w, s, f); err != nil { return err } return nil } func createGalleryFile(ctx context.Context, name string, folderStore models.FolderFinderCreator, fileCreator models.FileCreator) (*models.BaseFile, error) { folderPath := filepath.Dir(name) basename := filepath.Base(name) folder, err := getOrCreateFolder(ctx, folderStore, folderPath) if err != nil { return nil, err } folderID := folder.ID f := &models.BaseFile{ Basename: basename, ParentFolderID: folderID, } if err := fileCreator.Create(ctx, f); err != nil { return nil, err } return f, nil } func makeGallery(expectedResult bool) *models.Gallery { o := &models.Gallery{} // if expectedResult is true then we expect it to match, set the title accordingly if expectedResult { o.Title = expectedMatchTitle } return o } func createGallery(ctx context.Context, w models.GalleryWriter, o *models.Gallery, f *models.BaseFile) error { err := w.Create(ctx, &models.CreateGalleryInput{ Gallery: o, FileIDs: []models.FileID{f.ID}, }) if err != nil { return fmt.Errorf("Failed to create gallery with path '%s': %s", f.Path, err.Error()) } return nil } func withTxn(f func(ctx context.Context) error) error { return txn.WithTxn(testCtx, db, f) } func withDB(f func(ctx context.Context) error) error { return txn.WithDatabase(testCtx, db, f) } func populateDB() error { if err := withTxn(func(ctx context.Context) error { err := createPerformer(ctx, r.Performer) if err != nil { return err } _, err = createStudio(ctx, r.Studio, testName) if err != nil { return err } // create existing studio existingStudio, err := createStudio(ctx, r.Studio, existingStudioName) if err != nil { return err } existingStudioID = existingStudio.ID err = createTag(ctx, r.Tag) if err != nil { return err } err = createScenes(ctx, r.Scene, r.Folder, r.File) if err != nil { return err } err = createImages(ctx, r.Image, r.Folder, r.File) if err != nil { return err } err = createGalleries(ctx, r.Gallery, r.Folder, r.File) if err != nil { return err } return nil }); err != nil { return err } return nil } func TestParsePerformerScenes(t *testing.T) { var performers []*models.Performer if err := withTxn(func(ctx context.Context) error { var err error performers, err = r.Performer.All(ctx) return err }); err != nil { t.Errorf("Error getting performer: %s", err) return } tagger := Tagger{ TxnManager: db, } for _, p := range performers { if err := withDB(func(ctx context.Context) error { if err := p.LoadAliases(ctx, r.Performer); err != nil { return err } return tagger.PerformerScenes(ctx, p, nil, r.Scene) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } } // verify that scenes were tagged correctly withTxn(func(ctx context.Context) error { pqb := r.Performer scenes, err := r.Scene.All(ctx) if err != nil { t.Error(err.Error()) } for _, scene := range scenes { performers, err := pqb.FindBySceneID(ctx, scene.ID) if err != nil { t.Errorf("Error getting scene performers: %s", err.Error()) } // title is only set on scenes where we expect performer to be set if scene.Title == expectedMatchTitle && len(performers) == 0 { t.Errorf("Did not set performer '%s' for path '%s'", testName, scene.Path) } else if scene.Title != expectedMatchTitle && len(performers) > 0 { t.Errorf("Incorrectly set performer '%s' for path '%s'", testName, scene.Path) } } return nil }) } func TestParseStudioScenes(t *testing.T) { var studios []*models.Studio if err := withTxn(func(ctx context.Context) error { var err error studios, err = r.Studio.All(ctx) return err }); err != nil { t.Errorf("Error getting studio: %s", err) return } tagger := Tagger{ TxnManager: db, } for _, s := range studios { if err := withDB(func(ctx context.Context) error { aliases, err := r.Studio.GetAliases(ctx, s.ID) if err != nil { return err } return tagger.StudioScenes(ctx, s, nil, aliases, r.Scene) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } } // verify that scenes were tagged correctly withTxn(func(ctx context.Context) error { scenes, err := r.Scene.All(ctx) if err != nil { t.Error(err.Error()) } for _, scene := range scenes { // check for existing studio id scene first if scene.Code == existingStudioSceneName { if scene.StudioID == nil || *scene.StudioID != existingStudioID { t.Error("Incorrectly overwrote studio ID for scene with existing studio ID") } } else { // title is only set on scenes where we expect studio to be set if scene.Title == expectedMatchTitle { if scene.StudioID == nil { t.Errorf("Did not set studio '%s' for path '%s'", testName, scene.Path) } else if scene.StudioID != nil && *scene.StudioID != studios[1].ID { t.Errorf("Incorrect studio id %d set for path '%s'", scene.StudioID, scene.Path) } } else if scene.Title != expectedMatchTitle && scene.StudioID != nil && *scene.StudioID == studios[1].ID { t.Errorf("Incorrectly set studio '%s' for path '%s'", testName, scene.Path) } } } return nil }) } func TestParseTagScenes(t *testing.T) { var tags []*models.Tag if err := withTxn(func(ctx context.Context) error { var err error tags, err = r.Tag.All(ctx) return err }); err != nil { t.Errorf("Error getting performer: %s", err) return } tagger := Tagger{ TxnManager: db, } for _, s := range tags { if err := withDB(func(ctx context.Context) error { aliases, err := r.Tag.GetAliases(ctx, s.ID) if err != nil { return err } return tagger.TagScenes(ctx, s, nil, aliases, r.Scene) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } } // verify that scenes were tagged correctly withTxn(func(ctx context.Context) error { scenes, err := r.Scene.All(ctx) if err != nil { t.Error(err.Error()) } tqb := r.Tag for _, scene := range scenes { tags, err := tqb.FindBySceneID(ctx, scene.ID) if err != nil { t.Errorf("Error getting scene tags: %s", err.Error()) } // title is only set on scenes where we expect tag to be set if scene.Title == expectedMatchTitle && len(tags) == 0 { t.Errorf("Did not set tag '%s' for path '%s'", testName, scene.Path) } else if (scene.Title != expectedMatchTitle) && len(tags) > 0 { t.Errorf("Incorrectly set tag '%s' for path '%s'", testName, scene.Path) } } return nil }) } func TestParsePerformerImages(t *testing.T) { var performers []*models.Performer if err := withTxn(func(ctx context.Context) error { var err error performers, err = r.Performer.All(ctx) return err }); err != nil { t.Errorf("Error getting performer: %s", err) return } tagger := Tagger{ TxnManager: db, } for _, p := range performers { if err := withDB(func(ctx context.Context) error { if err := p.LoadAliases(ctx, r.Performer); err != nil { return err } return tagger.PerformerImages(ctx, p, nil, r.Image) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } } // verify that images were tagged correctly withTxn(func(ctx context.Context) error { pqb := r.Performer images, err := r.Image.All(ctx) if err != nil { t.Error(err.Error()) } for _, image := range images { performers, err := pqb.FindByImageID(ctx, image.ID) if err != nil { t.Errorf("Error getting image performers: %s", err.Error()) } // title is only set on images where we expect performer to be set expectedMatch := image.Title == expectedMatchTitle || image.Title == existingStudioImageName if expectedMatch && len(performers) == 0 { t.Errorf("Did not set performer '%s' for path '%s'", testName, image.Path) } else if !expectedMatch && len(performers) > 0 { t.Errorf("Incorrectly set performer '%s' for path '%s'", testName, image.Path) } } return nil }) } func TestParseStudioImages(t *testing.T) { var studios []*models.Studio if err := withTxn(func(ctx context.Context) error { var err error studios, err = r.Studio.All(ctx) return err }); err != nil { t.Errorf("Error getting studio: %s", err) return } tagger := Tagger{ TxnManager: db, } for _, s := range studios { if err := withDB(func(ctx context.Context) error { aliases, err := r.Studio.GetAliases(ctx, s.ID) if err != nil { return err } return tagger.StudioImages(ctx, s, nil, aliases, r.Image) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } } // verify that images were tagged correctly withTxn(func(ctx context.Context) error { images, err := r.Image.All(ctx) if err != nil { t.Error(err.Error()) } for _, image := range images { // check for existing studio id image first if image.Title == existingStudioImageName { if *image.StudioID != existingStudioID { t.Error("Incorrectly overwrote studio ID for image with existing studio ID") } } else { // title is only set on images where we expect studio to be set if image.Title == expectedMatchTitle { if image.StudioID == nil { t.Errorf("Did not set studio '%s' for path '%s'", testName, image.Path) } else if *image.StudioID != studios[1].ID { t.Errorf("Incorrect studio id %d set for path '%s'", *image.StudioID, image.Path) } } else if image.Title != expectedMatchTitle && image.StudioID != nil && *image.StudioID == studios[1].ID { t.Errorf("Incorrectly set studio '%s' for path '%s'", testName, image.Path) } } } return nil }) } func TestParseTagImages(t *testing.T) { var tags []*models.Tag if err := withTxn(func(ctx context.Context) error { var err error tags, err = r.Tag.All(ctx) return err }); err != nil { t.Errorf("Error getting performer: %s", err) return } tagger := Tagger{ TxnManager: db, } for _, s := range tags { if err := withDB(func(ctx context.Context) error { aliases, err := r.Tag.GetAliases(ctx, s.ID) if err != nil { return err } return tagger.TagImages(ctx, s, nil, aliases, r.Image) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } } // verify that images were tagged correctly withTxn(func(ctx context.Context) error { images, err := r.Image.All(ctx) if err != nil { t.Error(err.Error()) } tqb := r.Tag for _, image := range images { tags, err := tqb.FindByImageID(ctx, image.ID) if err != nil { t.Errorf("Error getting image tags: %s", err.Error()) } // title is only set on images where we expect performer to be set expectedMatch := image.Title == expectedMatchTitle || image.Title == existingStudioImageName if expectedMatch && len(tags) == 0 { t.Errorf("Did not set tag '%s' for path '%s'", testName, image.Path) } else if !expectedMatch && len(tags) > 0 { t.Errorf("Incorrectly set tag '%s' for path '%s'", testName, image.Path) } } return nil }) } func TestParsePerformerGalleries(t *testing.T) { var performers []*models.Performer if err := withTxn(func(ctx context.Context) error { var err error performers, err = r.Performer.All(ctx) return err }); err != nil { t.Errorf("Error getting performer: %s", err) return } tagger := Tagger{ TxnManager: db, } for _, p := range performers { if err := withDB(func(ctx context.Context) error { if err := p.LoadAliases(ctx, r.Performer); err != nil { return err } return tagger.PerformerGalleries(ctx, p, nil, r.Gallery) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } } // verify that galleries were tagged correctly withTxn(func(ctx context.Context) error { pqb := r.Performer galleries, err := r.Gallery.All(ctx) if err != nil { t.Error(err.Error()) } for _, gallery := range galleries { performers, err := pqb.FindByGalleryID(ctx, gallery.ID) if err != nil { t.Errorf("Error getting gallery performers: %s", err.Error()) } // title is only set on galleries where we expect performer to be set expectedMatch := gallery.Title == expectedMatchTitle || gallery.Title == existingStudioGalleryName if expectedMatch && len(performers) == 0 { t.Errorf("Did not set performer '%s' for path '%s'", testName, gallery.Path) } else if !expectedMatch && len(performers) > 0 { t.Errorf("Incorrectly set performer '%s' for path '%s'", testName, gallery.Path) } } return nil }) } func TestParseStudioGalleries(t *testing.T) { var studios []*models.Studio if err := withTxn(func(ctx context.Context) error { var err error studios, err = r.Studio.All(ctx) return err }); err != nil { t.Errorf("Error getting studio: %s", err) return } tagger := Tagger{ TxnManager: db, } for _, s := range studios { if err := withDB(func(ctx context.Context) error { aliases, err := r.Studio.GetAliases(ctx, s.ID) if err != nil { return err } return tagger.StudioGalleries(ctx, s, nil, aliases, r.Gallery) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } } // verify that galleries were tagged correctly withTxn(func(ctx context.Context) error { galleries, err := r.Gallery.All(ctx) if err != nil { t.Error(err.Error()) } for _, gallery := range galleries { // check for existing studio id gallery first if gallery.Title == existingStudioGalleryName { if *gallery.StudioID != existingStudioID { t.Error("Incorrectly overwrote studio ID for gallery with existing studio ID") } } else { // title is only set on galleries where we expect studio to be set if gallery.Title == expectedMatchTitle { if gallery.StudioID == nil { t.Errorf("Did not set studio '%s' for path '%s'", testName, gallery.Path) } else if *gallery.StudioID != studios[1].ID { t.Errorf("Incorrect studio id %d set for path '%s'", *gallery.StudioID, gallery.Path) } } else if gallery.Title != expectedMatchTitle && (gallery.StudioID != nil && *gallery.StudioID == studios[1].ID) { t.Errorf("Incorrectly set studio '%s' for path '%s'", testName, gallery.Path) } } } return nil }) } func TestParseTagGalleries(t *testing.T) { var tags []*models.Tag if err := withTxn(func(ctx context.Context) error { var err error tags, err = r.Tag.All(ctx) return err }); err != nil { t.Errorf("Error getting performer: %s", err) return } tagger := Tagger{ TxnManager: db, } for _, s := range tags { if err := withDB(func(ctx context.Context) error { aliases, err := r.Tag.GetAliases(ctx, s.ID) if err != nil { return err } return tagger.TagGalleries(ctx, s, nil, aliases, r.Gallery) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } } // verify that galleries were tagged correctly withTxn(func(ctx context.Context) error { galleries, err := r.Gallery.All(ctx) if err != nil { t.Error(err.Error()) } tqb := r.Tag for _, gallery := range galleries { tags, err := tqb.FindByGalleryID(ctx, gallery.ID) if err != nil { t.Errorf("Error getting gallery tags: %s", err.Error()) } // title is only set on galleries where we expect performer to be set expectedMatch := gallery.Title == expectedMatchTitle || gallery.Title == existingStudioGalleryName if expectedMatch && len(tags) == 0 { t.Errorf("Did not set tag '%s' for path '%s'", testName, gallery.Path) } else if !expectedMatch && len(tags) > 0 { t.Errorf("Incorrectly set tag '%s' for path '%s'", testName, gallery.Path) } } return nil }) } ================================================ FILE: internal/autotag/performer.go ================================================ package autotag import ( "context" "slices" "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/txn" ) type SceneQueryPerformerUpdater interface { models.SceneQueryer models.PerformerIDLoader models.SceneUpdater } type ImageQueryPerformerUpdater interface { models.ImageQueryer models.PerformerIDLoader models.ImageUpdater } type GalleryQueryPerformerUpdater interface { models.GalleryQueryer models.PerformerIDLoader models.GalleryUpdater } func getPerformerTaggers(p *models.Performer, cache *match.Cache) []tagger { ret := []tagger{{ ID: p.ID, Type: "performer", Name: p.Name, cache: cache, }} // TODO - disabled until we can have finer control over alias matching // for _, a := range p.Aliases.List() { // ret = append(ret, tagger{ // ID: p.ID, // Type: "performer", // Name: a, // cache: cache, // }) // } return ret } // PerformerScenes searches for scenes whose path matches the provided performer name and tags the scene with the performer. // Performer aliases must be loaded. func (tagger *Tagger) PerformerScenes(ctx context.Context, p *models.Performer, paths []string, rw SceneQueryPerformerUpdater) error { t := getPerformerTaggers(p, tagger.Cache) for _, tt := range t { if err := tt.tagScenes(ctx, paths, rw, func(o *models.Scene) (bool, error) { if err := o.LoadPerformerIDs(ctx, rw); err != nil { return false, err } existing := o.PerformerIDs.List() if slices.Contains(existing, p.ID) { return false, nil } if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { return scene.AddPerformer(ctx, rw, o, p.ID) }); err != nil { return false, err } return true, nil }); err != nil { return err } } return nil } // PerformerImages searches for images whose path matches the provided performer name and tags the image with the performer. func (tagger *Tagger) PerformerImages(ctx context.Context, p *models.Performer, paths []string, rw ImageQueryPerformerUpdater) error { t := getPerformerTaggers(p, tagger.Cache) for _, tt := range t { if err := tt.tagImages(ctx, paths, rw, func(o *models.Image) (bool, error) { if err := o.LoadPerformerIDs(ctx, rw); err != nil { return false, err } existing := o.PerformerIDs.List() if slices.Contains(existing, p.ID) { return false, nil } if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { return image.AddPerformer(ctx, rw, o, p.ID) }); err != nil { return false, err } return true, nil }); err != nil { return err } } return nil } // PerformerGalleries searches for galleries whose path matches the provided performer name and tags the gallery with the performer. func (tagger *Tagger) PerformerGalleries(ctx context.Context, p *models.Performer, paths []string, rw GalleryQueryPerformerUpdater) error { t := getPerformerTaggers(p, tagger.Cache) for _, tt := range t { if err := tt.tagGalleries(ctx, paths, rw, func(o *models.Gallery) (bool, error) { if err := o.LoadPerformerIDs(ctx, rw); err != nil { return false, err } existing := o.PerformerIDs.List() if slices.Contains(existing, p.ID) { return false, nil } if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { return gallery.AddPerformer(ctx, rw, o, p.ID) }); err != nil { return false, err } return true, nil }); err != nil { return err } } return nil } ================================================ FILE: internal/autotag/performer_test.go ================================================ package autotag import ( "path/filepath" "testing" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stashapp/stash/pkg/scene" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) func TestPerformerScenes(t *testing.T) { t.Parallel() type test struct { performerName string expectedRegex string } performerNames := []test{ { "performer name", `(?i)(?:^|_|[^\p{L}\d])performer[.\-_ ]*name(?:$|_|[^\p{L}\d])`, }, { "performer + name", `(?i)(?:^|_|[^\p{L}\d])performer[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\p{L}\d])`, }, } // trailing backslash tests only work where filepath separator is not backslash if filepath.Separator != '\\' { performerNames = append(performerNames, test{ `performer + name\`, `(?i)(?:^|_|[^\p{L}\d])performer[.\-_ ]*\+[.\-_ ]*name\\(?:$|_|[^\p{L}\d])`, }) } for _, p := range performerNames { testPerformerScenes(t, p.performerName, p.expectedRegex) } } func testPerformerScenes(t *testing.T, performerName, expectedRegex string) { db := mocks.NewDatabase() const performerID = 2 var scenes []*models.Scene matchingPaths, falsePaths := generateTestPaths(performerName, "mp4") for i, p := range append(matchingPaths, falsePaths...) { scenes = append(scenes, &models.Scene{ ID: i + 1, Path: p, PerformerIDs: models.NewRelatedIDs([]int{}), }) } performer := models.Performer{ ID: performerID, Name: performerName, Aliases: models.NewRelatedStrings([]string{}), } organized := false perPage := 1000 sort := "id" direction := models.SortDirectionEnumAsc expectedSceneFilter := &models.SceneFilterType{ Organized: &organized, Path: &models.StringCriterionInput{ Value: expectedRegex, Modifier: models.CriterionModifierMatchesRegex, }, } expectedFindFilter := &models.FindFilterType{ PerPage: &perPage, Sort: &sort, Direction: &direction, } db.Scene.On("Query", mock.Anything, scene.QueryOptions(expectedSceneFilter, expectedFindFilter, false)). Return(mocks.SceneQueryResult(scenes, len(scenes)), nil).Once() for i := range matchingPaths { sceneID := i + 1 matchPartial := mock.MatchedBy(func(got models.ScenePartial) bool { expected := models.ScenePartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerID}, Mode: models.RelationshipUpdateModeAdd, }, } return scenePartialsEqual(got, expected) }) db.Scene.On("UpdatePartial", mock.Anything, sceneID, matchPartial).Return(nil, nil).Once() } tagger := Tagger{ TxnManager: db, } err := tagger.PerformerScenes(testCtx, &performer, nil, db.Scene) assert := assert.New(t) assert.Nil(err) db.AssertExpectations(t) } func TestPerformerImages(t *testing.T) { t.Parallel() type test struct { performerName string expectedRegex string } performerNames := []test{ { "performer name", `(?i)(?:^|_|[^\p{L}\d])performer[.\-_ ]*name(?:$|_|[^\p{L}\d])`, }, { "performer + name", `(?i)(?:^|_|[^\p{L}\d])performer[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\p{L}\d])`, }, } for _, p := range performerNames { testPerformerImages(t, p.performerName, p.expectedRegex) } } func testPerformerImages(t *testing.T, performerName, expectedRegex string) { db := mocks.NewDatabase() const performerID = 2 var images []*models.Image matchingPaths, falsePaths := generateTestPaths(performerName, imageExt) for i, p := range append(matchingPaths, falsePaths...) { images = append(images, &models.Image{ ID: i + 1, Path: p, PerformerIDs: models.NewRelatedIDs([]int{}), }) } performer := models.Performer{ ID: performerID, Name: performerName, Aliases: models.NewRelatedStrings([]string{}), } organized := false perPage := 1000 sort := "id" direction := models.SortDirectionEnumAsc expectedImageFilter := &models.ImageFilterType{ Organized: &organized, Path: &models.StringCriterionInput{ Value: expectedRegex, Modifier: models.CriterionModifierMatchesRegex, }, } expectedFindFilter := &models.FindFilterType{ PerPage: &perPage, Sort: &sort, Direction: &direction, } db.Image.On("Query", mock.Anything, image.QueryOptions(expectedImageFilter, expectedFindFilter, false)). Return(mocks.ImageQueryResult(images, len(images)), nil).Once() for i := range matchingPaths { imageID := i + 1 matchPartial := mock.MatchedBy(func(got models.ImagePartial) bool { expected := models.ImagePartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerID}, Mode: models.RelationshipUpdateModeAdd, }, } return imagePartialsEqual(got, expected) }) db.Image.On("UpdatePartial", mock.Anything, imageID, matchPartial).Return(nil, nil).Once() } tagger := Tagger{ TxnManager: db, } err := tagger.PerformerImages(testCtx, &performer, nil, db.Image) assert := assert.New(t) assert.Nil(err) db.AssertExpectations(t) } func TestPerformerGalleries(t *testing.T) { t.Parallel() type test struct { performerName string expectedRegex string } performerNames := []test{ { "performer name", `(?i)(?:^|_|[^\p{L}\d])performer[.\-_ ]*name(?:$|_|[^\p{L}\d])`, }, { "performer + name", `(?i)(?:^|_|[^\p{L}\d])performer[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\p{L}\d])`, }, } for _, p := range performerNames { testPerformerGalleries(t, p.performerName, p.expectedRegex) } } func testPerformerGalleries(t *testing.T, performerName, expectedRegex string) { db := mocks.NewDatabase() const performerID = 2 var galleries []*models.Gallery matchingPaths, falsePaths := generateTestPaths(performerName, galleryExt) for i, p := range append(matchingPaths, falsePaths...) { v := p galleries = append(galleries, &models.Gallery{ ID: i + 1, Path: v, PerformerIDs: models.NewRelatedIDs([]int{}), }) } performer := models.Performer{ ID: performerID, Name: performerName, Aliases: models.NewRelatedStrings([]string{}), } organized := false perPage := 1000 sort := "id" direction := models.SortDirectionEnumAsc expectedGalleryFilter := &models.GalleryFilterType{ Organized: &organized, Path: &models.StringCriterionInput{ Value: expectedRegex, Modifier: models.CriterionModifierMatchesRegex, }, } expectedFindFilter := &models.FindFilterType{ PerPage: &perPage, Sort: &sort, Direction: &direction, } db.Gallery.On("Query", mock.Anything, expectedGalleryFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once() for i := range matchingPaths { galleryID := i + 1 matchPartial := mock.MatchedBy(func(got models.GalleryPartial) bool { expected := models.GalleryPartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerID}, Mode: models.RelationshipUpdateModeAdd, }, } return galleryPartialsEqual(got, expected) }) db.Gallery.On("UpdatePartial", mock.Anything, galleryID, matchPartial).Return(nil, nil).Once() } tagger := Tagger{ TxnManager: db, } err := tagger.PerformerGalleries(testCtx, &performer, nil, db.Gallery) assert := assert.New(t) assert.Nil(err) db.AssertExpectations(t) } ================================================ FILE: internal/autotag/scene.go ================================================ package autotag import ( "context" "slices" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" ) type SceneFinderUpdater interface { models.SceneQueryer models.SceneUpdater } type ScenePerformerUpdater interface { models.PerformerIDLoader models.SceneUpdater } type SceneTagUpdater interface { models.TagIDLoader models.SceneUpdater } func getSceneFileTagger(s *models.Scene, cache *match.Cache) tagger { return tagger{ ID: s.ID, Type: "scene", Name: s.DisplayName(), Path: s.Path, cache: cache, } } // ScenePerformers tags the provided scene with performers whose name matches the scene's path. func ScenePerformers(ctx context.Context, s *models.Scene, rw ScenePerformerUpdater, performerReader models.PerformerAutoTagQueryer, cache *match.Cache) error { t := getSceneFileTagger(s, cache) return t.tagPerformers(ctx, performerReader, func(subjectID, otherID int) (bool, error) { if err := s.LoadPerformerIDs(ctx, rw); err != nil { return false, err } existing := s.PerformerIDs.List() if slices.Contains(existing, otherID) { return false, nil } if err := scene.AddPerformer(ctx, rw, s, otherID); err != nil { return false, err } return true, nil }) } // SceneStudios tags the provided scene with the first studio whose name matches the scene's path. // // Scenes will not be tagged if studio is already set. func SceneStudios(ctx context.Context, s *models.Scene, rw SceneFinderUpdater, studioReader models.StudioAutoTagQueryer, cache *match.Cache) error { if s.StudioID != nil { // don't modify return nil } t := getSceneFileTagger(s, cache) return t.tagStudios(ctx, studioReader, func(subjectID, otherID int) (bool, error) { return addSceneStudio(ctx, rw, s, otherID) }) } // SceneTags tags the provided scene with tags whose name matches the scene's path. func SceneTags(ctx context.Context, s *models.Scene, rw SceneTagUpdater, tagReader models.TagAutoTagQueryer, cache *match.Cache) error { t := getSceneFileTagger(s, cache) return t.tagTags(ctx, tagReader, func(subjectID, otherID int) (bool, error) { if err := s.LoadTagIDs(ctx, rw); err != nil { return false, err } existing := s.TagIDs.List() if slices.Contains(existing, otherID) { return false, nil } if err := scene.AddTag(ctx, rw, s, otherID); err != nil { return false, err } return true, nil }) } ================================================ FILE: internal/autotag/scene_test.go ================================================ package autotag import ( "fmt" "path/filepath" "strings" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) const sceneExt = "mp4" var testSeparators = []string{ ".", "-", "_", " ", } var testEndSeparators = []string{ "{", "}", "(", ")", ",", } // asserts that got == expected // ignores expected.UpdatedAt, but ensures that got.UpdatedAt is set and not null func scenePartialsEqual(got, expected models.ScenePartial) bool { // updated at should be set and not null if !got.UpdatedAt.Set || got.UpdatedAt.Null { return false } // else ignore the exact value got.UpdatedAt = models.OptionalTime{} return assert.ObjectsAreEqual(got, expected) } func generateNamePatterns(name, separator, ext string) []string { var ret []string ret = append(ret, fmt.Sprintf("%s%saaa.%s", name, separator, ext)) ret = append(ret, fmt.Sprintf("aaa%s%s.%s", separator, name, ext)) ret = append(ret, fmt.Sprintf("aaa%s%s%sbbb.%s", separator, name, separator, ext)) ret = append(ret, filepath.Join("dir", fmt.Sprintf("%s%saaa.%s", name, separator, ext))) ret = append(ret, filepath.Join(fmt.Sprintf("dir%sdir", separator), fmt.Sprintf("%s%saaa.%s", name, separator, ext))) ret = append(ret, filepath.Join(fmt.Sprintf("%s%saaa", name, separator), "dir", fmt.Sprintf("bbb.%s", ext))) ret = append(ret, filepath.Join("dir", fmt.Sprintf("%s%s", name, separator), fmt.Sprintf("aaa.%s", ext))) return ret } func generateSplitNamePatterns(name, separator, ext string) []string { var ret []string splitted := strings.Split(name, " ") // only do this for names that are split into two if len(splitted) == 2 { ret = append(ret, fmt.Sprintf("%s%s%s.%s", splitted[0], separator, splitted[1], ext)) } return ret } func generateFalseNamePatterns(name string, separator, ext string) []string { splitted := strings.Split(name, " ") var ret []string // only do this for names that are split into two if len(splitted) == 2 { ret = append(ret, fmt.Sprintf("%s%saaa%s%s.%s", splitted[0], separator, separator, splitted[1], ext)) } return ret } func generateTestPaths(testName, ext string) (scenePatterns []string, falseScenePatterns []string) { separators := testSeparators separators = append(separators, testEndSeparators...) for _, separator := range separators { scenePatterns = append(scenePatterns, generateNamePatterns(testName, separator, ext)...) scenePatterns = append(scenePatterns, generateNamePatterns(strings.ToLower(testName), separator, ext)...) scenePatterns = append(scenePatterns, generateNamePatterns(strings.ReplaceAll(testName, " ", ""), separator, ext)...) falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, separator, ext)...) } // add test cases for intra-name separators for _, separator := range testSeparators { if separator != " " { scenePatterns = append(scenePatterns, generateNamePatterns(strings.ReplaceAll(testName, " ", separator), separator, ext)...) } } // add basic false scenarios falseScenePatterns = append(falseScenePatterns, fmt.Sprintf("aaa%s.%s", testName, ext)) falseScenePatterns = append(falseScenePatterns, fmt.Sprintf("%saaa.%s", testName, ext)) // add path separator false scenarios falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, string(filepath.Separator), ext)...) // split patterns only valid for ._- and whitespace for _, separator := range testSeparators { scenePatterns = append(scenePatterns, generateSplitNamePatterns(testName, separator, ext)...) } // false patterns for other separators for _, separator := range testEndSeparators { falseScenePatterns = append(falseScenePatterns, generateSplitNamePatterns(testName, separator, ext)...) } return } type pathTestTable struct { Path string Matches bool } func generateTestTable(testName, ext string) []pathTestTable { var ret []pathTestTable var scenePatterns []string var falseScenePatterns []string separators := testSeparators separators = append(separators, testEndSeparators...) for _, separator := range separators { scenePatterns = append(scenePatterns, generateNamePatterns(testName, separator, ext)...) scenePatterns = append(scenePatterns, generateNamePatterns(strings.ToLower(testName), separator, ext)...) falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, separator, ext)...) } for _, p := range scenePatterns { t := pathTestTable{ Path: p, Matches: true, } ret = append(ret, t) } for _, p := range falseScenePatterns { t := pathTestTable{ Path: p, Matches: false, } ret = append(ret, t) } return ret } func TestScenePerformers(t *testing.T) { t.Parallel() const sceneID = 1 const performerName = "performer name" const performerID = 2 performer := models.Performer{ ID: performerID, Name: performerName, Aliases: models.NewRelatedStrings([]string{}), } const reversedPerformerName = "name performer" const reversedPerformerID = 3 reversedPerformer := models.Performer{ ID: reversedPerformerID, Name: reversedPerformerName, Aliases: models.NewRelatedStrings([]string{}), } testTables := generateTestTable(performerName, sceneExt) assert := assert.New(t) for _, test := range testTables { db := mocks.NewDatabase() db.Performer.On("Query", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil) db.Performer.On("QueryForAutoTag", testCtx, mock.Anything).Return([]*models.Performer{&performer, &reversedPerformer}, nil).Once() scene := models.Scene{ ID: sceneID, Path: test.Path, PerformerIDs: models.NewRelatedIDs([]int{}), } if test.Matches { matchPartial := mock.MatchedBy(func(got models.ScenePartial) bool { expected := models.ScenePartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerID}, Mode: models.RelationshipUpdateModeAdd, }, } return scenePartialsEqual(got, expected) }) db.Scene.On("UpdatePartial", testCtx, sceneID, matchPartial).Return(nil, nil).Once() } err := ScenePerformers(testCtx, &scene, db.Scene, db.Performer, nil) assert.Nil(err) db.AssertExpectations(t) } } func TestSceneStudios(t *testing.T) { t.Parallel() var ( sceneID = 1 studioName = "studio name" studioID = 2 ) studio := models.Studio{ ID: studioID, Name: studioName, } const reversedStudioName = "name studio" const reversedStudioID = 3 reversedStudio := models.Studio{ ID: reversedStudioID, Name: reversedStudioName, } testTables := generateTestTable(studioName, sceneExt) assert := assert.New(t) doTest := func(db *mocks.Database, test pathTestTable) { if test.Matches { matchPartial := mock.MatchedBy(func(got models.ScenePartial) bool { expected := models.ScenePartial{ StudioID: models.NewOptionalInt(studioID), } return scenePartialsEqual(got, expected) }) db.Scene.On("UpdatePartial", testCtx, sceneID, matchPartial).Return(nil, nil).Once() } scene := models.Scene{ ID: sceneID, Path: test.Path, } err := SceneStudios(testCtx, &scene, db.Scene, db.Studio, nil) assert.Nil(err) db.AssertExpectations(t) } for _, test := range testTables { db := mocks.NewDatabase() db.Studio.On("Query", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil) db.Studio.On("QueryForAutoTag", testCtx, mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once() db.Studio.On("GetAliases", testCtx, mock.Anything).Return([]string{}, nil).Maybe() doTest(db, test) } const unmatchedName = "unmatched" studio.Name = unmatchedName // test against aliases for _, test := range testTables { db := mocks.NewDatabase() db.Studio.On("Query", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil) db.Studio.On("QueryForAutoTag", testCtx, mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once() db.Studio.On("GetAliases", testCtx, studioID).Return([]string{ studioName, }, nil).Once() db.Studio.On("GetAliases", testCtx, reversedStudioID).Return([]string{}, nil).Once() doTest(db, test) } } func TestSceneTags(t *testing.T) { t.Parallel() const sceneID = 1 const tagName = "tag name" const tagID = 2 tag := models.Tag{ ID: tagID, Name: tagName, } const reversedTagName = "name tag" const reversedTagID = 3 reversedTag := models.Tag{ ID: reversedTagID, Name: reversedTagName, } testTables := generateTestTable(tagName, sceneExt) assert := assert.New(t) doTest := func(db *mocks.Database, test pathTestTable) { if test.Matches { matchPartial := mock.MatchedBy(func(got models.ScenePartial) bool { expected := models.ScenePartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagID}, Mode: models.RelationshipUpdateModeAdd, }, } return scenePartialsEqual(got, expected) }) db.Scene.On("UpdatePartial", testCtx, sceneID, matchPartial).Return(nil, nil).Once() } scene := models.Scene{ ID: sceneID, Path: test.Path, TagIDs: models.NewRelatedIDs([]int{}), } err := SceneTags(testCtx, &scene, db.Scene, db.Tag, nil) assert.Nil(err) db.AssertExpectations(t) } for _, test := range testTables { db := mocks.NewDatabase() db.Tag.On("Query", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil) db.Tag.On("QueryForAutoTag", testCtx, mock.Anything).Return([]*models.Tag{&tag, &reversedTag}, nil).Once() db.Tag.On("GetAliases", testCtx, mock.Anything).Return([]string{}, nil).Maybe() doTest(db, test) } const unmatchedName = "unmatched" tag.Name = unmatchedName // test against aliases for _, test := range testTables { db := mocks.NewDatabase() db.Tag.On("Query", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil) db.Tag.On("QueryForAutoTag", testCtx, mock.Anything).Return([]*models.Tag{&tag, &reversedTag}, nil).Once() db.Tag.On("GetAliases", testCtx, tagID).Return([]string{ tagName, }, nil).Once() db.Tag.On("GetAliases", testCtx, reversedTagID).Return([]string{}, nil).Once() doTest(db, test) } } ================================================ FILE: internal/autotag/studio.go ================================================ package autotag import ( "context" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/txn" ) // the following functions aren't used in Tagger because they assume // use within a transaction func addSceneStudio(ctx context.Context, sceneWriter models.SceneUpdater, o *models.Scene, studioID int) (bool, error) { // don't set if already set if o.StudioID != nil { return false, nil } // set the studio id scenePartial := models.NewScenePartial() scenePartial.StudioID = models.NewOptionalInt(studioID) if _, err := sceneWriter.UpdatePartial(ctx, o.ID, scenePartial); err != nil { return false, err } return true, nil } func addImageStudio(ctx context.Context, imageWriter models.ImageUpdater, i *models.Image, studioID int) (bool, error) { // don't set if already set if i.StudioID != nil { return false, nil } // set the studio id imagePartial := models.NewImagePartial() imagePartial.StudioID = models.NewOptionalInt(studioID) if _, err := imageWriter.UpdatePartial(ctx, i.ID, imagePartial); err != nil { return false, err } return true, nil } func addGalleryStudio(ctx context.Context, galleryWriter GalleryFinderUpdater, o *models.Gallery, studioID int) (bool, error) { // don't set if already set if o.StudioID != nil { return false, nil } // set the studio id galleryPartial := models.NewGalleryPartial() galleryPartial.StudioID = models.NewOptionalInt(studioID) if _, err := galleryWriter.UpdatePartial(ctx, o.ID, galleryPartial); err != nil { return false, err } return true, nil } func getStudioTagger(p *models.Studio, aliases []string, cache *match.Cache) []tagger { ret := []tagger{{ ID: p.ID, Type: "studio", Name: p.Name, cache: cache, }} for _, a := range aliases { ret = append(ret, tagger{ ID: p.ID, Type: "studio", Name: a, }) } return ret } // StudioScenes searches for scenes whose path matches the provided studio name and tags the scene with the studio, if studio is not already set on the scene. func (tagger *Tagger) StudioScenes(ctx context.Context, p *models.Studio, paths []string, aliases []string, rw SceneFinderUpdater) error { t := getStudioTagger(p, aliases, tagger.Cache) for _, tt := range t { if err := tt.tagScenes(ctx, paths, rw, func(o *models.Scene) (bool, error) { // don't set if already set if o.StudioID != nil { return false, nil } // set the studio id scenePartial := models.NewScenePartial() scenePartial.StudioID = models.NewOptionalInt(p.ID) if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { _, err := rw.UpdatePartial(ctx, o.ID, scenePartial) return err }); err != nil { return false, err } return true, nil }); err != nil { return err } } return nil } // StudioImages searches for images whose path matches the provided studio name and tags the image with the studio, if studio is not already set on the image. func (tagger *Tagger) StudioImages(ctx context.Context, p *models.Studio, paths []string, aliases []string, rw ImageFinderUpdater) error { t := getStudioTagger(p, aliases, tagger.Cache) for _, tt := range t { if err := tt.tagImages(ctx, paths, rw, func(i *models.Image) (bool, error) { // don't set if already set if i.StudioID != nil { return false, nil } // set the studio id imagePartial := models.NewImagePartial() imagePartial.StudioID = models.NewOptionalInt(p.ID) if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { _, err := rw.UpdatePartial(ctx, i.ID, imagePartial) return err }); err != nil { return false, err } return true, nil }); err != nil { return err } } return nil } // StudioGalleries searches for galleries whose path matches the provided studio name and tags the gallery with the studio, if studio is not already set on the gallery. func (tagger *Tagger) StudioGalleries(ctx context.Context, p *models.Studio, paths []string, aliases []string, rw GalleryFinderUpdater) error { t := getStudioTagger(p, aliases, tagger.Cache) for _, tt := range t { if err := tt.tagGalleries(ctx, paths, rw, func(o *models.Gallery) (bool, error) { // don't set if already set if o.StudioID != nil { return false, nil } // set the studio id galleryPartial := models.NewGalleryPartial() galleryPartial.StudioID = models.NewOptionalInt(p.ID) if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { _, err := rw.UpdatePartial(ctx, o.ID, galleryPartial) return err }); err != nil { return false, err } return true, nil }); err != nil { return err } } return nil } ================================================ FILE: internal/autotag/studio_test.go ================================================ package autotag import ( "path/filepath" "testing" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stashapp/stash/pkg/scene" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) type testStudioCase struct { studioName string expectedRegex string aliasName string aliasRegex string } var ( testStudioCases = []testStudioCase{ { "studio name", `(?i)(?:^|_|[^\p{L}\d])studio[.\-_ ]*name(?:$|_|[^\p{L}\d])`, "", "", }, { "studio + name", `(?i)(?:^|_|[^\p{L}\d])studio[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\p{L}\d])`, "", "", }, { "studio name", `(?i)(?:^|_|[^\p{L}\d])studio[.\-_ ]*name(?:$|_|[^\p{L}\d])`, "alias name", `(?i)(?:^|_|[^\p{L}\d])alias[.\-_ ]*name(?:$|_|[^\p{L}\d])`, }, { "studio + name", `(?i)(?:^|_|[^\p{L}\d])studio[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\p{L}\d])`, "alias + name", `(?i)(?:^|_|[^\p{L}\d])alias[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\p{L}\d])`, }, } trailingBackslashStudioCases = []testStudioCase{ { `studio + name\`, `(?i)(?:^|_|[^\p{L}\d])studio[.\-_ ]*\+[.\-_ ]*name\\(?:$|_|[^\p{L}\d])`, "", "", }, { `studio + name\`, `(?i)(?:^|_|[^\p{L}\d])studio[.\-_ ]*\+[.\-_ ]*name\\(?:$|_|[^\p{L}\d])`, `alias + name\`, `(?i)(?:^|_|[^\p{L}\d])alias[.\-_ ]*\+[.\-_ ]*name\\(?:$|_|[^\p{L}\d])`, }, } ) func TestStudioScenes(t *testing.T) { t.Parallel() tc := testStudioCases // trailing backslash tests only work where filepath separator is not backslash if filepath.Separator != '\\' { tc = append(tc, trailingBackslashStudioCases...) } for _, p := range tc { testStudioScenes(t, p) } } func testStudioScenes(t *testing.T, tc testStudioCase) { studioName := tc.studioName expectedRegex := tc.expectedRegex aliasName := tc.aliasName aliasRegex := tc.aliasRegex db := mocks.NewDatabase() var studioID = 2 var aliases []string testPathName := studioName if aliasName != "" { aliases = []string{aliasName} testPathName = aliasName } matchingPaths, falsePaths := generateTestPaths(testPathName, "mp4") var scenes []*models.Scene for i, p := range append(matchingPaths, falsePaths...) { scenes = append(scenes, &models.Scene{ ID: i + 1, Path: p, }) } studio := models.Studio{ ID: studioID, Name: studioName, } organized := false perPage := 1000 sort := "id" direction := models.SortDirectionEnumAsc expectedSceneFilter := &models.SceneFilterType{ Organized: &organized, Path: &models.StringCriterionInput{ Value: expectedRegex, Modifier: models.CriterionModifierMatchesRegex, }, } expectedFindFilter := &models.FindFilterType{ PerPage: &perPage, Sort: &sort, Direction: &direction, } // if alias provided, then don't find by name onNameQuery := db.Scene.On("Query", testCtx, scene.QueryOptions(expectedSceneFilter, expectedFindFilter, false)) if aliasName == "" { onNameQuery.Return(mocks.SceneQueryResult(scenes, len(scenes)), nil).Once() } else { onNameQuery.Return(mocks.SceneQueryResult(nil, 0), nil).Once() expectedAliasFilter := &models.SceneFilterType{ Organized: &organized, Path: &models.StringCriterionInput{ Value: aliasRegex, Modifier: models.CriterionModifierMatchesRegex, }, } db.Scene.On("Query", mock.Anything, scene.QueryOptions(expectedAliasFilter, expectedFindFilter, false)). Return(mocks.SceneQueryResult(scenes, len(scenes)), nil).Once() } for i := range matchingPaths { sceneID := i + 1 matchPartial := mock.MatchedBy(func(got models.ScenePartial) bool { expected := models.ScenePartial{ StudioID: models.NewOptionalInt(studioID), } return scenePartialsEqual(got, expected) }) db.Scene.On("UpdatePartial", mock.Anything, sceneID, matchPartial).Return(nil, nil).Once() } tagger := Tagger{ TxnManager: db, } err := tagger.StudioScenes(testCtx, &studio, nil, aliases, db.Scene) assert := assert.New(t) assert.Nil(err) db.AssertExpectations(t) } func TestStudioImages(t *testing.T) { t.Parallel() for _, p := range testStudioCases { testStudioImages(t, p) } } func testStudioImages(t *testing.T, tc testStudioCase) { studioName := tc.studioName expectedRegex := tc.expectedRegex aliasName := tc.aliasName aliasRegex := tc.aliasRegex db := mocks.NewDatabase() var studioID = 2 var aliases []string testPathName := studioName if aliasName != "" { aliases = []string{aliasName} testPathName = aliasName } var images []*models.Image matchingPaths, falsePaths := generateTestPaths(testPathName, imageExt) for i, p := range append(matchingPaths, falsePaths...) { images = append(images, &models.Image{ ID: i + 1, Path: p, }) } studio := models.Studio{ ID: studioID, Name: studioName, } organized := false perPage := 1000 sort := "id" direction := models.SortDirectionEnumAsc expectedImageFilter := &models.ImageFilterType{ Organized: &organized, Path: &models.StringCriterionInput{ Value: expectedRegex, Modifier: models.CriterionModifierMatchesRegex, }, } expectedFindFilter := &models.FindFilterType{ PerPage: &perPage, Sort: &sort, Direction: &direction, } // if alias provided, then don't find by name onNameQuery := db.Image.On("Query", mock.Anything, image.QueryOptions(expectedImageFilter, expectedFindFilter, false)) if aliasName == "" { onNameQuery.Return(mocks.ImageQueryResult(images, len(images)), nil).Once() } else { onNameQuery.Return(mocks.ImageQueryResult(nil, 0), nil).Once() expectedAliasFilter := &models.ImageFilterType{ Organized: &organized, Path: &models.StringCriterionInput{ Value: aliasRegex, Modifier: models.CriterionModifierMatchesRegex, }, } db.Image.On("Query", mock.Anything, image.QueryOptions(expectedAliasFilter, expectedFindFilter, false)). Return(mocks.ImageQueryResult(images, len(images)), nil).Once() } for i := range matchingPaths { imageID := i + 1 matchPartial := mock.MatchedBy(func(got models.ImagePartial) bool { expected := models.ImagePartial{ StudioID: models.NewOptionalInt(studioID), } return imagePartialsEqual(got, expected) }) db.Image.On("UpdatePartial", mock.Anything, imageID, matchPartial).Return(nil, nil).Once() } tagger := Tagger{ TxnManager: db, } err := tagger.StudioImages(testCtx, &studio, nil, aliases, db.Image) assert := assert.New(t) assert.Nil(err) db.AssertExpectations(t) } func TestStudioGalleries(t *testing.T) { t.Parallel() for _, p := range testStudioCases { testStudioGalleries(t, p) } } func testStudioGalleries(t *testing.T, tc testStudioCase) { studioName := tc.studioName expectedRegex := tc.expectedRegex aliasName := tc.aliasName aliasRegex := tc.aliasRegex db := mocks.NewDatabase() var studioID = 2 var aliases []string testPathName := studioName if aliasName != "" { aliases = []string{aliasName} testPathName = aliasName } var galleries []*models.Gallery matchingPaths, falsePaths := generateTestPaths(testPathName, galleryExt) for i, p := range append(matchingPaths, falsePaths...) { v := p galleries = append(galleries, &models.Gallery{ ID: i + 1, Path: v, }) } studio := models.Studio{ ID: studioID, Name: studioName, } organized := false perPage := 1000 sort := "id" direction := models.SortDirectionEnumAsc expectedGalleryFilter := &models.GalleryFilterType{ Organized: &organized, Path: &models.StringCriterionInput{ Value: expectedRegex, Modifier: models.CriterionModifierMatchesRegex, }, } expectedFindFilter := &models.FindFilterType{ PerPage: &perPage, Sort: &sort, Direction: &direction, } // if alias provided, then don't find by name onNameQuery := db.Gallery.On("Query", mock.Anything, expectedGalleryFilter, expectedFindFilter) if aliasName == "" { onNameQuery.Return(galleries, len(galleries), nil).Once() } else { onNameQuery.Return(nil, 0, nil).Once() expectedAliasFilter := &models.GalleryFilterType{ Organized: &organized, Path: &models.StringCriterionInput{ Value: aliasRegex, Modifier: models.CriterionModifierMatchesRegex, }, } db.Gallery.On("Query", mock.Anything, expectedAliasFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once() } for i := range matchingPaths { galleryID := i + 1 matchPartial := mock.MatchedBy(func(got models.GalleryPartial) bool { expected := models.GalleryPartial{ StudioID: models.NewOptionalInt(studioID), } return galleryPartialsEqual(got, expected) }) db.Gallery.On("UpdatePartial", mock.Anything, galleryID, matchPartial).Return(nil, nil).Once() } tagger := Tagger{ TxnManager: db, } err := tagger.StudioGalleries(testCtx, &studio, nil, aliases, db.Gallery) assert := assert.New(t) assert.Nil(err) db.AssertExpectations(t) } ================================================ FILE: internal/autotag/tag.go ================================================ package autotag import ( "context" "slices" "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/txn" ) type SceneQueryTagUpdater interface { models.SceneQueryer models.TagIDLoader models.SceneUpdater } type ImageQueryTagUpdater interface { models.ImageQueryer models.TagIDLoader models.ImageUpdater } type GalleryQueryTagUpdater interface { models.GalleryQueryer models.TagIDLoader models.GalleryUpdater } func getTagTaggers(p *models.Tag, aliases []string, cache *match.Cache) []tagger { ret := []tagger{{ ID: p.ID, Type: "tag", Name: p.Name, cache: cache, }} for _, a := range aliases { ret = append(ret, tagger{ ID: p.ID, Type: "tag", Name: a, cache: cache, }) } return ret } // TagScenes searches for scenes whose path matches the provided tag name and tags the scene with the tag. func (tagger *Tagger) TagScenes(ctx context.Context, p *models.Tag, paths []string, aliases []string, rw SceneQueryTagUpdater) error { t := getTagTaggers(p, aliases, tagger.Cache) for _, tt := range t { if err := tt.tagScenes(ctx, paths, rw, func(o *models.Scene) (bool, error) { if err := o.LoadTagIDs(ctx, rw); err != nil { return false, err } existing := o.TagIDs.List() if slices.Contains(existing, p.ID) { return false, nil } if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { return scene.AddTag(ctx, rw, o, p.ID) }); err != nil { return false, err } return true, nil }); err != nil { return err } } return nil } // TagImages searches for images whose path matches the provided tag name and tags the image with the tag. func (tagger *Tagger) TagImages(ctx context.Context, p *models.Tag, paths []string, aliases []string, rw ImageQueryTagUpdater) error { t := getTagTaggers(p, aliases, tagger.Cache) for _, tt := range t { if err := tt.tagImages(ctx, paths, rw, func(o *models.Image) (bool, error) { if err := o.LoadTagIDs(ctx, rw); err != nil { return false, err } existing := o.TagIDs.List() if slices.Contains(existing, p.ID) { return false, nil } if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { return image.AddTag(ctx, rw, o, p.ID) }); err != nil { return false, err } return true, nil }); err != nil { return err } } return nil } // TagGalleries searches for galleries whose path matches the provided tag name and tags the gallery with the tag. func (tagger *Tagger) TagGalleries(ctx context.Context, p *models.Tag, paths []string, aliases []string, rw GalleryQueryTagUpdater) error { t := getTagTaggers(p, aliases, tagger.Cache) for _, tt := range t { if err := tt.tagGalleries(ctx, paths, rw, func(o *models.Gallery) (bool, error) { if err := o.LoadTagIDs(ctx, rw); err != nil { return false, err } existing := o.TagIDs.List() if slices.Contains(existing, p.ID) { return false, nil } if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { return gallery.AddTag(ctx, rw, o, p.ID) }); err != nil { return false, err } return true, nil }); err != nil { return err } } return nil } ================================================ FILE: internal/autotag/tag_test.go ================================================ package autotag import ( "path/filepath" "testing" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stashapp/stash/pkg/scene" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) type testTagCase struct { tagName string expectedRegex string aliasName string aliasRegex string } var ( testTagCases = []testTagCase{ { "tag name", `(?i)(?:^|_|[^\p{L}\d])tag[.\-_ ]*name(?:$|_|[^\p{L}\d])`, "", "", }, { "tag + name", `(?i)(?:^|_|[^\p{L}\d])tag[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\p{L}\d])`, "", "", }, { "tag name", `(?i)(?:^|_|[^\p{L}\d])tag[.\-_ ]*name(?:$|_|[^\p{L}\d])`, "alias name", `(?i)(?:^|_|[^\p{L}\d])alias[.\-_ ]*name(?:$|_|[^\p{L}\d])`, }, { "tag + name", `(?i)(?:^|_|[^\p{L}\d])tag[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\p{L}\d])`, "alias + name", `(?i)(?:^|_|[^\p{L}\d])alias[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\p{L}\d])`, }, } trailingBackslashCases = []testTagCase{ { `tag + name\`, `(?i)(?:^|_|[^\p{L}\d])tag[.\-_ ]*\+[.\-_ ]*name\\(?:$|_|[^\p{L}\d])`, "", "", }, { `tag + name\`, `(?i)(?:^|_|[^\p{L}\d])tag[.\-_ ]*\+[.\-_ ]*name\\(?:$|_|[^\p{L}\d])`, `alias + name\`, `(?i)(?:^|_|[^\p{L}\d])alias[.\-_ ]*\+[.\-_ ]*name\\(?:$|_|[^\p{L}\d])`, }, } ) func TestTagScenes(t *testing.T) { t.Parallel() tc := testTagCases // trailing backslash tests only work where filepath separator is not backslash if filepath.Separator != '\\' { tc = append(tc, trailingBackslashCases...) } for _, p := range tc { testTagScenes(t, p) } } func testTagScenes(t *testing.T, tc testTagCase) { tagName := tc.tagName expectedRegex := tc.expectedRegex aliasName := tc.aliasName aliasRegex := tc.aliasRegex db := mocks.NewDatabase() const tagID = 2 var aliases []string testPathName := tagName if aliasName != "" { aliases = []string{aliasName} testPathName = aliasName } matchingPaths, falsePaths := generateTestPaths(testPathName, "mp4") var scenes []*models.Scene for i, p := range append(matchingPaths, falsePaths...) { scenes = append(scenes, &models.Scene{ ID: i + 1, Path: p, TagIDs: models.NewRelatedIDs([]int{}), }) } tag := models.Tag{ ID: tagID, Name: tagName, } organized := false perPage := 1000 sort := "id" direction := models.SortDirectionEnumAsc expectedSceneFilter := &models.SceneFilterType{ Organized: &organized, Path: &models.StringCriterionInput{ Value: expectedRegex, Modifier: models.CriterionModifierMatchesRegex, }, } expectedFindFilter := &models.FindFilterType{ PerPage: &perPage, Sort: &sort, Direction: &direction, } // if alias provided, then don't find by name onNameQuery := db.Scene.On("Query", testCtx, scene.QueryOptions(expectedSceneFilter, expectedFindFilter, false)) if aliasName == "" { onNameQuery.Return(mocks.SceneQueryResult(scenes, len(scenes)), nil).Once() } else { onNameQuery.Return(mocks.SceneQueryResult(nil, 0), nil).Once() expectedAliasFilter := &models.SceneFilterType{ Organized: &organized, Path: &models.StringCriterionInput{ Value: aliasRegex, Modifier: models.CriterionModifierMatchesRegex, }, } db.Scene.On("Query", mock.Anything, scene.QueryOptions(expectedAliasFilter, expectedFindFilter, false)). Return(mocks.SceneQueryResult(scenes, len(scenes)), nil).Once() } for i := range matchingPaths { sceneID := i + 1 matchPartial := mock.MatchedBy(func(got models.ScenePartial) bool { expected := models.ScenePartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagID}, Mode: models.RelationshipUpdateModeAdd, }, } return scenePartialsEqual(got, expected) }) db.Scene.On("UpdatePartial", mock.Anything, sceneID, matchPartial).Return(nil, nil).Once() } tagger := Tagger{ TxnManager: db, } err := tagger.TagScenes(testCtx, &tag, nil, aliases, db.Scene) assert := assert.New(t) assert.Nil(err) db.AssertExpectations(t) } func TestTagImages(t *testing.T) { t.Parallel() for _, p := range testTagCases { testTagImages(t, p) } } func testTagImages(t *testing.T, tc testTagCase) { tagName := tc.tagName expectedRegex := tc.expectedRegex aliasName := tc.aliasName aliasRegex := tc.aliasRegex db := mocks.NewDatabase() const tagID = 2 var aliases []string testPathName := tagName if aliasName != "" { aliases = []string{aliasName} testPathName = aliasName } var images []*models.Image matchingPaths, falsePaths := generateTestPaths(testPathName, "mp4") for i, p := range append(matchingPaths, falsePaths...) { images = append(images, &models.Image{ ID: i + 1, Path: p, TagIDs: models.NewRelatedIDs([]int{}), }) } tag := models.Tag{ ID: tagID, Name: tagName, } organized := false perPage := 1000 sort := "id" direction := models.SortDirectionEnumAsc expectedImageFilter := &models.ImageFilterType{ Organized: &organized, Path: &models.StringCriterionInput{ Value: expectedRegex, Modifier: models.CriterionModifierMatchesRegex, }, } expectedFindFilter := &models.FindFilterType{ PerPage: &perPage, Sort: &sort, Direction: &direction, } // if alias provided, then don't find by name onNameQuery := db.Image.On("Query", testCtx, image.QueryOptions(expectedImageFilter, expectedFindFilter, false)) if aliasName == "" { onNameQuery.Return(mocks.ImageQueryResult(images, len(images)), nil).Once() } else { onNameQuery.Return(mocks.ImageQueryResult(nil, 0), nil).Once() expectedAliasFilter := &models.ImageFilterType{ Organized: &organized, Path: &models.StringCriterionInput{ Value: aliasRegex, Modifier: models.CriterionModifierMatchesRegex, }, } db.Image.On("Query", mock.Anything, image.QueryOptions(expectedAliasFilter, expectedFindFilter, false)). Return(mocks.ImageQueryResult(images, len(images)), nil).Once() } for i := range matchingPaths { imageID := i + 1 matchPartial := mock.MatchedBy(func(got models.ImagePartial) bool { expected := models.ImagePartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagID}, Mode: models.RelationshipUpdateModeAdd, }, } return imagePartialsEqual(got, expected) }) db.Image.On("UpdatePartial", mock.Anything, imageID, matchPartial).Return(nil, nil).Once() } tagger := Tagger{ TxnManager: db, } err := tagger.TagImages(testCtx, &tag, nil, aliases, db.Image) assert := assert.New(t) assert.Nil(err) db.AssertExpectations(t) } func TestTagGalleries(t *testing.T) { t.Parallel() for _, p := range testTagCases { testTagGalleries(t, p) } } func testTagGalleries(t *testing.T, tc testTagCase) { tagName := tc.tagName expectedRegex := tc.expectedRegex aliasName := tc.aliasName aliasRegex := tc.aliasRegex db := mocks.NewDatabase() const tagID = 2 var aliases []string testPathName := tagName if aliasName != "" { aliases = []string{aliasName} testPathName = aliasName } var galleries []*models.Gallery matchingPaths, falsePaths := generateTestPaths(testPathName, "mp4") for i, p := range append(matchingPaths, falsePaths...) { v := p galleries = append(galleries, &models.Gallery{ ID: i + 1, Path: v, TagIDs: models.NewRelatedIDs([]int{}), }) } tag := models.Tag{ ID: tagID, Name: tagName, } organized := false perPage := 1000 sort := "id" direction := models.SortDirectionEnumAsc expectedGalleryFilter := &models.GalleryFilterType{ Organized: &organized, Path: &models.StringCriterionInput{ Value: expectedRegex, Modifier: models.CriterionModifierMatchesRegex, }, } expectedFindFilter := &models.FindFilterType{ PerPage: &perPage, Sort: &sort, Direction: &direction, } // if alias provided, then don't find by name onNameQuery := db.Gallery.On("Query", testCtx, expectedGalleryFilter, expectedFindFilter) if aliasName == "" { onNameQuery.Return(galleries, len(galleries), nil).Once() } else { onNameQuery.Return(nil, 0, nil).Once() expectedAliasFilter := &models.GalleryFilterType{ Organized: &organized, Path: &models.StringCriterionInput{ Value: aliasRegex, Modifier: models.CriterionModifierMatchesRegex, }, } db.Gallery.On("Query", mock.Anything, expectedAliasFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once() } for i := range matchingPaths { galleryID := i + 1 matchPartial := mock.MatchedBy(func(got models.GalleryPartial) bool { expected := models.GalleryPartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagID}, Mode: models.RelationshipUpdateModeAdd, }, } return galleryPartialsEqual(got, expected) }) db.Gallery.On("UpdatePartial", mock.Anything, galleryID, matchPartial).Return(nil, nil).Once() } tagger := Tagger{ TxnManager: db, } err := tagger.TagGalleries(testCtx, &tag, nil, aliases, db.Gallery) assert := assert.New(t) assert.Nil(err) db.AssertExpectations(t) } ================================================ FILE: internal/autotag/tagger.go ================================================ // Package autotag provides methods to auto-tag scenes with performers, // studios and tags. // // The autotag engine tags scenes with performers/studios/tags if the scene's // path matches the performer/studio/tag name. A scene's path is considered // a match if it contains the performer/studio/tag's full name, ignoring any // '.', '-', '_' characters in the path. // // For example, for a performer "foo bar", the following paths would be // considered a match: "foo bar.mp4", "foobar.mp4", "foo.bar.mp4", // "foo-bar.mp4", "aaa.foo bar.bbb.mp4". // The following would not be considered a match: // "aafoo bar.mp4", "foo barbb.mp4", "foo/bar.mp4" package autotag import ( "context" "fmt" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/txn" ) type Tagger struct { TxnManager txn.Manager Cache *match.Cache } type tagger struct { ID int Type string Name string Path string trimExt bool cache *match.Cache } type addLinkFunc func(subjectID, otherID int) (bool, error) type addImageLinkFunc func(o *models.Image) (bool, error) type addGalleryLinkFunc func(o *models.Gallery) (bool, error) type addSceneLinkFunc func(o *models.Scene) (bool, error) func (t *tagger) addError(otherType, otherName string, err error) error { return fmt.Errorf("error adding %s '%s' to %s '%s': %s", otherType, otherName, t.Type, t.Name, err.Error()) } func (t *tagger) addLog(otherType, otherName string) { logger.Infof("Added %s '%s' to %s '%s'", otherType, otherName, t.Type, t.Name) } func (t *tagger) tagPerformers(ctx context.Context, performerReader models.PerformerAutoTagQueryer, addFunc addLinkFunc) error { others, err := match.PathToPerformers(ctx, t.Path, performerReader, t.cache, t.trimExt) if err != nil { return err } for _, p := range others { added, err := addFunc(t.ID, p.ID) if err != nil { return t.addError("performer", p.Name, err) } if added { t.addLog("performer", p.Name) } } return nil } func (t *tagger) tagStudios(ctx context.Context, studioReader models.StudioAutoTagQueryer, addFunc addLinkFunc) error { studio, err := match.PathToStudio(ctx, t.Path, studioReader, t.cache, t.trimExt) if err != nil { return err } if studio != nil { added, err := addFunc(t.ID, studio.ID) if err != nil { return t.addError("studio", studio.Name, err) } if added { t.addLog("studio", studio.Name) } } return nil } func (t *tagger) tagTags(ctx context.Context, tagReader models.TagAutoTagQueryer, addFunc addLinkFunc) error { others, err := match.PathToTags(ctx, t.Path, tagReader, t.cache, t.trimExt) if err != nil { return err } for _, p := range others { added, err := addFunc(t.ID, p.ID) if err != nil { return t.addError("tag", p.Name, err) } if added { t.addLog("tag", p.Name) } } return nil } func (t *tagger) tagScenes(ctx context.Context, paths []string, sceneReader models.SceneQueryer, addFunc addSceneLinkFunc) error { return match.PathToScenesFn(ctx, t.Name, paths, sceneReader, func(ctx context.Context, p *models.Scene) error { added, err := addFunc(p) if err != nil { return t.addError("scene", p.DisplayName(), err) } if added { t.addLog("scene", p.DisplayName()) } return nil }) } func (t *tagger) tagImages(ctx context.Context, paths []string, imageReader models.ImageQueryer, addFunc addImageLinkFunc) error { return match.PathToImagesFn(ctx, t.Name, paths, imageReader, func(ctx context.Context, p *models.Image) error { added, err := addFunc(p) if err != nil { return t.addError("image", p.DisplayName(), err) } if added { t.addLog("image", p.DisplayName()) } return nil }) } func (t *tagger) tagGalleries(ctx context.Context, paths []string, galleryReader models.GalleryQueryer, addFunc addGalleryLinkFunc) error { return match.PathToGalleriesFn(ctx, t.Name, paths, galleryReader, func(ctx context.Context, p *models.Gallery) error { added, err := addFunc(p) if err != nil { return t.addError("gallery", p.DisplayName(), err) } if added { t.addLog("gallery", p.DisplayName()) } return nil }) } ================================================ FILE: internal/build/version.go ================================================ // Package build provides the version information for the application. package build import ( "regexp" ) var version string var buildstamp string var githash string var officialBuild string func Version() (string, string, string) { return version, githash, buildstamp } func VersionString() string { var versionString string switch { case version != "": if githash != "" && !IsDevelop() { versionString = version + " (" + githash + ")" } else { versionString = version } case githash != "": versionString = githash default: versionString = "unknown" } if IsOfficial() { versionString += " - Official Build" } else { versionString += " - Unofficial Build" } if buildstamp != "" { versionString += " - " + buildstamp } return versionString } func IsOfficial() bool { return officialBuild == "true" } func IsDevelop() bool { if githash == "" { return false } // if the version is suffixed with -x-xxxx, then we are running a development build develop := false re := regexp.MustCompile(`-\d+-g\w+$`) if re.MatchString(version) { develop = true } return develop } ================================================ FILE: internal/desktop/desktop.go ================================================ // Package desktop provides desktop integration functionality for the application. package desktop import ( "fmt" "os" "path" "path/filepath" "runtime" "strconv" "strings" "github.com/pkg/browser" "github.com/stashapp/stash/internal/build" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "golang.org/x/term" ) var isDesktop bool // InitIsDesktop sets the value of isDesktop. // Changed IsDesktop to be evaluated once at startup because if it is // checked while there are open terminal sessions (such as the ffmpeg hardware // encoding checks), it may return false. func InitIsDesktop() { isDesktop = isDesktopCheck() } type FaviconProvider interface { GetFavicon() []byte GetFaviconPng() []byte } // Start starts the desktop icon process. It blocks until the process exits. // MUST be run on the main goroutine or will have no effect on macOS func Start(exit chan int, faviconProvider FaviconProvider) { if IsDesktop() { hideConsole() c := config.GetInstance() if !c.GetNoBrowser() { openURLInBrowser("") } writeStashIcon(faviconProvider) startSystray(exit, faviconProvider) } } // openURLInBrowser opens a browser to the Stash UI. Path can be an empty string for main page. func openURLInBrowser(path string) { // This can be done before actually starting the server, as modern browsers will // automatically reload the page if a local port is closed at page load and then opened. serverAddress := getServerURL(path) err := browser.OpenURL(serverAddress) if err != nil { logger.Error("Could not open browser: " + err.Error()) } } func SendNotification(title string, text string) { if IsDesktop() { c := config.GetInstance() if c.GetNotificationsEnabled() { sendNotification(title, text) } } } func IsDesktop() bool { return isDesktop } // isDesktop tries to determine if the application is running in a desktop environment // where desktop features like system tray and notifications should be enabled. func isDesktopCheck() bool { if isDoubleClickLaunched() { logger.Debug("Detected double-click launch") return true } // Check if running under root if os.Getuid() == 0 { logger.Debug("Running as root, disabling desktop features") return false } // Check if stdin is a terminal if term.IsTerminal(int(os.Stdin.Fd())) { logger.Debug("Running in terminal, disabling desktop features") return false } if isService() { logger.Debug("Running as a service, disabling desktop features") return false } if IsServerDockerized() { logger.Debug("Running in docker, disabling desktop features") return false } return true } func IsServerDockerized() bool { return isServerDockerized() } // writeStashIcon writes the current stash logo to config/icon.png func writeStashIcon(faviconProvider FaviconProvider) { c := config.GetInstance() if !c.IsNewSystem() { iconPath := path.Join(c.GetConfigPath(), "icon.png") err := os.WriteFile(iconPath, faviconProvider.GetFaviconPng(), 0644) if err != nil { logger.Errorf("Couldn't write icon file: %s", err.Error()) } } } // IsAllowedAutoUpdate tries to determine if the stash binary was installed from a // package manager or if touching the executable is otherwise a bad idea func IsAllowedAutoUpdate() bool { // Only try to update if downloaded from official sources if !build.IsOfficial() { return false } // Avoid updating if installed from package manager if runtime.GOOS == "linux" { executablePath, err := os.Executable() if err != nil { logger.Errorf("Cannot get executable path: %s", err) return false } executablePath, err = filepath.EvalSymlinks(executablePath) if err != nil { logger.Errorf("Cannot get executable path: %s", err) return false } if fsutil.IsPathInDir("/usr", executablePath) || fsutil.IsPathInDir("/opt", executablePath) { return false } if isServerDockerized() { return false } } return true } func getIconPath() string { return path.Join(config.GetInstance().GetConfigPath(), "icon.png") } func RevealInFileManager(path string) error { info, err := os.Stat(path) if err != nil { return fmt.Errorf("error checking path: %w", err) } absPath, err := filepath.Abs(path) if err != nil { return fmt.Errorf("error getting absolute path: %w", err) } return revealInFileManager(absPath, info) } func getServerURL(path string) string { c := config.GetInstance() serverAddress := c.GetHost() if serverAddress == "0.0.0.0" { serverAddress = "localhost" } serverAddress = serverAddress + ":" + strconv.Itoa(c.GetPort()) proto := "" if c.HasTLSConfig() { proto = "https://" } else { proto = "http://" } serverAddress = proto + serverAddress + "/" if path != "" { serverAddress += strings.TrimPrefix(path, "/") } return serverAddress } ================================================ FILE: internal/desktop/desktop_platform_darwin.go ================================================ //go:build darwin // +build darwin package desktop import ( "fmt" "os" "os/exec" gosxnotifier "github.com/kermieisinthehouse/gosx-notifier" "github.com/stashapp/stash/pkg/logger" ) func isService() bool { // MacOS /does/ support services, using launchd, but there is no straightforward way to check if it was used. return false } func isServerDockerized() bool { return false } func sendNotification(notificationTitle string, notificationText string) { notification := gosxnotifier.NewNotification(notificationText) notification.Title = notificationTitle notification.AppIcon = getIconPath() notification.Open = getServerURL("") notification.Sender = "cc.stashapp.stash" err := notification.Push() if err != nil { logger.Errorf("Could not send MacOS notification: %s", err.Error()) } } func revealInFileManager(path string, _ os.FileInfo) error { if err := exec.Command(`open`, `-R`, path).Run(); err != nil { return fmt.Errorf("error revealing path in Finder: %w", err) } return nil } func isDoubleClickLaunched() bool { return false } func hideConsole() { } ================================================ FILE: internal/desktop/desktop_platform_nixes.go ================================================ //go:build unix && !darwin // +build unix,!darwin package desktop import ( "fmt" "os" "os/exec" "path/filepath" "strings" "github.com/stashapp/stash/pkg/logger" ) // isService checks if started by init, e.g. stash is a *nix systemd service func isService() bool { return os.Getppid() == 1 } func isServerDockerized() bool { _, dockerEnvErr := os.Stat("/.dockerenv") cgroups, _ := os.ReadFile("/proc/self/cgroup") if !os.IsNotExist(dockerEnvErr) || strings.Contains(string(cgroups), "docker") { return true } return false } func sendNotification(notificationTitle string, notificationText string) { err := exec.Command("notify-send", "-i", getIconPath(), notificationTitle, notificationText, "-a", "Stash").Run() if err != nil { logger.Errorf("Error sending notification on Linux: %s", err.Error()) } } func revealInFileManager(path string, info os.FileInfo) error { dir := path if !info.IsDir() { dir = filepath.Dir(path) } if err := exec.Command("xdg-open", dir).Run(); err != nil { return fmt.Errorf("error opening directory in file manager: %w", err) } return nil } func isDoubleClickLaunched() bool { return false } func hideConsole() { } ================================================ FILE: internal/desktop/desktop_platform_windows.go ================================================ //go:build windows // +build windows package desktop import ( "os" "os/exec" "syscall" "unsafe" "github.com/go-toast/toast" "github.com/stashapp/stash/pkg/logger" "golang.org/x/sys/windows/svc" ) var ( kernel32 = syscall.NewLazyDLL("kernel32.dll") user32 = syscall.NewLazyDLL("user32.dll") ) func isService() bool { result, err := svc.IsWindowsService() if err != nil { logger.Errorf("Encountered error checking if running as Windows service: %s", err.Error()) return false } return result } // Detect if windows golang executable file is running via double click or from cmd/shell terminator // https://stackoverflow.com/questions/8610489/distinguish-if-program-runs-by-clicking-on-the-icon-typing-its-name-in-the-cons?rq=1 // https://github.com/shirou/w32/blob/master/kernel32.go // https://github.com/kbinani/win/blob/master/kernel32.go#L3268 // win.GetConsoleProcessList(new(uint32), win.DWORD(2)) // from https://gist.github.com/yougg/213250cc04a52e2b853590b06f49d865 func isDoubleClickLaunched() bool { lp := kernel32.NewProc("GetConsoleProcessList") if lp != nil { var pids [2]uint32 var maxCount uint32 = 2 ret, _, _ := lp.Call(uintptr(unsafe.Pointer(&pids)), uintptr(maxCount)) if ret > 1 { return false } } return true } func hideConsole() { const SW_HIDE = 0 h := getConsoleWindow() lp := user32.NewProc("ShowWindow") // don't want to check for errors and can't prevent dogsled _, _, _ = lp.Call(h, SW_HIDE) //nolint:dogsled } func getConsoleWindow() uintptr { lp := kernel32.NewProc("GetConsoleWindow") ret, _, _ := lp.Call() return ret } func isServerDockerized() bool { return false } func sendNotification(notificationTitle string, notificationText string) { notification := toast.Notification{ AppID: "Stash", Title: notificationTitle, Message: notificationText, Icon: getIconPath(), Actions: []toast.Action{{ Type: "protocol", Label: "Open Stash", Arguments: getServerURL(""), }}, } err := notification.Push() if err != nil { logger.Errorf("Error creating Windows notification: %s", err.Error()) } } func revealInFileManager(path string, _ os.FileInfo) error { c := exec.Command(`explorer`, `/select,`, path) logger.Debugf("Running: %s", c.String()) // explorer seems to return an error code even when it works, so ignore the error _ = c.Run() return nil } ================================================ FILE: internal/desktop/dialog_nonwindows.go ================================================ //go:build !windows // +build !windows package desktop func FatalError(err error) int { // nothing to do return 0 } ================================================ FILE: internal/desktop/dialog_windows.go ================================================ //go:build windows // +build windows package desktop import ( "fmt" "syscall" "unsafe" ) func FatalError(err error) int { const ( NULL = 0 MB_OK = 0 MB_ICONERROR = 0x10 ) return messageBox(NULL, fmt.Sprintf("Error: %v", err), "Stash - Fatal Error", MB_OK|MB_ICONERROR) } func messageBox(hwnd uintptr, caption, title string, flags uint) int { lpText, _ := syscall.UTF16PtrFromString(caption) lpCaption, _ := syscall.UTF16PtrFromString(title) ret, _, _ := syscall.NewLazyDLL("user32.dll").NewProc("MessageBoxW").Call( uintptr(hwnd), uintptr(unsafe.Pointer(lpText)), uintptr(unsafe.Pointer(lpCaption)), uintptr(flags)) return int(ret) } ================================================ FILE: internal/desktop/systray_nixes.go ================================================ //go:build (!windows && !darwin) || !cgo package desktop func startSystray(exit chan int, favicon FaviconProvider) { // The systray is not available on Linux because the required libraries (libappindicator3 and gtk+3.0) // are not able to be statically compiled. Technically, the systray works perfectly fine when dynamically // linked, but we cannot distribute it for compatibility reasons. // Additionally, the systray package requires CGo so the dependency cannot be used if building with // CGo disabled. } ================================================ FILE: internal/desktop/systray_nonlinux.go ================================================ //go:build (windows || darwin) && cgo package desktop import ( "fmt" "runtime" "strings" "github.com/kermieisinthehouse/systray" "golang.org/x/text/cases" "golang.org/x/text/language" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/logger" ) // MUST be run on the main goroutine or will have no effect on macOS func startSystray(exit chan int, faviconProvider FaviconProvider) { // Shows a small notification to inform that Stash will no longer show a terminal window, // and instead will be available in the tray. Will only show the first time a pre-desktop integration // system is started from a non-terminal method, e.g. double-clicking an icon. c := config.GetInstance() if c.GetShowOneTimeMovedNotification() { // Use platform-appropriate terminology location := "tray" if runtime.GOOS == "darwin" { location = "menu bar" } SendNotification("Stash has moved!", "Stash now runs in your "+location+", instead of a terminal window.") c.SetBool(config.ShowOneTimeMovedNotification, false) if err := c.Write(); err != nil { logger.Errorf("Error while writing configuration file: %v", err) } } // Listen for changes to rerender systray // TODO: This is disabled for now. The systray package does not clean up all of its resources when Quit() is called. // TODO: This results in this only working once, or changes being ignored. Our fork of systray fixes a crash(!) on macOS here. // go func() { // for { // <-config.GetInstance().GetConfigUpdatesChannel() // systray.Quit() // } // }() // "intercept" an exit code to quit the systray, allowing the call to systray.Run() below to return. go func() { exitCode := <-exit systray.Quit() exit <- exitCode }() systray.Run(func() { systrayInitialize(exit, faviconProvider) }, nil) } func systrayInitialize(exit chan<- int, faviconProvider FaviconProvider) { favicon := faviconProvider.GetFavicon() systray.SetTemplateIcon(favicon, favicon) c := config.GetInstance() systray.SetTooltip(fmt.Sprintf("🟢 Stash is Running on port %d.", c.GetPort())) openStashButton := systray.AddMenuItem("Open Stash", "Open a browser window to Stash") var menuItems []string systray.AddSeparator() if !c.IsNewSystem() { menuItems = c.GetMenuItems() for _, item := range menuItems { c := cases.Title(language.Und) titleCaseItem := c.String(strings.ToLower(item)) curr := systray.AddMenuItem(titleCaseItem, "Open to "+titleCaseItem) go func(item string) { for { <-curr.ClickedCh if item == "markers" { item = "scenes/markers" } openURLInBrowser(item) } }(item) } systray.AddSeparator() // TODO - Some ideas for future expansions // systray.AddMenuItem("Start a Scan", "Scan all libraries with default settings") // systray.AddMenuItem("Start Auto Tagging", "Auto Tag all libraries") // systray.AddMenuItem("Check for updates", "Check for a new Stash release") // systray.AddSeparator() } quitStashButton := systray.AddMenuItem("Quit Stash Server", "Quits the Stash server") go func() { for { select { case <-openStashButton.ClickedCh: openURLInBrowser("") case <-quitStashButton.ClickedCh: exit <- 0 return } } }() } ================================================ FILE: internal/dlna/activity.go ================================================ package dlna import ( "context" "fmt" "sync" "time" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/txn" ) const ( // DefaultSessionTimeout is the time after which a session is considered complete // if no new requests are received. // This is set high (5 minutes) because DLNA clients buffer aggressively and may not // send any HTTP requests for extended periods while the user is still watching. DefaultSessionTimeout = 5 * time.Minute // monitorInterval is how often we check for expired sessions. monitorInterval = 10 * time.Second ) // ActivityConfig provides configuration options for DLNA activity tracking. type ActivityConfig interface { // GetDLNAActivityTrackingEnabled returns true if activity tracking should be enabled. // If not implemented, defaults to true. GetDLNAActivityTrackingEnabled() bool // GetMinimumPlayPercent returns the minimum percentage of a video that must be // watched before incrementing the play count. Uses UI setting if available. GetMinimumPlayPercent() int } // SceneActivityWriter provides methods for saving scene activity. type SceneActivityWriter interface { SaveActivity(ctx context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error) AddViews(ctx context.Context, sceneID int, dates []time.Time) ([]time.Time, error) } // streamSession represents an active DLNA streaming session. type streamSession struct { SceneID int ClientIP string StartTime time.Time LastActivity time.Time VideoDuration float64 PlayCountAdded bool } // sessionKey generates a unique key for a session based on client IP and scene ID. func sessionKey(clientIP string, sceneID int) string { return fmt.Sprintf("%s:%d", clientIP, sceneID) } // percentWatched calculates the estimated percentage of video watched. // Uses a time-based approach since DLNA clients buffer aggressively and byte // positions don't correlate with actual playback position. // // The key insight: you cannot have watched more of the video than time has elapsed. // If the video is 30 minutes and only 1 minute has passed, maximum watched is ~3.3%. func (s *streamSession) percentWatched() float64 { if s.VideoDuration <= 0 { return 0 } // Calculate elapsed time from session start to last activity elapsed := s.LastActivity.Sub(s.StartTime).Seconds() if elapsed <= 0 { return 0 } // Maximum possible percent is based on elapsed time // You can't watch more of the video than time has passed timeBasedPercent := (elapsed / s.VideoDuration) * 100 // Cap at 100% if timeBasedPercent > 100 { return 100 } return timeBasedPercent } // estimatedResumeTime calculates the estimated resume time based on elapsed time. // Since DLNA clients buffer aggressively, byte positions don't correlate with playback. // Instead, we estimate based on how long the session has been active. // Returns the time in seconds, or 0 if the video is nearly complete (>=98%). func (s *streamSession) estimatedResumeTime() float64 { if s.VideoDuration <= 0 { return 0 } // Calculate elapsed time from session start elapsed := s.LastActivity.Sub(s.StartTime).Seconds() if elapsed <= 0 { return 0 } // If elapsed time exceeds 98% of video duration, reset resume time (matches frontend behavior) if elapsed >= s.VideoDuration*0.98 { return 0 } // Resume time is approximately where the user was watching // Capped by video duration if elapsed > s.VideoDuration { elapsed = s.VideoDuration } return elapsed } // ActivityTracker tracks DLNA streaming activity and saves it to the database. type ActivityTracker struct { txnManager txn.Manager sceneWriter SceneActivityWriter config ActivityConfig sessionTimeout time.Duration sessions map[string]*streamSession mutex sync.RWMutex ctx context.Context cancelFunc context.CancelFunc wg sync.WaitGroup } // NewActivityTracker creates a new ActivityTracker. func NewActivityTracker( txnManager txn.Manager, sceneWriter SceneActivityWriter, config ActivityConfig, ) *ActivityTracker { ctx, cancel := context.WithCancel(context.Background()) tracker := &ActivityTracker{ txnManager: txnManager, sceneWriter: sceneWriter, config: config, sessionTimeout: DefaultSessionTimeout, sessions: make(map[string]*streamSession), ctx: ctx, cancelFunc: cancel, } // Start the session monitor goroutine tracker.wg.Add(1) go tracker.monitorSessions() return tracker } // Stop stops the activity tracker and processes any remaining sessions. func (t *ActivityTracker) Stop() { t.cancelFunc() t.wg.Wait() // Process any remaining sessions t.mutex.Lock() sessions := make([]*streamSession, 0, len(t.sessions)) for _, session := range t.sessions { sessions = append(sessions, session) } t.sessions = make(map[string]*streamSession) t.mutex.Unlock() for _, session := range sessions { t.processCompletedSession(session) } } // RecordRequest records a streaming request for activity tracking. // Each request updates the session's LastActivity time, which is used for // time-based tracking of watch progress. func (t *ActivityTracker) RecordRequest(sceneID int, clientIP string, videoDuration float64) { if !t.isEnabled() { return } key := sessionKey(clientIP, sceneID) now := time.Now() t.mutex.Lock() defer t.mutex.Unlock() session, exists := t.sessions[key] if !exists { session = &streamSession{ SceneID: sceneID, ClientIP: clientIP, StartTime: now, VideoDuration: videoDuration, } t.sessions[key] = session logger.Debugf("[DLNA Activity] New session started: scene=%d, client=%s", sceneID, clientIP) } session.LastActivity = now } // monitorSessions periodically checks for expired sessions and processes them. func (t *ActivityTracker) monitorSessions() { defer t.wg.Done() ticker := time.NewTicker(monitorInterval) defer ticker.Stop() for { select { case <-t.ctx.Done(): return case <-ticker.C: t.processExpiredSessions() } } } // processExpiredSessions finds and processes sessions that have timed out. func (t *ActivityTracker) processExpiredSessions() { now := time.Now() var expiredSessions []*streamSession t.mutex.Lock() for key, session := range t.sessions { timeSinceStart := now.Sub(session.StartTime) timeSinceActivity := now.Sub(session.LastActivity) // Must have no HTTP activity for the full timeout period if timeSinceActivity <= t.sessionTimeout { continue } // DLNA clients buffer aggressively - they fetch most/all of the video quickly, // then play from cache with NO further HTTP requests. // // Two scenarios: // 1. User watched the whole video: timeSinceStart >= videoDuration // -> Set LastActivity to when timeout began (they finished watching) // 2. User stopped early: timeSinceStart < videoDuration // -> Keep LastActivity as-is (best estimate of when they stopped) videoDuration := time.Duration(session.VideoDuration) * time.Second if timeSinceStart >= videoDuration && videoDuration > 0 { // User likely watched the whole video, then it timed out // Estimate they watched until the timeout period started session.LastActivity = now.Add(-t.sessionTimeout) } // else: User stopped early - LastActivity is already our best estimate expiredSessions = append(expiredSessions, session) delete(t.sessions, key) } t.mutex.Unlock() for _, session := range expiredSessions { t.processCompletedSession(session) } } // processCompletedSession saves activity data for a completed streaming session. func (t *ActivityTracker) processCompletedSession(session *streamSession) { percentWatched := session.percentWatched() resumeTime := session.estimatedResumeTime() logger.Debugf("[DLNA Activity] Session completed: scene=%d, client=%s, videoDuration=%.1fs, percent=%.1f%%, resume=%.1fs", session.SceneID, session.ClientIP, session.VideoDuration, percentWatched, resumeTime) // Only save if there was meaningful activity (at least 1% watched) if percentWatched < 1 { logger.Debugf("[DLNA Activity] Session too short, skipping save") return } // Skip DB operations if txnManager is nil (for testing) if t.txnManager == nil { logger.Debugf("[DLNA Activity] No transaction manager, skipping DB save") return } // Determine what needs to be saved shouldSaveResume := resumeTime > 0 shouldAddView := !session.PlayCountAdded && percentWatched >= float64(t.getMinimumPlayPercent()) // Nothing to save if !shouldSaveResume && !shouldAddView { return } // Save everything in a single transaction ctx := context.Background() if err := txn.WithTxn(ctx, t.txnManager, func(ctx context.Context) error { // Save resume time only. DLNA clients buffer aggressively and don't report // playback position, so we can't accurately track play duration - saving // guesses would corrupt analytics. Resume time is still useful as a // "continue watching" hint even if imprecise. if shouldSaveResume { if _, err := t.sceneWriter.SaveActivity(ctx, session.SceneID, &resumeTime, nil); err != nil { return fmt.Errorf("save resume time: %w", err) } } // Increment play count (also updates last_played_at via view date) if shouldAddView { if _, err := t.sceneWriter.AddViews(ctx, session.SceneID, []time.Time{time.Now()}); err != nil { return fmt.Errorf("add view: %w", err) } session.PlayCountAdded = true logger.Debugf("[DLNA Activity] Incremented play count for scene %d (%.1f%% watched)", session.SceneID, percentWatched) } return nil }); err != nil { logger.Warnf("[DLNA Activity] Failed to save activity for scene %d: %v", session.SceneID, err) } } // isEnabled returns true if activity tracking is enabled. func (t *ActivityTracker) isEnabled() bool { if t.config == nil { return true // Default to enabled } return t.config.GetDLNAActivityTrackingEnabled() } // getMinimumPlayPercent returns the minimum play percentage for incrementing play count. func (t *ActivityTracker) getMinimumPlayPercent() int { if t.config == nil { return 0 // Default: any play increments count (matches frontend default) } return t.config.GetMinimumPlayPercent() } ================================================ FILE: internal/dlna/activity_test.go ================================================ package dlna import ( "context" "sync" "testing" "time" "github.com/stretchr/testify/assert" ) // mockSceneWriter is a mock implementation of SceneActivityWriter type mockSceneWriter struct { mu sync.Mutex saveActivityCalls []saveActivityCall addViewsCalls []addViewsCall } type saveActivityCall struct { sceneID int resumeTime *float64 playDuration *float64 } type addViewsCall struct { sceneID int dates []time.Time } func (m *mockSceneWriter) SaveActivity(_ context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error) { m.mu.Lock() m.saveActivityCalls = append(m.saveActivityCalls, saveActivityCall{ sceneID: sceneID, resumeTime: resumeTime, playDuration: playDuration, }) m.mu.Unlock() return true, nil } func (m *mockSceneWriter) AddViews(_ context.Context, sceneID int, dates []time.Time) ([]time.Time, error) { m.mu.Lock() m.addViewsCalls = append(m.addViewsCalls, addViewsCall{ sceneID: sceneID, dates: dates, }) m.mu.Unlock() return dates, nil } // mockConfig is a mock implementation of ActivityConfig type mockConfig struct { enabled bool minPlayPercent int } func (c *mockConfig) GetDLNAActivityTrackingEnabled() bool { return c.enabled } func (c *mockConfig) GetMinimumPlayPercent() int { return c.minPlayPercent } func TestStreamSession_PercentWatched(t *testing.T) { now := time.Now() tests := []struct { name string startTime time.Time lastActivity time.Time videoDuration float64 expected float64 }{ { name: "no video duration", startTime: now.Add(-60 * time.Second), lastActivity: now, videoDuration: 0, expected: 0, }, { name: "half watched", startTime: now.Add(-60 * time.Second), lastActivity: now, videoDuration: 120.0, // 2 minutes, watched for 1 minute = 50% expected: 50.0, }, { name: "fully watched", startTime: now.Add(-120 * time.Second), lastActivity: now, videoDuration: 120.0, // 2 minutes, watched for 2 minutes = 100% expected: 100.0, }, { name: "quarter watched", startTime: now.Add(-30 * time.Second), lastActivity: now, videoDuration: 120.0, // 2 minutes, watched for 30 seconds = 25% expected: 25.0, }, { name: "elapsed exceeds duration - capped at 100%", startTime: now.Add(-180 * time.Second), lastActivity: now, videoDuration: 120.0, // 2 minutes, but 3 minutes elapsed = capped at 100% expected: 100.0, }, { name: "no elapsed time", startTime: now, lastActivity: now, videoDuration: 120.0, expected: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { session := &streamSession{ StartTime: tt.startTime, LastActivity: tt.lastActivity, VideoDuration: tt.videoDuration, } result := session.percentWatched() assert.InDelta(t, tt.expected, result, 0.01) }) } } func TestStreamSession_EstimatedResumeTime(t *testing.T) { now := time.Now() tests := []struct { name string startTime time.Time lastActivity time.Time videoDuration float64 expected float64 }{ { name: "no elapsed time", startTime: now, lastActivity: now, videoDuration: 120.0, expected: 0, }, { name: "half way through", startTime: now.Add(-60 * time.Second), lastActivity: now, videoDuration: 120.0, // 2 minutes, watched for 1 minute = resume at 60s expected: 60.0, }, { name: "quarter way through", startTime: now.Add(-30 * time.Second), lastActivity: now, videoDuration: 120.0, // 2 minutes, watched for 30 seconds = resume at 30s expected: 30.0, }, { name: "98% complete - should reset to 0", startTime: now.Add(-118 * time.Second), lastActivity: now, videoDuration: 120.0, // 98.3% elapsed, should reset expected: 0, }, { name: "100% complete - should reset to 0", startTime: now.Add(-120 * time.Second), lastActivity: now, videoDuration: 120.0, expected: 0, }, { name: "elapsed exceeds duration - capped and reset to 0", startTime: now.Add(-180 * time.Second), lastActivity: now, videoDuration: 120.0, // 150% elapsed, capped at 100%, reset to 0 expected: 0, }, { name: "no video duration", startTime: now.Add(-60 * time.Second), lastActivity: now, videoDuration: 0, expected: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { session := &streamSession{ StartTime: tt.startTime, LastActivity: tt.lastActivity, VideoDuration: tt.videoDuration, } result := session.estimatedResumeTime() assert.InDelta(t, tt.expected, result, 1.0) // Allow 1 second tolerance }) } } func TestSessionKey(t *testing.T) { key := sessionKey("192.168.1.100", 42) assert.Equal(t, "192.168.1.100:42", key) } func TestActivityTracker_RecordRequest(t *testing.T) { config := &mockConfig{enabled: true, minPlayPercent: 50} // Create tracker without starting the goroutine (for unit testing) tracker := &ActivityTracker{ txnManager: nil, // Don't need DB for this test sceneWriter: nil, config: config, sessionTimeout: DefaultSessionTimeout, sessions: make(map[string]*streamSession), } // Record first request - should create new session tracker.RecordRequest(42, "192.168.1.100", 120.0) tracker.mutex.RLock() session := tracker.sessions["192.168.1.100:42"] tracker.mutex.RUnlock() assert.NotNil(t, session) assert.Equal(t, 42, session.SceneID) assert.Equal(t, "192.168.1.100", session.ClientIP) assert.Equal(t, 120.0, session.VideoDuration) assert.False(t, session.StartTime.IsZero()) assert.False(t, session.LastActivity.IsZero()) // Record second request - should update LastActivity firstActivity := session.LastActivity time.Sleep(10 * time.Millisecond) tracker.RecordRequest(42, "192.168.1.100", 120.0) tracker.mutex.RLock() session = tracker.sessions["192.168.1.100:42"] tracker.mutex.RUnlock() assert.True(t, session.LastActivity.After(firstActivity)) } func TestActivityTracker_DisabledTracking(t *testing.T) { config := &mockConfig{enabled: false, minPlayPercent: 50} // Create tracker without starting the goroutine (for unit testing) tracker := &ActivityTracker{ txnManager: nil, sceneWriter: nil, config: config, sessionTimeout: DefaultSessionTimeout, sessions: make(map[string]*streamSession), } // Record request - should be ignored when tracking is disabled tracker.RecordRequest(42, "192.168.1.100", 120.0) tracker.mutex.RLock() sessionCount := len(tracker.sessions) tracker.mutex.RUnlock() assert.Equal(t, 0, sessionCount) } func TestActivityTracker_SessionExpiration(t *testing.T) { // For this test, we'll test the session expiration logic directly // without the full transaction manager integration sceneWriter := &mockSceneWriter{} config := &mockConfig{enabled: true, minPlayPercent: 10} // Create a tracker with nil txnManager - we'll test processCompletedSession separately // Here we just verify the session management logic tracker := &ActivityTracker{ txnManager: nil, // Skip DB calls for this test sceneWriter: sceneWriter, config: config, sessionTimeout: 100 * time.Millisecond, sessions: make(map[string]*streamSession), } // Manually add a session // Use a short video duration (1 second) so the test can verify expiration quickly. now := time.Now() tracker.sessions["192.168.1.100:42"] = &streamSession{ SceneID: 42, ClientIP: "192.168.1.100", StartTime: now.Add(-5 * time.Second), // Started 5 seconds ago LastActivity: now.Add(-200 * time.Millisecond), // Last activity 200ms ago (> 100ms timeout) VideoDuration: 1.0, // Short video so timeSinceStart > videoDuration } // Verify session exists assert.Len(t, tracker.sessions, 1) // Process expired sessions - this will try to save activity but txnManager is nil // so it will skip the DB calls but still remove the session tracker.processExpiredSessions() // Verify session was removed (even though DB calls were skipped) assert.Len(t, tracker.sessions, 0) } func TestActivityTracker_SessionExpiration_StoppedEarly(t *testing.T) { // Test that sessions expire when user stops watching early (before video ends) // This was a bug where sessions wouldn't expire until video duration passed config := &mockConfig{enabled: true, minPlayPercent: 10} tracker := &ActivityTracker{ txnManager: nil, sceneWriter: nil, config: config, sessionTimeout: 100 * time.Millisecond, sessions: make(map[string]*streamSession), } // User started watching a 30-minute video but stopped after 5 seconds now := time.Now() tracker.sessions["192.168.1.100:42"] = &streamSession{ SceneID: 42, ClientIP: "192.168.1.100", StartTime: now.Add(-5 * time.Second), // Started 5 seconds ago LastActivity: now.Add(-200 * time.Millisecond), // Last activity 200ms ago (> 100ms timeout) VideoDuration: 1800.0, // 30 minute video - much longer than elapsed time } assert.Len(t, tracker.sessions, 1) // Session should expire because timeSinceActivity > timeout // Even though the video is 30 minutes and only 5 seconds have passed tracker.processExpiredSessions() // Verify session was expired assert.Len(t, tracker.sessions, 0, "Session should expire when user stops early, not wait for video duration") } func TestActivityTracker_MinimumPlayPercentThreshold(t *testing.T) { // Test the threshold logic without full transaction integration config := &mockConfig{enabled: true, minPlayPercent: 75} // High threshold tracker := &ActivityTracker{ txnManager: nil, sceneWriter: nil, config: config, sessionTimeout: 50 * time.Millisecond, sessions: make(map[string]*streamSession), } // Test that getMinimumPlayPercent returns the configured value assert.Equal(t, 75, tracker.getMinimumPlayPercent()) // Create a session with 30% watched (36 seconds of a 120 second video) now := time.Now() session := &streamSession{ SceneID: 42, StartTime: now.Add(-36 * time.Second), LastActivity: now, VideoDuration: 120.0, } // 30% is below 75% threshold percentWatched := session.percentWatched() assert.InDelta(t, 30.0, percentWatched, 0.1) assert.False(t, percentWatched >= float64(tracker.getMinimumPlayPercent())) } func TestActivityTracker_MultipleSessions(t *testing.T) { config := &mockConfig{enabled: true, minPlayPercent: 50} // Create tracker without starting the goroutine (for unit testing) tracker := &ActivityTracker{ txnManager: nil, sceneWriter: nil, config: config, sessionTimeout: DefaultSessionTimeout, sessions: make(map[string]*streamSession), } // Different clients watching same scene tracker.RecordRequest(42, "192.168.1.100", 120.0) tracker.RecordRequest(42, "192.168.1.101", 120.0) // Same client watching different scenes tracker.RecordRequest(43, "192.168.1.100", 180.0) tracker.mutex.RLock() assert.Len(t, tracker.sessions, 3) tracker.mutex.RUnlock() } func TestActivityTracker_ShortSessionIgnored(t *testing.T) { // Test that short sessions are ignored // Create a session with only ~0.8% watched (1 second of a 120 second video) now := time.Now() session := &streamSession{ SceneID: 42, ClientIP: "192.168.1.100", StartTime: now.Add(-1 * time.Second), // Only 1 second LastActivity: now, VideoDuration: 120.0, // 2 minutes } // Verify percent watched is below threshold (1s / 120s = 0.83%) assert.InDelta(t, 0.83, session.percentWatched(), 0.1) // Verify elapsed time is short elapsed := session.LastActivity.Sub(session.StartTime).Seconds() assert.InDelta(t, 1.0, elapsed, 0.5) // Both are below the minimum thresholds (1% and 5 seconds) percentWatched := session.percentWatched() shouldSkip := percentWatched < 1 && elapsed < 5 assert.True(t, shouldSkip, "Short session should be skipped") } ================================================ FILE: internal/dlna/cd-service-desc.go ================================================ package dlna // From: https://github.com/anacrolix/dms // Copyright (c) 2012, Matt Joiner . // All rights reserved. // Redistribution and use in source and binary forms, with or without // modification, are permitted provided that the following conditions are met: // * Redistributions of source code must retain the above copyright // notice, this list of conditions and the following disclaimer. // * Redistributions in binary form must reproduce the above copyright // notice, this list of conditions and the following disclaimer in the // documentation and/or other materials provided with the distribution. // * Neither the name of the nor the // names of its contributors may be used to endorse or promote products // derived from this software without specific prior written permission. // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND // ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE // DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY // DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES // (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; // LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND // ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS // SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. const contentDirectoryServiceDescription = ` 1 0 GetSearchCapabilities SearchCaps out SearchCapabilities GetSortCapabilities SortCaps out SortCapabilities GetSortExtensionCapabilities SortExtensionCaps out SortExtensionCapabilities GetFeatureList FeatureList out FeatureList GetSystemUpdateID Id out SystemUpdateID Browse ObjectID in A_ARG_TYPE_ObjectID BrowseFlag in A_ARG_TYPE_BrowseFlag Filter in A_ARG_TYPE_Filter StartingIndex in A_ARG_TYPE_Index RequestedCount in A_ARG_TYPE_Count SortCriteria in A_ARG_TYPE_SortCriteria Result out A_ARG_TYPE_Result NumberReturned out A_ARG_TYPE_Count TotalMatches out A_ARG_TYPE_Count UpdateID out A_ARG_TYPE_UpdateID Search ContainerID in A_ARG_TYPE_ObjectID SearchCriteria in A_ARG_TYPE_SearchCriteria Filter in A_ARG_TYPE_Filter StartingIndex in A_ARG_TYPE_Index RequestedCount in A_ARG_TYPE_Count SortCriteria in A_ARG_TYPE_SortCriteria Result out A_ARG_TYPE_Result NumberReturned out A_ARG_TYPE_Count TotalMatches out A_ARG_TYPE_Count UpdateID out A_ARG_TYPE_UpdateID CreateObject ContainerID in A_ARG_TYPE_ObjectID Elements in A_ARG_TYPE_Result ObjectID out A_ARG_TYPE_ObjectID Result out A_ARG_TYPE_Result DestroyObject ObjectID in A_ARG_TYPE_ObjectID UpdateObject ObjectID in A_ARG_TYPE_ObjectID CurrentTagValue in A_ARG_TYPE_TagValueList NewTagValue in A_ARG_TYPE_TagValueList MoveObject ObjectID in A_ARG_TYPE_ObjectID NewParentID in A_ARG_TYPE_ObjectID NewObjectID out A_ARG_TYPE_ObjectID ImportResource SourceURI in A_ARG_TYPE_URI DestinationURI in A_ARG_TYPE_URI TransferID out A_ARG_TYPE_TransferID ExportResource SourceURI in A_ARG_TYPE_URI DestinationURI in A_ARG_TYPE_URI TransferID out A_ARG_TYPE_TransferID StopTransferResource TransferID in A_ARG_TYPE_TransferID DeleteResource ResourceURI in A_ARG_TYPE_URI GetTransferProgress TransferID in A_ARG_TYPE_TransferID TransferStatus out A_ARG_TYPE_TransferStatus TransferLength out A_ARG_TYPE_TransferLength TransferTotal out A_ARG_TYPE_TransferTotal CreateReference ContainerID in A_ARG_TYPE_ObjectID ObjectID in A_ARG_TYPE_ObjectID NewID out A_ARG_TYPE_ObjectID SearchCapabilities string SortCapabilities string SortExtensionCapabilities string SystemUpdateID ui4 ContainerUpdateIDs string TransferIDs string FeatureList string A_ARG_TYPE_ObjectID string A_ARG_TYPE_Result string A_ARG_TYPE_SearchCriteria string A_ARG_TYPE_BrowseFlag string BrowseMetadata BrowseDirectChildren A_ARG_TYPE_Filter string A_ARG_TYPE_SortCriteria string A_ARG_TYPE_Index ui4 A_ARG_TYPE_Count ui4 A_ARG_TYPE_UpdateID ui4 A_ARG_TYPE_TransferID ui4 A_ARG_TYPE_TransferStatus string COMPLETED ERROR IN_PROGRESS STOPPED A_ARG_TYPE_TransferLength string A_ARG_TYPE_TransferTotal string A_ARG_TYPE_TagValueList string A_ARG_TYPE_URI uri ` ================================================ FILE: internal/dlna/cds.go ================================================ package dlna // from https://github.com/rclone/rclone // Copyright (C) 2012 by Nick Craig-Wood http://www.craig-wood.com/nick/ // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. import ( "context" "encoding/xml" "fmt" "net/http" "net/url" "os" "path" "path/filepath" "slices" "strconv" "strings" "time" "github.com/anacrolix/dms/dlna" "github.com/anacrolix/dms/upnp" "github.com/anacrolix/dms/upnpav" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" ) var pageSize = 100 type browse struct { ObjectID string BrowseFlag string Filter string StartingIndex int RequestedCount int } type contentDirectoryService struct { *Server upnp.Eventing } func formatDurationSexagesimal(d time.Duration) string { ns := d % time.Second d /= time.Second s := d % 60 d /= 60 m := d % 60 d /= 60 h := d ret := fmt.Sprintf("%d:%02d:%02d.%09d", h, m, s, ns) ret = strings.TrimRight(ret, "0") ret = strings.TrimRight(ret, ".") return ret } func (me *contentDirectoryService) updateIDString() string { return fmt.Sprintf("%d", uint32(os.Getpid())) } func sceneToContainer(scene *models.Scene, parent string, host string) interface{} { // make stash server URL // TODO - fix this iconURI := (&url.URL{ Scheme: "http", Host: host, Path: iconPath, RawQuery: url.Values{ "scene": {strconv.Itoa(scene.ID)}, }.Encode(), }).String() // Object goes first obj := upnpav.Object{ ID: strconv.Itoa(scene.ID), Restricted: 1, ParentID: parent, Title: scene.GetTitle(), Class: "object.item.videoItem", Icon: iconURI, AlbumArtURI: iconURI, } // Wrap up item := upnpav.Item{ Object: obj, Res: make([]upnpav.Resource, 0, 1), } mimeType := "video/mp4" var ( size int bitrate uint duration int64 ) f := scene.Files.Primary() if f != nil { size = int(f.Size) bitrate = uint(f.BitRate) duration = int64(f.Duration) } item.Res = append(item.Res, upnpav.Resource{ URL: (&url.URL{ Scheme: "http", Host: host, Path: resPath, RawQuery: url.Values{ "scene": {strconv.Itoa(scene.ID)}, }.Encode(), }).String(), ProtocolInfo: fmt.Sprintf("http-get:*:%s:%s", mimeType, dlna.ContentFeatures{ SupportRange: true, }.String()), Bitrate: bitrate, Duration: formatDurationSexagesimal(time.Duration(duration) * time.Second), Size: uint64(size), // Resolution: resolution, }) item.Res = append(item.Res, upnpav.Resource{ URL: iconURI, ProtocolInfo: "http-get:*:image/jpeg:DLNA.ORG_PN=JPEG_MED", }) return item } // ContentDirectory object from ObjectID. func (me *contentDirectoryService) objectFromID(id string) (o object, err error) { o.Path, err = url.QueryUnescape(id) if err != nil { return } if o.Path == "0" { o.Path = "/" } // o.Path = path.Clean(o.Path) // if !path.IsAbs(o.Path) { // err = fmt.Errorf("bad ObjectID %v", o.Path) // return // } o.RootObjectPath = me.RootObjectPath return } func childPath(paths []string) []string { if len(paths) > 1 { return paths[1:] } return nil } func (me *contentDirectoryService) Handle(action string, argsXML []byte, r *http.Request) (map[string]string, error) { host := r.Host // userAgent := r.UserAgent() switch action { case "GetSystemUpdateID": return map[string]string{ "Id": me.updateIDString(), }, nil case "GetSortCapabilities": return map[string]string{ "SortCaps": "dc:title", }, nil case "Browse": var browse browse if err := xml.Unmarshal([]byte(argsXML), &browse); err != nil { return nil, upnp.Errorf(upnp.ArgumentValueInvalidErrorCode, "cannot unmarshal browse argument: %s", err.Error()) } obj, err := me.objectFromID(browse.ObjectID) if err != nil { return nil, upnp.Errorf(upnpav.NoSuchObjectErrorCode, "cannot find object with id %q: %v", browse.ObjectID, err.Error()) } switch browse.BrowseFlag { case "BrowseDirectChildren": return me.handleBrowseDirectChildren(obj, host) case "BrowseMetadata": return me.handleBrowseMetadata(obj, host) default: return nil, upnp.Errorf(upnp.ArgumentValueInvalidErrorCode, "unhandled browse flag: %v", browse.BrowseFlag) } case "GetSearchCapabilities": return map[string]string{ "SearchCaps": "", }, nil // from https://github.com/rclone/rclone/blob/master/cmd/serve/dlna/cds.go // Samsung Extensions case "X_GetFeatureList": return map[string]string{ "FeatureList": ` `}, nil case "X_SetBookmark": // just ignore return map[string]string{}, nil default: return nil, upnp.InvalidActionError } } func (me *contentDirectoryService) handleBrowseDirectChildren(obj object, host string) (map[string]string, error) { // Read folder and return children // TODO: check if obj == 0 and return root objects // TODO: check if special path and return files var objs []interface{} if obj.IsRoot() { objs = getRootObjects() } paths := strings.Split(obj.Path, "/") // All videos if obj.Path == "all" { objs = me.getAllScenes(host) } if strings.HasPrefix(obj.Path, "all/") { page := getPageFromID(paths) if page != nil { objs = me.getPageVideos(&models.SceneFilterType{}, "all", *page, host) } } // Saved searches // if obj.Path == "saved-searches" { // var savedPlaylists []models.Playlist // db, _ := models.GetDB() // db.Where("is_deo_enabled = ?", true).Order("ordering asc").Find(&savedPlaylists) // db.Close() // for _, playlist := range savedPlaylists { // objs = append(objs, upnpav.Container{Object: upnpav.Object{ // ID: "saved-searches/" + strconv.Itoa(int(playlist.ID)), // Restricted: 1, // ParentID: "saved-searches", // Class: "object.container.storageFolder", // Title: playlist.Name, // }}) // } // } // if strings.HasPrefix(obj.Path, "saved-searches/") { // id := strings.Split(obj.Path, "/") // var savedPlaylist models.Playlist // db, _ := models.GetDB() // db.Where("id = ?", id[1]).First(&savedPlaylist) // db.Close() // var r models.RequestSceneList // if err := json.Unmarshal([]byte(savedPlaylist.SearchParams), &r); err == nil { // r.IsAccessible = optional.NewBool(true) // r.IsAvailable = optional.NewBool(true) // data := models.QueryScenesFull(r) // for i := range data.Scenes { // objs = append(objs, me.sceneToContainer(data.Scenes[i], "sites/"+id[1], host)) // } // } // } // Studios if obj.Path == "studios" { objs = me.getStudios() } if strings.HasPrefix(obj.Path, "studios/") { objs = me.getStudioScenes(childPath(paths), host) } // Tags if obj.Path == "tags" { objs = me.getTags() } if strings.HasPrefix(obj.Path, "tags/") { objs = me.getTagScenes(childPath(paths), host) } // Performers if obj.Path == "performers" { objs = me.getPerformers() } if strings.HasPrefix(obj.Path, "performers/") { objs = me.getPerformerScenes(childPath(paths), host) } // Groups - deprecated if obj.Path == "groups" { objs = me.getGroups() } if strings.HasPrefix(obj.Path, "groups/") { objs = me.getGroupScenes(childPath(paths), host) } // Rating if obj.Path == "rating" { objs = me.getRating() } if strings.HasPrefix(obj.Path, "rating/") { objs = me.getRatingScenes(childPath(paths), host) } return makeBrowseResult(objs, me.updateIDString()) } func (me *contentDirectoryService) handleBrowseMetadata(obj object, host string) (map[string]string, error) { var objs []interface{} var updateID string // if numeric, then must be scene, otherwise handle as if path sceneID, err := strconv.Atoi(obj.Path) if err != nil { // #1465 - handle root object if obj.IsRoot() { objs = getRootObject() } else { // HACK: just create a fake storage folder to return. The name won't // be correct, but hopefully the names returned from handleBrowseDirectChildren // will be used instead. objs = []interface{}{makeStorageFolder(obj.ID(), obj.ID(), obj.ParentID())} } updateID = me.updateIDString() } else { var scene *models.Scene r := me.repository if err := r.WithReadTxn(context.TODO(), func(ctx context.Context) error { scene, err = r.SceneFinder.Find(ctx, sceneID) if scene != nil { err = scene.LoadPrimaryFile(ctx, r.FileGetter) } if err != nil { return err } return nil }); err != nil { logger.Error(err.Error()) } if scene != nil { upnpObject := sceneToContainer(scene, "-1", host) objs = []interface{}{upnpObject} // http://upnp.org/specs/av/UPnP-av-ContentDirectory-v1-Service.pdf // maximum update ID is 2**32, then rolls back to 0 const maxUpdateID int64 = 1 << 32 updateID = fmt.Sprint(scene.UpdatedAt.Unix() % maxUpdateID) } else { return nil, upnp.Errorf(upnpav.NoSuchObjectErrorCode, "scene not found") } } return makeBrowseResult(objs, updateID) } func makeBrowseResult(objs []interface{}, updateID string) (map[string]string, error) { result, err := xml.Marshal(objs) if err != nil { return nil, upnp.Errorf(upnp.ActionFailedErrorCode, "could not marshal objects: %s", err.Error()) } return map[string]string{ "TotalMatches": fmt.Sprint(len(objs)), "NumberReturned": fmt.Sprint(len(objs)), "Result": didl_lite(string(result)), "UpdateID": updateID, }, nil } func makeStorageFolder(id, title, parentID string) upnpav.Container { defaultChildCount := 1 return upnpav.Container{ Object: upnpav.Object{ ID: id, Restricted: 1, ParentID: parentID, Class: "object.container.storageFolder", Title: title, }, ChildCount: defaultChildCount, } } func getRootObject() []interface{} { const rootID = "0" return []interface{}{makeStorageFolder(rootID, "stash", "-1")} } func getRootObjects() []interface{} { const rootID = "0" var objs []interface{} objs = append(objs, makeStorageFolder("all", "all", rootID)) objs = append(objs, makeStorageFolder("performers", "performers", rootID)) objs = append(objs, makeStorageFolder("tags", "tags", rootID)) objs = append(objs, makeStorageFolder("studios", "studios", rootID)) objs = append(objs, makeStorageFolder("groups", "groups", rootID)) objs = append(objs, makeStorageFolder("rating", "rating", rootID)) return objs } func getSortDirection(sceneFilter *models.SceneFilterType, sort string) models.SortDirectionEnum { direction := models.SortDirectionEnumDesc if sort == "title" { direction = models.SortDirectionEnumAsc } return direction } func (me *contentDirectoryService) getVideos(sceneFilter *models.SceneFilterType, parentID string, host string) []interface{} { var objs []interface{} r := me.repository if err := r.WithReadTxn(context.TODO(), func(ctx context.Context) error { sort := me.VideoSortOrder direction := getSortDirection(sceneFilter, sort) findFilter := &models.FindFilterType{ PerPage: &pageSize, Sort: &sort, Direction: &direction, } scenes, total, err := scene.QueryWithCount(ctx, r.SceneFinder, sceneFilter, findFilter) if err != nil { return err } if total > pageSize { pager := scenePager{ sceneFilter: sceneFilter, parentID: parentID, } objs, err = pager.getPages(ctx, r.SceneFinder, total) if err != nil { return err } } else { for _, s := range scenes { if err := s.LoadPrimaryFile(ctx, r.FileGetter); err != nil { return err } objs = append(objs, sceneToContainer(s, parentID, host)) } } return nil }); err != nil { logger.Error(err.Error()) } return objs } func (me *contentDirectoryService) getPageVideos(sceneFilter *models.SceneFilterType, parentID string, page int, host string) []interface{} { var objs []interface{} r := me.repository if err := r.WithReadTxn(context.TODO(), func(ctx context.Context) error { pager := scenePager{ sceneFilter: sceneFilter, parentID: parentID, } sort := me.VideoSortOrder direction := getSortDirection(sceneFilter, sort) var err error objs, err = pager.getPageVideos(ctx, r.SceneFinder, r.FileGetter, page, host, sort, direction) if err != nil { return err } return nil }); err != nil { logger.Error(err.Error()) } return objs } func getPageFromID(paths []string) *int { i := slices.Index(paths, "page") if i == -1 || i+1 >= len(paths) { return nil } ret, err := strconv.Atoi(paths[i+1]) if err != nil { return nil } return &ret } func (me *contentDirectoryService) getAllScenes(host string) []interface{} { return me.getVideos(&models.SceneFilterType{}, "all", host) } func (me *contentDirectoryService) getStudios() []interface{} { var objs []interface{} r := me.repository if err := r.WithReadTxn(context.TODO(), func(ctx context.Context) error { studios, err := r.StudioFinder.All(ctx) if err != nil { return err } for _, s := range studios { objs = append(objs, makeStorageFolder("studios/"+strconv.Itoa(s.ID), s.Name, "studios")) } return nil }); err != nil { logger.Errorf(err.Error()) } return objs } func (me *contentDirectoryService) getStudioScenes(paths []string, host string) []interface{} { sceneFilter := &models.SceneFilterType{ Studios: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIncludes, Value: []string{paths[0]}, }, } parentID := "studios/" + strings.Join(paths, "/") page := getPageFromID(paths) if page != nil { return me.getPageVideos(sceneFilter, parentID, *page, host) } return me.getVideos(sceneFilter, parentID, host) } func (me *contentDirectoryService) getTags() []interface{} { var objs []interface{} r := me.repository if err := r.WithReadTxn(context.TODO(), func(ctx context.Context) error { tags, err := r.TagFinder.All(ctx) if err != nil { return err } for _, s := range tags { objs = append(objs, makeStorageFolder("tags/"+strconv.Itoa(s.ID), s.Name, "tags")) } return nil }); err != nil { logger.Errorf(err.Error()) } return objs } func (me *contentDirectoryService) getTagScenes(paths []string, host string) []interface{} { sceneFilter := &models.SceneFilterType{ Tags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIncludes, Value: []string{paths[0]}, }, } parentID := "tags/" + strings.Join(paths, "/") page := getPageFromID(paths) if page != nil { return me.getPageVideos(sceneFilter, parentID, *page, host) } return me.getVideos(sceneFilter, parentID, host) } func (me *contentDirectoryService) getPerformers() []interface{} { var objs []interface{} r := me.repository if err := r.WithReadTxn(context.TODO(), func(ctx context.Context) error { performers, err := r.PerformerFinder.All(ctx) if err != nil { return err } for _, s := range performers { objs = append(objs, makeStorageFolder("performers/"+strconv.Itoa(s.ID), s.Name, "performers")) } return nil }); err != nil { logger.Errorf(err.Error()) } return objs } func (me *contentDirectoryService) getPerformerScenes(paths []string, host string) []interface{} { sceneFilter := &models.SceneFilterType{ Performers: &models.MultiCriterionInput{ Modifier: models.CriterionModifierIncludes, Value: []string{paths[0]}, }, } parentID := "performers/" + strings.Join(paths, "/") page := getPageFromID(paths) if page != nil { return me.getPageVideos(sceneFilter, parentID, *page, host) } return me.getVideos(sceneFilter, parentID, host) } func (me *contentDirectoryService) getGroups() []interface{} { var objs []interface{} r := me.repository if err := r.WithReadTxn(context.TODO(), func(ctx context.Context) error { groups, err := r.GroupFinder.All(ctx) if err != nil { return err } for _, s := range groups { objs = append(objs, makeStorageFolder("groups/"+strconv.Itoa(s.ID), s.Name, "groups")) } return nil }); err != nil { logger.Errorf(err.Error()) } return objs } func (me *contentDirectoryService) getGroupScenes(paths []string, host string) []interface{} { sceneFilter := &models.SceneFilterType{ Groups: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIncludes, Value: []string{paths[0]}, }, } parentID := "groups/" + strings.Join(paths, "/") page := getPageFromID(paths) if page != nil { return me.getPageVideos(sceneFilter, parentID, *page, host) } return me.getVideos(sceneFilter, parentID, host) } func (me *contentDirectoryService) getRating() []interface{} { var objs []interface{} for r := 1; r <= 5; r++ { rStr := strconv.Itoa(r) objs = append(objs, makeStorageFolder("rating/"+rStr, rStr, "rating")) } return objs } func (me *contentDirectoryService) getRatingScenes(paths []string, host string) []interface{} { r, err := strconv.Atoi(paths[0]) if err != nil { return nil } sceneFilter := &models.SceneFilterType{ Rating100: &models.IntCriterionInput{ Modifier: models.CriterionModifierEquals, Value: models.Rating5To100(r), }, } parentID := "rating/" + strings.Join(paths, "/") page := getPageFromID(paths) if page != nil { return me.getPageVideos(sceneFilter, parentID, *page, host) } return me.getVideos(sceneFilter, parentID, host) } // Represents a ContentDirectory object. type object struct { Path string // The cleaned, absolute path for the object relative to the server. RootObjectPath string } // Returns the actual local filesystem path for the object. func (o *object) FilePath() string { return filepath.Join(o.RootObjectPath, filepath.FromSlash(o.Path)) } // Returns the ObjectID for the object. This is used in various ContentDirectory actions. func (o object) ID() string { if len(o.Path) == 1 { return "0" } return url.QueryEscape(o.Path) } func (o *object) IsRoot() bool { return o.Path == "/" } // Returns the object's parent ObjectID. Fortunately it can be deduced from the // ObjectID (for now). func (o object) ParentID() string { if o.IsRoot() { return "-1" } o.Path = path.Dir(o.Path) return o.ID() } ================================================ FILE: internal/dlna/cds_test.go ================================================ package dlna // From: https://github.com/anacrolix/dms // Copyright (c) 2012, Matt Joiner . // All rights reserved. // Redistribution and use in source and binary forms, with or without // modification, are permitted provided that the following conditions are met: // * Redistributions of source code must retain the above copyright // notice, this list of conditions and the following disclaimer. // * Redistributions in binary form must reproduce the above copyright // notice, this list of conditions and the following disclaimer in the // documentation and/or other materials provided with the distribution. // * Neither the name of the nor the // names of its contributors may be used to endorse or promote products // derived from this software without specific prior written permission. // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND // ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE // DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY // DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES // (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; // LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND // ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS // SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import ( "net/http" "strings" "testing" "github.com/stretchr/testify/assert" ) func TestEscapeObjectID(t *testing.T) { o := object{ Path: "/some/file", } id := o.ID() if strings.ContainsAny(id, "/") { t.Skip("may not work with some players: object IDs contain '/'") } } func TestRootObjectID(t *testing.T) { if (object{Path: "/"}).ID() != "0" { t.FailNow() } } func TestRootParentObjectID(t *testing.T) { if (object{Path: "/"}).ParentID() != "-1" { t.FailNow() } } func testHandleBrowse(argsXML string) (map[string]string, error) { cds := contentDirectoryService{ Server: &Server{}, } r := &http.Request{} return cds.Handle("Browse", []byte(argsXML), r) } func TestBrowseMetadataRoot(t *testing.T) { argsXML := `0BrowseMetadata*00` _, err := testHandleBrowse(argsXML) assert.Nil(t, err) } func TestBrowseMetadataTags(t *testing.T) { argsXML := `tagsBrowseMetadata*00` _, err := testHandleBrowse(argsXML) assert.Nil(t, err) } ================================================ FILE: internal/dlna/cm-service-desc.go ================================================ package dlna // from https://github.com/rclone/rclone // Copyright (C) 2012 by Nick Craig-Wood http://www.craig-wood.com/nick/ // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. const connectionManagerServiceDescription = ` 1 0 GetProtocolInfo Source out SourceProtocolInfo Sink out SinkProtocolInfo PrepareForConnection RemoteProtocolInfo in A_ARG_TYPE_ProtocolInfo PeerConnectionManager in A_ARG_TYPE_ConnectionManager PeerConnectionID in A_ARG_TYPE_ConnectionID Direction in A_ARG_TYPE_Direction ConnectionID out A_ARG_TYPE_ConnectionID AVTransportID out A_ARG_TYPE_AVTransportID RcsID out A_ARG_TYPE_RcsID ConnectionComplete ConnectionID in A_ARG_TYPE_ConnectionID GetCurrentConnectionIDs ConnectionIDs out CurrentConnectionIDs GetCurrentConnectionInfo ConnectionID in A_ARG_TYPE_ConnectionID RcsID out A_ARG_TYPE_RcsID AVTransportID out A_ARG_TYPE_AVTransportID ProtocolInfo out A_ARG_TYPE_ProtocolInfo PeerConnectionManager out A_ARG_TYPE_ConnectionManager PeerConnectionID out A_ARG_TYPE_ConnectionID Direction out A_ARG_TYPE_Direction Status out A_ARG_TYPE_ConnectionStatus SourceProtocolInfo string SinkProtocolInfo string CurrentConnectionIDs string A_ARG_TYPE_ConnectionStatus string OK ContentFormatMismatch InsufficientBandwidth UnreliableChannel Unknown A_ARG_TYPE_ConnectionManager string A_ARG_TYPE_Direction string Input Output A_ARG_TYPE_ProtocolInfo string A_ARG_TYPE_ConnectionID i4 A_ARG_TYPE_AVTransportID i4 A_ARG_TYPE_RcsID i4 ` ================================================ FILE: internal/dlna/cms.go ================================================ package dlna import ( "net/http" "github.com/anacrolix/dms/upnp" ) // from https://github.com/rclone/rclone // Copyright (C) 2012 by Nick Craig-Wood http://www.craig-wood.com/nick/ // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. const defaultProtocolInfo = "http-get:*:video/mpeg:*,http-get:*:video/mp4:*,http-get:*:video/vnd.dlna.mpeg-tts:*,http-get:*:video/avi:*,http-get:*:video/x-matroska:*,http-get:*:video/x-ms-wmv:*,http-get:*:video/wtv:*,http-get:*:audio/mpeg:*,http-get:*:audio/mp3:*,http-get:*:audio/mp4:*,http-get:*:audio/x-ms-wma*,http-get:*:audio/wav:*,http-get:*:audio/L16:*,http-get:*image/jpeg:*,http-get:*image/png:*,http-get:*image/gif:*,http-get:*image/tiff:*,http-get:*:image/avif:*" type connectionManagerService struct { *Server upnp.Eventing } func (cms *connectionManagerService) Handle(action string, argsXML []byte, r *http.Request) (map[string]string, error) { switch action { case "GetProtocolInfo": return map[string]string{ "Source": defaultProtocolInfo, "Sink": "", }, nil default: return nil, upnp.InvalidActionError } } ================================================ FILE: internal/dlna/dms.go ================================================ package dlna // Derived from: https://github.com/anacrolix/dms // Copyright (c) 2012, Matt Joiner . // All rights reserved. // Redistribution and use in source and binary forms, with or without // modification, are permitted provided that the following conditions are met: // * Redistributions of source code must retain the above copyright // notice, this list of conditions and the following disclaimer. // * Redistributions in binary form must reproduce the above copyright // notice, this list of conditions and the following disclaimer in the // documentation and/or other materials provided with the distribution. // * Neither the name of the nor the // names of its contributors may be used to endorse or promote products // derived from this software without specific prior written permission. // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND // ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE // DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY // DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES // (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; // LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND // ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS // SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import ( "bytes" "context" "crypto/md5" "encoding/xml" "fmt" "io" "net" "net/http" "net/http/pprof" "net/url" "path" "strconv" "strings" "sync" "time" "github.com/anacrolix/dms/soap" "github.com/anacrolix/dms/ssdp" "github.com/anacrolix/dms/upnp" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) type SceneFinder interface { models.SceneGetter models.SceneQueryer } type StudioFinder interface { All(ctx context.Context) ([]*models.Studio, error) } type TagFinder interface { All(ctx context.Context) ([]*models.Tag, error) } type PerformerFinder interface { All(ctx context.Context) ([]*models.Performer, error) } type GroupFinder interface { All(ctx context.Context) ([]*models.Group, error) } const ( serverField = "Linux/3.4 DLNADOC/1.50 UPnP/1.0 DMS/1.0" rootDeviceType = "urn:schemas-upnp-org:device:MediaServer:1" rootDeviceModelName = "dms 1.0xb" resPath = "/res" iconPath = "/icon" rootDescPath = "/rootDesc.xml" contentDirectoryEventSubURL = "/evt/ContentDirectory" serviceControlURL = "/ctl" deviceIconPath = "/deviceIcon" ) func makeDeviceUuid(unique string) string { h := md5.New() if _, err := io.WriteString(h, unique); err != nil { panic("makeDeviceUuid write failed: " + err.Error()) } buf := h.Sum(nil) return upnp.FormatUUID(buf) } // Groups the service definition with its XML description. type service struct { upnp.Service SCPD string } // Exposed UPnP AV services. var services = []*service{ { Service: upnp.Service{ ServiceType: "urn:schemas-upnp-org:service:ContentDirectory:1", ServiceId: "urn:upnp-org:serviceId:ContentDirectory", EventSubURL: contentDirectoryEventSubURL, ControlURL: serviceControlURL, }, SCPD: contentDirectoryServiceDescription, }, { Service: upnp.Service{ ServiceType: "urn:schemas-upnp-org:service:ConnectionManager:1", ServiceId: "urn:upnp-org:serviceId:ConnectionManager", ControlURL: serviceControlURL, }, SCPD: connectionManagerServiceDescription, }, { Service: upnp.Service{ ServiceType: "urn:microsoft.com:service:X_MS_MediaReceiverRegistrar:1", ServiceId: "urn:microsoft.com:serviceId:X_MS_MediaReceiverRegistrar", ControlURL: serviceControlURL, }, SCPD: xmsMediaReceiverServiceDescription, }, } func init() { for _, s := range services { p := path.Join("/scpd", s.ServiceId) s.SCPDURL = p } } func devices() []string { return []string{ "urn:schemas-upnp-org:device:MediaServer:1", } } func serviceTypes() (ret []string) { for _, s := range services { ret = append(ret, s.ServiceType) } return } func (me *Server) httpPort() int { return me.HTTPConn.Addr().(*net.TCPAddr).Port } func (me *Server) serveHTTP() error { srv := &http.Server{ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if me.LogHeaders { logger.Debugf("%s %s", r.Method, r.RequestURI) for k, v := range r.Header { logger.Debugf("%s: %s", k, v) } } w.Header().Set("Ext", "") w.Header().Set("Server", serverField) me.httpServeMux.ServeHTTP(&mitmRespWriter{ ResponseWriter: w, logHeader: me.LogHeaders, }, r) }), } err := srv.Serve(me.HTTPConn) select { case <-me.closed: return nil default: return err } } // An interface with these flags should be valid for SSDP. const ssdpInterfaceFlags = net.FlagUp | net.FlagMulticast func (me *Server) doSSDP() { active := 0 stopped := make(chan struct{}) for _, if_ := range me.Interfaces { active++ go func(if_ net.Interface) { defer func() { stopped <- struct{}{} }() me.ssdpInterface(if_) }(if_) } for active > 0 { <-stopped active-- } } // Run SSDP server on an interface. func (me *Server) ssdpInterface(if_ net.Interface) { s := ssdp.Server{ Interface: if_, Devices: devices(), Services: serviceTypes(), Location: func(ip net.IP) string { return me.location(ip) }, Server: serverField, UUID: me.rootDeviceUUID, NotifyInterval: me.NotifyInterval, } if err := s.Init(); err != nil { if if_.Flags&ssdpInterfaceFlags != ssdpInterfaceFlags { // Didn't expect it to work anyway. return } if strings.Contains(err.Error(), "listen") { // OSX has a lot of dud interfaces. Failure to create a socket on // the interface are what we're expecting if the interface is no // good. return } logger.Errorf("error creating ssdp server on %s: %s", if_.Name, err) return } defer s.Close() logger.Debugf("started SSDP on %s", if_.Name) stopped := make(chan struct{}) go func() { defer close(stopped) // FIXME - this currently blocks forever unless it encounters an error // See https://github.com/anacrolix/dms/pull/150 // Needs to be fixed upstream //nolint:staticcheck if err := s.Serve(); err != nil { logger.Errorf("%q: %q\n", if_.Name, err) } }() select { case <-me.closed: // Returning will close the server. case <-stopped: } } var ( startTime time.Time ) type Icon struct { Width, Height, Depth int Mimetype string io.ReadSeeker } type Server struct { HTTPConn net.Listener FriendlyName string Interfaces []net.Interface httpServeMux *http.ServeMux RootObjectPath string rootDescXML []byte rootDeviceUUID string closed chan struct{} ssdpStopped chan struct{} // The service SOAP handler keyed by service URN. services map[string]UPnPService LogHeaders bool Icons []Icon // Stall event subscription requests until they drop. A workaround for // some bad clients. StallEventSubscribe bool // Time interval between SSPD announces NotifyInterval time.Duration repository Repository sceneServer sceneServer ipWhitelistManager *ipWhitelistManager activityTracker *ActivityTracker VideoSortOrder string subscribeLock sync.Mutex } // UPnP SOAP service. type UPnPService interface { Handle(action string, argsXML []byte, r *http.Request) (respArgs map[string]string, err error) Subscribe(callback []*url.URL, timeoutSeconds int) (sid string, actualTimeout int, err error) Unsubscribe(sid string) error } type Cache interface { Set(key interface{}, value interface{}) Get(key interface{}) (value interface{}, ok bool) } func init() { startTime = time.Now() } func xmlMarshalOrPanic(value interface{}) []byte { ret, err := xml.MarshalIndent(value, "", " ") if err != nil { panic(fmt.Sprintf("xmlMarshalOrPanic failed to marshal %v: %s", value, err)) } return ret } // TODO: Document the use of this for debugging. type mitmRespWriter struct { http.ResponseWriter loggedHeader bool logHeader bool } func (me *mitmRespWriter) WriteHeader(code int) { me.doLogHeader(code) me.ResponseWriter.WriteHeader(code) } func (me *mitmRespWriter) doLogHeader(code int) { if !me.logHeader { return } logger.Debugf("Response: %d", code) for k, v := range me.Header() { logger.Debugf("%s: %s", k, v) } me.loggedHeader = true } func (me *mitmRespWriter) Write(b []byte) (int, error) { if !me.loggedHeader { me.doLogHeader(200) } return me.ResponseWriter.Write(b) } // Deprecated: the CloseNotifier interface predates Go's context package. // New code should use Request.Context instead. func (me *mitmRespWriter) CloseNotify() <-chan bool { return me.ResponseWriter.(http.CloseNotifier).CloseNotify() } // Set the SCPD serve paths. func init() { for _, s := range services { p := path.Join("/scpd", s.ServiceId) s.SCPDURL = p } } // Install handlers to serve SCPD for each UPnP service. func handleSCPDs(mux *http.ServeMux) { for _, s := range services { mux.HandleFunc(s.SCPDURL, func(serviceDesc string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", `text/xml; charset="utf-8"`) http.ServeContent(w, r, ".xml", startTime, bytes.NewReader([]byte(serviceDesc))) } }(s.SCPD)) } } // Marshal SOAP response arguments into a response XML snippet. func marshalSOAPResponse(sa upnp.SoapAction, args map[string]string) []byte { soapArgs := make([]soap.Arg, 0, len(args)) for argName, value := range args { soapArgs = append(soapArgs, soap.Arg{ XMLName: xml.Name{Local: argName}, Value: value, }) } return []byte(fmt.Sprintf(`%[3]s`, sa.Action, sa.ServiceURN.String(), xmlMarshalOrPanic(soapArgs))) } // Handle a SOAP request and return the response arguments or UPnP error. func (me *Server) soapActionResponse(sa upnp.SoapAction, actionRequestXML []byte, r *http.Request) (map[string]string, error) { service, ok := me.services[sa.Type] if !ok { // TODO: What's the invalid service error?! return nil, upnp.Errorf(upnp.InvalidActionErrorCode, "Invalid service: %s", sa.Type) } logger.Tracef("%s::Handle %s - %s", sa.Type, sa.Action, actionRequestXML) ret, err := service.Handle(sa.Action, actionRequestXML, r) if err == nil { logger.Tracef("< %v", ret) } return ret, err } // Handle a service control HTTP request. func (me *Server) serviceControlHandler(w http.ResponseWriter, r *http.Request) { clientIp, _, _ := net.SplitHostPort(r.RemoteAddr) ip := net.ParseIP(clientIp).String() if !me.ipWhitelistManager.ipAllowed(ip) { // only log if we haven't seen it if !me.ipWhitelistManager.addRecent(ip) { logger.Infof("not allowed client %s", clientIp) } http.Error(w, "forbidden", http.StatusForbidden) return } soapActionString := r.Header.Get("SOAPACTION") soapAction, err := upnp.ParseActionHTTPHeader(soapActionString) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } var env soap.Envelope if err := xml.NewDecoder(r.Body).Decode(&env); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // AwoX/1.1 UPnP/1.0 DLNADOC/1.50 w.Header().Set("Content-Type", `text/xml; charset="utf-8"`) w.Header().Set("Ext", "") w.Header().Set("Server", serverField) soapRespXML, code := func() ([]byte, int) { respArgs, err := me.soapActionResponse(soapAction, env.Body.Action, r) if err != nil { upnpErr := upnp.ConvertError(err) return xmlMarshalOrPanic(soap.NewFault("UPnPError", upnpErr)), 500 } return marshalSOAPResponse(soapAction, respArgs), 200 }() bodyStr := fmt.Sprintf(`%s`, soapRespXML) w.WriteHeader(code) if _, err := w.Write([]byte(bodyStr)); err != nil { logger.Errorf(err.Error()) } } func (me *Server) serveIcon(w http.ResponseWriter, r *http.Request) { sceneId := r.URL.Query().Get("scene") if sceneId == "" { return } var scene *models.Scene repo := me.repository err := repo.WithReadTxn(r.Context(), func(ctx context.Context) error { idInt, err := strconv.Atoi(sceneId) if err != nil { return nil } scene, _ = repo.SceneFinder.Find(ctx, idInt) return nil }) if err != nil { logger.Warnf("failed to execute read transaction while trying to serve an icon: %v", err) } if scene == nil { return } me.sceneServer.ServeScreenshot(scene, w, r) } func (me *Server) contentDirectoryInitialEvent(ctx context.Context, urls []*url.URL, sid string) { body := xmlMarshalOrPanic(upnp.PropertySet{ Properties: []upnp.Property{ { Variable: upnp.Variable{ XMLName: xml.Name{ Local: "SystemUpdateID", }, Value: "0", }, }, // upnp.Property{ // Variable: upnp.Variable{ // XMLName: xml.Name{ // Local: "ContainerUpdateIDs", // }, // }, // }, // upnp.Property{ // Variable: upnp.Variable{ // XMLName: xml.Name{ // Local: "TransferIDs", // }, // }, // }, }, Space: "urn:schemas-upnp-org:event-1-0", }) body = append([]byte(``+"\n"), body...) for _, _url := range urls { bodyReader := bytes.NewReader(body) req, err := http.NewRequestWithContext(ctx, "NOTIFY", _url.String(), bodyReader) if err != nil { logger.Errorf("Could not create a request to notify %s: %s", _url.String(), err) continue } req.Header["CONTENT-TYPE"] = []string{`text/xml; charset="utf-8"`} req.Header["NT"] = []string{"upnp:event"} req.Header["NTS"] = []string{"upnp:propchange"} req.Header["SID"] = []string{sid} req.Header["SEQ"] = []string{"0"} // req.Header["TRANSFER-ENCODING"] = []string{"chunked"} // req.ContentLength = int64(bodyReader.Len()) resp, err := http.DefaultClient.Do(req) if err != nil { logger.Errorf("Could not notify %s: %s", _url.String(), err) continue } b, _ := io.ReadAll(resp.Body) if len(b) > 0 { logger.Debug(string(b)) } resp.Body.Close() } } func (me *Server) contentDirectoryEventSubHandler(w http.ResponseWriter, r *http.Request) { if me.StallEventSubscribe { // I have an LG TV that doesn't like my eventing implementation. // Returning unimplemented (501?) errors, results in repeat subscribe // attempts which hits some kind of error count limit on the TV // causing it to forcefully disconnect. It also won't work if the CDS // service doesn't include an EventSubURL. The best thing I can do is // cause every attempt to subscribe to timeout on the TV end, which // reduces the error rate enough that the TV continues to operate // without eventing. // // I've not found a reliable way to identify this TV, since it and // others don't seem to include any client-identifying headers on // SUBSCRIBE requests. // // TODO: Get eventing to work with the problematic TV. t := time.Now() <-r.Context().Done() logger.Debugf("stalled subscribe connection went away after %s", time.Since(t)) return } // The following code is a work in progress. It partially implements // the spec on eventing but hasn't been completed as I have nothing to // test it with. switch { case r.Method == "SUBSCRIBE" && r.Header.Get("SID") == "": urls := upnp.ParseCallbackURLs(r.Header.Get("CALLBACK")) var timeout int _, _ = fmt.Sscanf(r.Header.Get("TIMEOUT"), "Second-%d", &timeout) sid, timeout, _ := me.subscribe(urls, timeout) w.Header()["SID"] = []string{sid} w.Header()["TIMEOUT"] = []string{fmt.Sprintf("Second-%d", timeout)} // TODO: Shouldn't have to do this to get headers logged. w.WriteHeader(http.StatusOK) go func() { time.Sleep(100 * time.Millisecond) me.contentDirectoryInitialEvent(r.Context(), urls, sid) }() case r.Method == "SUBSCRIBE": http.Error(w, "meh", http.StatusPreconditionFailed) default: logger.Debugf("unhandled event method: %s", r.Method) } } // wrapper around service.Subscribe which requires concurrency protection // TODO - this should be addressed upstream func (me *Server) subscribe(urls []*url.URL, timeout int) (sid string, actualTimeout int, err error) { me.subscribeLock.Lock() defer me.subscribeLock.Unlock() service := me.services["ContentDirectory"] return service.Subscribe(urls, timeout) } func (me *Server) initMux(mux *http.ServeMux) { mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { resp.Header().Set("content-type", "text/html") err := rootTmpl.Execute(resp, struct { Readonly bool Path string }{ true, me.RootObjectPath, }) if err != nil { logger.Errorf(err.Error()) } }) mux.HandleFunc(contentDirectoryEventSubURL, me.contentDirectoryEventSubHandler) mux.HandleFunc(iconPath, me.serveIcon) mux.HandleFunc(resPath, func(w http.ResponseWriter, r *http.Request) { sceneId := r.URL.Query().Get("scene") var scene *models.Scene var videoDuration float64 repo := me.repository err := repo.WithReadTxn(r.Context(), func(ctx context.Context) error { sceneIdInt, err := strconv.Atoi(sceneId) if err != nil { return nil } scene, _ = repo.SceneFinder.Find(ctx, sceneIdInt) if scene != nil { // Load primary file to get duration for activity tracking if err := scene.LoadPrimaryFile(ctx, repo.FileGetter); err != nil { logger.Debugf("failed to load primary file for scene %d: %v", sceneIdInt, err) } if f := scene.Files.Primary(); f != nil { videoDuration = f.Duration } } return nil }) if err != nil { logger.Warnf("failed to execute read transaction for scene id (%v): %v", sceneId, err) } if scene == nil { return } w.Header().Set("transferMode.dlna.org", "Streaming") w.Header().Set("contentFeatures.dlna.org", "DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01500000000000000000000000000000") // Track activity - uses time-based tracking, updated on each request if me.activityTracker != nil { sceneIdInt, _ := strconv.Atoi(sceneId) clientIP, _, _ := net.SplitHostPort(r.RemoteAddr) me.activityTracker.RecordRequest(sceneIdInt, clientIP, videoDuration) } me.sceneServer.StreamSceneDirect(scene, w, r) }) mux.HandleFunc(rootDescPath, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", `text/xml; charset="utf-8"`) w.Header().Set("content-length", fmt.Sprint(len(me.rootDescXML))) w.Header().Set("server", serverField) if k, err := w.Write(me.rootDescXML); err != nil { logger.Warnf("could not write rootDescXML (wrote %v bytes of %v): %v", k, len(me.rootDescXML), err) } }) handleSCPDs(mux) mux.HandleFunc(serviceControlURL, me.serviceControlHandler) mux.HandleFunc("/debug/pprof/", pprof.Index) for i, di := range me.Icons { mux.HandleFunc(fmt.Sprintf("%s/%d", deviceIconPath, i), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", di.Mimetype) http.ServeContent(w, r, "", time.Time{}, di.ReadSeeker) }) } } func (me *Server) initServices() { me.services = map[string]UPnPService{ "ContentDirectory": &contentDirectoryService{ Server: me, }, "ConnectionManager": &connectionManagerService{ Server: me, }, "X_MS_MediaReceiverRegistrar": &mediaReceiverRegistrarService{ Server: me, }, } } func (me *Server) Serve() (err error) { me.initServices() me.closed = make(chan struct{}) if me.HTTPConn == nil { me.HTTPConn, err = net.Listen("tcp", "") if err != nil { return } } if me.Interfaces == nil { ifs, err := net.Interfaces() if err != nil { logger.Errorf(err.Error()) } var tmp []net.Interface for _, if_ := range ifs { if if_.Flags&net.FlagUp == 0 || if_.MTU <= 0 { continue } tmp = append(tmp, if_) } me.Interfaces = tmp } me.httpServeMux = http.NewServeMux() me.rootDeviceUUID = makeDeviceUuid(me.FriendlyName) me.rootDescXML, err = xml.MarshalIndent( upnp.DeviceDesc{ SpecVersion: upnp.SpecVersion{Major: 1, Minor: 0}, Device: upnp.Device{ DeviceType: rootDeviceType, FriendlyName: me.FriendlyName, Manufacturer: me.FriendlyName, ModelName: rootDeviceModelName, UDN: me.rootDeviceUUID, ServiceList: func() (ss []upnp.Service) { for _, s := range services { ss = append(ss, s.Service) } return }(), IconList: func() (ret []upnp.Icon) { for i, di := range me.Icons { ret = append(ret, upnp.Icon{ Height: di.Height, Width: di.Width, Depth: di.Depth, Mimetype: di.Mimetype, URL: fmt.Sprintf("%s/%d", deviceIconPath, i), }) } return }(), }, }, " ", " ") if err != nil { return } me.rootDescXML = append([]byte(``), me.rootDescXML...) logger.Debug("HTTP srv on", me.HTTPConn.Addr()) me.initMux(me.httpServeMux) me.ssdpStopped = make(chan struct{}) go func() { me.doSSDP() close(me.ssdpStopped) }() return me.serveHTTP() } func (me *Server) Close() (err error) { close(me.closed) err = me.HTTPConn.Close() <-me.ssdpStopped return } func didl_lite(chardata string) string { return `` + chardata + `` } func (me *Server) location(ip net.IP) string { url := url.URL{ Scheme: "http", Host: (&net.TCPAddr{ IP: ip, Port: me.httpPort(), }).String(), Path: rootDescPath, } return url.String() } ================================================ FILE: internal/dlna/doc.go ================================================ // Package dlna provides the DLNA functionality for the application. // Much of this code is adapted from https://github.com/anacrolix/dms package dlna ================================================ FILE: internal/dlna/html.go ================================================ package dlna // From: https://github.com/anacrolix/dms // Copyright (c) 2012, Matt Joiner . // All rights reserved. // Redistribution and use in source and binary forms, with or without // modification, are permitted provided that the following conditions are met: // * Redistributions of source code must retain the above copyright // notice, this list of conditions and the following disclaimer. // * Redistributions in binary form must reproduce the above copyright // notice, this list of conditions and the following disclaimer in the // documentation and/or other materials provided with the distribution. // * Neither the name of the nor the // names of its contributors may be used to endorse or promote products // derived from this software without specific prior written permission. // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND // ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE // DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY // DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES // (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; // LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND // ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS // SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import ( "html/template" ) var ( rootTmpl *template.Template ) func init() { rootTmpl = template.Must(template.New("root").Parse( `
Path:
`)) } ================================================ FILE: internal/dlna/mrrs.go ================================================ package dlna import ( "net/http" "github.com/anacrolix/dms/upnp" ) type mediaReceiverRegistrarService struct { *Server upnp.Eventing } func (mrrs *mediaReceiverRegistrarService) Handle(action string, argsXML []byte, r *http.Request) (map[string]string, error) { switch action { case "IsAuthorized", "IsValidated": return map[string]string{ "Result": "1", }, nil case "RegisterDevice": return map[string]string{ "RegistrationRespMsg": mrrs.rootDeviceUUID, }, nil default: return nil, upnp.InvalidActionError } } ================================================ FILE: internal/dlna/paging.go ================================================ package dlna import ( "context" "fmt" "math" "strconv" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" ) type scenePager struct { sceneFilter *models.SceneFilterType parentID string } func (p *scenePager) getPageID(page int) string { return p.parentID + "/page/" + strconv.Itoa(page) } func (p *scenePager) getPages(ctx context.Context, r models.SceneQueryer, total int) ([]interface{}, error) { var objs []interface{} // get the first scene of each page to set an appropriate title pages := int(math.Ceil(float64(total) / float64(pageSize))) singlePageSize := 1 sort := "title" findFilter := &models.FindFilterType{ PerPage: &singlePageSize, Sort: &sort, } for page := 1; page <= pages; page++ { // TODO - this is really slow. Not sure if there's a better way title := fmt.Sprintf("Page %d", page) if pages <= 10 || (page-1)%(pages/10) == 0 { thisPage := ((page - 1) * pageSize) + 1 findFilter.Page = &thisPage scenes, err := scene.Query(ctx, r, p.sceneFilter, findFilter) if err != nil { return nil, err } sceneTitle := scenes[0].GetTitle() // use the first three letters as a prefix if len(sceneTitle) > 3 { sceneTitle = sceneTitle[0:3] } title += fmt.Sprintf(" (%s...)", sceneTitle) } objs = append(objs, makeStorageFolder(p.getPageID(page), title, p.parentID)) } return objs, nil } func (p *scenePager) getPageVideos(ctx context.Context, r SceneFinder, f models.FileGetter, page int, host string, sort string, direction models.SortDirectionEnum) ([]interface{}, error) { var objs []interface{} findFilter := &models.FindFilterType{ PerPage: &pageSize, Page: &page, Sort: &sort, Direction: &direction, } scenes, err := scene.Query(ctx, r, p.sceneFilter, findFilter) if err != nil { return nil, err } for _, s := range scenes { if err := s.LoadPrimaryFile(ctx, f); err != nil { return nil, err } objs = append(objs, sceneToContainer(s, p.parentID, host)) } return objs, nil } ================================================ FILE: internal/dlna/service.go ================================================ package dlna import ( "context" "fmt" "net" "net/http" "path/filepath" "sync" "time" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/txn" ) type Repository struct { TxnManager models.TxnManager SceneFinder SceneFinder FileGetter models.FileGetter StudioFinder StudioFinder TagFinder TagFinder PerformerFinder PerformerFinder GroupFinder GroupFinder } func NewRepository(repo models.Repository) Repository { return Repository{ TxnManager: repo.TxnManager, FileGetter: repo.File, SceneFinder: repo.Scene, StudioFinder: repo.Studio, TagFinder: repo.Tag, PerformerFinder: repo.Performer, GroupFinder: repo.Group, } } func (r *Repository) WithReadTxn(ctx context.Context, fn txn.TxnFunc) error { return txn.WithReadTxn(ctx, r.TxnManager, fn) } type Status struct { Running bool `json:"running"` // If not currently running, time until it will be started. If running, time until it will be stopped Until *time.Time `json:"until"` RecentIPAddresses []string `json:"recentIPAddresses"` AllowedIPAddresses []*Dlnaip `json:"allowedIPAddresses"` } type Dlnaip struct { IPAddress string `json:"ipAddress"` // Time until IP will be no longer allowed/disallowed Until *time.Time `json:"until"` } type dmsConfig struct { Path string IfNames []string Http string FriendlyName string LogHeaders bool StallEventSubscribe bool NotifyInterval time.Duration VideoSortOrder string } type sceneServer interface { StreamSceneDirect(scene *models.Scene, w http.ResponseWriter, r *http.Request) ServeScreenshot(scene *models.Scene, w http.ResponseWriter, r *http.Request) } type Config interface { GetDLNAInterfaces() []string GetDLNAServerName() string GetDLNADefaultIPWhitelist() []string GetVideoSortOrder() string GetDLNAPortAsString() string GetDLNAActivityTrackingEnabled() bool } // activityConfig wraps Config to implement ActivityConfig. type activityConfig struct { config Config minPlayPercent int // cached from UI config } func (c *activityConfig) GetDLNAActivityTrackingEnabled() bool { return c.config.GetDLNAActivityTrackingEnabled() } func (c *activityConfig) GetMinimumPlayPercent() int { return c.minPlayPercent } type Service struct { repository Repository config Config sceneServer sceneServer ipWhitelistMgr *ipWhitelistManager activityTracker *ActivityTracker server *Server running bool mutex sync.Mutex startTimer *time.Timer startTime *time.Time stopTimer *time.Timer stopTime *time.Time } func (s *Service) getInterfaces() ([]net.Interface, error) { var ifs []net.Interface var err error ifNames := s.config.GetDLNAInterfaces() if len(ifNames) == 0 { ifs, err = net.Interfaces() } else { for _, n := range ifNames { if_, err := net.InterfaceByName(n) if err != nil { return nil, fmt.Errorf("error getting interface for name %s: %s", n, err.Error()) } if if_ != nil { ifs = append(ifs, *if_) } } } if err != nil { return nil, err } var tmp []net.Interface for _, if_ := range ifs { if if_.Flags&net.FlagUp == 0 || if_.MTU <= 0 { continue } tmp = append(tmp, if_) } ifs = tmp return ifs, nil } func (s *Service) init() error { friendlyName := s.config.GetDLNAServerName() if friendlyName == "" { friendlyName = "stash" } var dmsConfig = &dmsConfig{ Path: "", IfNames: s.config.GetDLNADefaultIPWhitelist(), Http: s.config.GetDLNAPortAsString(), FriendlyName: friendlyName, LogHeaders: false, NotifyInterval: 30 * time.Second, VideoSortOrder: s.config.GetVideoSortOrder(), } interfaces, err := s.getInterfaces() if err != nil { return err } s.server = &Server{ repository: s.repository, sceneServer: s.sceneServer, ipWhitelistManager: s.ipWhitelistMgr, activityTracker: s.activityTracker, Interfaces: interfaces, HTTPConn: func() net.Listener { conn, err := net.Listen("tcp", dmsConfig.Http) if err != nil { logger.Error(err.Error()) } return conn }(), FriendlyName: dmsConfig.FriendlyName, RootObjectPath: filepath.Clean(dmsConfig.Path), LogHeaders: dmsConfig.LogHeaders, // Icons: []Icon{ // { // Width: 48, // Height: 48, // Depth: 8, // Mimetype: "image/png", // //ReadSeeker: readIcon(config.Config.Interfaces.DLNA.ServiceImage, 48), // }, // { // Width: 128, // Height: 128, // Depth: 8, // Mimetype: "image/png", // //ReadSeeker: readIcon(config.Config.Interfaces.DLNA.ServiceImage, 128), // }, // }, StallEventSubscribe: dmsConfig.StallEventSubscribe, NotifyInterval: dmsConfig.NotifyInterval, VideoSortOrder: dmsConfig.VideoSortOrder, } return nil } // func getIconReader(fn string) (io.Reader, error) { // b, err := assets.ReadFile("dlna/" + fn + ".png") // return bytes.NewReader(b), err // } // func readIcon(path string, size uint) *bytes.Reader { // r, err := getIconReader(path) // if err != nil { // panic(err) // } // imageData, _, err := image.Decode(r) // if err != nil { // panic(err) // } // return resizeImage(imageData, size) // } // func resizeImage(imageData image.Image, size uint) *bytes.Reader { // img := resize.Resize(size, size, imageData, resize.Lanczos3) // var buff bytes.Buffer // png.Encode(&buff, img) // return bytes.NewReader(buff.Bytes()) // } // NewService initialises and returns a new DLNA service. // The sceneWriter parameter should implement SceneActivityWriter (typically models.SceneReaderWriter). // The minPlayPercent parameter is the minimum percentage of video that must be played to increment play count. func NewService(repo Repository, cfg Config, sceneServer sceneServer, sceneWriter SceneActivityWriter, minPlayPercent int) *Service { activityCfg := &activityConfig{ config: cfg, minPlayPercent: minPlayPercent, } ret := &Service{ repository: repo, sceneServer: sceneServer, config: cfg, ipWhitelistMgr: &ipWhitelistManager{ config: cfg, }, activityTracker: NewActivityTracker(repo.TxnManager, sceneWriter, activityCfg), mutex: sync.Mutex{}, } return ret } // Start starts the DLNA service. If duration is provided, then the service // is stopped after the duration has elapsed. func (s *Service) Start(duration *time.Duration) error { s.mutex.Lock() defer s.mutex.Unlock() if !s.running { if err := s.init(); err != nil { logger.Error(err) return err } go func() { logger.Info("Starting DLNA " + s.server.HTTPConn.Addr().String()) if err := s.server.Serve(); err != nil { logger.Error(err) } }() s.running = true if s.startTimer != nil { s.startTimer.Stop() s.startTimer = nil s.startTime = nil } } if duration != nil { // clear the existing stop timer if s.stopTimer != nil { s.stopTimer.Stop() s.stopTime = nil } if s.stopTimer == nil { s.stopTimer = time.AfterFunc(*duration, func() { s.Stop(nil) }) t := time.Now().Add(*duration) s.stopTime = &t } } return nil } // Stop stops the DLNA service. If duration is provided, then the service // is started after the duration has elapsed. func (s *Service) Stop(duration *time.Duration) { s.mutex.Lock() defer s.mutex.Unlock() if s.running { logger.Info("Stopping DLNA") // Stop activity tracker first to process any pending sessions if s.activityTracker != nil { s.activityTracker.Stop() } err := s.server.Close() if err != nil { logger.Error(err) } s.running = false if s.stopTimer != nil { s.stopTimer.Stop() s.stopTimer = nil s.stopTime = nil } } if duration != nil { // clear the existing stop timer if s.startTimer != nil { s.startTimer.Stop() } if s.startTimer == nil { s.startTimer = time.AfterFunc(*duration, func() { if err := s.Start(nil); err != nil { logger.Warnf("error restarting DLNA server: %v", err) } }) t := time.Now().Add(*duration) s.startTime = &t } } } // IsRunning returns true if the DLNA service is running. func (s *Service) IsRunning() bool { s.mutex.Lock() defer s.mutex.Unlock() return s.running } func (s *Service) Status() *Status { s.mutex.Lock() defer s.mutex.Unlock() ret := &Status{ Running: s.running, RecentIPAddresses: s.ipWhitelistMgr.getRecent(), AllowedIPAddresses: s.ipWhitelistMgr.getTempAllowed(), } if s.startTime != nil { t := *s.startTime ret.Until = &t } if s.stopTime != nil { t := *s.stopTime ret.Until = &t } return ret } func (s *Service) AddTempDLNAIP(pattern string, duration *time.Duration) { s.ipWhitelistMgr.allowPattern(pattern, duration) } func (s *Service) RemoveTempDLNAIP(pattern string) bool { return s.ipWhitelistMgr.removePattern(pattern) } ================================================ FILE: internal/dlna/whitelist.go ================================================ package dlna import ( "slices" "sync" "time" ) // only keep the 10 most recent IP addresses const recentListLength = 10 const wildcard = "*" type tempIPWhitelist struct { pattern string until *time.Time } type ipWhitelistManager struct { recentIPAddresses []string config Config tempWhitelist []tempIPWhitelist mutex sync.Mutex } // addRecent adds the provided address to the recent IP addresses list if it // was not already present. Returns true if it was already present. func (m *ipWhitelistManager) addRecent(addr string) bool { m.mutex.Lock() defer m.mutex.Unlock() i := slices.Index(m.recentIPAddresses, addr) if i != -1 { if i == 0 { // don't do anything if it's already at the start return true } // remove from the list m.recentIPAddresses = append(m.recentIPAddresses[:i], m.recentIPAddresses[i+1:]...) } // add to the top of the list m.recentIPAddresses = append([]string{addr}, m.recentIPAddresses...) if len(m.recentIPAddresses) > recentListLength { m.recentIPAddresses = m.recentIPAddresses[:recentListLength] } return i != -1 } func (m *ipWhitelistManager) getRecent() []string { m.mutex.Lock() defer m.mutex.Unlock() return m.recentIPAddresses } func (m *ipWhitelistManager) getTempAllowed() []*Dlnaip { m.mutex.Lock() defer m.mutex.Unlock() var ret []*Dlnaip now := time.Now() removeExpired := false for _, a := range m.tempWhitelist { if a.until != nil && now.After(*a.until) { removeExpired = true continue } ret = append(ret, &Dlnaip{ IPAddress: a.pattern, Until: a.until, }) } if removeExpired { m.removeExpiredWhitelists() } return ret } func (m *ipWhitelistManager) ipAllowed(addr string) bool { m.mutex.Lock() defer m.mutex.Unlock() for _, a := range m.config.GetDLNADefaultIPWhitelist() { if a == wildcard { return true } if addr == a { return true } } now := time.Now() removeExpired := false for _, a := range m.tempWhitelist { if a.until != nil && now.After(*a.until) { removeExpired = true continue } if a.pattern == wildcard { return true } if addr == a.pattern { return true } } if removeExpired { m.removeExpiredWhitelists() } return false } func (m *ipWhitelistManager) removeExpiredWhitelists() { // assumes mutex is already held var newList []tempIPWhitelist now := time.Now() for _, a := range m.tempWhitelist { if a.until != nil && now.After(*a.until) { continue } newList = append(newList, a) } m.tempWhitelist = newList } func (m *ipWhitelistManager) allowPattern(pattern string, duration *time.Duration) { if pattern == "" { return } m.mutex.Lock() defer m.mutex.Unlock() // overwrite existing var newList []tempIPWhitelist found := false var until *time.Time if duration != nil { u := time.Now().Add(*duration) until = &u } for _, a := range m.tempWhitelist { if a.pattern == pattern { a.until = until found = true } newList = append(newList, a) } if !found { newList = append(newList, tempIPWhitelist{ pattern: pattern, until: until, }) } m.tempWhitelist = newList } func (m *ipWhitelistManager) removePattern(pattern string) bool { if pattern == "" { return false } m.mutex.Lock() defer m.mutex.Unlock() var newList []tempIPWhitelist found := false for _, a := range m.tempWhitelist { if a.pattern == pattern { found = true continue } newList = append(newList, a) } m.tempWhitelist = newList return found } ================================================ FILE: internal/dlna/xmsr-service-desc.go ================================================ package dlna // from https://github.com/rclone/rclone // Copyright (C) 2012 by Nick Craig-Wood http://www.craig-wood.com/nick/ // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. const xmsMediaReceiverServiceDescription = ` 1 0 IsAuthorized DeviceID in A_ARG_TYPE_DeviceID Result out A_ARG_TYPE_Result RegisterDevice RegistrationReqMsg in A_ARG_TYPE_RegistrationReqMsg RegistrationRespMsg out A_ARG_TYPE_RegistrationRespMsg IsValidated DeviceID in A_ARG_TYPE_DeviceID Result out A_ARG_TYPE_Result A_ARG_TYPE_DeviceID string A_ARG_TYPE_Result int A_ARG_TYPE_RegistrationReqMsg bin.base64 A_ARG_TYPE_RegistrationRespMsg bin.base64 AuthorizationGrantedUpdateID ui4 AuthorizationDeniedUpdateID ui4 ValidationSucceededUpdateID ui4 ValidationRevokedUpdateID ui4 ` ================================================ FILE: internal/identify/identify.go ================================================ // Package identify provides the scene identification functionality for the application. // The identify functionality uses scene scrapers to identify a given scene and // set its metadata based on the scraped data. package identify import ( "context" "errors" "fmt" "slices" "strconv" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/txn" "github.com/stashapp/stash/pkg/utils" ) var ( ErrSkipSingleNamePerformer = errors.New("a performer was skipped because they only had a single name and no disambiguation") ) type MultipleMatchesFoundError struct { Source ScraperSource } func (e *MultipleMatchesFoundError) Error() string { return fmt.Sprintf("multiple matches found for %s", e.Source.Name) } type SceneScraper interface { ScrapeScenes(ctx context.Context, sceneID int) ([]*models.ScrapedScene, error) } type SceneUpdatePostHookExecutor interface { ExecuteSceneUpdatePostHooks(ctx context.Context, input models.SceneUpdateInput, inputFields []string) } type ScraperSource struct { Name string Options *MetadataOptions Scraper SceneScraper RemoteSite string } type SceneIdentifier struct { TxnManager txn.Manager SceneReaderUpdater SceneReaderUpdater StudioReaderWriter models.StudioReaderWriter PerformerCreator PerformerCreator TagFinderCreator models.TagFinderCreator DefaultOptions *MetadataOptions Sources []ScraperSource SceneUpdatePostHookExecutor SceneUpdatePostHookExecutor } func (t *SceneIdentifier) Identify(ctx context.Context, scene *models.Scene) error { result, err := t.scrapeScene(ctx, scene) var multipleMatchErr *MultipleMatchesFoundError if err != nil { if !errors.As(err, &multipleMatchErr) { return err } } if result == nil { if multipleMatchErr != nil { logger.Debugf("Identify skipped because multiple results returned for %s", scene.Path) // find if the scene should be tagged for multiple results options := t.getOptions(multipleMatchErr.Source) if options.SkipMultipleMatchTag != nil && len(*options.SkipMultipleMatchTag) > 0 { // Tag it with the multiple results tag err := t.addTagToScene(ctx, scene, *options.SkipMultipleMatchTag) if err != nil { return err } return nil } } else { logger.Debugf("Unable to identify %s", scene.Path) } return nil } // results were found, modify the scene if err := t.modifyScene(ctx, scene, result); err != nil { return fmt.Errorf("error modifying scene: %v", err) } return nil } type scrapeResult struct { result *models.ScrapedScene source ScraperSource } func (t *SceneIdentifier) scrapeScene(ctx context.Context, scene *models.Scene) (*scrapeResult, error) { // iterate through the input sources for _, source := range t.Sources { // scrape using the source results, err := source.Scraper.ScrapeScenes(ctx, scene.ID) if err != nil { logger.Errorf("error scraping from %v: %v", source.Scraper, err) continue } if len(results) > 0 { options := t.getOptions(source) if len(results) > 1 && utils.IsTrue(options.SkipMultipleMatches) { return nil, &MultipleMatchesFoundError{ Source: source, } } else { // if results were found then return return &scrapeResult{ result: results[0], source: source, }, nil } } } return nil, nil } // Returns a MetadataOptions object with any default options overwritten by source specific options func (t *SceneIdentifier) getOptions(source ScraperSource) MetadataOptions { var options MetadataOptions if t.DefaultOptions != nil { options = *t.DefaultOptions } if source.Options == nil { return options } if source.Options.SetCoverImage != nil { options.SetCoverImage = source.Options.SetCoverImage } if source.Options.SetOrganized != nil { options.SetOrganized = source.Options.SetOrganized } if source.Options.IncludeMalePerformers != nil { options.IncludeMalePerformers = source.Options.IncludeMalePerformers } if source.Options.PerformerGenders != nil { options.PerformerGenders = source.Options.PerformerGenders } if source.Options.SkipMultipleMatches != nil { options.SkipMultipleMatches = source.Options.SkipMultipleMatches } if source.Options.SkipMultipleMatchTag != nil && len(*source.Options.SkipMultipleMatchTag) > 0 { options.SkipMultipleMatchTag = source.Options.SkipMultipleMatchTag } if source.Options.SkipSingleNamePerformers != nil { options.SkipSingleNamePerformers = source.Options.SkipSingleNamePerformers } if source.Options.SkipSingleNamePerformerTag != nil && len(*source.Options.SkipSingleNamePerformerTag) > 0 { options.SkipSingleNamePerformerTag = source.Options.SkipSingleNamePerformerTag } return options } func (t *SceneIdentifier) getSceneUpdater(ctx context.Context, s *models.Scene, result *scrapeResult) (*scene.UpdateSet, error) { ret := &scene.UpdateSet{ ID: s.ID, } allOptions := []MetadataOptions{} if result.source.Options != nil { allOptions = append(allOptions, *result.source.Options) } if t.DefaultOptions != nil { allOptions = append(allOptions, *t.DefaultOptions) } fieldOptions := getFieldOptions(allOptions) options := t.getOptions(result.source) scraped := result.result rel := sceneRelationships{ sceneReader: t.SceneReaderUpdater, studioReaderWriter: t.StudioReaderWriter, performerCreator: t.PerformerCreator, tagCreator: t.TagFinderCreator, scene: s, result: result, fieldOptions: fieldOptions, skipSingleNamePerformers: utils.IsTrue(options.SkipSingleNamePerformers), } setOrganized := utils.IsTrue(options.SetOrganized) ret.Partial = getScenePartial(s, scraped, fieldOptions, setOrganized) studioID, err := rel.studio(ctx) if err != nil { return nil, fmt.Errorf("error getting studio: %w", err) } if studioID != nil { ret.Partial.StudioID = models.NewOptionalInt(*studioID) } // Determine allowed genders for performer filtering var allowedGenders []models.GenderEnum if options.PerformerGenders != nil { // New field takes precedence allowedGenders = options.PerformerGenders } else if options.IncludeMalePerformers != nil && !*options.IncludeMalePerformers { // Legacy: if includeMalePerformers is false, include all genders except male for _, g := range models.AllGenderEnum { if g != models.GenderEnumMale { allowedGenders = append(allowedGenders, g) } } } // nil allowedGenders means include all performers addSkipSingleNamePerformerTag := false performerIDs, err := rel.performers(ctx, allowedGenders) if err != nil { if errors.Is(err, ErrSkipSingleNamePerformer) { addSkipSingleNamePerformerTag = true } else { return nil, err } } if performerIDs != nil { ret.Partial.PerformerIDs = &models.UpdateIDs{ IDs: performerIDs, Mode: models.RelationshipUpdateModeSet, } } tagIDs, err := rel.tags(ctx) if err != nil { return nil, err } if addSkipSingleNamePerformerTag && options.SkipSingleNamePerformerTag != nil { tagID, err := strconv.ParseInt(*options.SkipSingleNamePerformerTag, 10, 64) if err != nil { return nil, fmt.Errorf("error converting tag ID %s: %w", *options.SkipSingleNamePerformerTag, err) } tagIDs = sliceutil.AppendUnique(tagIDs, int(tagID)) } if tagIDs != nil { ret.Partial.TagIDs = &models.UpdateIDs{ IDs: tagIDs, Mode: models.RelationshipUpdateModeSet, } } // SetCoverImage defaults to true if unset if options.SetCoverImage == nil || *options.SetCoverImage { ret.CoverImage, err = rel.cover(ctx) if err != nil { return nil, err } } // if anything changed, also update the updated at time on the applicable stash id changed := !ret.IsEmpty() stashIDs, err := rel.stashIDs(ctx, changed) if err != nil { return nil, err } if stashIDs != nil { ret.Partial.StashIDs = &models.UpdateStashIDs{ StashIDs: stashIDs, Mode: models.RelationshipUpdateModeSet, } } return ret, nil } func (t *SceneIdentifier) modifyScene(ctx context.Context, s *models.Scene, result *scrapeResult) error { var updater *scene.UpdateSet if err := txn.WithTxn(ctx, t.TxnManager, func(ctx context.Context) error { // load scene relationships if err := s.LoadURLs(ctx, t.SceneReaderUpdater); err != nil { return err } if err := s.LoadPerformerIDs(ctx, t.SceneReaderUpdater); err != nil { return err } if err := s.LoadTagIDs(ctx, t.SceneReaderUpdater); err != nil { return err } if err := s.LoadStashIDs(ctx, t.SceneReaderUpdater); err != nil { return err } var err error updater, err = t.getSceneUpdater(ctx, s, result) if err != nil { return err } // don't update anything if nothing was set if updater.IsEmpty() { logger.Debugf("Nothing to set for %s", s.Path) return nil } if _, err := updater.Update(ctx, t.SceneReaderUpdater); err != nil { return fmt.Errorf("error updating scene: %w", err) } as := "" title := updater.Partial.Title if title.Ptr() != nil { as = fmt.Sprintf(" as %s", title.Value) } logger.Infof("Successfully identified %s%s using %s", s.Path, as, result.source.Name) return nil }); err != nil { return err } // fire post-update hooks if !updater.IsEmpty() { updateInput := updater.UpdateInput() fields := utils.NotNilFields(updateInput, "json") t.SceneUpdatePostHookExecutor.ExecuteSceneUpdatePostHooks(ctx, updateInput, fields) } return nil } func (t *SceneIdentifier) addTagToScene(ctx context.Context, s *models.Scene, tagToAdd string) error { if err := txn.WithTxn(ctx, t.TxnManager, func(ctx context.Context) error { tagID, err := strconv.Atoi(tagToAdd) if err != nil { return fmt.Errorf("error converting tag ID %s: %w", tagToAdd, err) } if err := s.LoadTagIDs(ctx, t.SceneReaderUpdater); err != nil { return err } existing := s.TagIDs.List() if slices.Contains(existing, tagID) { // skip if the scene was already tagged return nil } if err := scene.AddTag(ctx, t.SceneReaderUpdater, s, tagID); err != nil { return err } ret, err := t.TagFinderCreator.Find(ctx, tagID) if err != nil { logger.Infof("Added tag id %s to skipped scene %s", tagToAdd, s.Path) } else { logger.Infof("Added tag %s to skipped scene %s", ret.Name, s.Path) } return nil }); err != nil { return err } return nil } func getFieldOptions(options []MetadataOptions) map[string]*FieldOptions { // prefer source-specific field strategies, then the defaults ret := make(map[string]*FieldOptions) for _, oo := range options { for _, f := range oo.FieldOptions { if _, found := ret[f.Field]; !found { ret[f.Field] = f } } } return ret } func getScenePartial(scene *models.Scene, scraped *models.ScrapedScene, fieldOptions map[string]*FieldOptions, setOrganized bool) models.ScenePartial { partial := models.ScenePartial{} if scraped.Title != nil && (scene.Title != *scraped.Title) { if shouldSetSingleValueField(fieldOptions["title"], scene.Title != "") { partial.Title = models.NewOptionalString(*scraped.Title) } } if scraped.Date != nil && (scene.Date == nil || scene.Date.String() != *scraped.Date) { if shouldSetSingleValueField(fieldOptions["date"], scene.Date != nil) { d, err := models.ParseDate(*scraped.Date) if err == nil { partial.Date = models.NewOptionalDate(d) } } } if scraped.Details != nil && (scene.Details != *scraped.Details) { if shouldSetSingleValueField(fieldOptions["details"], scene.Details != "") { partial.Details = models.NewOptionalString(*scraped.Details) } } if len(scraped.URLs) > 0 && shouldSetSingleValueField(fieldOptions["url"], false) { // if overwrite, then set over the top switch getFieldStrategy(fieldOptions["url"]) { case FieldStrategyOverwrite: // only overwrite if not equal if len(sliceutil.Exclude(scraped.URLs, scene.URLs.List())) != 0 { partial.URLs = &models.UpdateStrings{ Values: scraped.URLs, Mode: models.RelationshipUpdateModeSet, } } case FieldStrategyMerge: // if merge, add if not already present urls := sliceutil.AppendUniques(scene.URLs.List(), scraped.URLs) if len(urls) != len(scene.URLs.List()) { partial.URLs = &models.UpdateStrings{ Values: urls, Mode: models.RelationshipUpdateModeSet, } } } } if scraped.Director != nil && (scene.Director != *scraped.Director) { if shouldSetSingleValueField(fieldOptions["director"], scene.Director != "") { partial.Director = models.NewOptionalString(*scraped.Director) } } if scraped.Code != nil && (scene.Code != *scraped.Code) { if shouldSetSingleValueField(fieldOptions["code"], scene.Code != "") { partial.Code = models.NewOptionalString(*scraped.Code) } } if setOrganized && !scene.Organized { partial.Organized = models.NewOptionalBool(true) } return partial } func getFieldStrategy(strategy *FieldOptions) FieldStrategy { // if unset then default to MERGE fs := FieldStrategyMerge if strategy != nil && strategy.Strategy.IsValid() { fs = strategy.Strategy } return fs } func shouldSetSingleValueField(strategy *FieldOptions, hasExistingValue bool) bool { // if unset then default to MERGE fs := getFieldStrategy(strategy) if fs == FieldStrategyIgnore { return false } return !hasExistingValue || fs == FieldStrategyOverwrite } ================================================ FILE: internal/identify/identify_test.go ================================================ package identify import ( "context" "errors" "reflect" "slices" "strconv" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) var testCtx = context.Background() type mockSceneScraper struct { errIDs []int results map[int][]*models.ScrapedScene } func (s mockSceneScraper) ScrapeScenes(ctx context.Context, sceneID int) ([]*models.ScrapedScene, error) { if slices.Contains(s.errIDs, sceneID) { return nil, errors.New("scrape scene error") } return s.results[sceneID], nil } type mockHookExecutor struct { } func (s mockHookExecutor) ExecuteSceneUpdatePostHooks(ctx context.Context, input models.SceneUpdateInput, inputFields []string) { } func TestSceneIdentifier_Identify(t *testing.T) { const ( errID1 = iota errID2 missingID found1ID found2ID multiFoundID multiFound2ID errUpdateID ) var ( skipMultipleTagID = 1 skipMultipleTagIDStr = strconv.Itoa(skipMultipleTagID) ) var ( scrapedTitle = "scrapedTitle" scrapedTitle2 = "scrapedTitle2" boolFalse = false boolTrue = true ) defaultOptions := &MetadataOptions{ SetOrganized: &boolFalse, SetCoverImage: &boolFalse, PerformerGenders: []models.GenderEnum{ models.GenderEnumFemale, models.GenderEnumTransgenderFemale, models.GenderEnumTransgenderMale, models.GenderEnumIntersex, models.GenderEnumNonBinary, }, SkipSingleNamePerformers: &boolFalse, } sources := []ScraperSource{ { Scraper: mockSceneScraper{ errIDs: []int{errID1}, results: map[int][]*models.ScrapedScene{ found1ID: {{ Title: &scrapedTitle, }}, }, }, }, { Scraper: mockSceneScraper{ errIDs: []int{errID2}, results: map[int][]*models.ScrapedScene{ found2ID: {{ Title: &scrapedTitle, }}, errUpdateID: {{ Title: &scrapedTitle, }}, multiFoundID: { { Title: &scrapedTitle, }, { Title: &scrapedTitle2, }, }, multiFound2ID: { { Title: &scrapedTitle, }, { Title: &scrapedTitle2, }, }, }, }, }, } db := mocks.NewDatabase() db.Scene.On("GetURLs", mock.Anything, mock.Anything).Return(nil, nil) db.Scene.On("UpdatePartial", mock.Anything, mock.MatchedBy(func(id int) bool { return id == errUpdateID }), mock.Anything).Return(nil, errors.New("update error")) db.Scene.On("UpdatePartial", mock.Anything, mock.MatchedBy(func(id int) bool { return id != errUpdateID }), mock.Anything).Return(nil, nil) db.Tag.On("Find", mock.Anything, skipMultipleTagID).Return(&models.Tag{ ID: skipMultipleTagID, Name: skipMultipleTagIDStr, }, nil) tests := []struct { name string sceneID int options *MetadataOptions wantErr bool }{ { "error scraping", errID1, nil, false, }, { "error scraping from second", errID2, nil, false, }, { "found in first scraper", found1ID, nil, false, }, { "found in second scraper", found2ID, nil, false, }, { "not found", missingID, nil, false, }, { "error modifying", errUpdateID, nil, true, }, { "multiple found", multiFoundID, nil, false, }, { "multiple found - set tag", multiFound2ID, &MetadataOptions{ SkipMultipleMatches: &boolTrue, SkipMultipleMatchTag: &skipMultipleTagIDStr, }, false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { identifier := SceneIdentifier{ TxnManager: db, SceneReaderUpdater: db.Scene, StudioReaderWriter: db.Studio, PerformerCreator: db.Performer, TagFinderCreator: db.Tag, DefaultOptions: defaultOptions, Sources: sources, SceneUpdatePostHookExecutor: mockHookExecutor{}, } if tt.options != nil { identifier.DefaultOptions = tt.options } scene := &models.Scene{ ID: tt.sceneID, PerformerIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), } if err := identifier.Identify(testCtx, scene); (err != nil) != tt.wantErr { t.Errorf("SceneIdentifier.Identify() error = %v, wantErr %v", err, tt.wantErr) } }) } } func TestSceneIdentifier_modifyScene(t *testing.T) { db := mocks.NewDatabase() boolFalse := false defaultOptions := &MetadataOptions{ SetOrganized: &boolFalse, SetCoverImage: &boolFalse, PerformerGenders: []models.GenderEnum{ models.GenderEnumFemale, models.GenderEnumTransgenderFemale, models.GenderEnumTransgenderMale, models.GenderEnumIntersex, models.GenderEnumNonBinary, }, SkipSingleNamePerformers: &boolFalse, } tr := &SceneIdentifier{ TxnManager: db, SceneReaderUpdater: db.Scene, StudioReaderWriter: db.Studio, PerformerCreator: db.Performer, TagFinderCreator: db.Tag, DefaultOptions: defaultOptions, } type args struct { scene *models.Scene result *scrapeResult } tests := []struct { name string args args wantErr bool }{ { "empty update", args{ &models.Scene{ URLs: models.NewRelatedStrings([]string{}), PerformerIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), }, &scrapeResult{ result: &models.ScrapedScene{}, source: ScraperSource{ Options: defaultOptions, }, }, }, false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := tr.modifyScene(testCtx, tt.args.scene, tt.args.result); (err != nil) != tt.wantErr { t.Errorf("SceneIdentifier.modifyScene() error = %v, wantErr %v", err, tt.wantErr) } }) } } func Test_getFieldOptions(t *testing.T) { const ( inFirst = "inFirst" inSecond = "inSecond" inBoth = "inBoth" ) type args struct { options []MetadataOptions } tests := []struct { name string args args want map[string]*FieldOptions }{ { "simple", args{ []MetadataOptions{ { FieldOptions: []*FieldOptions{ { Field: inFirst, Strategy: FieldStrategyIgnore, }, { Field: inBoth, Strategy: FieldStrategyIgnore, }, }, }, { FieldOptions: []*FieldOptions{ { Field: inSecond, Strategy: FieldStrategyMerge, }, { Field: inBoth, Strategy: FieldStrategyMerge, }, }, }, }, }, map[string]*FieldOptions{ inFirst: { Field: inFirst, Strategy: FieldStrategyIgnore, }, inSecond: { Field: inSecond, Strategy: FieldStrategyMerge, }, inBoth: { Field: inBoth, Strategy: FieldStrategyIgnore, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := getFieldOptions(tt.args.options); !reflect.DeepEqual(got, tt.want) { t.Errorf("getFieldOptions() = %v, want %v", got, tt.want) } }) } } func Test_getScenePartial(t *testing.T) { var ( originalTitle = "originalTitle" originalDate = "2001-01-01" originalDetails = "originalDetails" originalURL = "originalURL" ) var ( scrapedTitle = "scrapedTitle" scrapedDate = "2002-02-02" scrapedDetails = "scrapedDetails" scrapedURL = "scrapedURL" ) originalDateObj, _ := models.ParseDate(originalDate) scrapedDateObj, _ := models.ParseDate(scrapedDate) originalScene := &models.Scene{ Title: originalTitle, Date: &originalDateObj, Details: originalDetails, URLs: models.NewRelatedStrings([]string{originalURL}), } organisedScene := *originalScene organisedScene.Organized = true emptyScene := &models.Scene{ URLs: models.NewRelatedStrings([]string{}), } postPartial := models.ScenePartial{ Title: models.NewOptionalString(scrapedTitle), Date: models.NewOptionalDate(scrapedDateObj), Details: models.NewOptionalString(scrapedDetails), URLs: &models.UpdateStrings{ Values: []string{scrapedURL}, Mode: models.RelationshipUpdateModeSet, }, } postPartialMerge := postPartial postPartialMerge.URLs = &models.UpdateStrings{ Values: []string{scrapedURL}, Mode: models.RelationshipUpdateModeSet, } scrapedScene := &models.ScrapedScene{ Title: &scrapedTitle, Date: &scrapedDate, Details: &scrapedDetails, URLs: []string{scrapedURL}, } scrapedUnchangedScene := &models.ScrapedScene{ Title: &originalTitle, Date: &originalDate, Details: &originalDetails, URLs: []string{originalURL}, } makeFieldOptions := func(input *FieldOptions) map[string]*FieldOptions { return map[string]*FieldOptions{ "title": input, "date": input, "details": input, "url": input, } } overwriteAll := makeFieldOptions(&FieldOptions{ Strategy: FieldStrategyOverwrite, }) ignoreAll := makeFieldOptions(&FieldOptions{ Strategy: FieldStrategyIgnore, }) mergeAll := makeFieldOptions(&FieldOptions{ Strategy: FieldStrategyMerge, }) setOrganised := true type args struct { scene *models.Scene scraped *models.ScrapedScene fieldOptions map[string]*FieldOptions setOrganized bool } tests := []struct { name string args args want models.ScenePartial }{ { "overwrite all", args{ originalScene, scrapedScene, overwriteAll, false, }, postPartial, }, { "ignore all", args{ originalScene, scrapedScene, ignoreAll, false, }, models.ScenePartial{}, }, { "merge (existing values)", args{ originalScene, scrapedScene, mergeAll, false, }, models.ScenePartial{ URLs: &models.UpdateStrings{ Values: []string{originalURL, scrapedURL}, Mode: models.RelationshipUpdateModeSet, }, }, }, { "merge (empty values)", args{ emptyScene, scrapedScene, mergeAll, false, }, postPartialMerge, }, { "unchanged", args{ originalScene, scrapedUnchangedScene, overwriteAll, false, }, models.ScenePartial{}, }, { "set organized", args{ originalScene, scrapedUnchangedScene, overwriteAll, true, }, models.ScenePartial{ Organized: models.NewOptionalBool(setOrganised), }, }, { "set organized unchanged", args{ &organisedScene, scrapedUnchangedScene, overwriteAll, true, }, models.ScenePartial{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := getScenePartial(tt.args.scene, tt.args.scraped, tt.args.fieldOptions, tt.args.setOrganized) assert.Equal(t, tt.want, got) }) } } func Test_shouldSetSingleValueField(t *testing.T) { const invalid = "invalid" type args struct { strategy *FieldOptions hasExistingValue bool } tests := []struct { name string args args want bool }{ { "ignore", args{ &FieldOptions{ Strategy: FieldStrategyIgnore, }, false, }, false, }, { "merge existing", args{ &FieldOptions{ Strategy: FieldStrategyMerge, }, true, }, false, }, { "merge absent", args{ &FieldOptions{ Strategy: FieldStrategyMerge, }, false, }, true, }, { "overwrite", args{ &FieldOptions{ Strategy: FieldStrategyOverwrite, }, true, }, true, }, { "nil (merge) existing", args{ &FieldOptions{}, true, }, false, }, { "nil (merge) absent", args{ &FieldOptions{}, false, }, true, }, { "invalid (merge) existing", args{ &FieldOptions{ Strategy: invalid, }, true, }, false, }, { "invalid (merge) absent", args{ &FieldOptions{ Strategy: invalid, }, false, }, true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := shouldSetSingleValueField(tt.args.strategy, tt.args.hasExistingValue); got != tt.want { t.Errorf("shouldSetSingleValueField() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: internal/identify/options.go ================================================ package identify import ( "fmt" "io" "strconv" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scraper" ) type Source struct { Source *scraper.Source `json:"source"` // Options defined for a source override the defaults Options *MetadataOptions `json:"options"` } type Options struct { // An ordered list of sources to identify items with. Only the first source that finds a match is used. Sources []*Source `json:"sources"` // Options defined here override the configured defaults Options *MetadataOptions `json:"options"` // scene ids to identify SceneIDs []string `json:"sceneIDs"` // paths of scenes to identify - ignored if scene ids are set Paths []string `json:"paths"` } type MetadataOptions struct { // any fields missing from here are defaulted to MERGE and createMissing false FieldOptions []*FieldOptions `json:"fieldOptions"` // defaults to true if not provided SetCoverImage *bool `json:"setCoverImage"` SetOrganized *bool `json:"setOrganized"` // defaults to true if not provided // Deprecated: use PerformerGenders instead IncludeMalePerformers *bool `json:"includeMalePerformers"` // Filter to only include performers with these genders. If not provided, all genders are included. PerformerGenders []models.GenderEnum `json:"performerGenders"` // defaults to true if not provided SkipMultipleMatches *bool `json:"skipMultipleMatches"` // ID of tag to tag skipped multiple matches with SkipMultipleMatchTag *string `json:"skipMultipleMatchTag"` // defaults to true if not provided SkipSingleNamePerformers *bool `json:"skipSingleNamePerformers"` // ID of tag to tag skipped single name performers with SkipSingleNamePerformerTag *string `json:"skipSingleNamePerformerTag"` } type FieldOptions struct { Field string `json:"field"` Strategy FieldStrategy `json:"strategy"` // creates missing objects if needed - only applicable for performers, tags and studios CreateMissing *bool `json:"createMissing"` } type FieldStrategy string const ( // Never sets the field value FieldStrategyIgnore FieldStrategy = "IGNORE" // For multi-value fields, merge with existing. // For single-value fields, ignore if already set FieldStrategyMerge FieldStrategy = "MERGE" // Always replaces the value if a value is found. // For multi-value fields, any existing values are removed and replaced with the // scraped values. FieldStrategyOverwrite FieldStrategy = "OVERWRITE" ) var AllFieldStrategy = []FieldStrategy{ FieldStrategyIgnore, FieldStrategyMerge, FieldStrategyOverwrite, } func (e FieldStrategy) IsValid() bool { switch e { case FieldStrategyIgnore, FieldStrategyMerge, FieldStrategyOverwrite: return true } return false } func (e FieldStrategy) String() string { return string(e) } func (e *FieldStrategy) UnmarshalGQL(v interface{}) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = FieldStrategy(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid IdentifyFieldStrategy", str) } return nil } func (e FieldStrategy) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } ================================================ FILE: internal/identify/performer.go ================================================ package identify import ( "context" "fmt" "strconv" "strings" "github.com/stashapp/stash/pkg/models" ) type PerformerCreator interface { models.PerformerCreator UpdateImage(ctx context.Context, performerID int, image []byte) error } func getPerformerID(ctx context.Context, endpoint string, w PerformerCreator, p *models.ScrapedPerformer, createMissing bool, skipSingleNamePerformers bool) (*int, error) { if p.StoredID != nil { // existing performer, just add it performerID, err := strconv.Atoi(*p.StoredID) if err != nil { return nil, fmt.Errorf("error converting performer ID %s: %w", *p.StoredID, err) } return &performerID, nil } else if createMissing && p.Name != nil { // name is mandatory // skip single name performers with no disambiguation if skipSingleNamePerformers && !strings.Contains(*p.Name, " ") && (p.Disambiguation == nil || len(*p.Disambiguation) == 0) { return nil, ErrSkipSingleNamePerformer } return createMissingPerformer(ctx, endpoint, w, p) } return nil, nil } func createMissingPerformer(ctx context.Context, endpoint string, w PerformerCreator, p *models.ScrapedPerformer) (*int, error) { newPerformer := p.ToPerformer(endpoint, nil) performerImage, err := p.GetImage(ctx, nil) if err != nil { return nil, err } err = w.Create(ctx, &models.CreatePerformerInput{Performer: newPerformer}) if err != nil { return nil, fmt.Errorf("error creating performer: %w", err) } // update image table if len(performerImage) > 0 { if err := w.UpdateImage(ctx, newPerformer.ID, performerImage); err != nil { return nil, err } } return &newPerformer.ID, nil } ================================================ FILE: internal/identify/performer_test.go ================================================ package identify import ( "errors" "reflect" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/mock" ) func Test_getPerformerID(t *testing.T) { const ( emptyEndpoint = "" endpoint = "endpoint" ) invalidStoredID := "invalidStoredID" validStoredIDStr := "1" validStoredID := 1 remoteSiteID := "2" name := "name" db := mocks.NewDatabase() db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.CreatePerformerInput")).Run(func(args mock.Arguments) { p := args.Get(1).(*models.CreatePerformerInput) p.ID = validStoredID }).Return(nil) type args struct { endpoint string p *models.ScrapedPerformer createMissing bool skipSingleName bool } tests := []struct { name string args args want *int wantErr bool }{ { "no performer", args{ emptyEndpoint, &models.ScrapedPerformer{}, false, false, }, nil, false, }, { "invalid stored id", args{ emptyEndpoint, &models.ScrapedPerformer{ StoredID: &invalidStoredID, }, false, false, }, nil, true, }, { "valid stored id", args{ emptyEndpoint, &models.ScrapedPerformer{ StoredID: &validStoredIDStr, }, false, false, }, &validStoredID, false, }, { "nil stored not creating", args{ emptyEndpoint, &models.ScrapedPerformer{ Name: &name, }, false, false, }, nil, false, }, { "nil name creating", args{ emptyEndpoint, &models.ScrapedPerformer{}, true, false, }, nil, false, }, { "single name no disambig creating", args{ emptyEndpoint, &models.ScrapedPerformer{ Name: &name, }, true, true, }, nil, true, }, { "valid name creating", args{ emptyEndpoint, &models.ScrapedPerformer{ Name: &name, RemoteSiteID: &remoteSiteID, }, true, false, }, &validStoredID, false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := getPerformerID(testCtx, tt.args.endpoint, db.Performer, tt.args.p, tt.args.createMissing, tt.args.skipSingleName) if (err != nil) != tt.wantErr { t.Errorf("getPerformerID() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("getPerformerID() = %v, want %v", got, tt.want) } }) } } func Test_createMissingPerformer(t *testing.T) { emptyEndpoint := "" validEndpoint := "validEndpoint" remoteSiteID := "remoteSiteID" validName := "validName" invalidName := "invalidName" performerID := 1 db := mocks.NewDatabase() db.Performer.On("Create", testCtx, mock.MatchedBy(func(p *models.CreatePerformerInput) bool { return p.Name == validName })).Run(func(args mock.Arguments) { p := args.Get(1).(*models.CreatePerformerInput) p.ID = performerID }).Return(nil) db.Performer.On("Create", testCtx, mock.MatchedBy(func(p *models.CreatePerformerInput) bool { return p.Name == invalidName })).Return(errors.New("error creating performer")) type args struct { endpoint string p *models.ScrapedPerformer } tests := []struct { name string args args want *int wantErr bool }{ { "simple", args{ emptyEndpoint, &models.ScrapedPerformer{ Name: &validName, RemoteSiteID: &remoteSiteID, }, }, &performerID, false, }, { "error creating", args{ emptyEndpoint, &models.ScrapedPerformer{ Name: &invalidName, RemoteSiteID: &remoteSiteID, }, }, nil, true, }, { "valid stash id", args{ validEndpoint, &models.ScrapedPerformer{ Name: &validName, RemoteSiteID: &remoteSiteID, }, }, &performerID, false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := createMissingPerformer(testCtx, tt.args.endpoint, db.Performer, tt.args.p) if (err != nil) != tt.wantErr { t.Errorf("createMissingPerformer() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("createMissingPerformer() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: internal/identify/scene.go ================================================ package identify import ( "bytes" "context" "errors" "fmt" "slices" "strconv" "strings" "time" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/utils" ) type SceneCoverGetter interface { GetCover(ctx context.Context, sceneID int) ([]byte, error) } type SceneReaderUpdater interface { SceneCoverGetter models.SceneUpdater models.PerformerIDLoader models.TagIDLoader models.StashIDLoader models.URLLoader } type sceneRelationships struct { sceneReader SceneCoverGetter studioReaderWriter models.StudioReaderWriter performerCreator PerformerCreator tagCreator models.TagCreator scene *models.Scene result *scrapeResult fieldOptions map[string]*FieldOptions skipSingleNamePerformers bool } func (g sceneRelationships) studio(ctx context.Context) (*int, error) { existingID := g.scene.StudioID fieldStrategy := g.fieldOptions["studio"] createMissing := fieldStrategy != nil && utils.IsTrue(fieldStrategy.CreateMissing) scraped := g.result.result.Studio endpoint := g.result.source.RemoteSite if scraped == nil || !shouldSetSingleValueField(fieldStrategy, existingID != nil) { return nil, nil } if scraped.StoredID != nil { // existing studio, just set it studioID, err := strconv.Atoi(*scraped.StoredID) if err != nil { return nil, fmt.Errorf("error converting studio ID %s: %w", *scraped.StoredID, err) } // only return value if different to current if existingID == nil || *existingID != studioID { return &studioID, nil } } else if createMissing { return createMissingStudio(ctx, endpoint, g.studioReaderWriter, scraped) } return nil, nil } func (g sceneRelationships) performers(ctx context.Context, allowedGenders []models.GenderEnum) ([]int, error) { fieldStrategy := g.fieldOptions["performers"] scraped := g.result.result.Performers // just check if ignored if len(scraped) == 0 || !shouldSetSingleValueField(fieldStrategy, false) { return nil, nil } createMissing := fieldStrategy != nil && utils.IsTrue(fieldStrategy.CreateMissing) strategy := FieldStrategyMerge if fieldStrategy != nil { strategy = fieldStrategy.Strategy } endpoint := g.result.source.RemoteSite var performerIDs []int originalPerformerIDs := g.scene.PerformerIDs.List() if strategy == FieldStrategyMerge { // add to existing performerIDs = originalPerformerIDs } singleNamePerformerSkipped := false for _, p := range scraped { if allowedGenders != nil && p.Gender != nil { gender := models.GenderEnum(strings.ToUpper(*p.Gender)) if !slices.Contains(allowedGenders, gender) { continue } } performerID, err := getPerformerID(ctx, endpoint, g.performerCreator, p, createMissing, g.skipSingleNamePerformers) if err != nil { if errors.Is(err, ErrSkipSingleNamePerformer) { singleNamePerformerSkipped = true continue } return nil, err } if performerID != nil { performerIDs = sliceutil.AppendUnique(performerIDs, *performerID) } } // don't return if nothing was added if sliceutil.SliceSame(originalPerformerIDs, performerIDs) { if singleNamePerformerSkipped { return nil, ErrSkipSingleNamePerformer } return nil, nil } if singleNamePerformerSkipped { return performerIDs, ErrSkipSingleNamePerformer } return performerIDs, nil } func (g sceneRelationships) tags(ctx context.Context) ([]int, error) { fieldStrategy := g.fieldOptions["tags"] scraped := g.result.result.Tags target := g.scene // just check if ignored if len(scraped) == 0 || !shouldSetSingleValueField(fieldStrategy, false) { return nil, nil } createMissing := fieldStrategy != nil && utils.IsTrue(fieldStrategy.CreateMissing) strategy := FieldStrategyMerge if fieldStrategy != nil { strategy = fieldStrategy.Strategy } var tagIDs []int originalTagIDs := target.TagIDs.List() if strategy == FieldStrategyMerge { // add to existing tagIDs = originalTagIDs } endpoint := g.result.source.RemoteSite for _, t := range scraped { if t.StoredID != nil { // existing tag, just add it tagID, err := strconv.ParseInt(*t.StoredID, 10, 64) if err != nil { return nil, fmt.Errorf("error converting tag ID %s: %w", *t.StoredID, err) } tagIDs = sliceutil.AppendUnique(tagIDs, int(tagID)) } else if createMissing { newTag := t.ToTag(endpoint, nil) err := g.tagCreator.Create(ctx, &models.CreateTagInput{ Tag: newTag, }) if err != nil { return nil, fmt.Errorf("error creating tag: %w", err) } tagIDs = append(tagIDs, newTag.ID) } } // don't return if nothing was added if sliceutil.SliceSame(originalTagIDs, tagIDs) { return nil, nil } return tagIDs, nil } // stashIDs returns the updated stash IDs for the scene // returns nil if not applicable or no changes were made // if setUpdateTime is true, then the updated_at field will be set to the current time // for the applicable matching stash ID func (g sceneRelationships) stashIDs(ctx context.Context, setUpdateTime bool) ([]models.StashID, error) { updateTime := time.Now() remoteSiteID := g.result.result.RemoteSiteID fieldStrategy := g.fieldOptions["stash_ids"] target := g.scene endpoint := g.result.source.RemoteSite // just check if ignored if remoteSiteID == nil || endpoint == "" || !shouldSetSingleValueField(fieldStrategy, false) { return nil, nil } strategy := FieldStrategyMerge if fieldStrategy != nil { strategy = fieldStrategy.Strategy } var stashIDs models.StashIDs originalStashIDs := target.StashIDs.List() if strategy == FieldStrategyMerge { // add to existing // make a copy so we don't modify the original stashIDs = append(stashIDs, originalStashIDs...) } // find and update the stash id if it exists for i, stashID := range stashIDs { if endpoint == stashID.Endpoint { // if stashID is the same, then don't set if !setUpdateTime && stashID.StashID == *remoteSiteID { return nil, nil } // replace the stash id and return stashID.StashID = *remoteSiteID stashID.UpdatedAt = updateTime stashIDs[i] = stashID return stashIDs, nil } } // not found, create new entry stashIDs = append(stashIDs, models.StashID{ StashID: *remoteSiteID, Endpoint: endpoint, UpdatedAt: updateTime, }) // don't return if nothing was changed // if we're setting update time, then we always return if !setUpdateTime && stashIDs.HasSameStashIDs(originalStashIDs) { return nil, nil } return stashIDs, nil } func (g sceneRelationships) cover(ctx context.Context) ([]byte, error) { scraped := g.result.result.Image if scraped == nil || *scraped == "" { return nil, nil } // always overwrite if present existingCover, err := g.sceneReader.GetCover(ctx, g.scene.ID) if err != nil { logger.Errorf("Error getting scene cover: %v", err) } data, err := utils.ProcessImageInput(ctx, *scraped) if err != nil { return nil, fmt.Errorf("error processing image input: %w", err) } // only return if different if !bytes.Equal(existingCover, data) { return data, nil } return nil, nil } ================================================ FILE: internal/identify/scene_test.go ================================================ package identify import ( "errors" "reflect" "strconv" "testing" "time" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stashapp/stash/pkg/utils" "github.com/stretchr/testify/mock" ) func Test_sceneRelationships_studio(t *testing.T) { validStoredID := "1" remoteSiteID := "2" var validStoredIDInt = 1 invalidStoredID := "invalidStoredID" createMissing := true defaultOptions := &FieldOptions{ Strategy: FieldStrategyMerge, } db := mocks.NewDatabase() db.Studio.On("Create", testCtx, mock.Anything).Run(func(args mock.Arguments) { s := args.Get(1).(*models.CreateStudioInput) s.ID = validStoredIDInt }).Return(nil) tr := sceneRelationships{ studioReaderWriter: db.Studio, fieldOptions: make(map[string]*FieldOptions), } tests := []struct { name string scene *models.Scene fieldOptions *FieldOptions result *models.ScrapedStudio want *int wantErr bool }{ { "nil studio", &models.Scene{}, defaultOptions, nil, nil, false, }, { "ignore", &models.Scene{}, &FieldOptions{ Strategy: FieldStrategyIgnore, }, &models.ScrapedStudio{ StoredID: &validStoredID, }, nil, false, }, { "invalid stored id", &models.Scene{}, defaultOptions, &models.ScrapedStudio{ StoredID: &invalidStoredID, }, nil, true, }, { "same stored id", &models.Scene{ StudioID: &validStoredIDInt, }, defaultOptions, &models.ScrapedStudio{ StoredID: &validStoredID, }, nil, false, }, { "different stored id", &models.Scene{}, defaultOptions, &models.ScrapedStudio{ StoredID: &validStoredID, }, &validStoredIDInt, false, }, { "no create missing", &models.Scene{}, defaultOptions, &models.ScrapedStudio{}, nil, false, }, { "create missing", &models.Scene{}, &FieldOptions{ Strategy: FieldStrategyMerge, CreateMissing: &createMissing, }, &models.ScrapedStudio{RemoteSiteID: &remoteSiteID}, &validStoredIDInt, false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tr.scene = tt.scene tr.fieldOptions["studio"] = tt.fieldOptions tr.result = &scrapeResult{ source: ScraperSource{ RemoteSite: "endpoint", }, result: &models.ScrapedScene{ Studio: tt.result, }, } got, err := tr.studio(testCtx) if (err != nil) != tt.wantErr { t.Errorf("sceneRelationships.studio() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("sceneRelationships.studio() = %v, want %v", got, tt.want) } }) } } func Test_sceneRelationships_performers(t *testing.T) { const ( sceneID = iota sceneWithPerformerID errSceneID existingPerformerID validStoredIDInt ) validStoredID := strconv.Itoa(validStoredIDInt) invalidStoredID := "invalidStoredID" createMissing := true existingPerformerStr := strconv.Itoa(existingPerformerID) validName := "validName" female := models.GenderEnumFemale.String() male := models.GenderEnumMale.String() defaultOptions := &FieldOptions{ Strategy: FieldStrategyMerge, } emptyScene := &models.Scene{ ID: sceneID, PerformerIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), } sceneWithPerformer := &models.Scene{ ID: sceneWithPerformerID, PerformerIDs: models.NewRelatedIDs([]int{ existingPerformerID, }), } db := mocks.NewDatabase() tr := sceneRelationships{ sceneReader: db.Scene, fieldOptions: make(map[string]*FieldOptions), } tests := []struct { name string scene *models.Scene fieldOptions *FieldOptions scraped []*models.ScrapedPerformer allowedGenders []models.GenderEnum want []int wantErr bool }{ { "ignore", emptyScene, &FieldOptions{ Strategy: FieldStrategyIgnore, }, []*models.ScrapedPerformer{ { StoredID: &validStoredID, }, }, nil, nil, false, }, { "none", emptyScene, defaultOptions, []*models.ScrapedPerformer{}, nil, nil, false, }, { "merge existing", sceneWithPerformer, defaultOptions, []*models.ScrapedPerformer{ { Name: &validName, StoredID: &existingPerformerStr, }, }, nil, nil, false, }, { "merge add", sceneWithPerformer, defaultOptions, []*models.ScrapedPerformer{ { Name: &validName, StoredID: &validStoredID, }, }, nil, []int{existingPerformerID, validStoredIDInt}, false, }, { "ignore male", emptyScene, defaultOptions, []*models.ScrapedPerformer{ { Name: &validName, StoredID: &validStoredID, Gender: &male, }, }, []models.GenderEnum{models.GenderEnumFemale, models.GenderEnumTransgenderMale, models.GenderEnumTransgenderFemale, models.GenderEnumIntersex, models.GenderEnumNonBinary}, nil, false, }, { "overwrite", sceneWithPerformer, &FieldOptions{ Strategy: FieldStrategyOverwrite, }, []*models.ScrapedPerformer{ { Name: &validName, StoredID: &validStoredID, }, }, nil, []int{validStoredIDInt}, false, }, { "ignore male (not male)", sceneWithPerformer, &FieldOptions{ Strategy: FieldStrategyOverwrite, }, []*models.ScrapedPerformer{ { Name: &validName, StoredID: &validStoredID, Gender: &female, }, }, []models.GenderEnum{models.GenderEnumFemale, models.GenderEnumTransgenderMale, models.GenderEnumTransgenderFemale, models.GenderEnumIntersex, models.GenderEnumNonBinary}, []int{validStoredIDInt}, false, }, { "error getting tag ID", emptyScene, &FieldOptions{ Strategy: FieldStrategyOverwrite, CreateMissing: &createMissing, }, []*models.ScrapedPerformer{ { Name: &validName, StoredID: &invalidStoredID, }, }, nil, nil, true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tr.scene = tt.scene tr.fieldOptions["performers"] = tt.fieldOptions tr.result = &scrapeResult{ result: &models.ScrapedScene{ Performers: tt.scraped, }, } got, err := tr.performers(testCtx, tt.allowedGenders) if (err != nil) != tt.wantErr { t.Errorf("sceneRelationships.performers() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("sceneRelationships.performers() = %v, want %v", got, tt.want) } }) } } func Test_sceneRelationships_tags(t *testing.T) { const ( sceneID = iota sceneWithTagID errSceneID existingID validStoredIDInt ) validStoredID := strconv.Itoa(validStoredIDInt) invalidStoredID := "invalidStoredID" createMissing := true existingIDStr := strconv.Itoa(existingID) validName := "validName" invalidName := "invalidName" defaultOptions := &FieldOptions{ Strategy: FieldStrategyMerge, } emptyScene := &models.Scene{ ID: sceneID, TagIDs: models.NewRelatedIDs([]int{}), PerformerIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), } sceneWithTag := &models.Scene{ ID: sceneWithTagID, TagIDs: models.NewRelatedIDs([]int{ existingID, }), PerformerIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), } db := mocks.NewDatabase() db.Tag.On("Create", testCtx, mock.MatchedBy(func(p *models.CreateTagInput) bool { return p.Tag.Name == validName })).Run(func(args mock.Arguments) { t := args.Get(1).(*models.CreateTagInput) t.Tag.ID = validStoredIDInt }).Return(nil) db.Tag.On("Create", testCtx, mock.MatchedBy(func(p *models.CreateTagInput) bool { return p.Tag.Name == invalidName })).Return(errors.New("error creating tag")) tr := sceneRelationships{ sceneReader: db.Scene, tagCreator: db.Tag, fieldOptions: make(map[string]*FieldOptions), } tests := []struct { name string scene *models.Scene fieldOptions *FieldOptions scraped []*models.ScrapedTag want []int wantErr bool }{ { "ignore", emptyScene, &FieldOptions{ Strategy: FieldStrategyIgnore, }, []*models.ScrapedTag{ { StoredID: &validStoredID, }, }, nil, false, }, { "none", emptyScene, defaultOptions, []*models.ScrapedTag{}, nil, false, }, { "merge existing", sceneWithTag, defaultOptions, []*models.ScrapedTag{ { Name: validName, StoredID: &existingIDStr, }, }, nil, false, }, { "merge add", sceneWithTag, defaultOptions, []*models.ScrapedTag{ { Name: validName, StoredID: &validStoredID, }, }, []int{existingID, validStoredIDInt}, false, }, { "overwrite", sceneWithTag, &FieldOptions{ Strategy: FieldStrategyOverwrite, }, []*models.ScrapedTag{ { Name: validName, StoredID: &validStoredID, }, }, []int{validStoredIDInt}, false, }, { "error getting tag ID", emptyScene, &FieldOptions{ Strategy: FieldStrategyOverwrite, }, []*models.ScrapedTag{ { Name: validName, StoredID: &invalidStoredID, }, }, nil, true, }, { "create missing", emptyScene, &FieldOptions{ Strategy: FieldStrategyOverwrite, CreateMissing: &createMissing, }, []*models.ScrapedTag{ { Name: validName, }, }, []int{validStoredIDInt}, false, }, { "error creating", emptyScene, &FieldOptions{ Strategy: FieldStrategyOverwrite, CreateMissing: &createMissing, }, []*models.ScrapedTag{ { Name: invalidName, }, }, nil, true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tr.scene = tt.scene tr.fieldOptions["tags"] = tt.fieldOptions tr.result = &scrapeResult{ result: &models.ScrapedScene{ Tags: tt.scraped, }, } got, err := tr.tags(testCtx) if (err != nil) != tt.wantErr { t.Errorf("sceneRelationships.tags() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("sceneRelationships.tags() = %v, want %v", got, tt.want) } }) } } func Test_sceneRelationships_stashIDs(t *testing.T) { const ( sceneID = iota sceneWithStashID errSceneID existingID validStoredIDInt ) existingEndpoint := "existingEndpoint" newEndpoint := "newEndpoint" remoteSiteID := "remoteSiteID" newRemoteSiteID := "newRemoteSiteID" defaultOptions := &FieldOptions{ Strategy: FieldStrategyMerge, } emptyScene := &models.Scene{ ID: sceneID, } sceneWithStashIDs := &models.Scene{ ID: sceneWithStashID, StashIDs: models.NewRelatedStashIDs([]models.StashID{ { StashID: remoteSiteID, Endpoint: existingEndpoint, UpdatedAt: time.Time{}, }, }), } db := mocks.NewDatabase() tr := sceneRelationships{ sceneReader: db.Scene, fieldOptions: make(map[string]*FieldOptions), } setTime := time.Now() tests := []struct { name string scene *models.Scene fieldOptions *FieldOptions endpoint string remoteSiteID *string setUpdateTime bool want []models.StashID wantErr bool }{ { "ignore", emptyScene, &FieldOptions{ Strategy: FieldStrategyIgnore, }, newEndpoint, &remoteSiteID, false, nil, false, }, { "no endpoint", emptyScene, defaultOptions, "", &remoteSiteID, false, nil, false, }, { "no site id", emptyScene, defaultOptions, newEndpoint, nil, false, nil, false, }, { "merge existing", sceneWithStashIDs, defaultOptions, existingEndpoint, &remoteSiteID, false, nil, false, }, { "merge existing set update time", sceneWithStashIDs, defaultOptions, existingEndpoint, &remoteSiteID, true, []models.StashID{ { StashID: remoteSiteID, Endpoint: existingEndpoint, UpdatedAt: setTime, }, }, false, }, { "merge existing new value", sceneWithStashIDs, defaultOptions, existingEndpoint, &newRemoteSiteID, false, []models.StashID{ { StashID: newRemoteSiteID, Endpoint: existingEndpoint, UpdatedAt: setTime, }, }, false, }, { "merge add", sceneWithStashIDs, defaultOptions, newEndpoint, &newRemoteSiteID, false, []models.StashID{ { StashID: remoteSiteID, Endpoint: existingEndpoint, UpdatedAt: time.Time{}, }, { StashID: newRemoteSiteID, Endpoint: newEndpoint, UpdatedAt: setTime, }, }, false, }, { "overwrite", sceneWithStashIDs, &FieldOptions{ Strategy: FieldStrategyOverwrite, }, newEndpoint, &newRemoteSiteID, false, []models.StashID{ { StashID: newRemoteSiteID, Endpoint: newEndpoint, UpdatedAt: setTime, }, }, false, }, { "overwrite same", sceneWithStashIDs, &FieldOptions{ Strategy: FieldStrategyOverwrite, }, existingEndpoint, &remoteSiteID, false, nil, false, }, { "overwrite same set update time", sceneWithStashIDs, &FieldOptions{ Strategy: FieldStrategyOverwrite, }, existingEndpoint, &remoteSiteID, true, []models.StashID{ { StashID: remoteSiteID, Endpoint: existingEndpoint, UpdatedAt: setTime, }, }, false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tr.scene = tt.scene tr.fieldOptions["stash_ids"] = tt.fieldOptions tr.result = &scrapeResult{ source: ScraperSource{ RemoteSite: tt.endpoint, }, result: &models.ScrapedScene{ RemoteSiteID: tt.remoteSiteID, }, } got, err := tr.stashIDs(testCtx, tt.setUpdateTime) if (err != nil) != tt.wantErr { t.Errorf("sceneRelationships.stashIDs() error = %v, wantErr %v", err, tt.wantErr) return } // massage updatedAt times to be consistent for comparison for i := range got { if !got[i].UpdatedAt.IsZero() { got[i].UpdatedAt = setTime } } if !reflect.DeepEqual(got, tt.want) { t.Errorf("sceneRelationships.stashIDs() = %+v, want %+v", got, tt.want) } }) } } func Test_sceneRelationships_cover(t *testing.T) { const ( sceneID = iota sceneWithStashID errSceneID existingID validStoredIDInt ) existingData := []byte("existingData") newData := []byte("newData") const base64Prefix = "data:image/png;base64," existingDataEncoded := base64Prefix + utils.GetBase64StringFromData(existingData) newDataEncoded := base64Prefix + utils.GetBase64StringFromData(newData) invalidData := newDataEncoded + "!!!" db := mocks.NewDatabase() db.Scene.On("GetCover", testCtx, sceneID).Return(existingData, nil) db.Scene.On("GetCover", testCtx, errSceneID).Return(nil, errors.New("error getting cover")) tr := sceneRelationships{ sceneReader: db.Scene, fieldOptions: make(map[string]*FieldOptions), } tests := []struct { name string sceneID int image *string want []byte wantErr bool }{ { "nil image", sceneID, nil, nil, false, }, { "different image", sceneID, &newDataEncoded, newData, false, }, { "same image", sceneID, &existingDataEncoded, nil, false, }, { "error getting scene cover", errSceneID, &newDataEncoded, newData, false, }, { "invalid data", sceneID, &invalidData, nil, true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tr.scene = &models.Scene{ ID: tt.sceneID, } tr.result = &scrapeResult{ result: &models.ScrapedScene{ Image: tt.image, }, } got, err := tr.cover(testCtx) if (err != nil) != tt.wantErr { t.Errorf("sceneRelationships.cover() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("sceneRelationships.cover() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: internal/identify/studio.go ================================================ package identify import ( "context" "strconv" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/studio" ) func createMissingStudio(ctx context.Context, endpoint string, w models.StudioReaderWriter, s *models.ScrapedStudio) (*int, error) { var err error if s.Parent != nil { if s.Parent.StoredID == nil { // The parent needs to be created newParentStudio := s.Parent.ToStudio(endpoint, nil) parentImage, err := s.Parent.GetImage(ctx, nil) if err != nil { logger.Errorf("Failed to make parent studio from scraped studio %s: %s", s.Parent.Name, err.Error()) return nil, err } // Create the studio err = w.Create(ctx, newParentStudio) if err != nil { return nil, err } // Update image table if len(parentImage) > 0 { if err := w.UpdateImage(ctx, newParentStudio.ID, parentImage); err != nil { return nil, err } } storedId := strconv.Itoa(newParentStudio.ID) s.Parent.StoredID = &storedId } else { // The parent studio matched an existing one and the user has chosen in the UI to link and/or update it storedID, _ := strconv.Atoi(*s.Parent.StoredID) existingStashIDs, err := w.GetStashIDs(ctx, storedID) if err != nil { return nil, err } studioPartial := s.Parent.ToPartial(*s.Parent.StoredID, endpoint, nil, existingStashIDs) parentImage, err := s.Parent.GetImage(ctx, nil) if err != nil { return nil, err } if err := studio.ValidateModify(ctx, studioPartial, w); err != nil { return nil, err } _, err = w.UpdatePartial(ctx, studioPartial) if err != nil { return nil, err } if len(parentImage) > 0 { if err := w.UpdateImage(ctx, studioPartial.ID, parentImage); err != nil { return nil, err } } } } newStudio := s.ToStudio(endpoint, nil) studioImage, err := s.GetImage(ctx, nil) if err != nil { return nil, err } err = w.Create(ctx, newStudio) if err != nil { return nil, err } // Update image table if len(studioImage) > 0 { if err := w.UpdateImage(ctx, newStudio.ID, studioImage); err != nil { return nil, err } } return &newStudio.ID, nil } ================================================ FILE: internal/identify/studio_test.go ================================================ package identify import ( "errors" "reflect" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/mock" ) func Test_createMissingStudio(t *testing.T) { emptyEndpoint := "" validEndpoint := "validEndpoint" invalidEndpoint := "invalidEndpoint" remoteSiteID := "remoteSiteID" validName := "validName" invalidName := "invalidName" createdID := 1 db := mocks.NewDatabase() db.Studio.On("Create", testCtx, mock.MatchedBy(func(p *models.CreateStudioInput) bool { return p.Name == validName })).Run(func(args mock.Arguments) { s := args.Get(1).(*models.CreateStudioInput) s.ID = createdID }).Return(nil) db.Studio.On("Create", testCtx, mock.MatchedBy(func(p *models.CreateStudioInput) bool { return p.Name == invalidName })).Return(errors.New("error creating studio")) db.Studio.On("UpdatePartial", testCtx, models.StudioPartial{ ID: createdID, StashIDs: &models.UpdateStashIDs{ StashIDs: []models.StashID{ { Endpoint: invalidEndpoint, StashID: remoteSiteID, }, }, Mode: models.RelationshipUpdateModeSet, }, }).Return(nil, errors.New("error updating stash ids")) db.Studio.On("UpdatePartial", testCtx, models.StudioPartial{ ID: createdID, StashIDs: &models.UpdateStashIDs{ StashIDs: []models.StashID{ { Endpoint: validEndpoint, StashID: remoteSiteID, }, }, Mode: models.RelationshipUpdateModeSet, }, }).Return(models.Studio{ ID: createdID, }, nil) type args struct { endpoint string studio *models.ScrapedStudio } tests := []struct { name string args args want *int wantErr bool }{ { "simple", args{ emptyEndpoint, &models.ScrapedStudio{ Name: validName, RemoteSiteID: &remoteSiteID, }, }, &createdID, false, }, { "error creating", args{ emptyEndpoint, &models.ScrapedStudio{ Name: invalidName, RemoteSiteID: &remoteSiteID, }, }, nil, true, }, { "valid stash id", args{ validEndpoint, &models.ScrapedStudio{ Name: validName, RemoteSiteID: &remoteSiteID, }, }, &createdID, false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := createMissingStudio(testCtx, tt.args.endpoint, db.Studio, tt.args.studio) if (err != nil) != tt.wantErr { t.Errorf("createMissingStudio() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("createMissingStudio() = %d, want %d", got, tt.want) } }) } } ================================================ FILE: internal/log/hook.go ================================================ package log import ( "io" "github.com/sirupsen/logrus" ) type fileLogHook struct { Writer io.Writer Formatter logrus.Formatter } func (hook *fileLogHook) Fire(entry *logrus.Entry) error { line, err := hook.Formatter.Format(entry) if err != nil { return err } _, err = hook.Writer.Write(line) return err } func (hook *fileLogHook) Levels() []logrus.Level { return logrus.AllLevels } ================================================ FILE: internal/log/logger.go ================================================ // Package log provides an implementation of [logger.LoggerImpl], using logrus. package log import ( "fmt" "io" "os" "strings" "sync" "time" "github.com/sirupsen/logrus" lumberjack "gopkg.in/natefinch/lumberjack.v2" ) type LogItem struct { Time time.Time `json:"time"` Type string `json:"type"` Message string `json:"message"` } type Logger struct { logger *logrus.Logger progressLogger *logrus.Logger mutex sync.Mutex logCache []LogItem logSubs []chan []LogItem waiting bool lastBroadcast time.Time logBuffer []LogItem } func NewLogger() *Logger { ret := &Logger{ logger: logrus.New(), progressLogger: logrus.New(), lastBroadcast: time.Now(), } ret.progressLogger.SetFormatter(new(ProgressFormatter)) return ret } // Init initialises the logger based on a logging configuration func (log *Logger) Init(logFile string, logOut bool, logLevel string, logFileMaxSize int) { var logger io.WriteCloser customFormatter := new(logrus.TextFormatter) customFormatter.TimestampFormat = "2006-01-02 15:04:05" customFormatter.ForceColors = true customFormatter.FullTimestamp = true log.logger.SetOutput(os.Stderr) log.logger.SetFormatter(customFormatter) // #1837 - trigger the console to use color-mode since it won't be // otherwise triggered until the first log entry // this is covers the situation where the logger is only logging to file // and therefore does not trigger the console color-mode - resulting in // the access log colouring not being applied _, _ = customFormatter.Format(logrus.NewEntry(log.logger)) // if size is 0, disable rotation if logFile != "" { if logFileMaxSize == 0 { var err error logger, err = os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { fmt.Fprintf(os.Stderr, "unable to open log file %s: %v\n", logFile, err) } } else { logger = &lumberjack.Logger{ Filename: logFile, MaxSize: logFileMaxSize, // Megabytes Compress: true, } } } if logger != nil { if logOut { // log to file separately disabling colours fileFormatter := new(logrus.TextFormatter) fileFormatter.TimestampFormat = customFormatter.TimestampFormat fileFormatter.FullTimestamp = customFormatter.FullTimestamp log.logger.AddHook(&fileLogHook{ Writer: logger, Formatter: fileFormatter, }) } else { // logging to file only // turn off the colouring for the file customFormatter.ForceColors = false log.logger.Out = logger } } // otherwise, output to StdErr log.SetLogLevel(logLevel) } func (log *Logger) SetLogLevel(level string) { log.logger.Level = logLevelFromString(level) } func logLevelFromString(level string) logrus.Level { ret := logrus.InfoLevel switch strings.ToLower(level) { case "debug": ret = logrus.DebugLevel case "warning": ret = logrus.WarnLevel case "error": ret = logrus.ErrorLevel case "trace": ret = logrus.TraceLevel } return ret } func (log *Logger) addToCache(l *LogItem) { // assumes mutex held // only add to cache if meets minimum log level level := logLevelFromString(l.Type) if level <= log.logger.Level { log.logCache = append([]LogItem{*l}, log.logCache...) if len(log.logCache) > 30 { log.logCache = log.logCache[:len(log.logCache)-1] } } } func (log *Logger) addLogItem(l *LogItem) { log.mutex.Lock() l.Time = time.Now() log.addToCache(l) log.mutex.Unlock() go log.broadcastLogItem(l) } func (log *Logger) GetLogCache() []LogItem { log.mutex.Lock() ret := make([]LogItem, len(log.logCache)) copy(ret, log.logCache) log.mutex.Unlock() return ret } func (log *Logger) SubscribeToLog(stop chan int) <-chan []LogItem { ret := make(chan []LogItem, 100) go func() { <-stop log.unsubscribeFromLog(ret) }() log.mutex.Lock() log.logSubs = append(log.logSubs, ret) log.mutex.Unlock() return ret } func (log *Logger) unsubscribeFromLog(toRemove chan []LogItem) { log.mutex.Lock() for i, c := range log.logSubs { if c == toRemove { log.logSubs = append(log.logSubs[:i], log.logSubs[i+1:]...) } } close(toRemove) log.mutex.Unlock() } func (log *Logger) doBroadcastLogItems() { // assumes mutex held for _, c := range log.logSubs { // don't block waiting to broadcast select { case c <- log.logBuffer: default: } } log.logBuffer = nil log.waiting = false log.lastBroadcast = time.Now() } func (log *Logger) broadcastLogItem(l *LogItem) { log.mutex.Lock() log.logBuffer = append(log.logBuffer, *l) // don't send more than once per second if !log.waiting { // if last broadcast was under a second ago, wait until a second has // passed timeSinceBroadcast := time.Since(log.lastBroadcast) if timeSinceBroadcast.Seconds() < 1 { log.waiting = true time.AfterFunc(time.Second-timeSinceBroadcast, func() { log.mutex.Lock() log.doBroadcastLogItems() log.mutex.Unlock() }) } else { log.doBroadcastLogItems() } } // if waiting then adding it to the buffer is sufficient log.mutex.Unlock() } func (log *Logger) Progressf(format string, args ...interface{}) { log.progressLogger.Infof(format, args...) l := &LogItem{ Type: "progress", Message: fmt.Sprintf(format, args...), } log.addLogItem(l) } func (log *Logger) Trace(args ...interface{}) { log.logger.Trace(args...) l := &LogItem{ Type: "trace", Message: fmt.Sprint(args...), } log.addLogItem(l) } func (log *Logger) Tracef(format string, args ...interface{}) { log.logger.Tracef(format, args...) l := &LogItem{ Type: "trace", Message: fmt.Sprintf(format, args...), } log.addLogItem(l) } func (log *Logger) TraceFunc(fn func() (string, []interface{})) { if log.logger.Level >= logrus.TraceLevel { msg, args := fn() log.Tracef(msg, args...) } } func (log *Logger) Debug(args ...interface{}) { log.logger.Debug(args...) l := &LogItem{ Type: "debug", Message: fmt.Sprint(args...), } log.addLogItem(l) } func (log *Logger) Debugf(format string, args ...interface{}) { log.logger.Debugf(format, args...) l := &LogItem{ Type: "debug", Message: fmt.Sprintf(format, args...), } log.addLogItem(l) } func (log *Logger) logFunc(level logrus.Level, logFn func(format string, args ...interface{}), fn func() (string, []interface{})) { if log.logger.Level >= level { msg, args := fn() logFn(msg, args...) } } func (log *Logger) DebugFunc(fn func() (string, []interface{})) { log.logFunc(logrus.DebugLevel, log.logger.Debugf, fn) } func (log *Logger) Info(args ...interface{}) { log.logger.Info(args...) l := &LogItem{ Type: "info", Message: fmt.Sprint(args...), } log.addLogItem(l) } func (log *Logger) Infof(format string, args ...interface{}) { log.logger.Infof(format, args...) l := &LogItem{ Type: "info", Message: fmt.Sprintf(format, args...), } log.addLogItem(l) } func (log *Logger) InfoFunc(fn func() (string, []interface{})) { log.logFunc(logrus.InfoLevel, log.logger.Infof, fn) } func (log *Logger) Warn(args ...interface{}) { log.logger.Warn(args...) l := &LogItem{ Type: "warn", Message: fmt.Sprint(args...), } log.addLogItem(l) } func (log *Logger) Warnf(format string, args ...interface{}) { log.logger.Warnf(format, args...) l := &LogItem{ Type: "warn", Message: fmt.Sprintf(format, args...), } log.addLogItem(l) } func (log *Logger) WarnFunc(fn func() (string, []interface{})) { log.logFunc(logrus.WarnLevel, log.logger.Warnf, fn) } func (log *Logger) Error(args ...interface{}) { log.logger.Error(args...) l := &LogItem{ Type: "error", Message: fmt.Sprint(args...), } log.addLogItem(l) } func (log *Logger) Errorf(format string, args ...interface{}) { log.logger.Errorf(format, args...) l := &LogItem{ Type: "error", Message: fmt.Sprintf(format, args...), } log.addLogItem(l) } func (log *Logger) ErrorFunc(fn func() (string, []interface{})) { log.logFunc(logrus.ErrorLevel, log.logger.Errorf, fn) } func (log *Logger) Fatal(args ...interface{}) { log.logger.Fatal(args...) } func (log *Logger) Fatalf(format string, args ...interface{}) { log.logger.Fatalf(format, args...) } ================================================ FILE: internal/log/progress_formatter.go ================================================ package log import ( "github.com/sirupsen/logrus" ) type ProgressFormatter struct{} func (f *ProgressFormatter) Format(entry *logrus.Entry) ([]byte, error) { msg := []byte("Processing --> " + entry.Message + "\r") return msg, nil } ================================================ FILE: internal/manager/apikey.go ================================================ package manager import ( "errors" "time" "github.com/golang-jwt/jwt/v4" "github.com/stashapp/stash/internal/manager/config" ) var ErrInvalidToken = errors.New("invalid apikey") const APIKeySubject = "APIKey" type APIKeyClaims struct { UserID string `json:"uid"` jwt.RegisteredClaims } func GenerateAPIKey(userID string) (string, error) { claims := &APIKeyClaims{ UserID: userID, RegisteredClaims: jwt.RegisteredClaims{ Subject: APIKeySubject, IssuedAt: jwt.NewNumericDate(time.Now()), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) ss, err := token.SignedString(config.GetInstance().GetJWTSignKey()) if err != nil { return "", err } return ss, nil } // GetUserIDFromAPIKey validates the provided api key and returns the user ID func GetUserIDFromAPIKey(apiKey string) (string, error) { claims := &APIKeyClaims{} token, err := jwt.ParseWithClaims(apiKey, claims, func(t *jwt.Token) (interface{}, error) { return config.GetInstance().GetJWTSignKey(), nil }) if err != nil { return "", err } if !token.Valid { return "", ErrInvalidToken } return claims.UserID, nil } ================================================ FILE: internal/manager/backup.go ================================================ package manager import ( "archive/zip" "fmt" "io" "io/fs" "os" "path/filepath" "strings" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" ) type databaseBackupZip struct { *zip.Writer } func (z *databaseBackupZip) zipFileRename(fn, outDir, outFn string) error { p := filepath.Join(outDir, outFn) p = filepath.ToSlash(p) f, err := z.Create(p) if err != nil { return fmt.Errorf("error creating zip entry for %s: %v", fn, err) } i, err := os.Open(fn) if err != nil { return fmt.Errorf("error opening %s: %v", fn, err) } defer i.Close() if _, err := io.Copy(f, i); err != nil { return fmt.Errorf("error writing %s to zip: %v", fn, err) } return nil } func (z *databaseBackupZip) zipFile(fn, outDir string) error { return z.zipFileRename(fn, outDir, filepath.Base(fn)) } func (s *Manager) BackupDatabase(download bool, includeBlobs bool) (string, string, error) { var backupPath string var backupName string // if we include blobs, then the output is a zip file // if not, using the same backup logic as before, which creates a sqlite file if !includeBlobs || s.Config.GetBlobsStorage() != config.BlobStorageTypeFilesystem { return s.backupDatabaseOnly(download) } // use tmp directory for the backup backupDir := s.Paths.Generated.Tmp if err := fsutil.EnsureDir(backupDir); err != nil { return "", "", fmt.Errorf("could not create backup directory %v: %w", backupDir, err) } f, err := os.CreateTemp(backupDir, "backup*.sqlite") if err != nil { return "", "", err } backupPath = f.Name() backupName = s.Database.DatabaseBackupPath("") f.Close() // delete the temp file so that the backup operation can create it if err := os.Remove(backupPath); err != nil { return "", "", fmt.Errorf("could not remove temporary backup file %v: %w", backupPath, err) } if err := s.Database.Backup(backupPath); err != nil { return "", "", err } // create a zip file zipFileDir := s.Paths.Generated.Downloads if !download { zipFileDir = s.Config.GetBackupDirectoryPathOrDefault() if zipFileDir != "" { if err := fsutil.EnsureDir(zipFileDir); err != nil { return "", "", fmt.Errorf("could not create backup directory %v: %w", zipFileDir, err) } } } zipFileName := backupName + ".zip" zipFilePath := filepath.Join(zipFileDir, zipFileName) logger.Debugf("Preparing zip file for database backup at %v", zipFilePath) zf, err := os.Create(zipFilePath) if err != nil { return "", "", fmt.Errorf("could not create zip file %v: %w", zipFilePath, err) } defer zf.Close() z := databaseBackupZip{ Writer: zip.NewWriter(zf), } defer z.Close() // move the database file into the zip dbFn := filepath.Base(s.Config.GetDatabasePath()) if err := z.zipFileRename(backupPath, "", dbFn); err != nil { return "", "", fmt.Errorf("could not add database backup to zip file: %w", err) } if err := os.Remove(backupPath); err != nil { return "", "", fmt.Errorf("could not remove temporary backup file %v: %w", backupPath, err) } // walk the blobs directory and add files to the zip blobsDir := s.Config.GetBlobsPath() err = filepath.WalkDir(blobsDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } // calculate out dir by removing the blobsDir prefix from the path outDir := filepath.Join("blobs", strings.TrimPrefix(filepath.Dir(path), blobsDir)) if err := z.zipFile(path, outDir); err != nil { return fmt.Errorf("could not add blob %v to zip file: %w", path, err) } return nil }) if err != nil { return "", "", fmt.Errorf("error walking blobs directory: %w", err) } return zipFilePath, zipFileName, nil } func (s *Manager) backupDatabaseOnly(download bool) (string, string, error) { var backupPath string var backupName string if download { backupDir := s.Paths.Generated.Downloads if err := fsutil.EnsureDir(backupDir); err != nil { return "", "", fmt.Errorf("could not create backup directory %v: %w", backupDir, err) } f, err := os.CreateTemp(backupDir, "backup*.sqlite") if err != nil { return "", "", err } backupPath = f.Name() backupName = s.Database.DatabaseBackupPath("") f.Close() // delete the temp file so that the backup operation can create it if err := os.Remove(backupPath); err != nil { return "", "", fmt.Errorf("could not remove temporary backup file %v: %w", backupPath, err) } } else { backupDir := s.Config.GetBackupDirectoryPathOrDefault() if backupDir != "" { if err := fsutil.EnsureDir(backupDir); err != nil { return "", "", fmt.Errorf("could not create backup directory %v: %w", backupDir, err) } } backupPath = s.Database.DatabaseBackupPath(backupDir) backupName = filepath.Base(backupPath) } err := s.Database.Backup(backupPath) if err != nil { return "", "", err } return backupPath, backupName, nil } ================================================ FILE: internal/manager/checksum.go ================================================ package manager import ( "context" "errors" "github.com/stashapp/stash/pkg/models" ) type SceneMissingHashCounter interface { CountMissingChecksum(ctx context.Context) (int, error) CountMissingOSHash(ctx context.Context) (int, error) } // ValidateVideoFileNamingAlgorithm validates changing the // VideoFileNamingAlgorithm configuration flag. // // If setting VideoFileNamingAlgorithm to MD5, then this function will ensure // that all checksum values are set on all scenes. // // Likewise, if VideoFileNamingAlgorithm is set to oshash, then this function // will ensure that all oshash values are set on all scenes. func ValidateVideoFileNamingAlgorithm(ctx context.Context, qb SceneMissingHashCounter, newValue models.HashAlgorithm) error { // if algorithm is being set to MD5, then all checksums must be present if newValue == models.HashAlgorithmMd5 { missingMD5, err := qb.CountMissingChecksum(ctx) if err != nil { return err } if missingMD5 > 0 { return errors.New("some checksums are missing on scenes. Run Scan with calculateMD5 set to true") } } else if newValue == models.HashAlgorithmOshash { missingOSHash, err := qb.CountMissingOSHash(ctx) if err != nil { return err } if missingOSHash > 0 { return errors.New("some oshash values are missing on scenes. Run Scan to populate") } } return nil } ================================================ FILE: internal/manager/config/config.go ================================================ package config import ( "fmt" "net/url" "os" "path/filepath" "reflect" "regexp" "runtime" "strconv" "strings" "sync" // "github.com/sasha-s/go-deadlock" // if you have deadlock issues "golang.org/x/crypto/bcrypt" "github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/v2" "github.com/stashapp/stash/internal/identify" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/hash" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/paths" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/utils" ) const ( Stash = "stash" Cache = "cache" BackupDirectoryPath = "backup_directory_path" Generated = "generated" Metadata = "metadata" BlobsPath = "blobs_path" Downloads = "downloads" ApiKey = "api_key" Username = "username" Password = "password" MaxSessionAge = "max_session_age" // SFWContentMode mode config key SFWContentMode = "sfw_content_mode" FFMpegPath = "ffmpeg_path" FFProbePath = "ffprobe_path" BlobsStorage = "blobs_storage" DefaultMaxSessionAge = 60 * 60 * 1 // 1 hours Database = "database" Exclude = "exclude" ImageExclude = "image_exclude" VideoExtensions = "video_extensions" ImageExtensions = "image_extensions" GalleryExtensions = "gallery_extensions" CreateGalleriesFromFolders = "create_galleries_from_folders" // CalculateMD5 is the config key used to determine if MD5 should be calculated // for video files. CalculateMD5 = "calculate_md5" // VideoFileNamingAlgorithm is the config key used to determine what hash // should be used when generating and using generated files for scenes. VideoFileNamingAlgorithm = "video_file_naming_algorithm" MaxTranscodeSize = "max_transcode_size" MaxStreamingTranscodeSize = "max_streaming_transcode_size" // ffmpeg extra args options TranscodeInputArgs = "ffmpeg.transcode.input_args" TranscodeOutputArgs = "ffmpeg.transcode.output_args" LiveTranscodeInputArgs = "ffmpeg.live_transcode.input_args" LiveTranscodeOutputArgs = "ffmpeg.live_transcode.output_args" ParallelTasks = "parallel_tasks" parallelTasksDefault = 1 UseCustomSpriteInterval = "use_custom_sprite_interval" UseCustomSpriteIntervalDefault = false SpriteInterval = "sprite_interval" SpriteIntervalDefault = 30 MinimumSprites = "minimum_sprites" MinimumSpritesDefault = 10 MaximumSprites = "maximum_sprites" MaximumSpritesDefault = 500 SpriteScreenshotSize = "sprite_screenshot_width" spriteScreenshotSizeDefault = 160 PreviewPreset = "preview_preset" TranscodeHardwareAcceleration = "ffmpeg.hardware_acceleration" SequentialScanning = "sequential_scanning" SequentialScanningDefault = false PreviewAudio = "preview_audio" previewAudioDefault = true PreviewSegmentDuration = "preview_segment_duration" previewSegmentDurationDefault = 0.75 PreviewSegments = "preview_segments" previewSegmentsDefault = 12 PreviewExcludeStart = "preview_exclude_start" previewExcludeStartDefault = "0" PreviewExcludeEnd = "preview_exclude_end" previewExcludeEndDefault = "0" WriteImageThumbnails = "write_image_thumbnails" writeImageThumbnailsDefault = true CreateImageClipsFromVideos = "create_image_clip_from_videos" createImageClipsFromVideosDefault = false Host = "host" hostDefault = "0.0.0.0" Port = "port" portDefault = 9999 ExternalHost = "external_host" // http proxy url if required Proxy = "proxy" // urls or IPs that should not use the proxy NoProxy = "no_proxy" noProxyDefault = "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12" // key used to sign JWT tokens JWTSignKey = "jwt_secret_key" // key used for session store SessionStoreKey = "session_store_key" // scraping options ScrapersPath = "scrapers_path" ScraperUserAgent = "scraper_user_agent" ScraperCertCheck = "scraper_cert_check" ScraperCDPPath = "scraper_cdp_path" ScraperExcludeTagPatterns = "scraper_exclude_tag_patterns" // stash-box options StashBoxes = "stash_boxes" PythonPath = "python_path" // plugin options PluginsPath = "plugins_path" PluginsSetting = "plugins.settings" PluginsSettingPrefix = PluginsSetting + "." DisabledPlugins = "plugins.disabled" sourceDefaultPath = "community" sourceDefaultName = "Community (stable)" PluginPackageSources = "plugins.package_sources" pluginPackageSourcesDefault = "https://stashapp.github.io/CommunityScripts/stable/index.yml" ScraperPackageSources = "scrapers.package_sources" scraperPackageSourcesDefault = "https://stashapp.github.io/CommunityScrapers/stable/index.yml" // i18n Language = "language" // served directories // this should be manually configured only CustomServedFolders = "custom_served_folders" // UI directory. Overrides to serve the UI from a specific location // rather than use the embedded UI. UILocation = "ui_location" // backwards compatible name LegacyCustomUILocation = "custom_ui_location" // Gallery Cover Regex GalleryCoverRegex = "gallery_cover_regex" galleryCoverRegexDefault = `(poster|cover|folder|board)\.[^\.]+$` // Interface options MenuItems = "menu_items" SoundOnPreview = "sound_on_preview" WallShowTitle = "wall_show_title" defaultWallShowTitle = true CustomPerformerImageLocation = "custom_performer_image_location" MaximumLoopDuration = "maximum_loop_duration" AutostartVideo = "autostart_video" AutostartVideoOnPlaySelected = "autostart_video_on_play_selected" autostartVideoOnPlaySelectedDefault = true ContinuePlaylistDefault = "continue_playlist_default" ShowStudioAsText = "show_studio_as_text" CSSEnabled = "cssenabled" JavascriptEnabled = "javascriptenabled" CustomLocalesEnabled = "customlocalesenabled" DisableCustomizations = "disable_customizations" ShowScrubber = "show_scrubber" showScrubberDefault = true WallPlayback = "wall_playback" defaultWallPlayback = "video" // Image lightbox options legacyImageLightboxSlideshowDelay = "slideshow_delay" ImageLightboxSlideshowDelay = "image_lightbox.slideshow_delay" ImageLightboxDisplayModeKey = "image_lightbox.display_mode" ImageLightboxScaleUp = "image_lightbox.scale_up" ImageLightboxResetZoomOnNav = "image_lightbox.reset_zoom_on_nav" ImageLightboxScrollModeKey = "image_lightbox.scroll_mode" ImageLightboxScrollAttemptsBeforeChange = "image_lightbox.scroll_attempts_before_change" ImageLightboxDisableAnimation = "image_lightbox.disable_animation" UI = "ui" defaultImageLightboxSlideshowDelay = 5 DisableDropdownCreatePerformer = "disable_dropdown_create.performer" DisableDropdownCreateStudio = "disable_dropdown_create.studio" DisableDropdownCreateTag = "disable_dropdown_create.tag" DisableDropdownCreateMovie = "disable_dropdown_create.movie" DisableDropdownCreateGallery = "disable_dropdown_create.gallery" HandyKey = "handy_key" FunscriptOffset = "funscript_offset" UseStashHostedFunscript = "use_stash_hosted_funscript" useStashHostedFunscriptDefault = false DrawFunscriptHeatmapRange = "draw_funscript_heatmap_range" drawFunscriptHeatmapRangeDefault = true ThemeColor = "theme_color" DefaultThemeColor = "#202b33" // Security dangerousAllowPublicWithoutAuth = "dangerous_allow_public_without_auth" dangerousAllowPublicWithoutAuthDefault = "false" SecurityTripwireAccessedFromPublicInternet = "security_tripwire_accessed_from_public_internet" securityTripwireAccessedFromPublicInternetDefault = "" sslCertPath = "ssl_cert_path" sslKeyPath = "ssl_key_path" // DLNA options DLNAServerName = "dlna.server_name" DLNADefaultEnabled = "dlna.default_enabled" DLNADefaultIPWhitelist = "dlna.default_whitelist" DLNAInterfaces = "dlna.interfaces" DLNAVideoSortOrder = "dlna.video_sort_order" dlnaVideoSortOrderDefault = "title" DLNAPort = "dlna.port" DLNAPortDefault = 1338 // Logging options LogFile = "logfile" LogOut = "logout" defaultLogOut = true LogLevel = "loglevel" defaultLogLevel = "Info" LogAccess = "logaccess" defaultLogAccess = true LogFileMaxSize = "logfile_max_size" defaultLogFileMaxSize = 0 // megabytes, default disabled // Default settings DefaultScanSettings = "defaults.scan_task" DefaultIdentifySettings = "defaults.identify_task" DefaultAutoTagSettings = "defaults.auto_tag_task" DefaultGenerateSettings = "defaults.generate_task" DeleteFileDefault = "defaults.delete_file" DeleteGeneratedDefault = "defaults.delete_generated" deleteGeneratedDefaultDefault = true // Trash/Recycle Bin options DeleteTrashPath = "delete_trash_path" // Desktop Integration Options NoBrowser = "nobrowser" NoBrowserDefault = false NotificationsEnabled = "notifications_enabled" NotificationsEnabledDefault = true ShowOneTimeMovedNotification = "show_one_time_moved_notification" ShowOneTimeMovedNotificationDefault = false // File upload options MaxUploadSize = "max_upload_size" // Developer options ExtraBlobsPaths = "developer_options.extra_blob_paths" ) // slice default values var ( defaultVideoExtensions = []string{"m4v", "mp4", "mov", "wmv", "avi", "mpg", "mpeg", "rmvb", "rm", "flv", "asf", "mkv", "webm", "f4v"} defaultImageExtensions = []string{"png", "jpg", "jpeg", "gif", "webp", "avif"} defaultGalleryExtensions = []string{"zip", "cbz"} defaultMenuItems = []string{"scenes", "images", "groups", "markers", "galleries", "performers", "studios", "tags"} ) type MissingConfigError struct { missingFields []string } func (e MissingConfigError) Error() string { return fmt.Sprintf("missing the following mandatory settings: %s", strings.Join(e.missingFields, ", ")) } // StashBoxError represents configuration errors of Stash-Box type StashBoxError struct { msg string } func (s *StashBoxError) Error() string { // "Stash-box" is a proper noun and is therefore capitcalized return "Stash-box: " + s.msg } type Config struct { // main instance - backed by config file main *koanf.Koanf // override instance - populated from flags/environment // not written to config file overrides *koanf.Koanf filePath string isNewSystem bool // configUpdates chan int certFile string keyFile string sync.RWMutex // deadlock.RWMutex // for deadlock testing/issues } var instance *Config func GetInstance() *Config { if instance == nil { panic("config not initialized") } return instance } func (i *Config) load(f string) error { if err := i.main.Load(file.Provider(f), yaml.Parser()); err != nil { return err } i.filePath = f return nil } func (i *Config) IsNewSystem() bool { return i.isNewSystem } func (i *Config) SetConfigFile(fn string) { i.Lock() defer i.Unlock() i.filePath = fn } func (i *Config) InitTLS() { configDirectory := i.GetConfigPath() tlsPaths := []string{ configDirectory, paths.GetStashHomeDirectory(), } i.certFile = i.getString(sslCertPath) if i.certFile == "" { // Look for default file i.certFile = fsutil.FindInPaths(tlsPaths, "stash.crt") } i.keyFile = i.getString(sslKeyPath) if i.keyFile == "" { // Look for default file i.keyFile = fsutil.FindInPaths(tlsPaths, "stash.key") } } func (i *Config) GetTLSFiles() (certFile, keyFile string) { return i.certFile, i.keyFile } func (i *Config) HasTLSConfig() bool { certFile, keyFile := i.GetTLSFiles() return certFile != "" && keyFile != "" } func (i *Config) GetNoBrowser() bool { return i.getBool(NoBrowser) } func (i *Config) GetNotificationsEnabled() bool { return i.getBool(NotificationsEnabled) } // GetShowOneTimeMovedNotification shows whether a small notification to inform the user that Stash // will no longer show a terminal window, and instead will be available in the tray, should be shown. // It is true when an existing system is started after upgrading, and set to false forever after it is shown. func (i *Config) GetShowOneTimeMovedNotification() bool { return i.getBool(ShowOneTimeMovedNotification) } // these methods are intended to ensure type safety (ie no primitive pointers) func (i *Config) SetBool(key string, value bool) { i.SetInterface(key, value) } func (i *Config) SetString(key string, value string) { i.SetInterface(key, value) } func (i *Config) SetInt(key string, value int) { i.SetInterface(key, value) } func (i *Config) SetFloat(key string, value float64) { i.SetInterface(key, value) } func (i *Config) SetInterface(key string, value interface{}) { i.Lock() defer i.Unlock() i.set(key, value) } func (i *Config) set(key string, value interface{}) { // assumes lock held // default behaviour for Set is to merge the value // we want to replace it i.main.Delete(key) if value == nil { return } // test for nil interface as well refVal := reflect.ValueOf(value) if refVal.Kind() == reflect.Ptr && refVal.IsNil() { return } _ = i.main.Set(key, value) } func (i *Config) SetDefault(key string, value interface{}) { i.Lock() defer i.Unlock() i.setDefault(key, value) } func (i *Config) setDefault(key string, value interface{}) { if !i.main.Exists(key) { i.set(key, value) } } func (i *Config) SetPassword(value string) { // if blank, don't bother hashing; we want it to be blank if value == "" { i.SetString(Password, "") } else { i.SetString(Password, hashPassword(value)) } } func (i *Config) Write() error { i.Lock() defer i.Unlock() data, err := i.marshal() if err != nil { return err } return os.WriteFile(i.filePath, data, 0640) } func (i *Config) Marshal() ([]byte, error) { i.RLock() defer i.RUnlock() return i.marshal() } func (i *Config) marshal() ([]byte, error) { return i.main.Marshal(yaml.Parser()) } // FileEnvSet returns true if the configuration file environment parameter // is set. func FileEnvSet() bool { return os.Getenv("STASH_CONFIG_FILE") != "" } // GetConfigFile returns the full path to the used configuration file. func (i *Config) GetConfigFile() string { i.RLock() defer i.RUnlock() return i.filePath } // GetConfigPath returns the path of the directory containing the used // configuration file. func (i *Config) GetConfigPath() string { return filepath.Dir(i.GetConfigFile()) } // GetConfigPathAbs returns the path of the directory containing the used // configuration file, resolved to an absolute path. Returns the return value // of GetConfigPath if the path cannot be made into an absolute path. func (i *Config) GetConfigPathAbs() string { p := filepath.Dir(i.GetConfigFile()) ret, _ := filepath.Abs(p) if ret == "" { return p } return ret } // GetDefaultDatabaseFilePath returns the default database filename, // which is located in the same directory as the config file. func (i *Config) GetDefaultDatabaseFilePath() string { return filepath.Join(i.GetConfigPath(), "stash-go.sqlite") } // forKey returns the Koanf instance that should be used to get the provided // key. Returns the overrides instance if the key exists there, otherwise it // returns the main instance. Assumes read lock held. func (i *Config) forKey(key string) *koanf.Koanf { v := i.main if i.overrides.Exists(key) { v = i.overrides } return v } // viper returns the viper instance that has the key set. Returns nil // if no instance has the key. Assumes read lock held. func (i *Config) with(key string) *koanf.Koanf { v := i.forKey(key) if v.Exists(key) { return v } return nil } func (i *Config) HasOverride(key string) bool { i.RLock() defer i.RUnlock() return i.overrides.Exists(key) } // These functions wrap the equivalent viper functions, checking the override // instance first, then the main instance. func (i *Config) unmarshalKey(key string, rawVal interface{}) error { i.RLock() defer i.RUnlock() return i.forKey(key).Unmarshal(key, rawVal) } func (i *Config) getStringSlice(key string) []string { i.RLock() defer i.RUnlock() return i.forKey(key).Strings(key) } func (i *Config) getString(key string) string { i.RLock() defer i.RUnlock() return i.forKey(key).String(key) } func (i *Config) getBool(key string) bool { i.RLock() defer i.RUnlock() return i.forKey(key).Bool(key) } func (i *Config) getBoolDefault(key string, def bool) bool { i.RLock() defer i.RUnlock() ret := def v := i.forKey(key) if v.Exists(key) { ret = v.Bool(key) } return ret } func (i *Config) getInt(key string) int { i.RLock() defer i.RUnlock() return i.forKey(key).Int(key) } func (i *Config) getFloat64(key string) float64 { i.RLock() defer i.RUnlock() return i.forKey(key).Float64(key) } func (i *Config) getStringMapString(key string) map[string]string { i.RLock() defer i.RUnlock() ret := i.forKey(key).StringMap(key) // GetStringMapString returns an empty map regardless of whether the // key exists or not. if len(ret) == 0 { return nil } return ret } // GetSFW returns true if SFW mode is enabled. // Default performer images are changed to more agnostic images when enabled. func (i *Config) GetSFWContentMode() bool { i.RLock() defer i.RUnlock() return i.getBool(SFWContentMode) } // GetStashPaths returns the configured stash library paths. // Works opposite to the usual case - it will return the override // value only if the main value is not set. func (i *Config) GetStashPaths() StashConfigs { i.RLock() defer i.RUnlock() var ret StashConfigs v := i.main if !v.Exists(Stash) { v = i.overrides } if err := v.Unmarshal(Stash, &ret); err != nil || len(ret) == 0 { // fallback to legacy format ss := v.Strings(Stash) ret = nil for _, path := range ss { toAdd := &StashConfig{ Path: path, } ret = append(ret, toAdd) } } return ret } func (i *Config) GetCachePath() string { return i.getString(Cache) } func (i *Config) GetGeneratedPath() string { return i.getString(Generated) } func (i *Config) GetBlobsPath() string { return i.getString(BlobsPath) } // GetExtraBlobsPaths returns extra blobs paths. // For developer/advanced use only. func (i *Config) GetExtraBlobsPaths() []string { return i.getStringSlice(ExtraBlobsPaths) } func (i *Config) GetBlobsStorage() BlobsStorageType { ret := BlobsStorageType(i.getString(BlobsStorage)) if !ret.IsValid() { // default to database storage // for legacy systems this is probably the safer option ret = BlobStorageTypeDatabase } return ret } func (i *Config) GetMetadataPath() string { return i.getString(Metadata) } func (i *Config) GetDatabasePath() string { return i.getString(Database) } func (i *Config) GetBackupDirectoryPath() string { return i.getString(BackupDirectoryPath) } func (i *Config) GetBackupDirectoryPathOrDefault() string { ret := i.GetBackupDirectoryPath() if ret == "" { // #4915 - default to the same directory as the database return filepath.Dir(i.GetDatabasePath()) } return ret } // GetFFMpegPath returns the path to the FFMpeg executable. // If empty, stash will attempt to resolve it from the path. func (i *Config) GetFFMpegPath() string { return i.getString(FFMpegPath) } // GetFFProbePath returns the path to the FFProbe executable. // If empty, stash will attempt to resolve it from the path. func (i *Config) GetFFProbePath() string { return i.getString(FFProbePath) } func (i *Config) GetJWTSignKey() []byte { return []byte(i.getString(JWTSignKey)) } func (i *Config) GetSessionStoreKey() []byte { return []byte(i.getString(SessionStoreKey)) } func (i *Config) GetDefaultScrapersPath() string { // default to the same directory as the config file fn := filepath.Join(i.GetConfigPath(), "scrapers") return fn } func (i *Config) GetExcludes() []string { return i.getStringSlice(Exclude) } func (i *Config) GetImageExcludes() []string { return i.getStringSlice(ImageExclude) } func (i *Config) GetVideoExtensions() []string { ret := i.getStringSlice(VideoExtensions) if len(ret) == 0 { ret = defaultVideoExtensions } return ret } func (i *Config) GetImageExtensions() []string { ret := i.getStringSlice(ImageExtensions) if len(ret) == 0 { ret = defaultImageExtensions } return ret } func (i *Config) GetGalleryExtensions() []string { ret := i.getStringSlice(GalleryExtensions) if len(ret) == 0 { ret = defaultGalleryExtensions } return ret } func (i *Config) GetCreateGalleriesFromFolders() bool { return i.getBool(CreateGalleriesFromFolders) } func (i *Config) GetLanguage() string { ret := i.getString(Language) // default to English if ret == "" { return "en-US" } return ret } // IsCalculateMD5 returns true if MD5 checksums should be generated for // scene video files. func (i *Config) IsCalculateMD5() bool { return i.getBool(CalculateMD5) } // GetVideoFileNamingAlgorithm returns what hash algorithm should be used for // naming generated scene video files. func (i *Config) GetVideoFileNamingAlgorithm() models.HashAlgorithm { ret := i.getString(VideoFileNamingAlgorithm) // default to oshash if ret == "" { return models.HashAlgorithmOshash } return models.HashAlgorithm(ret) } func (i *Config) GetSequentialScanning() bool { return i.getBool(SequentialScanning) } func (i *Config) GetGalleryCoverRegex() string { var regexString = i.getString(GalleryCoverRegex) _, err := regexp.Compile(regexString) if err != nil { logger.Warnf("Gallery cover regex '%v' invalid, reverting to default.", regexString) return galleryCoverRegexDefault } return regexString } func (i *Config) GetScrapersPath() string { return i.getString(ScrapersPath) } func (i *Config) GetScraperUserAgent() string { return i.getString(ScraperUserAgent) } // GetScraperCDPPath gets the path to the Chrome executable or remote address // to an instance of Chrome. func (i *Config) GetScraperCDPPath() string { return i.getString(ScraperCDPPath) } // GetScraperCertCheck returns true if the scraper should check for insecure // certificates when fetching an image or a page. func (i *Config) GetScraperCertCheck() bool { return i.getBoolDefault(ScraperCertCheck, true) } func (i *Config) GetScraperExcludeTagPatterns() []string { return i.getStringSlice(ScraperExcludeTagPatterns) } func (i *Config) GetStashBoxes() []*models.StashBox { var boxes []*models.StashBox if err := i.unmarshalKey(StashBoxes, &boxes); err != nil { logger.Warnf("error in unmarshalkey: %v", err) } return boxes } func (i *Config) GetDefaultPluginsPath() string { // default to the same directory as the config file fn := filepath.Join(i.GetConfigPath(), "plugins") return fn } func (i *Config) GetPluginsPath() string { return i.getString(PluginsPath) } func (i *Config) GetAllPluginConfiguration() map[string]map[string]interface{} { i.RLock() defer i.RUnlock() ret := make(map[string]map[string]interface{}) v := i.forKey(PluginsSetting) sub := v.Cut(PluginsSetting) if sub == nil { return ret } for plugin := range sub.Raw() { ret[plugin] = sub.Cut(plugin).Raw() } return ret } func (i *Config) GetPluginConfiguration(pluginID string) map[string]interface{} { i.RLock() defer i.RUnlock() key := PluginsSettingPrefix + pluginID return i.forKey(key).Cut(key).Raw() } // SetPluginConfiguration sets the configuration for a plugin. // It will overwrite any existing configuration. func (i *Config) SetPluginConfiguration(pluginID string, v map[string]interface{}) { i.Lock() defer i.Unlock() key := PluginsSettingPrefix + pluginID i.set(key, v) } func (i *Config) GetDisabledPlugins() []string { return i.getStringSlice(DisabledPlugins) } func (i *Config) GetPythonPath() string { return i.getString(PythonPath) } func (i *Config) GetHost() string { ret := i.getString(Host) if ret == "" { ret = hostDefault } return ret } func (i *Config) GetPort() int { ret := i.getInt(Port) if ret == 0 { ret = portDefault } return ret } func (i *Config) GetThemeColor() string { return i.getString(ThemeColor) } func (i *Config) GetExternalHost() string { return i.getString(ExternalHost) } // GetPreviewSegmentDuration returns the duration of a single segment in a // scene preview file, in seconds. func (i *Config) GetPreviewSegmentDuration() float64 { return i.getFloat64(PreviewSegmentDuration) } // GetParallelTasks returns the number of parallel tasks that should be started // by scan or generate task. func (i *Config) GetParallelTasks() int { return i.getInt(ParallelTasks) } func (i *Config) GetParallelTasksWithAutoDetection() int { parallelTasks := i.getInt(ParallelTasks) if parallelTasks <= 0 { parallelTasks = (runtime.NumCPU() / 4) + 1 } return parallelTasks } // GetUseCustomSpriteInterval returns true if the sprite minimum, maximum, and interval settings // should be used instead of the default func (i *Config) GetUseCustomSpriteInterval() bool { value := i.getBool(UseCustomSpriteInterval) return value } // GetSpriteInterval returns the time (in seconds) to be between each scrubber sprite // A value of 0 indicates that the sprite interval should be automatically determined // based on the minimum sprite setting. func (i *Config) GetSpriteInterval() float64 { value := i.getFloat64(SpriteInterval) return value } // GetMinimumSprites returns the minimum number of sprites that have to be generated // A value of 0 will be overridden with the default of 10. func (i *Config) GetMinimumSprites() int { value := i.getInt(MinimumSprites) if value <= 0 { return MinimumSpritesDefault } return value } // GetMaximumSprites returns the maximum number of sprites that can be generated // A value of 0 indicates no maximum. func (i *Config) GetMaximumSprites() int { value := i.getInt(MaximumSprites) return value } // GetSpriteScreenshotSize returns the required size of the screenshots to be taken // during sprite generation in pixels. This will be the width for landscape scenes // and the height for portrait scenes, with the other dimension being scaled to maintain // the aspect ratio. If the value is less than or equal to 0, the default will be used. func (i *Config) GetSpriteScreenshotSize() int { value := i.getInt(SpriteScreenshotSize) if value <= 0 { return spriteScreenshotSizeDefault } return value } func (i *Config) GetPreviewAudio() bool { return i.getBool(PreviewAudio) } // GetPreviewSegments returns the amount of segments in a scene preview file. func (i *Config) GetPreviewSegments() int { return i.getInt(PreviewSegments) } // GetPreviewExcludeStart returns the configuration setting string for // excluding the start of scene videos for preview generation. This can // be in two possible formats. A float value is interpreted as the amount // of seconds to exclude from the start of the video before it is included // in the preview. If the value is suffixed with a '%' character (for example // '2%'), then it is interpreted as a proportion of the total video duration. func (i *Config) GetPreviewExcludeStart() string { return i.getString(PreviewExcludeStart) } // GetPreviewExcludeEnd returns the configuration setting string for // excluding the end of scene videos for preview generation. A float value // is interpreted as the amount of seconds to exclude from the end of the video // when generating previews. If the value is suffixed with a '%' character, // then it is interpreted as a proportion of the total video duration. func (i *Config) GetPreviewExcludeEnd() string { return i.getString(PreviewExcludeEnd) } // GetPreviewPreset returns the preset when generating previews. Defaults to // Slow. func (i *Config) GetPreviewPreset() models.PreviewPreset { ret := i.getString(PreviewPreset) // default to slow if ret == "" { return models.PreviewPresetSlow } return models.PreviewPreset(ret) } func (i *Config) GetTranscodeHardwareAcceleration() bool { return i.getBool(TranscodeHardwareAcceleration) } func (i *Config) GetMaxTranscodeSize() models.StreamingResolutionEnum { ret := i.getString(MaxTranscodeSize) // default to original if ret == "" { return models.StreamingResolutionEnumOriginal } return models.StreamingResolutionEnum(ret) } func (i *Config) GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum { ret := i.getString(MaxStreamingTranscodeSize) // default to original if ret == "" { return models.StreamingResolutionEnumOriginal } return models.StreamingResolutionEnum(ret) } func (i *Config) GetTranscodeInputArgs() []string { return i.getStringSlice(TranscodeInputArgs) } func (i *Config) GetTranscodeOutputArgs() []string { return i.getStringSlice(TranscodeOutputArgs) } func (i *Config) GetLiveTranscodeInputArgs() []string { return i.getStringSlice(LiveTranscodeInputArgs) } func (i *Config) GetLiveTranscodeOutputArgs() []string { return i.getStringSlice(LiveTranscodeOutputArgs) } func (i *Config) GetDrawFunscriptHeatmapRange() bool { return i.getBoolDefault(DrawFunscriptHeatmapRange, drawFunscriptHeatmapRangeDefault) } // IsWriteImageThumbnails returns true if image thumbnails should be written // to disk after generating on the fly. func (i *Config) IsWriteImageThumbnails() bool { return i.getBool(WriteImageThumbnails) } func (i *Config) IsCreateImageClipsFromVideos() bool { return i.getBool(CreateImageClipsFromVideos) } func (i *Config) GetAPIKey() string { return i.getString(ApiKey) } func (i *Config) GetUsername() string { return i.getString(Username) } func (i *Config) GetPasswordHash() string { return i.getString(Password) } func (i *Config) GetCredentials() (string, string) { if i.HasCredentials() { return i.getString(Username), i.getString(Password) } return "", "" } func (i *Config) HasCredentials() bool { username := i.getString(Username) pwHash := i.getString(Password) return username != "" && pwHash != "" } func hashPassword(password string) string { hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) return string(hash) } func (i *Config) ValidateCredentials(username string, password string) bool { if !i.HasCredentials() { // don't need to authenticate if no credentials saved return true } authUser, authPWHash := i.GetCredentials() err := bcrypt.CompareHashAndPassword([]byte(authPWHash), []byte(password)) return username == authUser && err == nil } func stashBoxValidate(str string) bool { u, err := url.Parse(str) return err == nil && u.Scheme != "" && u.Host != "" && strings.HasSuffix(u.Path, "/graphql") } type StashBoxInput struct { Endpoint string `json:"endpoint"` APIKey string `json:"api_key"` Name string `json:"name"` MaxRequestsPerMinute int `json:"max_requests_per_minute"` } func (i *Config) ValidateStashBoxes(boxes []*StashBoxInput) error { isMulti := len(boxes) > 1 for _, box := range boxes { // Validate each stash-box configuration field, return on error if box.APIKey == "" { return &StashBoxError{msg: "API Key cannot be blank"} } if box.Endpoint == "" { return &StashBoxError{msg: "endpoint cannot be blank"} } if !stashBoxValidate(box.Endpoint) { return &StashBoxError{msg: "endpoint is invalid"} } if isMulti && box.Name == "" { return &StashBoxError{msg: "name cannot be blank"} } } return nil } // GetMaxSessionAge gets the maximum age for session cookies, in seconds. // Session cookie expiry times are refreshed every request. func (i *Config) GetMaxSessionAge() int { i.RLock() defer i.RUnlock() ret := DefaultMaxSessionAge v := i.forKey(MaxSessionAge) if v.Exists(MaxSessionAge) { ret = v.Int(MaxSessionAge) } return ret } // GetCustomServedFolders gets the map of custom paths to their applicable // filesystem locations func (i *Config) GetCustomServedFolders() utils.URLMap { return i.getStringMapString(CustomServedFolders) } func (i *Config) GetUILocation() string { if ret := i.getString(UILocation); ret != "" { return ret } return i.getString(LegacyCustomUILocation) } // Interface options func (i *Config) GetMenuItems() []string { i.RLock() defer i.RUnlock() v := i.forKey(MenuItems) if v.Exists(MenuItems) { return v.Strings(MenuItems) } return defaultMenuItems } func (i *Config) GetSoundOnPreview() bool { return i.getBool(SoundOnPreview) } func (i *Config) GetWallShowTitle() bool { i.RLock() defer i.RUnlock() ret := defaultWallShowTitle v := i.forKey(WallShowTitle) if v.Exists(WallShowTitle) { ret = v.Bool(WallShowTitle) } return ret } func (i *Config) GetCustomPerformerImageLocation() string { return i.getString(CustomPerformerImageLocation) } func (i *Config) GetWallPlayback() string { i.RLock() defer i.RUnlock() ret := defaultWallPlayback v := i.forKey(WallPlayback) if v.Exists(WallPlayback) { ret = v.String(WallPlayback) } return ret } func (i *Config) GetShowScrubber() bool { return i.getBoolDefault(ShowScrubber, showScrubberDefault) } func (i *Config) GetMaximumLoopDuration() int { return i.getInt(MaximumLoopDuration) } func (i *Config) GetAutostartVideo() bool { return i.getBool(AutostartVideo) } func (i *Config) GetAutostartVideoOnPlaySelected() bool { return i.getBoolDefault(AutostartVideoOnPlaySelected, autostartVideoOnPlaySelectedDefault) } func (i *Config) GetContinuePlaylistDefault() bool { return i.getBool(ContinuePlaylistDefault) } func (i *Config) GetShowStudioAsText() bool { return i.getBool(ShowStudioAsText) } func (i *Config) getSlideshowDelay() int { // assume have lock ret := defaultImageLightboxSlideshowDelay v := i.forKey(ImageLightboxSlideshowDelay) if v.Exists(ImageLightboxSlideshowDelay) { ret = v.Int(ImageLightboxSlideshowDelay) } else { // fallback to old location v := i.forKey(legacyImageLightboxSlideshowDelay) if v.Exists(legacyImageLightboxSlideshowDelay) { ret = v.Int(legacyImageLightboxSlideshowDelay) } } return ret } func (i *Config) GetImageLightboxOptions() ConfigImageLightboxResult { i.RLock() defer i.RUnlock() delay := i.getSlideshowDelay() ret := ConfigImageLightboxResult{ SlideshowDelay: &delay, } if v := i.with(ImageLightboxDisplayModeKey); v != nil { mode := ImageLightboxDisplayMode(v.String(ImageLightboxDisplayModeKey)) ret.DisplayMode = &mode } if v := i.with(ImageLightboxScaleUp); v != nil { value := v.Bool(ImageLightboxScaleUp) ret.ScaleUp = &value } if v := i.with(ImageLightboxResetZoomOnNav); v != nil { value := v.Bool(ImageLightboxResetZoomOnNav) ret.ResetZoomOnNav = &value } if v := i.with(ImageLightboxScrollModeKey); v != nil { mode := ImageLightboxScrollMode(v.String(ImageLightboxScrollModeKey)) ret.ScrollMode = &mode } if v := i.with(ImageLightboxScrollAttemptsBeforeChange); v != nil { ret.ScrollAttemptsBeforeChange = v.Int(ImageLightboxScrollAttemptsBeforeChange) } if v := i.with(ImageLightboxDisableAnimation); v != nil { value := v.Bool(ImageLightboxDisableAnimation) ret.DisableAnimation = &value } return ret } func (i *Config) GetDisableDropdownCreate() *ConfigDisableDropdownCreate { return &ConfigDisableDropdownCreate{ Performer: i.getBool(DisableDropdownCreatePerformer), Studio: i.getBool(DisableDropdownCreateStudio), Tag: i.getBool(DisableDropdownCreateTag), Movie: i.getBool(DisableDropdownCreateMovie), Gallery: i.getBool(DisableDropdownCreateGallery), } } func (i *Config) GetUIConfiguration() map[string]interface{} { i.RLock() defer i.RUnlock() return i.forKey(UI).Cut(UI).Raw() } // GetMinimumPlayPercent returns the minimum percentage of a video that must be // watched before incrementing the play count. Returns 0 if not configured. func (i *Config) GetMinimumPlayPercent() int { uiConfig := i.GetUIConfiguration() if uiConfig == nil { return 0 } if val, ok := uiConfig["minimumPlayPercent"]; ok { switch v := val.(type) { case int: return v case float64: return int(v) case int64: return int(v) } } return 0 } func (i *Config) SetUIConfiguration(v map[string]interface{}) { i.Lock() defer i.Unlock() i.set(UI, v) } func (i *Config) GetCSSPath() string { // use custom.css in the same directory as the config file configFileUsed := i.GetConfigFile() configDir := filepath.Dir(configFileUsed) fn := filepath.Join(configDir, "custom.css") return fn } func (i *Config) GetCSS() string { fn := i.GetCSSPath() exists, _ := fsutil.FileExists(fn) if !exists { return "" } buf, err := os.ReadFile(fn) if err != nil { return "" } return string(buf) } func (i *Config) SetCSS(css string) { fn := i.GetCSSPath() i.Lock() defer i.Unlock() buf := []byte(css) if err := os.WriteFile(fn, buf, 0777); err != nil { logger.Warnf("error while writing %v bytes to %v: %v", len(buf), fn, err) } } func (i *Config) GetCSSEnabled() bool { return i.getBool(CSSEnabled) } func (i *Config) GetJavascriptPath() string { // use custom.js in the same directory as the config file configFileUsed := i.GetConfigFile() configDir := filepath.Dir(configFileUsed) fn := filepath.Join(configDir, "custom.js") return fn } func (i *Config) GetJavascript() string { fn := i.GetJavascriptPath() exists, _ := fsutil.FileExists(fn) if !exists { return "" } buf, err := os.ReadFile(fn) if err != nil { return "" } return string(buf) } func (i *Config) SetJavascript(javascript string) { fn := i.GetJavascriptPath() i.Lock() defer i.Unlock() buf := []byte(javascript) if err := os.WriteFile(fn, buf, 0777); err != nil { logger.Warnf("error while writing %v bytes to %v: %v", len(buf), fn, err) } } func (i *Config) GetJavascriptEnabled() bool { return i.getBool(JavascriptEnabled) } func (i *Config) GetCustomLocalesPath() string { // use custom-locales.json in the same directory as the config file configFileUsed := i.GetConfigFile() configDir := filepath.Dir(configFileUsed) fn := filepath.Join(configDir, "custom-locales.json") return fn } func (i *Config) GetCustomLocales() string { fn := i.GetCustomLocalesPath() exists, _ := fsutil.FileExists(fn) if !exists { return "" } buf, err := os.ReadFile(fn) if err != nil { return "" } return string(buf) } func (i *Config) SetCustomLocales(customLocales string) { fn := i.GetCustomLocalesPath() i.Lock() defer i.Unlock() buf := []byte(customLocales) if err := os.WriteFile(fn, buf, 0777); err != nil { logger.Warnf("error while writing %v bytes to %v: %v", len(buf), fn, err) } } func (i *Config) GetCustomLocalesEnabled() bool { return i.getBool(CustomLocalesEnabled) } // GetDisableCustomizations returns true if all customizations (plugins, custom CSS, // custom JavaScript, and custom locales) should be disabled. This is useful for // troubleshooting issues without permanently disabling individual customizations. func (i *Config) GetDisableCustomizations() bool { return i.getBool(DisableCustomizations) } func (i *Config) GetHandyKey() string { return i.getString(HandyKey) } func (i *Config) GetFunscriptOffset() int { return i.getInt(FunscriptOffset) } func (i *Config) GetUseStashHostedFunscript() bool { return i.getBoolDefault(UseStashHostedFunscript, useStashHostedFunscriptDefault) } func (i *Config) GetDeleteFileDefault() bool { return i.getBool(DeleteFileDefault) } func (i *Config) GetDeleteGeneratedDefault() bool { return i.getBoolDefault(DeleteGeneratedDefault, deleteGeneratedDefaultDefault) } func (i *Config) GetDeleteTrashPath() string { return i.getString(DeleteTrashPath) } func (i *Config) SetDeleteTrashPath(value string) { i.SetString(DeleteTrashPath, value) } // GetDefaultIdentifySettings returns the default Identify task settings. // Returns nil if the settings could not be unmarshalled, or if it // has not been set. func (i *Config) GetDefaultIdentifySettings() *identify.Options { i.RLock() defer i.RUnlock() v := i.forKey(DefaultIdentifySettings) if v.Exists(DefaultIdentifySettings) && v.Get(DefaultIdentifySettings) != nil { var ret identify.Options if err := v.Unmarshal(DefaultIdentifySettings, &ret); err != nil { return nil } return &ret } return nil } // GetDefaultScanSettings returns the default Scan task settings. // Returns nil if the settings could not be unmarshalled, or if it // has not been set. func (i *Config) GetDefaultScanSettings() *ScanMetadataOptions { i.RLock() defer i.RUnlock() v := i.forKey(DefaultScanSettings) if v.Exists(DefaultScanSettings) && v.Get(DefaultScanSettings) != nil { var ret ScanMetadataOptions if err := v.Unmarshal(DefaultScanSettings, &ret); err != nil { return nil } return &ret } return nil } // GetDefaultAutoTagSettings returns the default Scan task settings. // Returns nil if the settings could not be unmarshalled, or if it // has not been set. func (i *Config) GetDefaultAutoTagSettings() *AutoTagMetadataOptions { i.RLock() defer i.RUnlock() v := i.forKey(DefaultAutoTagSettings) if v.Exists(DefaultAutoTagSettings) { var ret AutoTagMetadataOptions if err := v.Unmarshal(DefaultAutoTagSettings, &ret); err != nil { return nil } return &ret } return nil } // GetDefaultGenerateSettings returns the default Scan task settings. // Returns nil if the settings could not be unmarshalled, or if it // has not been set. func (i *Config) GetDefaultGenerateSettings() *models.GenerateMetadataOptions { i.RLock() defer i.RUnlock() v := i.forKey(DefaultGenerateSettings) if v.Exists(DefaultGenerateSettings) { var ret models.GenerateMetadataOptions if err := v.Unmarshal(DefaultGenerateSettings, &ret); err != nil { return nil } return &ret } return nil } // GetDangerousAllowPublicWithoutAuth determines if the security feature is enabled. // See https://discourse.stashapp.cc/t/-/1658 func (i *Config) GetDangerousAllowPublicWithoutAuth() bool { return i.getBool(dangerousAllowPublicWithoutAuth) } // GetSecurityTripwireAccessedFromPublicInternet returns a public IP address if stash // has been accessed from the public internet, with no auth enabled, and // DangerousAllowPublicWithoutAuth disabled. Returns an empty string otherwise. func (i *Config) GetSecurityTripwireAccessedFromPublicInternet() string { return i.getString(SecurityTripwireAccessedFromPublicInternet) } // GetDLNAServerName returns the visible name of the DLNA server. If empty, // "stash" will be used. func (i *Config) GetDLNAServerName() string { return i.getString(DLNAServerName) } // GetDLNADefaultEnabled returns true if the DLNA is enabled by default. func (i *Config) GetDLNADefaultEnabled() bool { return i.getBool(DLNADefaultEnabled) } // GetDLNADefaultIPWhitelist returns a list of IP addresses/wildcards that // are allowed to use the DLNA service. func (i *Config) GetDLNADefaultIPWhitelist() []string { return i.getStringSlice(DLNADefaultIPWhitelist) } // GetDLNAInterfaces returns a list of interface names to expose DLNA on. If // empty, runs on all interfaces. func (i *Config) GetDLNAInterfaces() []string { return i.getStringSlice(DLNAInterfaces) } // GetDLNAPort returns the port to run the DLNA server on. If empty, 1338 // will be used. func (i *Config) GetDLNAPort() int { ret := i.getInt(DLNAPort) if ret == 0 { ret = DLNAPortDefault } return ret } // GetDLNAPortAsString returns the port to run the DLNA server on as a string. func (i *Config) GetDLNAPortAsString() string { return ":" + strconv.Itoa(i.GetDLNAPort()) } // GetDLNAActivityTrackingEnabled returns true if DLNA activity tracking is enabled. // This uses the same "trackActivity" UI setting that controls frontend play history tracking. // When enabled, scenes played via DLNA will have their play count and duration tracked. func (i *Config) GetDLNAActivityTrackingEnabled() bool { uiConfig := i.GetUIConfiguration() if uiConfig == nil { return true // Default to enabled } if val, ok := uiConfig["trackActivity"]; ok { if v, ok := val.(bool); ok { return v } } return true // Default to enabled } // GetVideoSortOrder returns the sort order to display videos. If // empty, videos will be sorted by titles. func (i *Config) GetVideoSortOrder() string { ret := i.getString(DLNAVideoSortOrder) if ret == "" { ret = dlnaVideoSortOrderDefault } return ret } // GetLogFile returns the filename of the file to output logs to. // An empty string means that file logging will be disabled. func (i *Config) GetLogFile() string { return i.getString(LogFile) } // GetLogOut returns true if logging should be output to the terminal // in addition to writing to a log file. Logging will be output to the // terminal if file logging is disabled. Defaults to true. func (i *Config) GetLogOut() bool { return i.getBoolDefault(LogOut, defaultLogOut) } // GetLogLevel returns the lowest log level to write to the log. // Should be one of "Debug", "Info", "Warning", "Error" func (i *Config) GetLogLevel() string { value := i.getString(LogLevel) if value != "Debug" && value != "Info" && value != "Warning" && value != "Error" && value != "Trace" { value = defaultLogLevel } return value } // GetLogAccess returns true if http requests should be logged to the terminal. // HTTP requests are not logged to the log file. Defaults to true. func (i *Config) GetLogAccess() bool { return i.getBoolDefault(LogAccess, defaultLogAccess) } // GetLogFileMaxSize returns the maximum size of the log file in megabytes for lumberjack to rotate func (i *Config) GetLogFileMaxSize() int { value := i.getInt(LogFileMaxSize) if value < 0 { value = defaultLogFileMaxSize } return value } // Max allowed graphql upload size in megabytes func (i *Config) GetMaxUploadSize() int64 { i.RLock() defer i.RUnlock() ret := int64(1024) v := i.forKey(MaxUploadSize) if v.Exists(MaxUploadSize) { ret = v.Int64(MaxUploadSize) } return ret << 20 } // GetProxy returns the url of a http proxy to be used for all outgoing http calls. func (i *Config) GetProxy() string { // Validate format reg := regexp.MustCompile(`^((?:socks5h?|https?):\/\/)(([\P{Cc}]+):([\P{Cc}]+)@)?(([a-zA-Z0-9][a-zA-Z0-9.-]*)(:[0-9]{1,5})?)`) proxy := i.getString(Proxy) if proxy != "" && reg.MatchString(proxy) { logger.Debug("Proxy is valid, using it") return proxy } else if proxy != "" { logger.Error("Proxy is invalid, please review your configuration") return "" } return "" } // GetProxy returns the url of a http proxy to be used for all outgoing http calls. func (i *Config) GetNoProxy() string { // NoProxy does not require validation, it is validated by the native Go library sufficiently return i.getString(NoProxy) } // ActivatePublicAccessTripwire sets the security_tripwire_accessed_from_public_internet // config field to the provided IP address to indicate that stash has been accessed // from this public IP without authentication. func (i *Config) ActivatePublicAccessTripwire(requestIP string) error { i.SetString(SecurityTripwireAccessedFromPublicInternet, requestIP) return i.Write() } func (i *Config) getPackageSources(key string) []*models.PackageSource { var sources []*models.PackageSource if err := i.unmarshalKey(key, &sources); err != nil { logger.Warnf("error in unmarshalkey: %v", err) } return sources } func (i *Config) GetPluginPackageSources() []*models.PackageSource { return i.getPackageSources(PluginPackageSources) } func (i *Config) GetScraperPackageSources() []*models.PackageSource { return i.getPackageSources(ScraperPackageSources) } type packagePathGetter struct { getterFn func() []*models.PackageSource } func (g packagePathGetter) GetAllSourcePaths() []string { p := g.getterFn() var ret []string for _, v := range p { ret = sliceutil.AppendUnique(ret, v.LocalPath) } return ret } func (g packagePathGetter) GetSourcePath(srcURL string) string { p := g.getterFn() for _, v := range p { if v.URL == srcURL { return v.LocalPath } } return "" } func (i *Config) GetPluginPackagePathGetter() packagePathGetter { return packagePathGetter{ getterFn: i.GetPluginPackageSources, } } func (i *Config) GetScraperPackagePathGetter() packagePathGetter { return packagePathGetter{ getterFn: i.GetScraperPackageSources, } } func (i *Config) Validate() error { i.RLock() defer i.RUnlock() mandatoryPaths := []string{ Database, Generated, } var missingFields []string for _, p := range mandatoryPaths { if !i.forKey(p).Exists(p) || i.forKey(p).String(p) == "" { missingFields = append(missingFields, p) } } if len(missingFields) > 0 { return MissingConfigError{ missingFields: missingFields, } } if i.GetBlobsStorage() == BlobStorageTypeFilesystem && i.forKey(BlobsPath).String(BlobsPath) == "" { return MissingConfigError{ missingFields: []string{BlobsPath}, } } return nil } func (i *Config) setDefaultValues() { // read data before write lock scope defaultDatabaseFilePath := i.GetDefaultDatabaseFilePath() defaultScrapersPath := i.GetDefaultScrapersPath() defaultPluginsPath := i.GetDefaultPluginsPath() i.Lock() defer i.Unlock() // set the default host and port so that these are written to the config // file i.setDefault(Host, hostDefault) i.setDefault(Port, portDefault) i.setDefault(ParallelTasks, parallelTasksDefault) i.setDefault(SequentialScanning, SequentialScanningDefault) i.setDefault(PreviewSegmentDuration, previewSegmentDurationDefault) i.setDefault(PreviewSegments, previewSegmentsDefault) i.setDefault(PreviewExcludeStart, previewExcludeStartDefault) i.setDefault(PreviewExcludeEnd, previewExcludeEndDefault) i.setDefault(PreviewAudio, previewAudioDefault) i.setDefault(SoundOnPreview, false) i.setDefault(UseCustomSpriteInterval, UseCustomSpriteIntervalDefault) i.setDefault(SpriteInterval, SpriteIntervalDefault) i.setDefault(MinimumSprites, MinimumSpritesDefault) i.setDefault(MaximumSprites, MaximumSpritesDefault) i.setDefault(SpriteScreenshotSize, spriteScreenshotSizeDefault) i.setDefault(ThemeColor, DefaultThemeColor) i.setDefault(WriteImageThumbnails, writeImageThumbnailsDefault) i.setDefault(CreateImageClipsFromVideos, createImageClipsFromVideosDefault) i.setDefault(Database, defaultDatabaseFilePath) i.setDefault(dangerousAllowPublicWithoutAuth, dangerousAllowPublicWithoutAuthDefault) i.setDefault(SecurityTripwireAccessedFromPublicInternet, securityTripwireAccessedFromPublicInternetDefault) // Set generated to the metadata path for backwards compat i.setDefault(Generated, i.main.String(Metadata)) i.setDefault(NoBrowser, NoBrowserDefault) i.setDefault(NotificationsEnabled, NotificationsEnabledDefault) i.setDefault(ShowOneTimeMovedNotification, ShowOneTimeMovedNotificationDefault) // Set default scrapers and plugins paths i.setDefault(ScrapersPath, defaultScrapersPath) i.setDefault(PluginsPath, defaultPluginsPath) // Set default gallery cover regex i.setDefault(GalleryCoverRegex, galleryCoverRegexDefault) // Set NoProxy default i.setDefault(NoProxy, noProxyDefault) // set default package sources i.setDefault(PluginPackageSources, []map[string]string{{ "name": sourceDefaultName, "url": pluginPackageSourcesDefault, "localpath": sourceDefaultPath, }}) i.setDefault(ScraperPackageSources, []map[string]string{{ "name": sourceDefaultName, "url": scraperPackageSourcesDefault, "localpath": sourceDefaultPath, }}) } // setExistingSystemDefaults sets config options that are new and unset in an existing install, // but should have a separate default than for brand-new systems, to maintain behavior. // The config file will not be written. func (i *Config) setExistingSystemDefaults() { i.Lock() defer i.Unlock() if !i.isNewSystem { // Existing systems as of the introduction of auto-browser open should retain existing // behavior and not start the browser automatically. if !i.main.Exists(NoBrowser) { i.set(NoBrowser, true) } // Existing systems as of the introduction of the taskbar should inform users. if !i.main.Exists(ShowOneTimeMovedNotification) { i.set(ShowOneTimeMovedNotification, true) } } } // SetInitialConfig fills in missing required config fields. The config file will not be written. func (i *Config) SetInitialConfig() error { // generate some api keys const apiKeyLength = 32 if string(i.GetJWTSignKey()) == "" { signKey, err := hash.GenerateRandomKey(apiKeyLength) if err != nil { return fmt.Errorf("error generating JWTSignKey: %w", err) } i.SetString(JWTSignKey, signKey) } if string(i.GetSessionStoreKey()) == "" { sessionStoreKey, err := hash.GenerateRandomKey(apiKeyLength) if err != nil { return fmt.Errorf("error generating session store key: %w", err) } i.SetString(SessionStoreKey, sessionStoreKey) } i.setDefaultValues() return nil } func (i *Config) FinalizeSetup() { i.isNewSystem = false // i.configUpdates <- 0 } ================================================ FILE: internal/manager/config/config_concurrency_test.go ================================================ package config import ( "sync" "testing" "time" ) // should be run with -race func TestConcurrentConfigAccess(t *testing.T) { i := InitializeEmpty() const workers = 8 const loops = 200 var wg sync.WaitGroup for k := 0; k < workers; k++ { wg.Add(1) go func(wk int) { for l := 0; l < loops; l++ { start := time.Now() if err := i.SetInitialConfig(); err != nil { t.Errorf("Failure setting initial configuration in worker %v iteration %v: %v", wk, l, err) } i.HasCredentials() i.ValidateCredentials("", "") i.GetConfigFile() i.GetConfigPath() i.GetDefaultDatabaseFilePath() i.SetInterface(BackupDirectoryPath, i.GetBackupDirectoryPath()) i.GetStashPaths() _ = i.ValidateStashBoxes(nil) _ = i.Validate() _ = i.ActivatePublicAccessTripwire("") i.SetInterface(Cache, i.GetCachePath()) i.SetInterface(Generated, i.GetGeneratedPath()) i.SetInterface(Metadata, i.GetMetadataPath()) i.SetInterface(Database, i.GetDatabasePath()) // these must be set as strings since the original values are also strings // setting them as []byte will cause the returned string to be corrupted i.SetInterface(JWTSignKey, string(i.GetJWTSignKey())) i.SetInterface(SessionStoreKey, string(i.GetSessionStoreKey())) i.GetDefaultScrapersPath() i.SetInterface(Exclude, i.GetExcludes()) i.SetInterface(ImageExclude, i.GetImageExcludes()) i.SetInterface(VideoExtensions, i.GetVideoExtensions()) i.SetInterface(ImageExtensions, i.GetImageExtensions()) i.SetInterface(GalleryExtensions, i.GetGalleryExtensions()) i.SetInterface(CreateGalleriesFromFolders, i.GetCreateGalleriesFromFolders()) i.SetInterface(Language, i.GetLanguage()) i.SetInterface(VideoFileNamingAlgorithm, i.GetVideoFileNamingAlgorithm()) i.SetInterface(ScrapersPath, i.GetScrapersPath()) i.SetInterface(ScraperUserAgent, i.GetScraperUserAgent()) i.SetInterface(ScraperCDPPath, i.GetScraperCDPPath()) i.SetInterface(ScraperCertCheck, i.GetScraperCertCheck()) i.SetInterface(ScraperExcludeTagPatterns, i.GetScraperExcludeTagPatterns()) i.SetInterface(StashBoxes, i.GetStashBoxes()) i.GetDefaultPluginsPath() i.SetInterface(PluginsPath, i.GetPluginsPath()) i.SetInterface(Host, i.GetHost()) i.SetInterface(Port, i.GetPort()) i.SetInterface(ExternalHost, i.GetExternalHost()) i.SetInterface(PreviewSegmentDuration, i.GetPreviewSegmentDuration()) i.SetInterface(ParallelTasks, i.GetParallelTasks()) i.SetInterface(ParallelTasks, i.GetParallelTasksWithAutoDetection()) i.SetInterface(PreviewAudio, i.GetPreviewAudio()) i.SetInterface(PreviewSegments, i.GetPreviewSegments()) i.SetInterface(PreviewExcludeStart, i.GetPreviewExcludeStart()) i.SetInterface(PreviewExcludeEnd, i.GetPreviewExcludeEnd()) i.SetInterface(PreviewPreset, i.GetPreviewPreset()) i.SetInterface(MaxTranscodeSize, i.GetMaxTranscodeSize()) i.SetInterface(MaxStreamingTranscodeSize, i.GetMaxStreamingTranscodeSize()) i.SetInterface(ApiKey, i.GetAPIKey()) i.SetInterface(Username, i.GetUsername()) i.SetInterface(Password, i.GetPasswordHash()) i.GetCredentials() i.SetInterface(MaxSessionAge, i.GetMaxSessionAge()) i.SetInterface(CustomServedFolders, i.GetCustomServedFolders()) i.SetInterface(LegacyCustomUILocation, i.GetUILocation()) i.SetInterface(MenuItems, i.GetMenuItems()) i.SetInterface(SoundOnPreview, i.GetSoundOnPreview()) i.SetInterface(WallShowTitle, i.GetWallShowTitle()) i.SetInterface(CustomPerformerImageLocation, i.GetCustomPerformerImageLocation()) i.SetInterface(WallPlayback, i.GetWallPlayback()) i.SetInterface(MaximumLoopDuration, i.GetMaximumLoopDuration()) i.SetInterface(AutostartVideo, i.GetAutostartVideo()) i.SetInterface(ShowStudioAsText, i.GetShowStudioAsText()) i.SetInterface(legacyImageLightboxSlideshowDelay, *i.GetImageLightboxOptions().SlideshowDelay) i.SetInterface(ImageLightboxSlideshowDelay, *i.GetImageLightboxOptions().SlideshowDelay) i.GetCSSPath() i.GetCSS() i.GetJavascriptPath() i.GetJavascript() i.GetCustomLocalesPath() i.GetCustomLocales() i.SetInterface(CSSEnabled, i.GetCSSEnabled()) i.SetInterface(CSSEnabled, i.GetCustomLocalesEnabled()) i.SetInterface(HandyKey, i.GetHandyKey()) i.SetInterface(UseStashHostedFunscript, i.GetUseStashHostedFunscript()) i.SetInterface(DLNAServerName, i.GetDLNAServerName()) i.SetInterface(DLNADefaultEnabled, i.GetDLNADefaultEnabled()) i.SetInterface(DLNADefaultIPWhitelist, i.GetDLNADefaultIPWhitelist()) i.SetInterface(DLNAInterfaces, i.GetDLNAInterfaces()) i.SetInterface(DLNAPort, i.GetDLNAPort()) i.SetInterface(LogFile, i.GetLogFile()) i.SetInterface(LogOut, i.GetLogOut()) i.SetInterface(LogLevel, i.GetLogLevel()) i.SetInterface(LogAccess, i.GetLogAccess()) i.SetInterface(MaxUploadSize, i.GetMaxUploadSize()) i.SetInterface(FunscriptOffset, i.GetFunscriptOffset()) i.SetInterface(DefaultIdentifySettings, i.GetDefaultIdentifySettings()) i.SetInterface(DeleteGeneratedDefault, i.GetDeleteGeneratedDefault()) i.SetInterface(DeleteFileDefault, i.GetDeleteFileDefault()) i.SetInterface(dangerousAllowPublicWithoutAuth, i.GetDangerousAllowPublicWithoutAuth()) i.SetInterface(SecurityTripwireAccessedFromPublicInternet, i.GetSecurityTripwireAccessedFromPublicInternet()) i.SetInterface(DisableDropdownCreatePerformer, i.GetDisableDropdownCreate().Performer) i.SetInterface(DisableDropdownCreateStudio, i.GetDisableDropdownCreate().Studio) i.SetInterface(DisableDropdownCreateTag, i.GetDisableDropdownCreate().Tag) i.SetInterface(DisableDropdownCreateMovie, i.GetDisableDropdownCreate().Movie) i.SetInterface(AutostartVideoOnPlaySelected, i.GetAutostartVideoOnPlaySelected()) i.SetInterface(ContinuePlaylistDefault, i.GetContinuePlaylistDefault()) i.SetInterface(PythonPath, i.GetPythonPath()) t.Logf("Worker %v iteration %v took %v", wk, l, time.Since(start)) } wg.Done() }(k) } wg.Wait() } ================================================ FILE: internal/manager/config/config_test.go ================================================ package config import ( "testing" "github.com/stretchr/testify/assert" ) func TestConfig_GetAllPluginConfiguration(t *testing.T) { i := InitializeEmpty() assert.Equal(t, i.GetAllPluginConfiguration(), map[string]map[string]interface{}{}) i.SetPluginConfiguration("plugin1", map[string]interface{}{"key1": "value1"}) assert.Equal(t, map[string]map[string]interface{}{ "plugin1": {"key1": "value1"}, }, i.GetAllPluginConfiguration()) i.SetPluginConfiguration("plugin2", map[string]interface{}{"key2": "value2"}) assert.Equal(t, map[string]map[string]interface{}{ "plugin1": {"key1": "value1"}, "plugin2": {"key2": "value2"}, }, i.GetAllPluginConfiguration()) // ensure SetPluginConfiguration overwrites existing configuration i.SetPluginConfiguration("plugin2", map[string]interface{}{"key3": "value3"}) assert.Equal(t, map[string]map[string]interface{}{ "plugin1": {"key1": "value1"}, "plugin2": {"key3": "value3"}, }, i.GetAllPluginConfiguration()) } ================================================ FILE: internal/manager/config/enums.go ================================================ package config import ( "fmt" "io" "strconv" ) type BlobsStorageType string const ( // Database BlobStorageTypeDatabase BlobsStorageType = "DATABASE" // Filesystem BlobStorageTypeFilesystem BlobsStorageType = "FILESYSTEM" ) var AllBlobStorageType = []BlobsStorageType{ BlobStorageTypeDatabase, BlobStorageTypeFilesystem, } func (e BlobsStorageType) IsValid() bool { switch e { case BlobStorageTypeDatabase, BlobStorageTypeFilesystem: return true } return false } func (e BlobsStorageType) String() string { return string(e) } func (e *BlobsStorageType) UnmarshalGQL(v interface{}) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = BlobsStorageType(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid BlobStorageType", str) } return nil } func (e BlobsStorageType) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } ================================================ FILE: internal/manager/config/init.go ================================================ package config import ( "errors" "fmt" "net" "os" "path/filepath" "strings" "github.com/knadh/koanf/providers/env" "github.com/knadh/koanf/providers/posflag" "github.com/knadh/koanf/v2" "github.com/spf13/pflag" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" ) type flagStruct struct { configFilePath string nobrowser bool } var ( flags flagStruct homeDir, _ = os.UserHomeDir() defaultConfigLocations = []string{ "config.yml", filepath.Join(homeDir, ".stash", "config.yml"), } // map of env vars to config keys envBinds = map[string]string{ "host": Host, "port": Port, "external_host": ExternalHost, "generated": Generated, "metadata": Metadata, "blobs": BlobsPath, "cache": Cache, "stash": Stash, "ui": UILocation, } ) var errConfigNotFound = errors.New("config file not found") func init() { pflag.IP("host", net.IPv4(0, 0, 0, 0), "ip address for the host") pflag.Int("port", 9999, "port to serve from") pflag.StringVarP(&flags.configFilePath, "config", "c", "", "config file to use") pflag.BoolVar(&flags.nobrowser, "nobrowser", false, "Don't open a browser window after launch") pflag.StringP("ui-location", "u", "", "path to the webui") } // Called at startup func Initialize() (*Config, error) { cfg := &Config{ main: koanf.New("."), overrides: koanf.New("."), } cfg.initOverrides() err := cfg.initConfig() if err != nil { return nil, err } if cfg.isNewSystem { if cfg.Validate() == nil { // system has been initialised by the environment cfg.isNewSystem = false } } if !cfg.isNewSystem { cfg.setExistingSystemDefaults() err := cfg.SetInitialConfig() if err != nil { return nil, err } err = cfg.Write() if err != nil { return nil, err } err = cfg.Validate() if err != nil { return nil, err } } instance = cfg return instance, nil } // Called by tests to initialize an empty config func InitializeEmpty() *Config { cfg := &Config{ main: koanf.New("."), overrides: koanf.New("."), } instance = cfg return instance } func (i *Config) loadFromCommandLine() { v := i.overrides if err := v.Load(posflag.ProviderWithFlag(pflag.CommandLine, ".", v, func(f *pflag.Flag) (string, interface{}) { // ignore flags that have not been changed if !f.Changed { return "", nil } return f.Name, posflag.FlagVal(pflag.CommandLine, f) }), nil); err != nil { logger.Errorf("failed to load flags: %v", err) } } func (i *Config) loadFromEnv() { v := i.overrides if err := v.Load(env.ProviderWithValue("STASH_", ".", func(key, value string) (string, interface{}) { key = strings.ToLower(strings.TrimPrefix(key, "STASH_")) if newKey, ok := envBinds[key]; ok { return newKey, value } return "", nil }), nil); err != nil { logger.Errorf("failed to load envs: %v", err) } } func (i *Config) initOverrides() { i.loadFromCommandLine() i.loadFromEnv() } func (i *Config) initConfig() error { configFile := "" envConfigFile := os.Getenv("STASH_CONFIG_FILE") if flags.configFilePath != "" { configFile = flags.configFilePath } else if envConfigFile != "" { configFile = envConfigFile } if configFile != "" { // if file does not exist, assume it is a new system if exists, _ := fsutil.FileExists(configFile); !exists { i.isNewSystem = true i.SetConfigFile(configFile) // ensure we can write to the file if err := fsutil.Touch(configFile); err != nil { return fmt.Errorf(`could not write to provided config path "%s": %v`, configFile, err) } else { // remove the file os.Remove(configFile) } return nil } else { // load from provided config file if err := i.loadFirstFromFiles([]string{configFile}); err != nil { return err } } } else { // load from default locations if err := i.loadFirstFromFiles(defaultConfigLocations); err != nil { if errors.Is(err, errConfigNotFound) { i.isNewSystem = true return nil } return err } } return nil } func (i *Config) loadFirstFromFiles(f []string) error { for _, ff := range f { if exists, _ := fsutil.FileExists(ff); exists { return i.load(ff) } } return errConfigNotFound } ================================================ FILE: internal/manager/config/stash_config.go ================================================ package config import ( "path/filepath" "github.com/stashapp/stash/pkg/fsutil" ) // Stash configuration details type StashConfigInput struct { Path string `json:"path"` ExcludeVideo bool `json:"excludeVideo"` ExcludeImage bool `json:"excludeImage"` } type StashConfig struct { Path string `json:"path"` ExcludeVideo bool `json:"excludeVideo"` ExcludeImage bool `json:"excludeImage"` } type StashConfigs []*StashConfig func (s StashConfigs) GetStashFromPath(path string) *StashConfig { for _, f := range s { if fsutil.IsPathInDir(f.Path, filepath.Dir(path)) { return f } } return nil } func (s StashConfigs) GetStashFromDirPath(dirPath string) *StashConfig { for _, f := range s { if fsutil.IsPathInDir(f.Path, dirPath) { return f } } return nil } func (s StashConfigs) Paths() []string { paths := make([]string, len(s)) for i, c := range s { // #6618 - clean the path to ensure comparison works correctly paths[i] = filepath.Clean(c.Path) } return paths } ================================================ FILE: internal/manager/config/tasks.go ================================================ package config type ScanMetadataOptions struct { // Forces a rescan on files even if they have not changed Rescan bool `json:"rescan"` // Generate scene covers during scan ScanGenerateCovers bool `json:"scanGenerateCovers"` // Generate previews during scan ScanGeneratePreviews bool `json:"scanGeneratePreviews"` // Generate image previews during scan ScanGenerateImagePreviews bool `json:"scanGenerateImagePreviews"` // Generate sprites during scan ScanGenerateSprites bool `json:"scanGenerateSprites"` // Generate video phashes during scan ScanGeneratePhashes bool `json:"scanGeneratePhashes"` // Generate image phashes during scan ScanGenerateImagePhashes bool `json:"scanGenerateImagePhashes"` // Generate image thumbnails during scan ScanGenerateThumbnails bool `json:"scanGenerateThumbnails"` // Generate image thumbnails during scan ScanGenerateClipPreviews bool `json:"scanGenerateClipPreviews"` } type AutoTagMetadataOptions struct { // IDs of performers to tag files with, or "*" for all Performers []string `json:"performers"` // IDs of studios to tag files with, or "*" for all Studios []string `json:"studios"` // IDs of tags to tag files with, or "*" for all Tags []string `json:"tags"` } ================================================ FILE: internal/manager/config/ui.go ================================================ package config import ( "fmt" "io" "strconv" ) type ConfigImageLightboxResult struct { SlideshowDelay *int `json:"slideshowDelay"` DisplayMode *ImageLightboxDisplayMode `json:"displayMode"` ScaleUp *bool `json:"scaleUp"` ResetZoomOnNav *bool `json:"resetZoomOnNav"` ScrollMode *ImageLightboxScrollMode `json:"scrollMode"` ScrollAttemptsBeforeChange int `json:"scrollAttemptsBeforeChange"` DisableAnimation *bool `json:"disableAnimation"` } type ImageLightboxDisplayMode string const ( ImageLightboxDisplayModeOriginal ImageLightboxDisplayMode = "ORIGINAL" ImageLightboxDisplayModeFitXy ImageLightboxDisplayMode = "FIT_XY" ImageLightboxDisplayModeFitX ImageLightboxDisplayMode = "FIT_X" ) var AllImageLightboxDisplayMode = []ImageLightboxDisplayMode{ ImageLightboxDisplayModeOriginal, ImageLightboxDisplayModeFitXy, ImageLightboxDisplayModeFitX, } func (e ImageLightboxDisplayMode) IsValid() bool { switch e { case ImageLightboxDisplayModeOriginal, ImageLightboxDisplayModeFitXy, ImageLightboxDisplayModeFitX: return true } return false } func (e ImageLightboxDisplayMode) String() string { return string(e) } func (e *ImageLightboxDisplayMode) UnmarshalGQL(v interface{}) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = ImageLightboxDisplayMode(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid ImageLightboxDisplayMode", str) } return nil } func (e ImageLightboxDisplayMode) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } type ImageLightboxScrollMode string const ( ImageLightboxScrollModeZoom ImageLightboxScrollMode = "ZOOM" ImageLightboxScrollModePanY ImageLightboxScrollMode = "PAN_Y" ) var AllImageLightboxScrollMode = []ImageLightboxScrollMode{ ImageLightboxScrollModeZoom, ImageLightboxScrollModePanY, } func (e ImageLightboxScrollMode) IsValid() bool { switch e { case ImageLightboxScrollModeZoom, ImageLightboxScrollModePanY: return true } return false } func (e ImageLightboxScrollMode) String() string { return string(e) } func (e *ImageLightboxScrollMode) UnmarshalGQL(v interface{}) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = ImageLightboxScrollMode(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid ImageLightboxScrollMode", str) } return nil } func (e ImageLightboxScrollMode) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } type ConfigDisableDropdownCreate struct { Performer bool `json:"performer"` Tag bool `json:"tag"` Studio bool `json:"studio"` Movie bool `json:"movie"` Gallery bool `json:"gallery"` } ================================================ FILE: internal/manager/downloads.go ================================================ package manager import ( "net/http" "os" "sync" "time" "github.com/stashapp/stash/pkg/hash" "github.com/stashapp/stash/pkg/logger" ) // DownloadStore manages single-use generated files for the UI to download. type DownloadStore struct { m map[string]*storeFile mutex sync.Mutex } type storeFile struct { path string contentType string keep bool wg sync.WaitGroup once sync.Once } func NewDownloadStore() *DownloadStore { return &DownloadStore{ m: make(map[string]*storeFile), } } func (s *DownloadStore) RegisterFile(fp string, contentType string, keep bool) (string, error) { const keyLength = 4 const attempts = 100 // keep generating random keys until we get a free one // prevent infinite loop by only attempting a finite amount of times var h string generate := true a := 0 s.mutex.Lock() for generate && a < attempts { var err error h, err = hash.GenerateRandomKey(keyLength) if err != nil { return "", err } _, generate = s.m[h] a++ } s.m[h] = &storeFile{ path: fp, contentType: contentType, keep: keep, } s.mutex.Unlock() return h, nil } func (s *DownloadStore) Serve(hash string, w http.ResponseWriter, r *http.Request) { s.mutex.Lock() f, ok := s.m[hash] if !ok { s.mutex.Unlock() http.NotFound(w, r) return } if !f.keep { s.waitAndRemoveFile(hash, &w, r) } s.mutex.Unlock() if f.contentType != "" { w.Header().Add("Content-Type", f.contentType) } w.Header().Set("Cache-Control", "no-store") http.ServeFile(w, r, f.path) } func (s *DownloadStore) waitAndRemoveFile(hash string, w *http.ResponseWriter, r *http.Request) { f := s.m[hash] notify := r.Context().Done() f.wg.Add(1) go func() { <-notify s.mutex.Lock() defer s.mutex.Unlock() f.wg.Done() }() go f.once.Do(func() { // leave it up for 30 seconds after the first request to allow for multiple requests time.Sleep(30 * time.Second) f.wg.Wait() s.mutex.Lock() defer s.mutex.Unlock() delete(s.m, hash) err := os.Remove(f.path) if err != nil { logger.Errorf("error removing %s after downloading: %s", f.path, err.Error()) } }) } ================================================ FILE: internal/manager/enums.go ================================================ package manager import ( "fmt" "io" "strconv" ) type SystemStatusEnum string const ( SystemStatusEnumSetup SystemStatusEnum = "SETUP" SystemStatusEnumNeedsMigration SystemStatusEnum = "NEEDS_MIGRATION" SystemStatusEnumOk SystemStatusEnum = "OK" ) var AllSystemStatusEnum = []SystemStatusEnum{ SystemStatusEnumSetup, SystemStatusEnumNeedsMigration, SystemStatusEnumOk, } func (e SystemStatusEnum) IsValid() bool { switch e { case SystemStatusEnumSetup, SystemStatusEnumNeedsMigration, SystemStatusEnumOk: return true } return false } func (e SystemStatusEnum) String() string { return string(e) } func (e *SystemStatusEnum) UnmarshalGQL(v interface{}) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = SystemStatusEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid SystemStatusEnum", str) } return nil } func (e SystemStatusEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } ================================================ FILE: internal/manager/exclude_files.go ================================================ package manager import ( "regexp" "strings" "github.com/stashapp/stash/pkg/logger" ) func excludeFiles(files []string, patterns []string) ([]string, int) { if patterns == nil { logger.Infof("No exclude patterns in config") return files, 0 } else { var results []string var exclCount int fileRegexps := generateRegexps(patterns) if len(fileRegexps) == 0 { logger.Infof("Excluded 0 files from scan") return files, 0 } for _, f := range files { if matchFileSimple(f, fileRegexps) { logger.Infof("File matched pattern. Excluding:\"%s\"", f) exclCount++ } else { // if pattern doesn't match add file to list results = append(results, f) } } logger.Infof("Excluded %d file(s) from scan", exclCount) return results, exclCount } } func matchFileRegex(file string, fileRegexps []*regexp.Regexp) bool { for _, regPattern := range fileRegexps { if regPattern.MatchString(file) { return true } } return false } func matchFile(file string, patterns []string) bool { if patterns != nil { fileRegexps := generateRegexps(patterns) return matchFileRegex(file, fileRegexps) } return false } func generateRegexps(patterns []string) []*regexp.Regexp { var fileRegexps []*regexp.Regexp for _, pattern := range patterns { if pattern == "" || pattern == " " { logger.Warnf("Skipping empty exclude pattern") continue } if !strings.HasPrefix(pattern, "(?i)") { pattern = "(?i)" + pattern } reg, err := regexp.Compile(pattern) if err != nil { logger.Errorf("Exclude :%v", err) } else { fileRegexps = append(fileRegexps, reg) } } if len(fileRegexps) == 0 { return nil } else { return fileRegexps } } func matchFileSimple(file string, regExps []*regexp.Regexp) bool { for _, regPattern := range regExps { if regPattern.MatchString(file) { return true } } return false } ================================================ FILE: internal/manager/exclude_files_test.go ================================================ package manager import ( "fmt" "testing" "github.com/stashapp/stash/pkg/logger" ) var excludeTestFilenames = []string{ "/stash/videos/filename.mp4", "/stash/videos/new filename.mp4", "filename sample.mp4", "/stash/videos/exclude/not wanted.webm", "/stash/videos/exclude/not wanted2.webm", "/somewhere/trash/not wanted.wmv", "/disk2/stash/videos/exclude/!!wanted!!.avi", "/disk2/stash/videos/xcl/not wanted.avi", "/stash/videos/partial.file.001.webm", "/stash/videos/partial.file.002.webm", "/stash/videos/partial.file.003.webm", "/stash/videos/sample file sample.mkv", "/stash/videos/.ckRVp1/.still_encoding.mp4", "c:\\stash\\videos\\exclude\\filename windows.mp4", "c:\\stash\\videos\\filename windows.mp4", "\\\\network\\videos\\filename windows network.mp4", "\\\\network\\share\\windows network wanted.mp4", "\\\\network\\share\\windows network wanted sample.mp4", "\\\\network\\private\\windows.network.skip.mp4", "/stash/videos/a5.mp4", "/stash/videos/mIxEdCaSe.mp4"} var excludeTests = []struct { testPattern []string expected int }{ {[]string{"sample\\.mp4$", "trash", "\\.[\\d]{3}\\.webm$"}, 6}, // generic {[]string{"no_match\\.mp4"}, 0}, // no match {[]string{"^/stash/videos/exclude/", "/videos/xcl/"}, 3}, // linux {[]string{"/\\.[[:word:]]+/"}, 1}, // linux hidden dirs (handbrake unraid issue?) {[]string{"c:\\\\stash\\\\videos\\\\exclude"}, 1}, // windows {[]string{"\\/[/invalid"}, 0}, // invalid pattern {[]string{"\\/[/invalid", "sample\\.[[:alnum:]]+$"}, 3}, // invalid pattern but continue {[]string{"^\\\\\\\\network"}, 4}, // windows net share {[]string{"\\\\private\\\\"}, 1}, // windows net share {[]string{"\\\\private\\\\", "sample\\.mp4"}, 3}, // windows net share {[]string{"\\D\\d\\.mp4"}, 1}, // validates that \D doesn't get converted to lowercase \d {[]string{"mixedcase\\.mp4"}, 1}, // validates we can match the mixed case file {[]string{"MIXEDCASE\\.mp4"}, 1}, // validates we can match the mixed case file {[]string{"(?i)MIXEDCASE\\.mp4"}, 1}, // validates we can match the mixed case file without adding another (?i) to it } func TestExcludeFiles(t *testing.T) { for _, test := range excludeTests { err := runExclude(excludeTestFilenames, test.testPattern, test.expected) if err != nil { t.Error(err) } } } func runExclude(filenames []string, patterns []string, expCount int) error { files, count := excludeFiles(filenames, patterns) if count != expCount { return fmt.Errorf("Was expecting %d, found %d", expCount, count) } if len(files) != len(filenames)-expCount { return fmt.Errorf("Returned list should have %d files, not %d ", len(filenames)-expCount, len(files)) } return nil } func TestMatchFile(t *testing.T) { for _, test := range excludeTests { err := runMatch(excludeTestFilenames, test.testPattern, test.expected) if err != nil { t.Error(err) } } } func runMatch(filenames []string, patterns []string, expCount int) error { count := 0 for _, file := range filenames { if matchFile(file, patterns) { logger.Infof("File \"%s\" matched pattern\n", file) count++ } } if count != expCount { return fmt.Errorf("Was expecting %d, found %d", expCount, count) } return nil } ================================================ FILE: internal/manager/fingerprint.go ================================================ package manager import ( "errors" "fmt" "io" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/hash/md5" "github.com/stashapp/stash/pkg/hash/oshash" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) type fingerprintCalculator struct { Config *config.Config } func (c *fingerprintCalculator) calculateOshash(f *models.BaseFile, o file.Opener) (*models.Fingerprint, error) { r, err := o.Open() if err != nil { return nil, fmt.Errorf("opening file: %w", err) } defer r.Close() rc, isRC := r.(io.ReadSeeker) if !isRC { return nil, errors.New("cannot calculate oshash for non-readcloser") } hash, err := oshash.FromReader(rc, f.Size) if err != nil { return nil, fmt.Errorf("calculating oshash: %w", err) } return &models.Fingerprint{ Type: models.FingerprintTypeOshash, Fingerprint: hash, }, nil } func (c *fingerprintCalculator) calculateMD5(o file.Opener) (*models.Fingerprint, error) { r, err := o.Open() if err != nil { return nil, fmt.Errorf("opening file: %w", err) } defer r.Close() hash, err := md5.FromReader(r) if err != nil { return nil, fmt.Errorf("calculating md5: %w", err) } return &models.Fingerprint{ Type: models.FingerprintTypeMD5, Fingerprint: hash, }, nil } func (c *fingerprintCalculator) CalculateFingerprints(f *models.BaseFile, o file.Opener, useExisting bool) ([]models.Fingerprint, error) { var ret []models.Fingerprint calculateMD5 := true if useAsVideo(f.Path) { var ( fp *models.Fingerprint err error ) if useExisting { fp = f.Fingerprints.For(models.FingerprintTypeOshash) } if fp == nil { // calculate oshash first fp, err = c.calculateOshash(f, o) if err != nil { return nil, err } } ret = append(ret, *fp) // only calculate MD5 if enabled in config calculateMD5 = c.Config.IsCalculateMD5() } if calculateMD5 { var ( fp *models.Fingerprint err error ) if useExisting { fp = f.Fingerprints.For(models.FingerprintTypeMD5) } if fp == nil { if useExisting { // log to indicate missing fingerprint is being calculated logger.Infof("Calculating checksum for %s ...", f.Path) } fp, err = c.calculateMD5(o) if err != nil { return nil, err } } ret = append(ret, *fp) } return ret, nil } ================================================ FILE: internal/manager/generator.go ================================================ package manager import ( "context" "fmt" "math" "strconv" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" ) type generatorInfo struct { ChunkCount int FrameRate float64 NumberOfFrames int // NthFrame used for sprite generation NthFrame int VideoFile ffmpeg.VideoFile } func newGeneratorInfo(videoFile ffmpeg.VideoFile) (*generatorInfo, error) { exists, err := fsutil.FileExists(videoFile.Path) if !exists { logger.Errorf("video file not found") return nil, err } generator := &generatorInfo{VideoFile: videoFile} return generator, nil } func (g *generatorInfo) calculateFrameRate(videoStream *ffmpeg.FFProbeStream) error { var framerate float64 if g.VideoFile.FrameRate == 0 { framerate, _ = strconv.ParseFloat(videoStream.RFrameRate, 64) } else { framerate = g.VideoFile.FrameRate } numberOfFrames, _ := strconv.Atoi(videoStream.NbFrames) if numberOfFrames == 0 && isValidFloat64(framerate) && g.VideoFile.VideoStreamDuration > 0 { // TODO: test numberOfFrames = int(framerate * g.VideoFile.VideoStreamDuration) } // If we are missing the frame count or frame rate then seek through the file and extract the info with regex if numberOfFrames == 0 || !isValidFloat64(framerate) { info, err := instance.FFMpeg.CalculateFrameRate(context.TODO(), &g.VideoFile) if err != nil { logger.Errorf("error calculating frame rate: %v", err) } else { if numberOfFrames == 0 { numberOfFrames = info.NumberOfFrames } if !isValidFloat64(framerate) { framerate = info.FrameRate } } } // Something seriously wrong with this file if numberOfFrames == 0 || !isValidFloat64(framerate) { logger.Errorf( "number of frames or framerate is 0. nb_frames <%s> framerate <%f> duration <%f>", videoStream.NbFrames, framerate, g.VideoFile.VideoStreamDuration, ) } g.FrameRate = framerate g.NumberOfFrames = numberOfFrames return nil } // isValidFloat64 ensures the given value is a valid number (not NaN) which is not equal to 0 func isValidFloat64(value float64) bool { return !math.IsNaN(value) && value != 0 } func (g *generatorInfo) configure() error { videoStream := g.VideoFile.VideoStream if videoStream == nil { return fmt.Errorf("missing video stream") } if err := g.calculateFrameRate(videoStream); err != nil { return err } // #2250 - ensure ChunkCount is valid if g.ChunkCount < 1 { logger.Warnf("[generator] Segment count (%d) must be > 0. Using 1 instead.", g.ChunkCount) g.ChunkCount = 1 } g.NthFrame = g.NumberOfFrames / g.ChunkCount return nil } ================================================ FILE: internal/manager/generator_interactive_heatmap_speed.go ================================================ package manager import ( "bytes" "encoding/json" "fmt" "image" "image/draw" "image/png" "math" "os" "sort" "github.com/lucasb-eyer/go-colorful" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" ) type InteractiveHeatmapSpeedGenerator struct { InteractiveSpeed int Funscript Script Width int Height int NumSegments int DrawRange bool } type Script struct { // Version of Launchscript // #5600 - ignore version, don't validate type Version json.RawMessage `json:"version"` // Inverted causes up and down movement to be flipped. Inverted bool `json:"inverted,omitempty"` // Range is the percentage of a full stroke to use. Range int `json:"range,omitempty"` // Actions are the timed moves. Actions []Action `json:"actions"` } // Action is a move at a specific time. type Action struct { // At time in milliseconds the action should fire. At float64 `json:"at"` // Pos is the place in percent to move to. Pos int `json:"pos"` Speed float64 } type GradientTable []struct { Col colorful.Color Pos float64 YRange [2]float64 } func NewInteractiveHeatmapSpeedGenerator(drawRange bool) *InteractiveHeatmapSpeedGenerator { return &InteractiveHeatmapSpeedGenerator{ Width: 1280, Height: 60, NumSegments: 600, DrawRange: drawRange, } } func (g *InteractiveHeatmapSpeedGenerator) Generate(funscriptPath string, heatmapPath string, sceneDuration float64) error { funscript, err := g.LoadFunscriptData(funscriptPath, sceneDuration) if err != nil { return err } if len(funscript.Actions) == 0 { return fmt.Errorf("no valid actions in funscript") } sceneDurationMilli := int64(sceneDuration * 1000) g.Funscript = funscript g.Funscript.UpdateIntensityAndSpeed() err = g.RenderHeatmap(heatmapPath, sceneDurationMilli) if err != nil { return err } g.InteractiveSpeed = g.Funscript.CalculateMedian() return nil } func (g *InteractiveHeatmapSpeedGenerator) LoadFunscriptData(path string, sceneDuration float64) (Script, error) { data, err := os.ReadFile(path) if err != nil { return Script{}, err } var funscript Script err = json.Unmarshal(data, &funscript) if err != nil { return Script{}, err } if funscript.Actions == nil { return Script{}, fmt.Errorf("actions list missing in %s", path) } sort.SliceStable(funscript.Actions, func(i, j int) bool { return funscript.Actions[i].At < funscript.Actions[j].At }) // trim actions with negative timestamps to avoid index range errors when generating heatmap // #3181 - also trim actions that occur after the scene duration loggedBadTimestamp := false sceneDurationMilli := sceneDuration * 1000 isValid := func(x float64) bool { return x >= 0 && x < sceneDurationMilli } i := 0 for _, x := range funscript.Actions { if isValid(x.At) { funscript.Actions[i] = x i++ } else if !loggedBadTimestamp { loggedBadTimestamp = true logger.Warnf("Invalid timestamp %d in %s: subsequent invalid timestamps will not be logged", x.At, path) } } funscript.Actions = funscript.Actions[:i] return funscript, nil } func (funscript *Script) UpdateIntensityAndSpeed() { var t1, t2 float64 var p1, p2 int var intensity float64 for i := range funscript.Actions { if i == 0 { continue } t1 = funscript.Actions[i].At t2 = funscript.Actions[i-1].At p1 = funscript.Actions[i].Pos p2 = funscript.Actions[i-1].Pos speed := math.Abs(float64(p1 - p2)) intensity = float64(speed/float64(t1-t2)) * 1000 funscript.Actions[i].Speed = intensity } } // funscript needs to have intensity updated first func (g *InteractiveHeatmapSpeedGenerator) RenderHeatmap(heatmapPath string, sceneDurationMilli int64) error { gradient := g.Funscript.getGradientTable(g.NumSegments, sceneDurationMilli) img := image.NewRGBA(image.Rect(0, 0, g.Width, g.Height)) for x := 0; x < g.Width; x++ { xPos := float64(x) / float64(g.Width) c := gradient.GetInterpolatedColorFor(xPos) y0 := 0 y1 := g.Height if g.DrawRange { yRange := gradient.GetYRange(xPos) top := int(yRange[0] / 100.0 * float64(g.Height)) bottom := int(yRange[1] / 100.0 * float64(g.Height)) y0 = g.Height - top y1 = g.Height - bottom } draw.Draw(img, image.Rect(x, y0, x+1, y1), &image.Uniform{c}, image.Point{}, draw.Src) } // add 10 minute marks maxts := sceneDurationMilli const tick = 600000 var ts int64 = tick c, _ := colorful.Hex("#000000") for ts < maxts { x := int(float64(ts) / float64(maxts) * float64(g.Width)) draw.Draw(img, image.Rect(x-1, g.Height/2, x+1, g.Height), &image.Uniform{c}, image.Point{}, draw.Src) ts += tick } outpng, err := os.Create(heatmapPath) if err != nil { return err } defer outpng.Close() err = png.Encode(outpng, img) return err } func (funscript *Script) CalculateMedian() int { sort.Slice(funscript.Actions, func(i, j int) bool { return funscript.Actions[i].Speed < funscript.Actions[j].Speed }) mNumber := len(funscript.Actions) / 2 if len(funscript.Actions)%2 != 0 { return int(funscript.Actions[mNumber].Speed) } return int((funscript.Actions[mNumber-1].Speed + funscript.Actions[mNumber].Speed) / 2) } func (gt GradientTable) GetInterpolatedColorFor(t float64) colorful.Color { for i := 0; i < len(gt)-1; i++ { c1 := gt[i] c2 := gt[i+1] if c1.Pos <= t && t <= c2.Pos { // We are in between c1 and c2. Go blend them! t := (t - c1.Pos) / (c2.Pos - c1.Pos) return c1.Col.BlendHcl(c2.Col, t).Clamped() } } // Nothing found? Means we're at (or past) the last gradient keypoint. return gt[len(gt)-1].Col } func (gt GradientTable) GetYRange(t float64) [2]float64 { for i := 0; i < len(gt)-1; i++ { c1 := gt[i] c2 := gt[i+1] if c1.Pos <= t && t <= c2.Pos { // TODO: We are in between c1 and c2. Go blend them! return c1.YRange } } // Nothing found? Means we're at (or past) the last gradient keypoint. return gt[len(gt)-1].YRange } func (funscript Script) getGradientTable(numSegments int, sceneDurationMilli int64) GradientTable { const windowSize = 15 const backfillThreshold = float64(500) segments := make([]struct { count int intensity int yRange [2]float64 at float64 }, numSegments) gradient := make(GradientTable, numSegments) posList := []int{} maxts := sceneDurationMilli for _, a := range funscript.Actions { posList = append(posList, a.Pos) if len(posList) > windowSize { posList = posList[1:] } sortedPos := make([]int, len(posList)) copy(sortedPos, posList) sort.Ints(sortedPos) topHalf := sortedPos[len(sortedPos)/2:] bottomHalf := sortedPos[0 : len(sortedPos)/2] var totalBottom int var totalTop int for _, value := range bottomHalf { totalBottom += value } for _, value := range topHalf { totalTop += value } averageBottom := float64(totalBottom) / float64(len(bottomHalf)) averageTop := float64(totalTop) / float64(len(topHalf)) segment := int(float64(a.At) / float64(maxts+1) * float64(numSegments)) // #3181 - sanity check. Clamp segment to numSegments-1 if segment >= numSegments { segment = numSegments - 1 } segments[segment].at = a.At segments[segment].count++ segments[segment].intensity += int(a.Speed) segments[segment].yRange[0] = averageTop segments[segment].yRange[1] = averageBottom } lastSegment := segments[0] // Fill in gaps in segments for i := 0; i < numSegments; i++ { segmentTS := float64((maxts / int64(numSegments)) * int64(i)) // Empty segment - fill it with the previous up to backfillThreshold ms if segments[i].count == 0 { if segmentTS-lastSegment.at < backfillThreshold { segments[i].count = lastSegment.count segments[i].intensity = lastSegment.intensity segments[i].yRange[0] = lastSegment.yRange[0] segments[i].yRange[1] = lastSegment.yRange[1] } } else { lastSegment = segments[i] } } for i := 0; i < numSegments; i++ { gradient[i].Pos = float64(i) / float64(numSegments-1) gradient[i].YRange = segments[i].yRange if segments[i].count > 0 { gradient[i].Col = getSegmentColor(float64(segments[i].intensity) / float64(segments[i].count)) } else { gradient[i].Col = getSegmentColor(0.0) } } return gradient } func getSegmentColor(intensity float64) colorful.Color { colorBlue, _ := colorful.Hex("#1e90ff") // DodgerBlue colorGreen, _ := colorful.Hex("#228b22") // ForestGreen colorYellow, _ := colorful.Hex("#ffd700") // Gold colorRed, _ := colorful.Hex("#dc143c") // Crimson colorPurple, _ := colorful.Hex("#800080") // Purple colorBlack, _ := colorful.Hex("#0f001e") colorBackground, _ := colorful.Hex("#30404d") // Same as GridCard bg var stepSize = 125.0 var f float64 var c colorful.Color switch { case intensity <= 25: c = colorBackground case intensity <= 1*stepSize: f = (intensity - 0*stepSize) / stepSize c = colorBlue.BlendLab(colorGreen, f) case intensity <= 2*stepSize: f = (intensity - 1*stepSize) / stepSize c = colorGreen.BlendLab(colorYellow, f) case intensity <= 3*stepSize: f = (intensity - 2*stepSize) / stepSize c = colorYellow.BlendLab(colorRed, f) case intensity <= 4*stepSize: f = (intensity - 3*stepSize) / stepSize c = colorRed.BlendRgb(colorPurple, f) default: f = (intensity - 4*stepSize) / (5 * stepSize) f = math.Min(f, 1.0) c = colorPurple.BlendLab(colorBlack, f) } return c } func LoadFunscriptData(path string) (Script, error) { data, err := os.ReadFile(path) if err != nil { return Script{}, err } var funscript Script err = json.Unmarshal(data, &funscript) if err != nil { return Script{}, err } if funscript.Actions == nil { return Script{}, fmt.Errorf("actions list missing in %s", path) } sort.SliceStable(funscript.Actions, func(i, j int) bool { return funscript.Actions[i].At < funscript.Actions[j].At }) return funscript, nil } func convertRange(value int, fromLow int, fromHigh int, toLow int, toHigh int) int { return ((value-fromLow)*(toHigh-toLow))/(fromHigh-fromLow) + toLow } func ConvertFunscriptToCSV(funscriptPath string) ([]byte, error) { funscript, err := LoadFunscriptData(funscriptPath) if err != nil { return nil, err } var buffer bytes.Buffer for _, action := range funscript.Actions { pos := action.Pos if funscript.Inverted { pos = convertRange(pos, 0, 100, 100, 0) } if funscript.Range > 0 { pos = convertRange(pos, 0, funscript.Range, 0, 100) } // I don't know whether the csv format requires int or float, so for now we'll use int buffer.WriteString(fmt.Sprintf("%d,%d\r\n", int(math.Round(action.At)), pos)) } return buffer.Bytes(), nil } func ConvertFunscriptToCSVFile(funscriptPath string, csvPath string) error { csvBytes, err := ConvertFunscriptToCSV(funscriptPath) if err != nil { return err } return fsutil.WriteFile(csvPath, csvBytes) } ================================================ FILE: internal/manager/generator_sprite.go ================================================ package manager import ( "context" "errors" "fmt" "image" "math" "github.com/disintegration/imaging" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/scene/generate" ) type SpriteGenerator struct { Info *generatorInfo VideoChecksum string ImageOutputPath string VTTOutputPath string Config SpriteGeneratorConfig SlowSeek bool // use alternate seek function, very slow! Overwrite bool g *generate.Generator } // SpriteGeneratorConfig holds configuration for the SpriteGenerator type SpriteGeneratorConfig struct { // MinimumSprites is the minimum number of sprites to generate, even if the video duration is short // SpriteInterval will be adjusted accordingly to ensure at least this many sprites are generated. // A value of 0 means no minimum, and the generator will use the provided SpriteInterval or // calculate it based on the video duration and MaximumSprites MinimumSprites int // MaximumSprites is the maximum number of sprites to generate, even if the video duration is long // SpriteInterval will be adjusted accordingly to ensure no more than this many sprites are generated // A value of 0 means no maximum, and the generator will use the provided SpriteInterval or // calculate it based on the video duration and MinimumSprites MaximumSprites int // SpriteInterval is the default interval in seconds between each sprite. // If MinimumSprites or MaximumSprites are set, this value will be adjusted accordingly // to ensure the desired number of sprites are generated // A value of 0 means the generator will calculate the interval based on the video duration and // the provided MinimumSprites and MaximumSprites SpriteInterval float64 // SpriteSize is the size in pixels of the longest dimension of each sprite image. // The other dimension will be automatically calculated to maintain the aspect ratio of the video SpriteSize int } const ( // DefaultSpriteAmount is the default number of sprites to generate if no configuration is provided // This corresponds to the legacy behavior of the generator, which generates 81 sprites at equal // intervals across the video duration DefaultSpriteAmount = 81 // DefaultSpriteSize is the default size in pixels of the longest dimension of each sprite image // if no configuration is provided. This corresponds to the legacy behavior of the generator. DefaultSpriteSize = 160 ) var DefaultSpriteGeneratorConfig = SpriteGeneratorConfig{ MinimumSprites: DefaultSpriteAmount, MaximumSprites: DefaultSpriteAmount, SpriteInterval: 0, SpriteSize: DefaultSpriteSize, } // NewSpriteGenerator creates a new SpriteGenerator for the given video file and configuration // It calculates the appropriate sprite interval and count based on the video duration and the provided configuration func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageOutputPath string, vttOutputPath string, config SpriteGeneratorConfig) (*SpriteGenerator, error) { exists, err := fsutil.FileExists(videoFile.Path) if !exists { return nil, err } if videoFile.VideoStreamDuration <= 0 { s := fmt.Sprintf("video %s: duration(%.3f)/frame count(%d) invalid, skipping sprite creation", videoFile.Path, videoFile.VideoStreamDuration, videoFile.FrameCount) return nil, errors.New(s) } config.SpriteInterval = calculateSpriteInterval(videoFile, config) chunkCount := int(math.Ceil(videoFile.VideoStreamDuration / config.SpriteInterval)) // adjust the chunk count to the next highest perfect square, to ensure the sprite image // is completely filled (no empty space in the grid) and the grid is as square as possible (minimizing the number of rows/columns) gridSize := generate.GetSpriteGridSize(chunkCount) newChunkCount := gridSize * gridSize if newChunkCount != chunkCount { logger.Debugf("[generator] adjusting chunk count from %d to %d to fit a %dx%d grid", chunkCount, newChunkCount, gridSize, gridSize) chunkCount = newChunkCount } if config.SpriteSize <= 0 { config.SpriteSize = DefaultSpriteSize } slowSeek := false // For files with small duration / low frame count try to seek using frame number intead of seconds if videoFile.VideoStreamDuration < 5 || (0 < videoFile.FrameCount && videoFile.FrameCount <= int64(chunkCount)) { // some files can have FrameCount == 0, only use SlowSeek if duration < 5 if videoFile.VideoStreamDuration <= 0 { s := fmt.Sprintf("video %s: duration(%.3f)/frame count(%d) invalid, skipping sprite creation", videoFile.Path, videoFile.VideoStreamDuration, videoFile.FrameCount) return nil, errors.New(s) } logger.Warnf("[generator] video %s too short (%.3fs, %d frames), using frame seeking", videoFile.Path, videoFile.VideoStreamDuration, videoFile.FrameCount) slowSeek = true // do an actual frame count of the file ( number of frames = read frames) ffprobe := GetInstance().FFProbe fc, err := ffprobe.GetReadFrameCount(videoFile.Path) if err == nil { if fc != videoFile.FrameCount { logger.Warnf("[generator] updating framecount (%d) for %s with read frames count (%d)", videoFile.FrameCount, videoFile.Path, fc) videoFile.FrameCount = fc } } } generator, err := newGeneratorInfo(videoFile) if err != nil { return nil, err } generator.ChunkCount = chunkCount if err := generator.configure(); err != nil { return nil, err } return &SpriteGenerator{ Info: generator, VideoChecksum: videoChecksum, ImageOutputPath: imageOutputPath, VTTOutputPath: vttOutputPath, Config: config, SlowSeek: slowSeek, g: &generate.Generator{ Encoder: instance.FFMpeg, FFMpegConfig: instance.Config, LockManager: instance.ReadLockManager, ScenePaths: instance.Paths.Scene, }, }, nil } func calculateSpriteInterval(videoFile ffmpeg.VideoFile, config SpriteGeneratorConfig) float64 { // If a custom sprite interval is provided, start with that spriteInterval := config.SpriteInterval // If no custom interval is provided, calculate the interval based on the // video duration and minimum sprite count if spriteInterval <= 0 { minSprites := config.MinimumSprites if minSprites <= 0 { panic("invalid configuration: MinimumSprites must be greater than 0 if SpriteInterval is not set") } logger.Debugf("[generator] calculating sprite interval for video duration %.3fs with minimum sprites %d", videoFile.VideoStreamDuration, minSprites) return videoFile.VideoStreamDuration / float64(minSprites) } // Calculate the number of sprites that would be generated with the provided interval spriteCount := int(math.Ceil(videoFile.VideoStreamDuration / spriteInterval)) // If the calculated sprite count is greater than the maximum, adjust the interval to meet the maximum if config.MaximumSprites > 0 && spriteCount > int(config.MaximumSprites) { spriteInterval = videoFile.VideoStreamDuration / float64(config.MaximumSprites) logger.Debugf("[generator] provided sprite interval %.1fs results in %d sprites, which exceeds the maximum of %d, adjusting interval to %.1fs", config.SpriteInterval, spriteCount, config.MaximumSprites, spriteInterval) } // If the calculated sprite count is less than the minimum, adjust the interval to meet the minimum if config.MinimumSprites > 0 && spriteCount < int(config.MinimumSprites) { spriteInterval = videoFile.VideoStreamDuration / float64(config.MinimumSprites) logger.Debugf("[generator] provided sprite interval %.1fs results in %d sprites, which is less than the minimum of %d, adjusting interval to %.1fs", config.SpriteInterval, spriteCount, config.MinimumSprites, spriteInterval) } return spriteInterval } func (g *SpriteGenerator) Generate() error { if err := g.generateSpriteImage(); err != nil { return err } if err := g.generateSpriteVTT(); err != nil { return err } return nil } func (g *SpriteGenerator) generateSpriteImage() error { if !g.Overwrite && g.imageExists() { return nil } var images []image.Image isPortrait := g.Info.VideoFile.Height > g.Info.VideoFile.Width if !g.SlowSeek { logger.Infof("[generator] generating sprite image for %s", g.Info.VideoFile.Path) // generate `ChunkCount` thumbnails stepSize := g.Info.VideoFile.VideoStreamDuration / float64(g.Info.ChunkCount) for i := 0; i < g.Info.ChunkCount; i++ { time := float64(i) * stepSize img, err := g.g.SpriteScreenshot(context.TODO(), g.Info.VideoFile.Path, time, g.Config.SpriteSize, isPortrait) if err != nil { return err } images = append(images, img) } } else { logger.Infof("[generator] generating sprite image for %s (%d frames)", g.Info.VideoFile.Path, g.Info.VideoFile.FrameCount) stepFrame := float64(g.Info.VideoFile.FrameCount-1) / float64(g.Info.ChunkCount) for i := 0; i < g.Info.ChunkCount; i++ { // generate exactly `ChunkCount` thumbnails, using duplicate frames if needed frame := math.Round(float64(i) * stepFrame) if frame >= math.MaxInt || frame <= math.MinInt { return errors.New("invalid frame number conversion") } img, err := g.g.SpriteScreenshotSlow(context.TODO(), g.Info.VideoFile.Path, int(frame), g.Config.SpriteSize) if err != nil { return err } images = append(images, img) } } if len(images) == 0 { return fmt.Errorf("images slice is empty, failed to generate sprite images for %s", g.Info.VideoFile.Path) } return imaging.Save(g.g.CombineSpriteImages(images), g.ImageOutputPath) } func (g *SpriteGenerator) generateSpriteVTT() error { if !g.Overwrite && g.vttExists() { return nil } logger.Infof("[generator] generating sprite vtt for %s", g.Info.VideoFile.Path) var stepSize float64 if !g.SlowSeek { stepSize = float64(g.Info.NthFrame) / g.Info.FrameRate } else { // for files with a low framecount (= time.Minute: // 1m23s or 2h45m12s t = t.Round(time.Second) case t >= time.Second: // 45.36s t = t.Round(10 * time.Millisecond) default: // 51ms t = t.Round(time.Millisecond) } return t.String() } func initJobManager(cfg *config.Config) *job.Manager { ret := job.NewManager() // desktop notifications ctx := context.Background() c := ret.Subscribe(context.Background()) go func() { for { select { case j := <-c.RemovedJob: if cfg.GetNotificationsEnabled() { cleanDesc := strings.TrimRight(j.Description, ".") if j.StartTime == nil { // Task was never started return } timeElapsed := j.EndTime.Sub(*j.StartTime) msg := fmt.Sprintf("Task \"%s\" finished in %s.", cleanDesc, formatDuration(timeElapsed)) desktop.SendNotification("Task Finished", msg) } case <-ctx.Done(): return } } }() return ret } // postInit initialises the paths, caches and database after the initial // configuration has been set. Should only be called if the configuration // is valid. func (s *Manager) postInit(ctx context.Context) error { s.RefreshConfig() s.SessionStore = session.NewStore(s.Config) s.PluginCache.RegisterSessionStore(s.SessionStore) s.RefreshPluginCache() s.RefreshPluginSourceManager() s.RefreshScraperCache() s.RefreshScraperSourceManager() s.RefreshDLNA() s.SetBlobStoreOptions() s.writeStashIcon() // clear the downloads and tmp directories // #1021 - only clear these directories if the generated folder is non-empty if s.Config.GetGeneratedPath() != "" { const deleteTimeout = 1 * time.Second utils.Timeout(func() { if err := fsutil.EmptyDir(s.Paths.Generated.Downloads); err != nil { logger.Warnf("could not empty downloads directory: %v", err) } if err := fsutil.EnsureDir(s.Paths.Generated.Tmp); err != nil { logger.Warnf("could not create temporary directory: %v", err) } else { if err := fsutil.EmptyDir(s.Paths.Generated.Tmp); err != nil { logger.Warnf("could not empty temporary directory: %v", err) } } }, deleteTimeout, func(done chan struct{}) { logger.Info("Please wait. Deleting temporary files...") // print <-done // and wait for deletion logger.Info("Temporary files deleted.") }) } if err := s.Database.Open(s.Config.GetDatabasePath()); err != nil { var migrationNeededErr *sqlite.MigrationNeededError if errors.As(err, &migrationNeededErr) { logger.Warn(err) } else { return err } } // Set the proxy if defined in config if s.Config.GetProxy() != "" { os.Setenv("HTTP_PROXY", s.Config.GetProxy()) os.Setenv("HTTPS_PROXY", s.Config.GetProxy()) os.Setenv("NO_PROXY", s.Config.GetNoProxy()) logger.Info("Using HTTP proxy") } s.RefreshFFMpeg(ctx) s.RefreshStreamManager() return nil } func (s *Manager) checkSecurityTripwire() { if err := session.CheckExternalAccessTripwire(s.Config); err != nil { session.LogExternalAccessError(*err) } } func (s *Manager) writeStashIcon() { iconPath := filepath.Join(s.Config.GetConfigPath(), "icon.png") err := os.WriteFile(iconPath, ui.FaviconProvider.GetFaviconPng(), 0644) if err != nil { logger.Errorf("Couldn't write icon file: %v", err) } } func (s *Manager) RefreshFFMpeg(ctx context.Context) { // use same directory as config path // executing binaries requires directory to be included // https://pkg.go.dev/os/exec#hdr-Executables_in_the_current_directory configDirectory := s.Config.GetConfigPathAbs() stashHomeDir := paths.GetStashHomeDirectory() // prefer the configured paths ffmpegPath := s.Config.GetFFMpegPath() ffprobePath := s.Config.GetFFProbePath() // ensure the paths are valid if ffmpegPath != "" { // path was set explicitly if err := ffmpeg.ValidateFFMpeg(ffmpegPath); err != nil { logger.Errorf("invalid ffmpeg path: %v", err) return } if err := ffmpeg.ValidateFFMpegCodecSupport(ffmpegPath); err != nil { logger.Warn(err) } } else { ffmpegPath = ffmpeg.ResolveFFMpeg(configDirectory, stashHomeDir) } if ffprobePath != "" { if err := ffmpeg.ValidateFFProbe(ffmpegPath); err != nil { logger.Errorf("invalid ffprobe path: %v", err) return } } else { ffprobePath = ffmpeg.ResolveFFProbe(configDirectory, stashHomeDir) } if ffmpegPath == "" { logger.Warn("Couldn't find FFmpeg") } if ffprobePath == "" { logger.Warn("Couldn't find FFProbe") } if ffmpegPath != "" && ffprobePath != "" { logger.Debugf("using ffmpeg: %s", ffmpegPath) logger.Debugf("using ffprobe: %s", ffprobePath) s.FFMpeg = ffmpeg.NewEncoder(ffmpegPath) s.FFProbe = ffmpeg.NewFFProbe(ffprobePath) // initialise hardware support with background context s.FFMpeg.InitHWSupport(context.Background()) } } ================================================ FILE: internal/manager/json_utils.go ================================================ package manager import ( "path/filepath" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/paths" ) type jsonUtils struct { json paths.JSONPaths } func (jp *jsonUtils) savePerformer(fn string, performer *jsonschema.Performer) error { return jsonschema.SavePerformerFile(filepath.Join(jp.json.Performers, fn), performer) } func (jp *jsonUtils) saveStudio(fn string, studio *jsonschema.Studio) error { return jsonschema.SaveStudioFile(filepath.Join(jp.json.Studios, fn), studio) } func (jp *jsonUtils) saveTag(fn string, tag *jsonschema.Tag) error { return jsonschema.SaveTagFile(filepath.Join(jp.json.Tags, fn), tag) } func (jp *jsonUtils) saveGroup(fn string, group *jsonschema.Group) error { return jsonschema.SaveGroupFile(filepath.Join(jp.json.Groups, fn), group) } func (jp *jsonUtils) saveScene(fn string, scene *jsonschema.Scene) error { return jsonschema.SaveSceneFile(filepath.Join(jp.json.Scenes, fn), scene) } func (jp *jsonUtils) saveImage(fn string, image *jsonschema.Image) error { return jsonschema.SaveImageFile(filepath.Join(jp.json.Images, fn), image) } func (jp *jsonUtils) saveGallery(fn string, gallery *jsonschema.Gallery) error { return jsonschema.SaveGalleryFile(filepath.Join(jp.json.Galleries, fn), gallery) } func (jp *jsonUtils) saveFile(fn string, file jsonschema.DirEntry) error { return jsonschema.SaveFileFile(filepath.Join(jp.json.Files, fn), file) } func (jp *jsonUtils) saveSavedFilter(fn string, savedFilter *jsonschema.SavedFilter) error { return jsonschema.SaveSavedFilterFile(filepath.Join(jp.json.SavedFilters, fn), savedFilter) } ================================================ FILE: internal/manager/log.go ================================================ package manager import ( "errors" "os/exec" "github.com/stashapp/stash/pkg/logger" ) func logErrorOutput(err error) { var exitErr *exec.ExitError if errors.As(err, &exitErr) { logger.Errorf("command stderr: %v", string(exitErr.Stderr)) } } ================================================ FILE: internal/manager/manager.go ================================================ // Package manager provides the core manager of the application. // This consolidates all the services and managers into a single struct. package manager import ( "context" "errors" "fmt" "net/http" "os" "path/filepath" "runtime" "time" "github.com/remeh/sizedwaitgroup" "github.com/stashapp/stash/internal/dlna" "github.com/stashapp/stash/internal/log" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/paths" "github.com/stashapp/stash/pkg/pkg" "github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/scraper" "github.com/stashapp/stash/pkg/session" "github.com/stashapp/stash/pkg/sqlite" // register custom migrations _ "github.com/stashapp/stash/pkg/sqlite/migrations" ) type Manager struct { Config *config.Config Logger *log.Logger // ImageThumbnailGenerateWaitGroup is the global wait group image thumbnail generation // It uses the parallel tasks setting from the configuration. ImageThumbnailGenerateWaitGroup sizedwaitgroup.SizedWaitGroup Paths *paths.Paths FFMpeg *ffmpeg.FFMpeg FFProbe *ffmpeg.FFProbe StreamManager *ffmpeg.StreamManager JobManager *job.Manager ReadLockManager *fsutil.ReadLockManager DownloadStore *DownloadStore SessionStore *session.Store PluginCache *plugin.Cache ScraperCache *scraper.Cache PluginPackageManager *pkg.Manager ScraperPackageManager *pkg.Manager DLNAService *dlna.Service Database *sqlite.Database Repository models.Repository SceneService SceneService ImageService ImageService GalleryService GalleryService GroupService GroupService scanSubs *subscriptionManager } var instance *Manager func GetInstance() *Manager { if instance == nil { panic("manager not initialized") } return instance } func (s *Manager) SetBlobStoreOptions() { storageType := s.Config.GetBlobsStorage() blobsPath := s.Config.GetBlobsPath() extraBlobsPaths := s.Config.GetExtraBlobsPaths() s.Database.SetBlobStoreOptions(sqlite.BlobStoreOptions{ UseFilesystem: storageType == config.BlobStorageTypeFilesystem, UseDatabase: storageType == config.BlobStorageTypeDatabase, Path: blobsPath, SupplementaryPaths: extraBlobsPaths, }) } func (s *Manager) RefreshConfig() { cfg := s.Config *s.Paths = paths.NewPaths(cfg.GetGeneratedPath(), cfg.GetBlobsPath()) if cfg.Validate() == nil { if err := fsutil.EnsureDir(s.Paths.Generated.Screenshots); err != nil { logger.Warnf("could not create screenshots directory: %v", err) } if err := fsutil.EnsureDir(s.Paths.Generated.Vtt); err != nil { logger.Warnf("could not create VTT directory: %v", err) } if err := fsutil.EnsureDir(s.Paths.Generated.Markers); err != nil { logger.Warnf("could not create markers directory: %v", err) } if err := fsutil.EnsureDir(s.Paths.Generated.Transcodes); err != nil { logger.Warnf("could not create transcodes directory: %v", err) } if err := fsutil.EnsureDir(s.Paths.Generated.Downloads); err != nil { logger.Warnf("could not create downloads directory: %v", err) } if err := fsutil.EnsureDir(s.Paths.Generated.InteractiveHeatmap); err != nil { logger.Warnf("could not create interactive heatmaps directory: %v", err) } s.ImageThumbnailGenerateWaitGroup.Size = cfg.GetParallelTasksWithAutoDetection() } } // RefreshPluginCache refreshes the plugin cache. // Call this when the plugin configuration changes. func (s *Manager) RefreshPluginCache() { s.PluginCache.ReloadPlugins() } // RefreshScraperCache refreshes the scraper cache. // Call this when the scraper configuration changes. func (s *Manager) RefreshScraperCache() { s.ScraperCache.ReloadScrapers() } // RefreshStreamManager refreshes the stream manager. // Call this when the cache directory changes. func (s *Manager) RefreshStreamManager() { // shutdown existing manager if needed if s.StreamManager != nil { s.StreamManager.Shutdown() s.StreamManager = nil } cfg := s.Config cacheDir := cfg.GetCachePath() s.StreamManager = ffmpeg.NewStreamManager(cacheDir, s.FFMpeg, s.FFProbe, cfg, s.ReadLockManager) } // RefreshDLNA starts/stops the DLNA service as needed. func (s *Manager) RefreshDLNA() { dlnaService := s.DLNAService enabled := s.Config.GetDLNADefaultEnabled() if !enabled && dlnaService.IsRunning() { dlnaService.Stop(nil) } else if enabled && !dlnaService.IsRunning() { if err := dlnaService.Start(nil); err != nil { logger.Warnf("error starting DLNA service: %v", err) } } } func createPackageManager(localPath string, srcPathGetter pkg.SourcePathGetter) *pkg.Manager { const timeout = 10 * time.Second httpClient := &http.Client{ Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, }, Timeout: timeout, } return &pkg.Manager{ Local: &pkg.Store{ BaseDir: localPath, ManifestFile: pkg.ManifestFile, }, PackagePathGetter: srcPathGetter, Client: httpClient, } } func (s *Manager) RefreshScraperSourceManager() { s.ScraperPackageManager = createPackageManager(s.Config.GetScrapersPath(), s.Config.GetScraperPackagePathGetter()) } func (s *Manager) RefreshPluginSourceManager() { s.PluginPackageManager = createPackageManager(s.Config.GetPluginsPath(), s.Config.GetPluginPackagePathGetter()) } func setSetupDefaults(input *SetupInput) { if input.ConfigLocation == "" { input.ConfigLocation = filepath.Join(fsutil.GetHomeDirectory(), ".stash", "config.yml") } configDir := filepath.Dir(input.ConfigLocation) if input.GeneratedLocation == "" { input.GeneratedLocation = filepath.Join(configDir, "generated") } if input.CacheLocation == "" { input.CacheLocation = filepath.Join(configDir, "cache") } if input.DatabaseFile == "" { input.DatabaseFile = filepath.Join(configDir, "stash-go.sqlite") } if input.BlobsLocation == "" { input.BlobsLocation = filepath.Join(configDir, "blobs") } } func (s *Manager) Setup(ctx context.Context, input SetupInput) error { setSetupDefaults(&input) cfg := s.Config // create the config directory if it does not exist // don't do anything if config is already set in the environment if !config.FileEnvSet() { // #3304 - if config path is relative, it breaks the ffmpeg/ffprobe // paths since they must not be relative. The config file property is // resolved to an absolute path when stash is run normally, so convert // relative paths to absolute paths during setup. // #6287 - this should no longer be necessary since the ffmpeg code // converts to absolute paths. Converting the config location to // absolute means that scraper and plugin paths default to absolute // which we don't want. configFile := input.ConfigLocation configDir := filepath.Dir(configFile) if exists, _ := fsutil.DirExists(configDir); !exists { if err := os.MkdirAll(configDir, 0755); err != nil { return fmt.Errorf("error creating config directory: %v", err) } } if err := fsutil.Touch(configFile); err != nil { return fmt.Errorf("error creating config file: %v", err) } s.Config.SetConfigFile(configFile) } if err := cfg.SetInitialConfig(); err != nil { return fmt.Errorf("error setting initial configuration: %v", err) } // create the generated directory if it does not exist if !cfg.HasOverride(config.Generated) { if exists, _ := fsutil.DirExists(input.GeneratedLocation); !exists { if err := os.MkdirAll(input.GeneratedLocation, 0755); err != nil { return fmt.Errorf("error creating generated directory: %v", err) } } s.Config.SetString(config.Generated, input.GeneratedLocation) } // create the cache directory if it does not exist if !cfg.HasOverride(config.Cache) { if exists, _ := fsutil.DirExists(input.CacheLocation); !exists { if err := os.MkdirAll(input.CacheLocation, 0755); err != nil { return fmt.Errorf("error creating cache directory: %v", err) } } cfg.SetString(config.Cache, input.CacheLocation) } if input.SFWContentMode { cfg.SetBool(config.SFWContentMode, true) } if input.StoreBlobsInDatabase { cfg.SetInterface(config.BlobsStorage, config.BlobStorageTypeDatabase) } else { if !cfg.HasOverride(config.BlobsPath) { if exists, _ := fsutil.DirExists(input.BlobsLocation); !exists { if err := os.MkdirAll(input.BlobsLocation, 0755); err != nil { return fmt.Errorf("error creating blobs directory: %v", err) } } cfg.SetString(config.BlobsPath, input.BlobsLocation) } cfg.SetInterface(config.BlobsStorage, config.BlobStorageTypeFilesystem) } // set the configuration if !cfg.HasOverride(config.Database) { cfg.SetString(config.Database, input.DatabaseFile) } cfg.SetInterface(config.Stash, input.Stashes) if err := cfg.Write(); err != nil { return fmt.Errorf("error writing configuration file: %v", err) } // finish initialization if err := s.postInit(ctx); err != nil { return fmt.Errorf("error completing initialization: %v", err) } cfg.FinalizeSetup() return nil } func (s *Manager) validateFFmpeg() error { if s.FFMpeg == nil || s.FFProbe == nil { return errors.New("missing ffmpeg and/or ffprobe") } return nil } func (s *Manager) AnonymiseDatabase(download bool) (string, string, error) { var outPath string var outName string if download { outDir := s.Paths.Generated.Downloads if err := fsutil.EnsureDir(outDir); err != nil { return "", "", fmt.Errorf("could not create output directory %v: %w", outDir, err) } f, err := os.CreateTemp(outDir, "anonymous*.sqlite") if err != nil { return "", "", err } outPath = f.Name() outName = s.Database.AnonymousDatabasePath("") f.Close() } else { outDir := s.Config.GetBackupDirectoryPathOrDefault() if outDir != "" { if err := fsutil.EnsureDir(outDir); err != nil { return "", "", fmt.Errorf("could not create output directory %v: %w", outDir, err) } } outPath = s.Database.AnonymousDatabasePath(outDir) outName = filepath.Base(outPath) } err := s.Database.Anonymise(outPath) if err != nil { return "", "", err } return outPath, outName, nil } func (s *Manager) GetSystemStatus() *SystemStatus { workingDir := fsutil.GetWorkingDirectory() homeDir := fsutil.GetHomeDirectory() database := s.Database dbSchema := int(database.Version()) dbPath := database.DatabasePath() appSchema := int(database.AppSchemaVersion()) status := SystemStatusEnumOk if s.Config.IsNewSystem() { status = SystemStatusEnumSetup } else if dbSchema < appSchema { status = SystemStatusEnumNeedsMigration } configFile := s.Config.GetConfigFile() ffmpegPath := "" if s.FFMpeg != nil { ffmpegPath = s.FFMpeg.Path() } ffprobePath := "" if s.FFProbe != nil { ffprobePath = s.FFProbe.Path() } return &SystemStatus{ Os: runtime.GOOS, WorkingDir: workingDir, HomeDir: homeDir, DatabaseSchema: &dbSchema, DatabasePath: &dbPath, AppSchema: appSchema, Status: status, ConfigPath: &configFile, FfmpegPath: &ffmpegPath, FfprobePath: &ffprobePath, } } // Shutdown gracefully stops the manager func (s *Manager) Shutdown() { // TODO: Each part of the manager needs to gracefully stop at some point if s.StreamManager != nil { s.StreamManager.Shutdown() s.StreamManager = nil } err := s.Database.Close() if err != nil { logger.Errorf("Error closing database: %s", err) } } ================================================ FILE: internal/manager/manager_tasks.go ================================================ package manager import ( "context" "errors" "fmt" "strconv" "sync" "time" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/file" file_image "github.com/stashapp/stash/pkg/file/image" "github.com/stashapp/stash/pkg/file/video" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) func useAsVideo(pathname string) bool { stash := config.StashConfigs.GetStashFromDirPath(instance.Config.GetStashPaths(), pathname) if instance.Config.IsCreateImageClipsFromVideos() && stash != nil && stash.ExcludeVideo { return false } return isVideo(pathname) } func useAsImage(pathname string) bool { stash := config.StashConfigs.GetStashFromDirPath(instance.Config.GetStashPaths(), pathname) if instance.Config.IsCreateImageClipsFromVideos() && stash != nil && stash.ExcludeVideo { return isImage(pathname) || isVideo(pathname) } return isImage(pathname) } func isZip(pathname string) bool { gExt := config.GetInstance().GetGalleryExtensions() return fsutil.MatchExtension(pathname, gExt) } func isVideo(pathname string) bool { vidExt := config.GetInstance().GetVideoExtensions() return fsutil.MatchExtension(pathname, vidExt) } func isImage(pathname string) bool { imgExt := config.GetInstance().GetImageExtensions() return fsutil.MatchExtension(pathname, imgExt) } func getScanPaths(inputPaths []string) []*config.StashConfig { stashPaths := config.GetInstance().GetStashPaths() if len(inputPaths) == 0 { return stashPaths } var ret config.StashConfigs for _, p := range inputPaths { s := stashPaths.GetStashFromDirPath(p) if s == nil { logger.Warnf("%s is not in the configured stash paths", p) continue } // make a copy, changing the path ss := *s ss.Path = p ret = append(ret, &ss) } return ret } // Filters the input array for paths that are within the paths managed by stash func filterStashPaths(inputPaths []string) []string { if len(inputPaths) == 0 { return inputPaths } stashPaths := config.GetInstance().GetStashPaths() var ret []string for _, p := range inputPaths { s := stashPaths.GetStashFromDirPath(p) if s == nil { logger.Warnf("%s is not in the configured stash paths", p) continue } ret = append(ret, p) } return ret } // ScanSubscribe subscribes to a notification that is triggered when a // scan or clean is complete. func (s *Manager) ScanSubscribe(ctx context.Context) <-chan bool { return s.scanSubs.subscribe(ctx) } type ScanMetadataInput struct { Paths []string `json:"paths"` config.ScanMetadataOptions `mapstructure:",squash"` // Filter options for the scan Filter *ScanMetaDataFilterInput `json:"filter"` } // Filter options for meta data scannning type ScanMetaDataFilterInput struct { // If set, files with a modification time before this time point are ignored by the scan MinModTime *time.Time `json:"minModTime"` } func (s *Manager) Scan(ctx context.Context, input ScanMetadataInput) (int, error) { if err := s.validateFFmpeg(); err != nil { return 0, err } cfg := config.GetInstance() scanner := &file.Scanner{ Repository: file.NewRepository(s.Repository), FileDecorators: []file.Decorator{ &file.FilteredDecorator{ Decorator: &video.Decorator{ FFProbe: s.FFProbe, }, Filter: file.FilterFunc(videoFileFilter), }, &file.FilteredDecorator{ Decorator: &file_image.Decorator{ FFProbe: s.FFProbe, }, Filter: file.FilterFunc(imageFileFilter), }, }, FingerprintCalculator: &fingerprintCalculator{s.Config}, FS: &file.OsFS{}, ZipFileExtensions: cfg.GetGalleryExtensions(), // ScanFilters is set in ScanJob.Execute // HandlerRequiredFilters is set in ScanJob.Execute RootPaths: cfg.GetStashPaths().Paths(), Rescan: input.Rescan, } scanJob := ScanJob{ scanner: scanner, input: input, subscriptions: s.scanSubs, } return s.JobManager.Add(ctx, "Scanning...", &scanJob), nil } func (s *Manager) Import(ctx context.Context) (int, error) { config := config.GetInstance() metadataPath := config.GetMetadataPath() if metadataPath == "" { return 0, errors.New("metadata path must be set in config") } j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error { task := ImportTask{ repository: s.Repository, resetter: s.Database, BaseDir: metadataPath, Reset: true, DuplicateBehaviour: ImportDuplicateEnumFail, MissingRefBehaviour: models.ImportMissingRefEnumFail, fileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(), } task.Start(ctx) // TODO - return error from task return nil }) return s.JobManager.Add(ctx, "Importing...", j), nil } func (s *Manager) Export(ctx context.Context) (int, error) { config := config.GetInstance() metadataPath := config.GetMetadataPath() if metadataPath == "" { return 0, errors.New("metadata path must be set in config") } j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error { var wg sync.WaitGroup wg.Add(1) task := ExportTask{ repository: s.Repository, full: true, fileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(), } task.Start(ctx, &wg) // TODO - return error from task return nil }) return s.JobManager.Add(ctx, "Exporting...", j), nil } func (s *Manager) RunSingleTask(ctx context.Context, t Task) int { var wg sync.WaitGroup wg.Add(1) j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error { t.Start(ctx) defer wg.Done() // TODO - return error from task return nil }) return s.JobManager.Add(ctx, t.GetDescription(), j) } func (s *Manager) Generate(ctx context.Context, input GenerateMetadataInput) (int, error) { if err := s.validateFFmpeg(); err != nil { return 0, err } if err := instance.Paths.Generated.EnsureTmpDir(); err != nil { logger.Warnf("could not generate temporary directory: %v", err) } j := &GenerateJob{ repository: s.Repository, input: input, } return s.JobManager.Add(ctx, "Generating...", j), nil } func (s *Manager) GenerateDefaultScreenshot(ctx context.Context, sceneId string) int { return s.generateScreenshot(ctx, sceneId, nil) } func (s *Manager) GenerateScreenshot(ctx context.Context, sceneId string, at float64) int { return s.generateScreenshot(ctx, sceneId, &at) } // generate default screenshot if at is nil func (s *Manager) generateScreenshot(ctx context.Context, sceneId string, at *float64) int { if err := instance.Paths.Generated.EnsureTmpDir(); err != nil { logger.Warnf("failure generating screenshot: %v", err) } j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error { sceneIdInt, err := strconv.Atoi(sceneId) if err != nil { return fmt.Errorf("error parsing scene id %s: %w", sceneId, err) } var scene *models.Scene if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { scene, err = s.Repository.Scene.Find(ctx, sceneIdInt) if err != nil { return err } if scene == nil { return fmt.Errorf("scene with id %s not found", sceneId) } return scene.LoadPrimaryFile(ctx, s.Repository.File) }); err != nil { return fmt.Errorf("error finding scene for screenshot generation: %w", err) } task := GenerateCoverTask{ repository: s.Repository, Scene: *scene, ScreenshotAt: at, Overwrite: true, } task.Start(ctx) logger.Infof("Generate screenshot finished") // TODO - return error from task return nil }) return s.JobManager.Add(ctx, fmt.Sprintf("Generating screenshot for scene id %s", sceneId), j) } type AutoTagMetadataInput struct { // Paths to tag, null for all files Paths []string `json:"paths"` // IDs of performers to tag files with, or "*" for all Performers []string `json:"performers"` // IDs of studios to tag files with, or "*" for all Studios []string `json:"studios"` // IDs of tags to tag files with, or "*" for all Tags []string `json:"tags"` } func (s *Manager) AutoTag(ctx context.Context, input AutoTagMetadataInput) int { j := autoTagJob{ repository: s.Repository, input: input, } return s.JobManager.Add(ctx, "Auto-tagging...", &j) } type CleanMetadataInput struct { Paths []string `json:"paths"` // Do a dry run. Don't delete any files DryRun bool `json:"dryRun"` IgnoreZipFileContents bool `json:"ignoreZipFileContents"` } func (s *Manager) Clean(ctx context.Context, input CleanMetadataInput) int { cleaner := &file.Cleaner{ FS: &file.OsFS{}, Repository: file.NewRepository(s.Repository), Handlers: []file.CleanHandler{ &cleanHandler{}, }, TrashPath: s.Config.GetDeleteTrashPath(), } j := cleanJob{ cleaner: cleaner, repository: s.Repository, sceneService: s.SceneService, imageService: s.ImageService, input: input, scanSubs: s.scanSubs, } return s.JobManager.Add(ctx, "Cleaning...", &j) } func (s *Manager) OptimiseDatabase(ctx context.Context) int { j := OptimiseDatabaseJob{ Optimiser: s.Database, } return s.JobManager.Add(ctx, "Optimising database...", &j) } func (s *Manager) MigrateHash(ctx context.Context) int { j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error { fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm() logger.Infof("Migrating generated files for %s naming hash", fileNamingAlgo.String()) var scenes []*models.Scene if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { var err error scenes, err = s.Repository.Scene.All(ctx) return err }); err != nil { return fmt.Errorf("failed to fetch list of scenes for migration: %w", err) } var wg sync.WaitGroup total := len(scenes) progress.SetTotal(total) for _, scene := range scenes { progress.Increment() if job.IsCancelled(ctx) { logger.Info("Stopping due to user request") return nil } if scene == nil { logger.Errorf("nil scene, skipping migrate") continue } wg.Add(1) task := MigrateHashTask{Scene: scene, fileNamingAlgorithm: fileNamingAlgo} go func() { task.Start() wg.Done() }() wg.Wait() } logger.Info("Finished migrating") return nil }) return s.JobManager.Add(ctx, "Migrating scene hashes...", j) } // batchTagType indicates which batch tagging mode to use type batchTagType int const ( batchTagByIds batchTagType = iota batchTagByNamesOrStashIds batchTagAll ) // getBatchTagType determines the batch tag mode based on the input func (input StashBoxBatchTagInput) getBatchTagType(hasPerformerFields bool) batchTagType { switch { case len(input.Ids) > 0: return batchTagByIds case hasPerformerFields && len(input.PerformerIds) > 0: return batchTagByIds case len(input.StashIDs) > 0 || len(input.Names) > 0: return batchTagByNamesOrStashIds case hasPerformerFields && len(input.PerformerNames) > 0: return batchTagByNamesOrStashIds default: return batchTagAll } } // Accepts either ids, or a combination of names and stash_ids. // If none are set, then all existing items will be tagged. type StashBoxBatchTagInput struct { // Stash endpoint to use for the tagging // // Deprecated: use StashBoxEndpoint Endpoint *int `json:"endpoint"` StashBoxEndpoint *string `json:"stash_box_endpoint"` // Fields to exclude when executing the tagging ExcludeFields []string `json:"exclude_fields"` // Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false Refresh bool `json:"refresh"` // If batch adding studios or tags, should their parent entities also be created? CreateParent bool `json:"createParent"` // IDs in stash of the items to update. // If set, names and stash_ids fields will be ignored. Ids []string `json:"ids"` // Names of the items in the stash-box instance to search for and create Names []string `json:"names"` // Stash IDs of the items in the stash-box instance to search for and create StashIDs []string `json:"stash_ids"` // IDs in stash of the performers to update // // Deprecated: use Ids PerformerIds []string `json:"performer_ids"` // Names of the performers in the stash-box instance to search for and create // // Deprecated: use Names PerformerNames []string `json:"performer_names"` } func (s *Manager) batchTagPerformersByIds(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) { var tasks []Task err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { performerQuery := s.Repository.Performer ids := input.Ids if len(ids) == 0 { ids = input.PerformerIds //nolint:staticcheck } for _, performerID := range ids { if id, err := strconv.Atoi(performerID); err == nil { performer, err := performerQuery.Find(ctx, id) if err != nil { return err } if err := performer.LoadStashIDs(ctx, performerQuery); err != nil { return fmt.Errorf("loading performer stash ids: %w", err) } hasStashID := performer.StashIDs.ForEndpoint(box.Endpoint) != nil if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) { tasks = append(tasks, &stashBoxBatchPerformerTagTask{ performer: performer, box: box, excludedFields: input.ExcludeFields, }) } } } return nil }) return tasks, err } func (s *Manager) batchTagPerformersByNamesOrStashIds(input StashBoxBatchTagInput, box *models.StashBox) []Task { var tasks []Task for i := range input.StashIDs { stashID := input.StashIDs[i] if len(stashID) > 0 { tasks = append(tasks, &stashBoxBatchPerformerTagTask{ stashID: &stashID, box: box, excludedFields: input.ExcludeFields, }) } } names := input.Names if len(names) == 0 { names = input.PerformerNames //nolint:staticcheck } for i := range names { name := names[i] if len(name) > 0 { tasks = append(tasks, &stashBoxBatchPerformerTagTask{ name: &name, box: box, excludedFields: input.ExcludeFields, }) } } return tasks } func (s *Manager) batchTagAllPerformers(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) { var tasks []Task err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { performerQuery := s.Repository.Performer var performers []*models.Performer var err error performers, err = performerQuery.FindByStashIDStatus(ctx, input.Refresh, box.Endpoint) if err != nil { return fmt.Errorf("error querying performers: %v", err) } for _, performer := range performers { if err := performer.LoadStashIDs(ctx, performerQuery); err != nil { return fmt.Errorf("error loading stash ids for performer %s: %v", performer.Name, err) } tasks = append(tasks, &stashBoxBatchPerformerTagTask{ performer: performer, box: box, excludedFields: input.ExcludeFields, }) } return nil }) return tasks, err } func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int { j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error { logger.Infof("Initiating stash-box batch performer tag") var tasks []Task var err error switch input.getBatchTagType(true) { case batchTagByIds: tasks, err = s.batchTagPerformersByIds(ctx, input, box) case batchTagByNamesOrStashIds: tasks = s.batchTagPerformersByNamesOrStashIds(input, box) case batchTagAll: tasks, err = s.batchTagAllPerformers(ctx, input, box) } if err != nil { return err } if len(tasks) == 0 { return nil } progress.SetTotal(len(tasks)) logger.Infof("Starting stash-box batch operation for %d performers", len(tasks)) for _, task := range tasks { progress.ExecuteTask(task.GetDescription(), func() { task.Start(ctx) }) progress.Increment() } return nil }) return s.JobManager.Add(ctx, "Batch stash-box performer tag...", j) } func (s *Manager) batchTagStudiosByIds(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) { var tasks []Task err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { studioQuery := s.Repository.Studio for _, studioID := range input.Ids { if id, err := strconv.Atoi(studioID); err == nil { studio, err := studioQuery.Find(ctx, id) if err != nil { return err } if err := studio.LoadStashIDs(ctx, studioQuery); err != nil { return fmt.Errorf("loading studio stash ids: %w", err) } hasStashID := studio.StashIDs.ForEndpoint(box.Endpoint) != nil if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) { tasks = append(tasks, &stashBoxBatchStudioTagTask{ studio: studio, createParent: input.CreateParent, box: box, excludedFields: input.ExcludeFields, }) } } } return nil }) return tasks, err } func (s *Manager) batchTagStudiosByNamesOrStashIds(input StashBoxBatchTagInput, box *models.StashBox) []Task { var tasks []Task for i := range input.StashIDs { stashID := input.StashIDs[i] if len(stashID) > 0 { tasks = append(tasks, &stashBoxBatchStudioTagTask{ stashID: &stashID, createParent: input.CreateParent, box: box, excludedFields: input.ExcludeFields, }) } } for i := range input.Names { name := input.Names[i] if len(name) > 0 { tasks = append(tasks, &stashBoxBatchStudioTagTask{ name: &name, createParent: input.CreateParent, box: box, excludedFields: input.ExcludeFields, }) } } return tasks } func (s *Manager) batchTagAllStudios(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) { var tasks []Task err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { studioQuery := s.Repository.Studio var studios []*models.Studio var err error studios, err = studioQuery.FindByStashIDStatus(ctx, input.Refresh, box.Endpoint) if err != nil { return fmt.Errorf("error querying studios: %v", err) } for _, studio := range studios { tasks = append(tasks, &stashBoxBatchStudioTagTask{ studio: studio, createParent: input.CreateParent, box: box, excludedFields: input.ExcludeFields, }) } return nil }) return tasks, err } func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int { j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error { logger.Infof("Initiating stash-box batch studio tag") var tasks []Task var err error switch input.getBatchTagType(false) { case batchTagByIds: tasks, err = s.batchTagStudiosByIds(ctx, input, box) case batchTagByNamesOrStashIds: tasks = s.batchTagStudiosByNamesOrStashIds(input, box) case batchTagAll: tasks, err = s.batchTagAllStudios(ctx, input, box) } if err != nil { return err } if len(tasks) == 0 { return nil } progress.SetTotal(len(tasks)) logger.Infof("Starting stash-box batch operation for %d studios", len(tasks)) for _, task := range tasks { progress.ExecuteTask(task.GetDescription(), func() { task.Start(ctx) }) progress.Increment() } return nil }) return s.JobManager.Add(ctx, "Batch stash-box studio tag...", j) } func (s *Manager) batchTagTagsByIds(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) { var tasks []Task err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { tagQuery := s.Repository.Tag for _, tagID := range input.Ids { if id, err := strconv.Atoi(tagID); err == nil { t, err := tagQuery.Find(ctx, id) if err != nil { return err } if err := t.LoadStashIDs(ctx, tagQuery); err != nil { return fmt.Errorf("loading tag stash ids: %w", err) } hasStashID := t.StashIDs.ForEndpoint(box.Endpoint) != nil if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) { tasks = append(tasks, &stashBoxBatchTagTagTask{ tag: t, createParent: input.CreateParent, box: box, excludedFields: input.ExcludeFields, }) } } } return nil }) return tasks, err } func (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box *models.StashBox) []Task { var tasks []Task for i := range input.StashIDs { stashID := input.StashIDs[i] if len(stashID) > 0 { tasks = append(tasks, &stashBoxBatchTagTagTask{ stashID: &stashID, createParent: input.CreateParent, box: box, excludedFields: input.ExcludeFields, }) } } for i := range input.Names { name := input.Names[i] if len(name) > 0 { tasks = append(tasks, &stashBoxBatchTagTagTask{ name: &name, createParent: input.CreateParent, box: box, excludedFields: input.ExcludeFields, }) } } return tasks } func (s *Manager) batchTagAllTags(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) { var tasks []Task err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { tagQuery := s.Repository.Tag var tags []*models.Tag var err error tags, err = tagQuery.FindByStashIDStatus(ctx, input.Refresh, box.Endpoint) if err != nil { return fmt.Errorf("error querying tags: %v", err) } for _, t := range tags { tasks = append(tasks, &stashBoxBatchTagTagTask{ tag: t, createParent: input.CreateParent, box: box, excludedFields: input.ExcludeFields, }) } return nil }) return tasks, err } func (s *Manager) StashBoxBatchTagTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int { j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error { logger.Infof("Initiating stash-box batch tag tag") var tasks []Task var err error switch input.getBatchTagType(false) { case batchTagByIds: tasks, err = s.batchTagTagsByIds(ctx, input, box) case batchTagByNamesOrStashIds: tasks = s.batchTagTagsByNamesOrStashIds(input, box) case batchTagAll: tasks, err = s.batchTagAllTags(ctx, input, box) } if err != nil { return err } if len(tasks) == 0 { return nil } progress.SetTotal(len(tasks)) logger.Infof("Starting stash-box batch operation for %d tags", len(tasks)) for _, task := range tasks { progress.ExecuteTask(task.GetDescription(), func() { task.Start(ctx) }) progress.Increment() } return nil }) return s.JobManager.Add(ctx, "Batch stash-box tag tag...", j) } ================================================ FILE: internal/manager/models.go ================================================ package manager import ( "github.com/stashapp/stash/internal/manager/config" ) type SystemStatus struct { DatabaseSchema *int `json:"databaseSchema"` DatabasePath *string `json:"databasePath"` ConfigPath *string `json:"configPath"` AppSchema int `json:"appSchema"` Status SystemStatusEnum `json:"status"` Os string `json:"os"` WorkingDir string `json:"working_dir"` HomeDir string `json:"home_dir"` FfmpegPath *string `json:"ffmpegPath"` FfprobePath *string `json:"ffprobePath"` } type SetupInput struct { // Empty to indicate $HOME/.stash/config.yml default ConfigLocation string `json:"configLocation"` Stashes []*config.StashConfigInput `json:"stashes"` SFWContentMode bool `json:"sfwContentMode"` // Empty to indicate default DatabaseFile string `json:"databaseFile"` // Empty to indicate default GeneratedLocation string `json:"generatedLocation"` // Empty to indicate default CacheLocation string `json:"cacheLocation"` StoreBlobsInDatabase bool `json:"storeBlobsInDatabase"` // Empty to indicate default BlobsLocation string `json:"blobsLocation"` } type MigrateInput struct { BackupPath string `json:"backupPath"` } ================================================ FILE: internal/manager/repository.go ================================================ package manager import ( "context" "github.com/stashapp/stash/pkg/group" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" ) type SceneService interface { Create(ctx context.Context, input models.CreateSceneInput) (*models.Scene, error) AssignFile(ctx context.Context, sceneID int, fileID models.FileID) error Merge(ctx context.Context, sourceIDs []int, destinationID int, fileDeleter *scene.FileDeleter, options scene.MergeOptions) error Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error FindByIDs(ctx context.Context, ids []int, load ...scene.LoadRelationshipOption) ([]*models.Scene, error) sceneFingerprintGetter } type ImageService interface { Destroy(ctx context.Context, image *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error DestroyZipImages(ctx context.Context, zipFile models.File, fileDeleter *image.FileDeleter, deleteGenerated bool) ([]*models.Image, error) } type GalleryService interface { AddImages(ctx context.Context, g *models.Gallery, toAdd ...int) error RemoveImages(ctx context.Context, g *models.Gallery, toRemove ...int) error SetCover(ctx context.Context, g *models.Gallery, coverImageId int) error ResetCover(ctx context.Context, g *models.Gallery) error Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) ([]*models.Image, error) ValidateImageGalleryChange(ctx context.Context, i *models.Image, updateIDs models.UpdateIDs) error Updated(ctx context.Context, galleryID int) error } type GroupService interface { Create(ctx context.Context, input *models.CreateGroupInput) error UpdatePartial(ctx context.Context, id int, updatedGroup models.GroupPartial, frontImage group.ImageInput, backImage group.ImageInput) (*models.Group, error) AddSubGroups(ctx context.Context, groupID int, subGroups []models.GroupIDDescription, insertIndex *int) error RemoveSubGroups(ctx context.Context, groupID int, subGroupIDs []int) error ReorderSubGroups(ctx context.Context, groupID int, subGroupIDs []int, insertPointID int, insertAfter bool) error } ================================================ FILE: internal/manager/running_streams.go ================================================ package manager import ( "context" "errors" "mime" "net/http" "path/filepath" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/internal/static" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/txn" "github.com/stashapp/stash/pkg/utils" ) func KillRunningStreams(scene *models.Scene, fileNamingAlgo models.HashAlgorithm) { instance.ReadLockManager.Cancel(scene.Path) sceneHash := scene.GetHash(fileNamingAlgo) if sceneHash == "" { return } transcodePath := GetInstance().Paths.Scene.GetTranscodePath(sceneHash) instance.ReadLockManager.Cancel(transcodePath) } type SceneCoverGetter interface { GetCover(ctx context.Context, sceneID int) ([]byte, error) } type SceneServer struct { TxnManager txn.Manager SceneCoverGetter SceneCoverGetter } func (s *SceneServer) StreamSceneDirect(scene *models.Scene, w http.ResponseWriter, r *http.Request) { // #3526 - return 404 if the scene does not have any files if scene.Path == "" { http.Error(w, http.StatusText(404), 404) return } sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()) fp := GetInstance().Paths.Scene.GetStreamPath(scene.Path, sceneHash) streamRequestCtx := ffmpeg.NewStreamRequestContext(w, r) // #2579 - hijacking and closing the connection here causes video playback to fail in Safari // We trust that the request context will be closed, so we don't need to call Cancel on the // returned context here. _ = GetInstance().ReadLockManager.ReadLock(streamRequestCtx, fp) _, filename := filepath.Split(fp) contentDisposition := mime.FormatMediaType("inline", map[string]string{"filename": filename}) w.Header().Set("Content-Disposition", contentDisposition) http.ServeFile(w, r, fp) } func (s *SceneServer) ServeScreenshot(scene *models.Scene, w http.ResponseWriter, r *http.Request) { var cover []byte readTxnErr := txn.WithReadTxn(r.Context(), s.TxnManager, func(ctx context.Context) error { var err error cover, err = s.SceneCoverGetter.GetCover(ctx, scene.ID) return err }) if errors.Is(readTxnErr, context.Canceled) { return } if readTxnErr != nil { logger.Warnf("read transaction error on fetch screenshot: %v", readTxnErr) } if cover == nil { // fallback to legacy image if present if scene.Path != "" { sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()) filepath := GetInstance().Paths.Scene.GetLegacyScreenshotPath(sceneHash) // fall back to the scene image blob if the file isn't present screenshotExists, _ := fsutil.FileExists(filepath) if screenshotExists { if r.URL.Query().Has("t") { w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "no-cache") } http.ServeFile(w, r, filepath) return } } // fallback to default cover if none found cover = static.ReadAll(static.DefaultSceneImage) } utils.ServeImage(w, r, cover) } ================================================ FILE: internal/manager/scan_stashignore_test.go ================================================ //go:build integration // +build integration package manager import ( "context" "io/fs" "os" "path/filepath" "testing" "github.com/stashapp/stash/pkg/file" // Necessary to register custom migrations. _ "github.com/stashapp/stash/pkg/sqlite/migrations" ) // stashIgnorePathFilter wraps StashIgnoreFilter to implement PathFilter for testing. // It provides a fixed library root for the filter. type stashIgnorePathFilter struct { filter *file.StashIgnoreFilter libraryRoot string } func (f *stashIgnorePathFilter) Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool { return f.filter.Accept(ctx, path, info, f.libraryRoot, zipFilePath) } // createTestFileOnDisk creates a file with some content. func createTestFileOnDisk(t *testing.T, dir, name string) string { t.Helper() path := filepath.Join(dir, name) if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatalf("failed to create directory for %s: %v", path, err) } // Write some content so the file has a non-zero size. if err := os.WriteFile(path, []byte("test content for "+name), 0644); err != nil { t.Fatalf("failed to create file %s: %v", path, err) } return path } // createStashIgnoreFile creates a .stashignore file with the given content. func createStashIgnoreFile(t *testing.T, dir, content string) { t.Helper() path := filepath.Join(dir, ".stashignore") if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatalf("failed to create .stashignore: %v", err) } } func TestScannerWithStashIgnore(t *testing.T) { // Create temp directory structure. tmpDir := t.TempDir() // Create test files. createTestFileOnDisk(t, tmpDir, "video1.mp4") createTestFileOnDisk(t, tmpDir, "video2.mp4") createTestFileOnDisk(t, tmpDir, "ignore_me.mp4") createTestFileOnDisk(t, tmpDir, "subdir/video3.mp4") createTestFileOnDisk(t, tmpDir, "subdir/skip_this.mp4") createTestFileOnDisk(t, tmpDir, "excluded_dir/video4.mp4") createTestFileOnDisk(t, tmpDir, "temp/processing.mp4") // Create .stashignore file. stashignore := `# Ignore specific files ignore_me.mp4 subdir/skip_this.mp4 # Ignore directories excluded_dir/ temp/ ` createStashIgnoreFile(t, tmpDir, stashignore) // Create stashignore filter with library root. stashIgnoreFilter := &stashIgnorePathFilter{ filter: file.NewStashIgnoreFilter(), libraryRoot: tmpDir, } // Create scanner. scanner := &file.Scanner{ ScanFilters: []file.PathFilter{stashIgnoreFilter}, } testScenarios := []struct { path string accepted bool }{ {filepath.Join(tmpDir, "video1.mp4"), true}, {filepath.Join(tmpDir, "video2.mp4"), true}, {filepath.Join(tmpDir, "ignore_me.mp4"), false}, {filepath.Join(tmpDir, "subdir/video3.mp4"), true}, {filepath.Join(tmpDir, "subdir/skip_this.mp4"), false}, {filepath.Join(tmpDir, "excluded_dir/video4.mp4"), false}, {filepath.Join(tmpDir, "temp/processing.mp4"), false}, } ctx := context.Background() for _, scenario := range testScenarios { info, err := os.Stat(scenario.path) if err != nil { t.Fatalf("failed to stat file %s: %v", scenario.path, err) } accepted := scanner.AcceptEntry(ctx, scenario.path, info, "") if accepted != scenario.accepted { t.Errorf("unexpected accept result for %s: expected %v, got %v", scenario.path, scenario.accepted, accepted) } } } func TestScannerWithNestedStashIgnore(t *testing.T) { // Create temp directory structure. tmpDir := t.TempDir() // Create test files. createTestFileOnDisk(t, tmpDir, "root.mp4") createTestFileOnDisk(t, tmpDir, "root.tmp") createTestFileOnDisk(t, tmpDir, "subdir/sub.mp4") createTestFileOnDisk(t, tmpDir, "subdir/sub.log") createTestFileOnDisk(t, tmpDir, "subdir/sub.tmp") // Root .stashignore excludes *.tmp. createStashIgnoreFile(t, tmpDir, "*.tmp\n") // Subdir .stashignore excludes *.log. createStashIgnoreFile(t, filepath.Join(tmpDir, "subdir"), "*.log\n") // Create stashignore filter with library root. stashIgnoreFilter := &stashIgnorePathFilter{ filter: file.NewStashIgnoreFilter(), libraryRoot: tmpDir, } // Create scanner. scanner := &file.Scanner{ ScanFilters: []file.PathFilter{stashIgnoreFilter}, } testScenarios := []struct { path string accepted bool }{ {filepath.Join(tmpDir, "root.mp4"), true}, {filepath.Join(tmpDir, "root.tmp"), false}, {filepath.Join(tmpDir, "subdir/sub.mp4"), true}, {filepath.Join(tmpDir, "subdir/sub.log"), false}, {filepath.Join(tmpDir, "subdir/sub.tmp"), false}, } ctx := context.Background() for _, scenario := range testScenarios { info, err := os.Stat(scenario.path) if err != nil { t.Fatalf("failed to stat file %s: %v", scenario.path, err) } accepted := scanner.AcceptEntry(ctx, scenario.path, info, "") if accepted != scenario.accepted { t.Errorf("unexpected accept result for %s: expected %v, got %v", scenario.path, scenario.accepted, accepted) } } } func TestScannerWithoutStashIgnore(t *testing.T) { // Create temp directory structure (no .stashignore). tmpDir := t.TempDir() // Create test files. createTestFileOnDisk(t, tmpDir, "video1.mp4") createTestFileOnDisk(t, tmpDir, "video2.mp4") createTestFileOnDisk(t, tmpDir, "subdir/video3.mp4") // Create stashignore filter with library root (but no .stashignore file exists). stashIgnoreFilter := &stashIgnorePathFilter{ filter: file.NewStashIgnoreFilter(), libraryRoot: tmpDir, } // Create scanner. scanner := &file.Scanner{ ScanFilters: []file.PathFilter{stashIgnoreFilter}, } testScenarios := []struct { path string accepted bool }{ {filepath.Join(tmpDir, "video1.mp4"), true}, {filepath.Join(tmpDir, "video2.mp4"), true}, {filepath.Join(tmpDir, "subdir/video3.mp4"), true}, } ctx := context.Background() for _, scenario := range testScenarios { info, err := os.Stat(scenario.path) if err != nil { t.Fatalf("failed to stat file %s: %v", scenario.path, err) } accepted := scanner.AcceptEntry(ctx, scenario.path, info, "") if accepted != scenario.accepted { t.Errorf("unexpected accept result for %s: expected %v, got %v", scenario.path, scenario.accepted, accepted) } } } func TestScannerWithNegationPattern(t *testing.T) { // Create temp directory structure. tmpDir := t.TempDir() // Create test files. createTestFileOnDisk(t, tmpDir, "file1.tmp") createTestFileOnDisk(t, tmpDir, "file2.tmp") createTestFileOnDisk(t, tmpDir, "keep_this.tmp") createTestFileOnDisk(t, tmpDir, "video.mp4") // Create .stashignore with negation. stashignore := `*.tmp !keep_this.tmp ` createStashIgnoreFile(t, tmpDir, stashignore) // Create stashignore filter with library root. stashIgnoreFilter := &stashIgnorePathFilter{ filter: file.NewStashIgnoreFilter(), libraryRoot: tmpDir, } // Create scanner. scanner := &file.Scanner{ ScanFilters: []file.PathFilter{stashIgnoreFilter}, } testScenarios := []struct { path string accepted bool }{ {filepath.Join(tmpDir, "file1.tmp"), false}, {filepath.Join(tmpDir, "file2.tmp"), false}, {filepath.Join(tmpDir, "keep_this.tmp"), true}, {filepath.Join(tmpDir, "video.mp4"), true}, } ctx := context.Background() for _, scenario := range testScenarios { info, err := os.Stat(scenario.path) if err != nil { t.Fatalf("failed to stat file %s: %v", scenario.path, err) } accepted := scanner.AcceptEntry(ctx, scenario.path, info, "") if accepted != scenario.accepted { t.Errorf("unexpected accept result for %s: expected %v, got %v", scenario.path, scenario.accepted, accepted) } } } ================================================ FILE: internal/manager/scene.go ================================================ package manager import ( "fmt" "net/url" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/models" ) type SceneStreamEndpoint struct { URL string `json:"url"` MimeType *string `json:"mime_type"` Label *string `json:"label"` } type endpointType struct { label string mimeType string extension string } var ( directEndpointType = endpointType{ label: "Direct stream", mimeType: ffmpeg.MimeMp4Video, extension: "", } mp4EndpointType = endpointType{ label: "MP4", mimeType: ffmpeg.MimeMp4Video, extension: ".mp4", } mkvEndpointType = endpointType{ label: "MKV", // use mp4 mimetype to trick the client, since many clients won't try mkv mimeType: ffmpeg.MimeMp4Video, extension: ".mkv", } webmEndpointType = endpointType{ label: "WEBM", mimeType: ffmpeg.MimeWebmVideo, extension: ".webm", } hlsEndpointType = endpointType{ label: "HLS", mimeType: ffmpeg.MimeHLS, extension: ".m3u8", } dashEndpointType = endpointType{ label: "DASH", mimeType: ffmpeg.MimeDASH, extension: ".mpd", } ) func GetVideoFileContainer(file *models.VideoFile) (ffmpeg.Container, error) { var container ffmpeg.Container format := file.Format if format != "" { container = ffmpeg.Container(format) } else { // container isn't in the DB // shouldn't happen, fallback to ffprobe ffprobe := GetInstance().FFProbe tmpVideoFile, err := ffprobe.NewVideoFile(file.Path) if err != nil { return ffmpeg.Container(""), fmt.Errorf("error reading video file: %v", err) } return ffmpeg.MatchContainer(tmpVideoFile.Container, file.Path) } return container, nil } func GetSceneStreamPaths(scene *models.Scene, directStreamURL *url.URL, maxStreamingTranscodeSize models.StreamingResolutionEnum) ([]*SceneStreamEndpoint, error) { if scene == nil { return nil, fmt.Errorf("nil scene") } pf := scene.Files.Primary() if pf == nil { return nil, nil } // convert StreamingResolutionEnum to ResolutionEnum maxStreamingResolution := models.ResolutionEnum(maxStreamingTranscodeSize) sceneResolution := models.GetMinResolution(pf) includeSceneStreamPath := func(streamingResolution models.StreamingResolutionEnum) bool { var minResolution int if streamingResolution == models.StreamingResolutionEnumOriginal { minResolution = sceneResolution } else { // convert StreamingResolutionEnum to ResolutionEnum so we can get the min // resolution convertedRes := models.ResolutionEnum(streamingResolution) minResolution = convertedRes.GetMinResolution() // don't include if scene resolution is smaller than the streamingResolution if sceneResolution != 0 && sceneResolution < minResolution { return false } } // if we always allow everything, then return true if maxStreamingTranscodeSize == models.StreamingResolutionEnumOriginal { return true } return maxStreamingResolution.GetMinResolution() >= minResolution } makeStreamEndpoint := func(t endpointType, resolution models.StreamingResolutionEnum) *SceneStreamEndpoint { url := *directStreamURL url.Path += t.extension label := t.label if resolution != "" { v := url.Query() v.Set("resolution", resolution.String()) url.RawQuery = v.Encode() switch resolution { case models.StreamingResolutionEnumFourK: label += " 4K (2160p)" case models.StreamingResolutionEnumFullHd: label += " Full HD (1080p)" case models.StreamingResolutionEnumStandardHd: label += " HD (720p)" case models.StreamingResolutionEnumStandard: label += " Standard (480p)" case models.StreamingResolutionEnumLow: label += " Low (240p)" } } return &SceneStreamEndpoint{ URL: url.String(), MimeType: &t.mimeType, Label: &label, } } var endpoints []*SceneStreamEndpoint // direct stream should only apply when the audio codec is supported audioCodec := ffmpeg.MissingUnsupported if pf.AudioCodec != "" { audioCodec = ffmpeg.ProbeAudioCodec(pf.AudioCodec) } // don't care if we can't get the container container, _ := GetVideoFileContainer(pf) if HasTranscode(scene, config.GetInstance().GetVideoFileNamingAlgorithm()) || ffmpeg.IsValidAudioForContainer(audioCodec, container) { endpoints = append(endpoints, makeStreamEndpoint(directEndpointType, "")) } // only add mkv stream endpoint if the scene container is an mkv already if container == ffmpeg.Matroska { endpoints = append(endpoints, makeStreamEndpoint(mkvEndpointType, "")) } mp4Streams := []*SceneStreamEndpoint{} webmStreams := []*SceneStreamEndpoint{} hlsStreams := []*SceneStreamEndpoint{} dashStreams := []*SceneStreamEndpoint{} if includeSceneStreamPath(models.StreamingResolutionEnumOriginal) { mp4Streams = append(mp4Streams, makeStreamEndpoint(mp4EndpointType, models.StreamingResolutionEnumOriginal)) webmStreams = append(webmStreams, makeStreamEndpoint(webmEndpointType, models.StreamingResolutionEnumOriginal)) hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsEndpointType, models.StreamingResolutionEnumOriginal)) dashStreams = append(dashStreams, makeStreamEndpoint(dashEndpointType, models.StreamingResolutionEnumOriginal)) } if includeSceneStreamPath(models.StreamingResolutionEnumFourK) { mp4Streams = append(mp4Streams, makeStreamEndpoint(mp4EndpointType, models.StreamingResolutionEnumFourK)) webmStreams = append(webmStreams, makeStreamEndpoint(webmEndpointType, models.StreamingResolutionEnumFourK)) hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsEndpointType, models.StreamingResolutionEnumFourK)) dashStreams = append(dashStreams, makeStreamEndpoint(dashEndpointType, models.StreamingResolutionEnumFourK)) } if includeSceneStreamPath(models.StreamingResolutionEnumFullHd) { mp4Streams = append(mp4Streams, makeStreamEndpoint(mp4EndpointType, models.StreamingResolutionEnumFullHd)) webmStreams = append(webmStreams, makeStreamEndpoint(webmEndpointType, models.StreamingResolutionEnumFullHd)) hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsEndpointType, models.StreamingResolutionEnumFullHd)) dashStreams = append(dashStreams, makeStreamEndpoint(dashEndpointType, models.StreamingResolutionEnumFullHd)) } if includeSceneStreamPath(models.StreamingResolutionEnumStandardHd) { mp4Streams = append(mp4Streams, makeStreamEndpoint(mp4EndpointType, models.StreamingResolutionEnumStandardHd)) webmStreams = append(webmStreams, makeStreamEndpoint(webmEndpointType, models.StreamingResolutionEnumStandardHd)) hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsEndpointType, models.StreamingResolutionEnumStandardHd)) dashStreams = append(dashStreams, makeStreamEndpoint(dashEndpointType, models.StreamingResolutionEnumStandardHd)) } if includeSceneStreamPath(models.StreamingResolutionEnumStandard) { mp4Streams = append(mp4Streams, makeStreamEndpoint(mp4EndpointType, models.StreamingResolutionEnumStandard)) webmStreams = append(webmStreams, makeStreamEndpoint(webmEndpointType, models.StreamingResolutionEnumStandard)) hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsEndpointType, models.StreamingResolutionEnumStandard)) dashStreams = append(dashStreams, makeStreamEndpoint(dashEndpointType, models.StreamingResolutionEnumStandard)) } if includeSceneStreamPath(models.StreamingResolutionEnumLow) { mp4Streams = append(mp4Streams, makeStreamEndpoint(mp4EndpointType, models.StreamingResolutionEnumLow)) webmStreams = append(webmStreams, makeStreamEndpoint(webmEndpointType, models.StreamingResolutionEnumLow)) hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsEndpointType, models.StreamingResolutionEnumLow)) dashStreams = append(dashStreams, makeStreamEndpoint(dashEndpointType, models.StreamingResolutionEnumLow)) } endpoints = append(endpoints, mp4Streams...) endpoints = append(endpoints, webmStreams...) endpoints = append(endpoints, hlsStreams...) endpoints = append(endpoints, dashStreams...) return endpoints, nil } // HasTranscode returns true if a transcoded video exists for the provided // scene. It will check using the OSHash of the scene first, then fall back // to the checksum. func HasTranscode(scene *models.Scene, fileNamingAlgo models.HashAlgorithm) bool { if scene == nil { return false } sceneHash := scene.GetHash(fileNamingAlgo) if sceneHash == "" { return false } transcodePath := instance.Paths.Scene.GetTranscodePath(sceneHash) ret, _ := fsutil.FileExists(transcodePath) return ret } ================================================ FILE: internal/manager/subscribe.go ================================================ package manager import ( "context" "sync" ) type subscriptionManager struct { subscriptions []chan bool mutex sync.Mutex } func (m *subscriptionManager) subscribe(ctx context.Context) <-chan bool { m.mutex.Lock() defer m.mutex.Unlock() c := make(chan bool, 10) m.subscriptions = append(m.subscriptions, c) go func() { <-ctx.Done() m.mutex.Lock() defer m.mutex.Unlock() close(c) for i, s := range m.subscriptions { if s == c { m.subscriptions = append(m.subscriptions[:i], m.subscriptions[i+1:]...) break } } }() return c } func (m *subscriptionManager) notify() { m.mutex.Lock() defer m.mutex.Unlock() for _, s := range m.subscriptions { s <- true } } ================================================ FILE: internal/manager/task/clean_generated.go ================================================ package task import ( "context" "errors" "fmt" "io/fs" "os" "path/filepath" "strconv" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/paths" ) type CleanGeneratedOptions struct { BlobFiles bool `json:"blobs"` Sprites bool `json:"sprites"` Screenshots bool `json:"screenshots"` Transcodes bool `json:"transcodes"` Markers bool `json:"markers"` ImageThumbnails bool `json:"imageThumbnails"` DryRun bool `json:"dryRun"` } type BlobCleaner interface { EntryExists(ctx context.Context, checksum string) (bool, error) } type CleanGeneratedJob struct { Options CleanGeneratedOptions Paths *paths.Paths BlobsStorageType config.BlobsStorageType VideoFileNamingAlgorithm models.HashAlgorithm BlobCleaner BlobCleaner Repository models.Repository dryRunPrefix string totalTasks int tasksComplete int } func (j *CleanGeneratedJob) deleteFile(path string) { if j.Options.DryRun { logger.Debugf("would delete file: %s", path) return } if err := os.Remove(path); err != nil { logger.Errorf("error deleting file %s: %v", path, err) } } func (j *CleanGeneratedJob) deleteDir(path string) { if j.Options.DryRun { logger.Debugf("would delete file: %s", path) return } if err := os.RemoveAll(path); err != nil { logger.Errorf("error deleting directory %s: %v", path, err) } } func (j *CleanGeneratedJob) countTasks() int { tasks := 0 if j.Options.BlobFiles { tasks++ } if j.Options.Sprites { tasks++ } if j.Options.Screenshots { tasks++ } if j.Options.Transcodes { tasks++ } if j.Options.Markers { tasks++ } if j.Options.ImageThumbnails { tasks++ } return tasks } func (j *CleanGeneratedJob) taskComplete(progress *job.Progress) { j.tasksComplete++ progress.SetPercent(float64(j.tasksComplete) / float64(j.totalTasks)) } func (j *CleanGeneratedJob) logError(err error) { if !errors.Is(err, context.Canceled) { logger.Error(err) } } func (j *CleanGeneratedJob) Execute(ctx context.Context, progress *job.Progress) error { j.tasksComplete = 0 if !j.BlobsStorageType.IsValid() { return fmt.Errorf("invalid blobs storage type: %s", j.BlobsStorageType) } if !j.VideoFileNamingAlgorithm.IsValid() { return fmt.Errorf("invalid video file naming algorithm: %s", j.VideoFileNamingAlgorithm) } if j.Options.DryRun { j.dryRunPrefix = "[dry run] " } logger.Infof("Cleaning generated files %s", j.dryRunPrefix) j.totalTasks = j.countTasks() if j.Options.BlobFiles { progress.ExecuteTask("Cleaning blob files", func() { if err := j.cleanBlobFiles(ctx, progress); err != nil { j.logError(fmt.Errorf("error cleaning blob files: %w", err)) } }) j.taskComplete(progress) } if j.Options.Sprites { progress.ExecuteTask("Cleaning sprite files", func() { if err := j.cleanSpriteFiles(ctx, progress); err != nil { j.logError(fmt.Errorf("error cleaning sprite files: %w", err)) } }) j.taskComplete(progress) } if j.Options.Screenshots { progress.ExecuteTask("Cleaning screenshot files", func() { if err := j.cleanScreenshotFiles(ctx, progress); err != nil { j.logError(fmt.Errorf("error cleaning screenshot files: %w", err)) } }) j.taskComplete(progress) } if j.Options.Transcodes { progress.ExecuteTask("Cleaning transcode files", func() { if err := j.cleanTranscodeFiles(ctx, progress); err != nil { j.logError(fmt.Errorf("error cleaning transcode files: %w", err)) } }) j.taskComplete(progress) } if j.Options.Markers { progress.ExecuteTask("Cleaning marker files", func() { if err := j.cleanMarkerFiles(ctx, progress); err != nil { j.logError(fmt.Errorf("error cleaning marker files: %w", err)) } }) j.taskComplete(progress) } if j.Options.ImageThumbnails { progress.ExecuteTask("Cleaning thumbnail files", func() { if err := j.cleanThumbnailFiles(ctx, progress); err != nil { j.logError(fmt.Errorf("error cleaning thumbnail files: %w", err)) } }) j.taskComplete(progress) } if job.IsCancelled(ctx) { logger.Info("Stopping due to user request") return nil } logger.Infof("Finished cleaning generated files") return nil } func (j *CleanGeneratedJob) setTaskProgress(taskProgress float64, progress *job.Progress) { progress.SetPercent((float64(j.tasksComplete) + taskProgress) / float64(j.totalTasks)) } func (j *CleanGeneratedJob) logDelete(format string, args ...interface{}) { logger.Infof(j.dryRunPrefix+format, args...) } // estimates the progress by the hash prefix - first two characters // this is a rough estimate, but it's better than nothing // the prefix ranges from 00 to ff func (j *CleanGeneratedJob) estimateProgress(hashPrefix string) (float64, error) { toInt, err := strconv.ParseInt(hashPrefix, 16, 64) if err != nil { return 0, err } const total = 256 // ff return float64(toInt) / total, nil } func (j *CleanGeneratedJob) setProgressFromFilename(prefix string, progress *job.Progress) { p, err := j.estimateProgress(prefix) if err != nil { logger.Errorf("error estimating progress: %v", err) return } j.setTaskProgress(p, progress) } func (j *CleanGeneratedJob) getIntraFolderPrefix(basename string) (string, error) { var hash string _, err := fmt.Sscanf(basename, "%2x", &hash) if err != nil { return "", err } return fmt.Sprintf("%x", hash), nil } func (j *CleanGeneratedJob) getBlobFileHash(basename string) (string, error) { var hash string _, err := fmt.Sscanf(basename, "%32x", &hash) if err != nil { return "", err } return fmt.Sprintf("%x", hash), nil } func (j *CleanGeneratedJob) cleanBlobFiles(ctx context.Context, progress *job.Progress) error { if job.IsCancelled(ctx) { return nil } if j.BlobsStorageType != config.BlobStorageTypeFilesystem { logger.Debugf("skipping blob file cleanup, storage type is not filesystem") return nil } logger.Infof("Cleaning blob files") // walk through the blob directory if err := filepath.Walk(j.Paths.Blobs, func(path string, info fs.FileInfo, err error) error { if err != nil { return err } if err = ctx.Err(); err != nil { return err } if info.IsDir() { if path == j.Paths.Blobs { return nil } // ignore any directory that isn't a two character hash prefix _, err := j.getIntraFolderPrefix(info.Name()) if err != nil { logger.Warnf("Ignoring unknown directory: %s", path) return fs.SkipDir } // estimate progress by the hash prefix if filepath.Dir(path) == j.Paths.Blobs { hashPrefix := filepath.Base(path) j.setProgressFromFilename(hashPrefix, progress) } return nil } blobname := info.Name() // ignore any files that aren't a 32 character hash _, err = j.getBlobFileHash(blobname) if err != nil { logger.Warnf("ignoring unknown blob file: %s", blobname) return nil } // if blob entry does not exist, delete the file if err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error { exists, err := j.BlobCleaner.EntryExists(ctx, blobname) if err != nil { return err } if !exists { j.logDelete("deleting unused blob file: %s", blobname) j.deleteFile(path) } return nil }); err != nil { logger.Errorf("error checking blob entry: %v", err) return nil } return nil }); err != nil { return err } return nil } func (j *CleanGeneratedJob) getScenesWithHash(ctx context.Context, hash string) ([]*models.Scene, error) { fp := models.Fingerprint{ Fingerprint: hash, } if j.VideoFileNamingAlgorithm == models.HashAlgorithmMd5 { fp.Type = models.FingerprintTypeMD5 } else { fp.Type = models.FingerprintTypeOshash } return j.Repository.Scene.FindByFingerprints(ctx, []models.Fingerprint{fp}) } const ( md5Length = 32 oshashLength = 16 ) func (j *CleanGeneratedJob) hashPatternPrefix() string { hashLen := oshashLength if j.VideoFileNamingAlgorithm == models.HashAlgorithmMd5 { hashLen = md5Length } return fmt.Sprintf("%%%dx", hashLen) } func (j *CleanGeneratedJob) getSpriteFileHash(basename string) (string, error) { patternPrefix := j.hashPatternPrefix() spritePattern := patternPrefix + "_sprite.jpg" var hash string _, err := fmt.Sscanf(basename, spritePattern, &hash) if err != nil { // also try thumbs thumbPattern := patternPrefix + "_thumbs.vtt" _, err = fmt.Sscanf(basename, thumbPattern, &hash) if err != nil { return "", err } } return fmt.Sprintf("%x", hash), nil } func (j *CleanGeneratedJob) cleanSpriteFiles(ctx context.Context, progress *job.Progress) error { if job.IsCancelled(ctx) { return nil } logger.Infof("Cleaning sprite files") // walk through the sprite directory if err := filepath.Walk(j.Paths.Generated.Vtt, func(path string, info fs.FileInfo, err error) error { if err != nil { return err } if err = ctx.Err(); err != nil { return err } if info.IsDir() { return nil } filename := info.Name() hash, err := j.getSpriteFileHash(filename) if err != nil { logger.Warnf("Ignoring unknown sprite file: %s", filename) return nil } j.setProgressFromFilename(hash[0:2], progress) var exists []*models.Scene if err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error { exists, err = j.getScenesWithHash(ctx, hash) return err }); err != nil { logger.Errorf("error checking scene entry for sprite: %v", err) return nil } if len(exists) == 0 { j.logDelete("deleting unused sprite file: %s", filename) j.deleteFile(path) } return nil }); err != nil { return err } return nil } func (j *CleanGeneratedJob) cleanSceneFiles(ctx context.Context, path string, typ string, getSceneFileHash func(filename string) (string, error), progress *job.Progress) error { if job.IsCancelled(ctx) { return nil } logger.Infof("Cleaning %s files", typ) // walk through the sprite directory if err := filepath.Walk(path, func(path string, info fs.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } if err = ctx.Err(); err != nil { return err } filename := info.Name() hash, err := getSceneFileHash(filename) if err != nil { logger.Warnf("Ignoring unknown %s file: %s", typ, filename) return nil } j.setProgressFromFilename(hash[0:2], progress) var exists []*models.Scene if err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error { exists, err = j.getScenesWithHash(ctx, hash) return err }); err != nil { logger.Errorf("error checking scene entry: %v", err) return nil } if len(exists) == 0 { j.logDelete("deleting unused %s file: %s", typ, filename) j.deleteFile(path) } return nil }); err != nil { return err } return nil } func (j *CleanGeneratedJob) getScreenshotFileHash(basename string) (string, error) { var hash string var ext string // include the extension - which could be mp4/jpg/webp _, err := fmt.Sscanf(basename, j.hashPatternPrefix()+".%s", &hash, &ext) if err != nil { return "", err } return fmt.Sprintf("%x", hash), nil } func (j *CleanGeneratedJob) cleanScreenshotFiles(ctx context.Context, progress *job.Progress) error { return j.cleanSceneFiles(ctx, j.Paths.Generated.Screenshots, "screenshot", j.getScreenshotFileHash, progress) } func (j *CleanGeneratedJob) getTranscodeFileHash(basename string) (string, error) { var hash string _, err := fmt.Sscanf(basename, j.hashPatternPrefix()+".mp4", &hash) if err != nil { return "", err } return fmt.Sprintf("%x", hash), nil } func (j *CleanGeneratedJob) cleanTranscodeFiles(ctx context.Context, progress *job.Progress) error { return j.cleanSceneFiles(ctx, j.Paths.Generated.Transcodes, "transcode", j.getTranscodeFileHash, progress) } func (j *CleanGeneratedJob) getMarkerSceneFileHash(basename string) (string, error) { var hash string _, err := fmt.Sscanf(basename, j.hashPatternPrefix(), &hash) if err != nil { return "", err } return fmt.Sprintf("%x", hash), nil } func (j *CleanGeneratedJob) getMarkerFileSeconds(basename string) (int, error) { var ret int var ext string // include the extension - which could be mp4/jpg/webp _, err := fmt.Sscanf(basename, "%d.%s", &ret, &ext) if err != nil { return 0, err } return ret, nil } func (j *CleanGeneratedJob) cleanMarkerFiles(ctx context.Context, progress *job.Progress) error { if job.IsCancelled(ctx) { return nil } logger.Infof("Cleaning marker files") var scenes []*models.Scene var sceneHash string var markers []*models.SceneMarker // walk through the markers directory if err := filepath.Walk(j.Paths.Generated.Markers, func(path string, info fs.FileInfo, err error) error { if err != nil { return err } if err = ctx.Err(); err != nil { return err } if info.IsDir() { // ignore markers directory if path == j.Paths.Generated.Markers { return nil } markers = nil if filepath.Dir(path) != j.Paths.Generated.Markers { logger.Warnf("Ignoring unknown marker directory: %s", path) return nil } sceneHash, err = j.getMarkerSceneFileHash(info.Name()) if err != nil { logger.Warnf("Ignoring unknown marker directory: %s", path) return nil } j.setProgressFromFilename(sceneHash[0:2], progress) // check if the scene exists var walkErr error if err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error { var err error scenes, err = j.getScenesWithHash(ctx, sceneHash) if err != nil { return fmt.Errorf("error checking scene entry: %v", err) } if len(scenes) == 0 { j.logDelete("deleting unused marker directory: %s", sceneHash) j.deleteDir(path) // #5911 - we've just deleted the directory, so skip it in the walk to avoid errors walkErr = fs.SkipDir return nil } // get the markers now for _, scene := range scenes { thisMarkers, err := j.Repository.SceneMarker.FindBySceneID(ctx, scene.ID) if err != nil { return fmt.Errorf("error getting markers for scene: %v", err) } markers = append(markers, thisMarkers...) } return nil }); err != nil { logger.Error(err.Error()) } return walkErr } filename := info.Name() seconds, err := j.getMarkerFileSeconds(filename) if err != nil { logger.Warnf("Ignoring unknown marker file: %s", filename) return nil } // scenes should be set by the directory walk hash := filepath.Base(filepath.Dir(path)) if hash != sceneHash { logger.Errorf("internal error: scene hash mismatch: %s != %s", hash, sceneHash) return nil } if len(scenes) == 0 { logger.Errorf("no scenes found for marker file: %s", filename) return nil } // find the marker var marker *models.SceneMarker for _, m := range markers { if int(m.Seconds) == seconds { marker = m break } } if marker == nil { // not found, delete the file j.logDelete("deleting unused marker file: %s", filename) j.deleteFile(path) } return nil }); err != nil { return err } return nil } func (j *CleanGeneratedJob) getImagesWithHash(ctx context.Context, checksum string) ([]*models.Image, error) { var exists []*models.Image if err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error { // if scene entry does not exist, delete the file var err error exists, err = j.Repository.Image.FindByChecksum(ctx, checksum) return err }); err != nil { return nil, err } return exists, nil } func (j *CleanGeneratedJob) getThumbnailFileHash(basename string) (string, error) { var ( hash string width int ext string ) // include the extension - which could be jpg/webp _, err := fmt.Sscanf(basename, "%32x_%d.%s", &hash, &width, &ext) if err != nil { return "", err } return fmt.Sprintf("%x", hash), nil } func (j *CleanGeneratedJob) cleanThumbnailFiles(ctx context.Context, progress *job.Progress) error { if job.IsCancelled(ctx) { return nil } logger.Infof("Cleaning image thumbnail files") // walk through the sprite directory if err := filepath.Walk(j.Paths.Generated.Thumbnails, func(path string, info fs.FileInfo, err error) error { if err != nil { return err } if err = ctx.Err(); err != nil { return err } if info.IsDir() { if path == j.Paths.Generated.Thumbnails { return nil } // ensure the directory is a hash prefix _, err := j.getIntraFolderPrefix(info.Name()) if err != nil { logger.Warnf("Ignoring unknown thumbnail directory: %s", path) return nil } // estimate progress by the hash prefix if filepath.Dir(path) == j.Paths.Generated.Thumbnails { hashPrefix := filepath.Base(path) j.setProgressFromFilename(hashPrefix, progress) } return nil } filename := info.Name() checksum, err := j.getThumbnailFileHash(filename) if err != nil { logger.Warnf("Ignoring unknown thumbnail file: %s", filename) return nil } exists, err := j.getImagesWithHash(ctx, checksum) if err != nil { logger.Errorf("error checking image entry: %v", err) return nil } if len(exists) == 0 { j.logDelete("deleting unused thumbnail file: %s", filename) j.deleteFile(path) } return nil }); err != nil { return err } return nil } ================================================ FILE: internal/manager/task/download_ffmpeg.go ================================================ package task import ( "archive/zip" "context" "fmt" "io" "net/http" "os" "path" "path/filepath" "runtime" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" ) type DownloadFFmpegJob struct { ConfigDirectory string OnComplete func(ctx context.Context) urls []string downloaded int } func (s *DownloadFFmpegJob) Execute(ctx context.Context, progress *job.Progress) error { if err := s.download(ctx, progress); err != nil { if job.IsCancelled(ctx) { return nil } return err } if s.OnComplete != nil { s.OnComplete(ctx) } return nil } func (s *DownloadFFmpegJob) setTaskProgress(taskProgress float64, progress *job.Progress) { progress.SetPercent((float64(s.downloaded) + taskProgress) / float64(len(s.urls))) } func (s *DownloadFFmpegJob) download(ctx context.Context, progress *job.Progress) error { s.urls = ffmpeg.GetFFmpegURL() // set steps based on the number of URLs for _, url := range s.urls { err := s.downloadSingle(ctx, url, progress) if err != nil { return err } s.downloaded++ } // validate that the urls contained what we needed executables := []string{fsutil.GetExeName("ffmpeg"), fsutil.GetExeName("ffprobe")} for _, executable := range executables { _, err := os.Stat(filepath.Join(s.ConfigDirectory, executable)) if err != nil { return err } } return nil } type downloadProgressReader struct { io.Reader setProgress func(taskProgress float64) bytesRead int64 total int64 } func (r *downloadProgressReader) Read(p []byte) (int, error) { read, err := r.Reader.Read(p) if err == nil { r.bytesRead += int64(read) if r.total > 0 { progress := float64(r.bytesRead) / float64(r.total) r.setProgress(progress) } } return read, err } func (s *DownloadFFmpegJob) downloadSingle(ctx context.Context, url string, progress *job.Progress) error { if url == "" { return fmt.Errorf("no ffmpeg url for this platform") } configDirectory := s.ConfigDirectory // Configure where we want to download the archive urlBase := path.Base(url) archivePath := filepath.Join(configDirectory, urlBase) _ = os.Remove(archivePath) // remove archive if it already exists out, err := os.Create(archivePath) if err != nil { return err } defer out.Close() logger.Infof("Downloading %s...", url) progress.ExecuteTask(fmt.Sprintf("Downloading %s", url), func() { err = s.downloadFile(ctx, url, out, progress) }) if err != nil { return fmt.Errorf("failed to download ffmpeg from %s: %w", url, err) } logger.Info("Downloading complete") logger.Infof("Unzipping %s...", archivePath) progress.ExecuteTask(fmt.Sprintf("Unzipping %s", archivePath), func() { err = s.unzip(archivePath) }) if err != nil { return fmt.Errorf("failed to unzip ffmpeg archive: %w", err) } // On OSX or Linux set downloaded files permissions if runtime.GOOS == "darwin" || runtime.GOOS == "linux" { _, err = os.Stat(filepath.Join(configDirectory, "ffmpeg")) if !os.IsNotExist(err) { if err = os.Chmod(filepath.Join(configDirectory, "ffmpeg"), 0755); err != nil { return err } } _, err = os.Stat(filepath.Join(configDirectory, "ffprobe")) if !os.IsNotExist(err) { if err := os.Chmod(filepath.Join(configDirectory, "ffprobe"), 0755); err != nil { return err } } // TODO: In future possible clear xattr to allow running on osx without user intervention // TODO: this however may not be required. // xattr -c /path/to/binary -- xattr.Remove(path, "com.apple.quarantine") } return nil } func (s *DownloadFFmpegJob) downloadFile(ctx context.Context, url string, out *os.File, progress *job.Progress) error { // Make the HTTP request req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err } transport := &http.Transport{Proxy: http.ProxyFromEnvironment} client := &http.Client{ Transport: transport, } resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() // Check server response if resp.StatusCode != http.StatusOK { return fmt.Errorf("bad status: %s", resp.Status) } reader := &downloadProgressReader{ Reader: resp.Body, total: resp.ContentLength, setProgress: func(taskProgress float64) { s.setTaskProgress(taskProgress, progress) }, } // Write the response to the archive file location if _, err := io.Copy(out, reader); err != nil { return err } mime := resp.Header.Get("Content-Type") if mime != "application/zip" { // try detecting MIME type since some servers don't return the correct one data := make([]byte, 500) // http.DetectContentType only reads up to 500 bytes _, _ = out.ReadAt(data, 0) mime = http.DetectContentType(data) } if mime != "application/zip" { return fmt.Errorf("downloaded file is not a zip archive") } return nil } func (s *DownloadFFmpegJob) unzip(src string) error { zipReader, err := zip.OpenReader(src) if err != nil { return err } defer zipReader.Close() for _, f := range zipReader.File { if f.FileInfo().IsDir() { continue } filename := f.FileInfo().Name() if filename != "ffprobe" && filename != "ffmpeg" && filename != "ffprobe.exe" && filename != "ffmpeg.exe" { continue } rc, err := f.Open() if err != nil { return err } unzippedPath := filepath.Join(s.ConfigDirectory, filename) unzippedOutput, err := os.Create(unzippedPath) if err != nil { return err } _, err = io.Copy(unzippedOutput, rc) if err != nil { return err } if err := unzippedOutput.Close(); err != nil { return err } } return nil } ================================================ FILE: internal/manager/task/migrate.go ================================================ package task import ( "context" "errors" "fmt" "os" "path/filepath" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/sqlite" ) type migrateJobConfig interface { GetBackupDirectoryPath() string GetBackupDirectoryPathOrDefault() string } type MigrateJob struct { BackupPath string Config migrateJobConfig Database *sqlite.Database } type databaseSchemaInfo struct { CurrentSchemaVersion uint RequiredSchemaVersion uint StepsRequired uint } func (s *MigrateJob) Execute(ctx context.Context, progress *job.Progress) error { schemaInfo, err := s.required() if err != nil { return err } if schemaInfo.StepsRequired == 0 { logger.Infof("database is already at the latest schema version") return nil } logger.Infof("Migrating database from %d to %d", schemaInfo.CurrentSchemaVersion, schemaInfo.RequiredSchemaVersion) // set the number of tasks = backup + required steps + optimise progress.SetTotal(int(schemaInfo.StepsRequired + 2)) database := s.Database // always backup so that we can roll back to the previous version if // migration fails backupPath := s.BackupPath if backupPath == "" { backupPath = database.DatabaseBackupPath(s.Config.GetBackupDirectoryPath()) } else { // check if backup path is a filename or path // filename goes into backup directory, path is kept as is filename := filepath.Base(backupPath) if backupPath == filename { backupPath = filepath.Join(s.Config.GetBackupDirectoryPathOrDefault(), filename) } } progress.ExecuteTask("Backing up database", func() { defer progress.Increment() // perform database backup err = database.Backup(backupPath) }) if err != nil { return fmt.Errorf("error backing up database: %s", err) } err = s.runMigrations(ctx, progress) if err != nil { errStr := fmt.Sprintf("error performing migration: %s", err) // roll back to the backed up version restoreErr := database.RestoreFromBackup(backupPath) if restoreErr != nil { errStr = fmt.Sprintf("ERROR: unable to restore database from backup after migration failure: %s\n%s", restoreErr.Error(), errStr) } else { errStr = "An error occurred migrating the database to the latest schema version. The backup database file was automatically renamed to restore the database.\n" + errStr } return errors.New(errStr) } // if no backup path was provided, then delete the created backup if s.BackupPath == "" { if err := os.Remove(backupPath); err != nil { logger.Warnf("error removing unwanted database backup (%s): %s", backupPath, err.Error()) } } // reinitialise the database if err := database.ReInitialise(); err != nil { return fmt.Errorf("error reinitialising database: %s", err) } logger.Infof("Database migration complete") return nil } func (s *MigrateJob) required() (ret databaseSchemaInfo, err error) { database := s.Database m, err := sqlite.NewMigrator(database) if err != nil { return } defer m.Close() ret.CurrentSchemaVersion = m.CurrentSchemaVersion() ret.RequiredSchemaVersion = m.RequiredSchemaVersion() if ret.RequiredSchemaVersion < ret.CurrentSchemaVersion { // shouldn't happen return } ret.StepsRequired = ret.RequiredSchemaVersion - ret.CurrentSchemaVersion return } func (s *MigrateJob) runMigrations(ctx context.Context, progress *job.Progress) error { database := s.Database m, err := sqlite.NewMigrator(database) if err != nil { return err } defer m.Close() logger.Info("Running migrations") for { currentSchemaVersion := m.CurrentSchemaVersion() targetSchemaVersion := m.RequiredSchemaVersion() if currentSchemaVersion >= targetSchemaVersion { break } var err error progress.ExecuteTask(fmt.Sprintf("Migrating database to schema version %d", currentSchemaVersion+1), func() { err = m.RunMigration(ctx, currentSchemaVersion+1) }) if err != nil { return fmt.Errorf("error running migration for schema %d: %s", currentSchemaVersion+1, err) } progress.Increment() } // perform post-migrate analyze using the migrator connection progress.ExecuteTask("Optimising database", func() { err = m.PostMigrate(ctx) progress.Increment() }) if err != nil { return fmt.Errorf("error optimising database: %s", err) } return nil } ================================================ FILE: internal/manager/task/migrate_blobs.go ================================================ package task import ( "context" "fmt" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/txn" ) type BlobStoreMigrator interface { Count(ctx context.Context) (int, error) FindBlobs(ctx context.Context, n uint, lastChecksum string) ([]string, error) MigrateBlob(ctx context.Context, checksum string, deleteOld bool) error } type Vacuumer interface { Vacuum(ctx context.Context) error } type MigrateBlobsJob struct { TxnManager txn.Manager BlobStore BlobStoreMigrator Vacuumer Vacuumer DeleteOld bool } func (j *MigrateBlobsJob) Execute(ctx context.Context, progress *job.Progress) error { var ( count int err error ) progress.ExecuteTask("Counting blobs", func() { count, err = j.countBlobs(ctx) progress.SetTotal(count) }) if err != nil { return fmt.Errorf("error counting blobs: %w", err) } if count == 0 { logger.Infof("No blobs to migrate") return nil } logger.Infof("Migrating %d blobs", count) progress.ExecuteTask(fmt.Sprintf("Migrating %d blobs", count), func() { err = j.migrateBlobs(ctx, progress) }) if job.IsCancelled(ctx) { logger.Info("Cancelled migrating blobs") return nil } if err != nil { return fmt.Errorf("error migrating blobs: %w", err) } // run a vacuum to reclaim space progress.ExecuteTask("Vacuuming database", func() { err = j.Vacuumer.Vacuum(ctx) if err != nil { logger.Errorf("Error vacuuming database: %v", err) } }) logger.Infof("Finished migrating blobs") return nil } func (j *MigrateBlobsJob) countBlobs(ctx context.Context) (int, error) { var count int if err := txn.WithReadTxn(ctx, j.TxnManager, func(ctx context.Context) error { var err error count, err = j.BlobStore.Count(ctx) return err }); err != nil { return 0, err } return count, nil } func (j *MigrateBlobsJob) migrateBlobs(ctx context.Context, progress *job.Progress) error { lastChecksum := "" batch, err := j.getBatch(ctx, lastChecksum) for len(batch) > 0 && err == nil && ctx.Err() == nil { for _, checksum := range batch { if ctx.Err() != nil { return nil } lastChecksum = checksum progress.ExecuteTask("Migrating blob "+checksum, func() { defer progress.Increment() if err := txn.WithTxn(ctx, j.TxnManager, func(ctx context.Context) error { return j.BlobStore.MigrateBlob(ctx, checksum, j.DeleteOld) }); err != nil { logger.Errorf("Error migrating blob %s: %v", checksum, err) } }) } batch, err = j.getBatch(ctx, lastChecksum) } return err } func (j *MigrateBlobsJob) getBatch(ctx context.Context, lastChecksum string) ([]string, error) { const batchSize = 1000 var batch []string err := txn.WithReadTxn(ctx, j.TxnManager, func(ctx context.Context) error { var err error batch, err = j.BlobStore.FindBlobs(ctx, batchSize, lastChecksum) return err }) return batch, err } ================================================ FILE: internal/manager/task/migrate_scene_screenshots.go ================================================ package task import ( "context" "errors" "fmt" "io" "os" "path/filepath" "strings" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/txn" ) type MigrateSceneScreenshotsJob struct { ScreenshotsPath string Input scene.MigrateSceneScreenshotsInput SceneRepo scene.HashFinderCoverUpdater TxnManager txn.Manager } func (j *MigrateSceneScreenshotsJob) Execute(ctx context.Context, progress *job.Progress) error { var err error progress.ExecuteTask("Counting files", func() { var count int count, err = j.countFiles(ctx) progress.SetTotal(count) }) if err != nil { return fmt.Errorf("error counting files: %w", err) } progress.ExecuteTask("Migrating files", func() { err = j.migrateFiles(ctx, progress) }) if job.IsCancelled(ctx) { logger.Info("Cancelled migrating scene screenshots") return nil } if err != nil { return fmt.Errorf("error migrating scene screenshots: %w", err) } logger.Infof("Finished migrating scene screenshots") return nil } func (j *MigrateSceneScreenshotsJob) countFiles(ctx context.Context) (int, error) { f, err := os.Open(j.ScreenshotsPath) if err != nil { return 0, err } defer f.Close() const batchSize = 1000 ret := 0 files, err := f.ReadDir(batchSize) for err == nil && ctx.Err() == nil { ret += len(files) files, err = f.ReadDir(batchSize) } if errors.Is(err, io.EOF) { // end of directory return ret, nil } return 0, err } func (j *MigrateSceneScreenshotsJob) migrateFiles(ctx context.Context, progress *job.Progress) error { f, err := os.Open(j.ScreenshotsPath) if err != nil { return err } defer f.Close() m := scene.ScreenshotMigrator{ Options: j.Input, SceneUpdater: j.SceneRepo, TxnManager: j.TxnManager, } const batchSize = 1000 files, err := f.ReadDir(batchSize) for err == nil && ctx.Err() == nil { for _, f := range files { if ctx.Err() != nil { return nil } progress.ExecuteTask("Migrating file "+f.Name(), func() { defer progress.Increment() path := filepath.Join(j.ScreenshotsPath, f.Name()) // sanity check - only process files if f.IsDir() { logger.Warnf("Skipping directory %s", path) return } // ignore non-jpg files if !strings.HasSuffix(f.Name(), ".jpg") { return } // ignore .thumb files if strings.HasSuffix(f.Name(), ".thumb.jpg") { return } if err := m.MigrateScreenshots(ctx, path); err != nil { logger.Errorf("Error migrating screenshots for %s: %v", path, err) } }) } files, err = f.ReadDir(batchSize) } if errors.Is(err, io.EOF) { // end of directory return nil } return err } ================================================ FILE: internal/manager/task/packages.go ================================================ package task import ( "context" "fmt" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/pkg" ) type PackagesJob struct { PackageManager *pkg.Manager OnComplete func() } func (j *PackagesJob) installPackage(ctx context.Context, p models.PackageSpecInput, progress *job.Progress) error { defer progress.Increment() if err := j.PackageManager.Install(ctx, p); err != nil { return fmt.Errorf("installing package: %w", err) } return nil } type InstallPackagesJob struct { PackagesJob Packages []*models.PackageSpecInput } func (j *InstallPackagesJob) Execute(ctx context.Context, progress *job.Progress) error { progress.SetTotal(len(j.Packages)) for _, p := range j.Packages { if job.IsCancelled(ctx) { logger.Info("Cancelled installing packages") return nil } logger.Infof("Installing package %s", p.ID) taskDesc := fmt.Sprintf("Installing %s", p.ID) progress.ExecuteTask(taskDesc, func() { if err := j.installPackage(ctx, *p, progress); err != nil { logger.Errorf("Error installing package %s from %s: %v", p.ID, p.SourceURL, err) } }) } if j.OnComplete != nil { j.OnComplete() } logger.Infof("Finished installing packages") return nil } type UpdatePackagesJob struct { PackagesJob Packages []*models.PackageSpecInput } func (j *UpdatePackagesJob) Execute(ctx context.Context, progress *job.Progress) error { // if no packages are specified, update all if len(j.Packages) == 0 { installed, err := j.PackageManager.InstalledStatus(ctx) if err != nil { return fmt.Errorf("error getting installed packages: %w", err) } for _, p := range installed { if p.Upgradable() { j.Packages = append(j.Packages, &models.PackageSpecInput{ ID: p.Local.ID, SourceURL: p.Remote.Repository.Path(), }) } } } progress.SetTotal(len(j.Packages)) for _, p := range j.Packages { if job.IsCancelled(ctx) { logger.Info("Cancelled updating packages") return nil } logger.Infof("Updating package %s", p.ID) taskDesc := fmt.Sprintf("Updating %s", p.ID) progress.ExecuteTask(taskDesc, func() { if err := j.installPackage(ctx, *p, progress); err != nil { logger.Errorf("Error updating package %s from %s: %v", p.ID, p.SourceURL, err) } }) } if j.OnComplete != nil { j.OnComplete() } logger.Infof("Finished updating packages") return nil } type UninstallPackagesJob struct { PackagesJob Packages []*models.PackageSpecInput } func (j *UninstallPackagesJob) Execute(ctx context.Context, progress *job.Progress) error { progress.SetTotal(len(j.Packages)) for _, p := range j.Packages { if job.IsCancelled(ctx) { logger.Info("Cancelled installing packages") return nil } logger.Infof("Uninstalling package %s", p.ID) taskDesc := fmt.Sprintf("Uninstalling %s", p.ID) progress.ExecuteTask(taskDesc, func() { if err := j.PackageManager.Uninstall(ctx, *p); err != nil { logger.Errorf("Error uninstalling package %s: %v", p.ID, err) } }) } if j.OnComplete != nil { j.OnComplete() } logger.Infof("Finished uninstalling packages") return nil } ================================================ FILE: internal/manager/task.go ================================================ package manager import "context" type Task interface { Start(context.Context) GetDescription() string } ================================================ FILE: internal/manager/task_autotag.go ================================================ package manager import ( "context" "fmt" "path/filepath" "strconv" "strings" "sync" "time" "github.com/stashapp/stash/internal/autotag" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" ) type autoTagJob struct { repository models.Repository input AutoTagMetadataInput cache match.Cache } func (j *autoTagJob) Execute(ctx context.Context, progress *job.Progress) error { begin := time.Now() input := j.input if j.isFileBasedAutoTag(input) { // doing file-based auto-tag j.autoTagFiles(ctx, progress, input.Paths, len(input.Performers) > 0, len(input.Studios) > 0, len(input.Tags) > 0) } else { // doing specific performer/studio/tag auto-tag j.autoTagSpecific(ctx, progress) } logger.Infof("Finished auto-tag after %s", time.Since(begin).String()) return nil } func (j *autoTagJob) isFileBasedAutoTag(input AutoTagMetadataInput) bool { const wildcard = "*" performerIds := input.Performers studioIds := input.Studios tagIds := input.Tags return (len(performerIds) == 0 || performerIds[0] == wildcard) && (len(studioIds) == 0 || studioIds[0] == wildcard) && (len(tagIds) == 0 || tagIds[0] == wildcard) } func (j *autoTagJob) autoTagFiles(ctx context.Context, progress *job.Progress, paths []string, performers, studios, tags bool) { t := autoTagFilesTask{ paths: paths, performers: performers, studios: studios, tags: tags, progress: progress, repository: j.repository, cache: &j.cache, } t.process(ctx) } func (j *autoTagJob) autoTagSpecific(ctx context.Context, progress *job.Progress) { input := j.input performerIds := input.Performers studioIds := input.Studios tagIds := input.Tags performerCount := len(performerIds) studioCount := len(studioIds) tagCount := len(tagIds) r := j.repository if err := r.WithReadTxn(ctx, func(ctx context.Context) error { performerQuery := r.Performer studioQuery := r.Studio tagQuery := r.Tag const wildcard = "*" var err error if performerCount == 1 && performerIds[0] == wildcard { performerCount, err = performerQuery.Count(ctx) if err != nil { return fmt.Errorf("getting performer count: %v", err) } } if studioCount == 1 && studioIds[0] == wildcard { studioCount, err = studioQuery.Count(ctx) if err != nil { return fmt.Errorf("getting studio count: %v", err) } } if tagCount == 1 && tagIds[0] == wildcard { tagCount, err = tagQuery.Count(ctx) if err != nil { return fmt.Errorf("getting tag count: %v", err) } } return nil }); err != nil { if !job.IsCancelled(ctx) { logger.Errorf("auto-tag error: %v", err) } return } total := performerCount + studioCount + tagCount progress.SetTotal(total) logger.Infof("Starting auto-tag of %d performers, %d studios, %d tags", performerCount, studioCount, tagCount) j.autoTagPerformers(ctx, progress, input.Paths, performerIds) j.autoTagStudios(ctx, progress, input.Paths, studioIds) j.autoTagTags(ctx, progress, input.Paths, tagIds) } func (j *autoTagJob) autoTagPerformers(ctx context.Context, progress *job.Progress, paths []string, performerIds []string) { if job.IsCancelled(ctx) { return } r := j.repository tagger := autotag.Tagger{ TxnManager: r.TxnManager, Cache: &j.cache, } for _, performerId := range performerIds { var performers []*models.Performer if err := r.WithDB(ctx, func(ctx context.Context) error { performerQuery := r.Performer ignoreAutoTag := false perPage := -1 if performerId == "*" { var err error performers, _, err = performerQuery.Query(ctx, &models.PerformerFilterType{ IgnoreAutoTag: &ignoreAutoTag, }, &models.FindFilterType{ PerPage: &perPage, }) if err != nil { return fmt.Errorf("querying performers: %w", err) } } else { performerIdInt, err := strconv.Atoi(performerId) if err != nil { return fmt.Errorf("parsing performer id %s: %w", performerId, err) } performer, err := performerQuery.Find(ctx, performerIdInt) if err != nil { return fmt.Errorf("finding performer id %s: %w", performerId, err) } if performer == nil { return fmt.Errorf("performer with id %s not found", performerId) } if performer.IgnoreAutoTag { logger.Infof("Skipping performer %s because auto-tag is disabled", performer.Name) return nil } if err := performer.LoadAliases(ctx, r.Performer); err != nil { return fmt.Errorf("loading aliases for performer %d: %w", performer.ID, err) } performers = append(performers, performer) } for _, performer := range performers { if job.IsCancelled(ctx) { return nil } err := func() error { if err := tagger.PerformerScenes(ctx, performer, paths, r.Scene); err != nil { return fmt.Errorf("processing scenes: %w", err) } if err := tagger.PerformerImages(ctx, performer, paths, r.Image); err != nil { return fmt.Errorf("processing images: %w", err) } if err := tagger.PerformerGalleries(ctx, performer, paths, r.Gallery); err != nil { return fmt.Errorf("processing galleries: %w", err) } return nil }() if job.IsCancelled(ctx) { return nil } if err != nil { return fmt.Errorf("tagging performer '%s': %s", performer.Name, err.Error()) } progress.Increment() } return nil }); err != nil { logger.Errorf("auto-tag error: %v", err) } if job.IsCancelled(ctx) { logger.Info("Stopping performer auto-tag due to user request") return } } } func (j *autoTagJob) autoTagStudios(ctx context.Context, progress *job.Progress, paths []string, studioIds []string) { if job.IsCancelled(ctx) { return } r := j.repository tagger := autotag.Tagger{ TxnManager: r.TxnManager, Cache: &j.cache, } for _, studioId := range studioIds { var studios []*models.Studio if err := r.WithDB(ctx, func(ctx context.Context) error { studioQuery := r.Studio ignoreAutoTag := false perPage := -1 if studioId == "*" { var err error studios, _, err = studioQuery.Query(ctx, &models.StudioFilterType{ IgnoreAutoTag: &ignoreAutoTag, }, &models.FindFilterType{ PerPage: &perPage, }) if err != nil { return fmt.Errorf("querying studios: %v", err) } } else { studioIdInt, err := strconv.Atoi(studioId) if err != nil { return fmt.Errorf("parsing studio id %s: %s", studioId, err.Error()) } studio, err := studioQuery.Find(ctx, studioIdInt) if err != nil { return fmt.Errorf("finding studio id %s: %s", studioId, err.Error()) } if studio == nil { return fmt.Errorf("studio with id %s not found", studioId) } if studio.IgnoreAutoTag { logger.Infof("Skipping studio %s because auto-tag is disabled", studio.Name) return nil } studios = append(studios, studio) } for _, studio := range studios { if job.IsCancelled(ctx) { return nil } err := func() error { aliases, err := r.Studio.GetAliases(ctx, studio.ID) if err != nil { return fmt.Errorf("getting studio aliases: %w", err) } if err := tagger.StudioScenes(ctx, studio, paths, aliases, r.Scene); err != nil { return fmt.Errorf("processing scenes: %w", err) } if err := tagger.StudioImages(ctx, studio, paths, aliases, r.Image); err != nil { return fmt.Errorf("processing images: %w", err) } if err := tagger.StudioGalleries(ctx, studio, paths, aliases, r.Gallery); err != nil { return fmt.Errorf("processing galleries: %w", err) } return nil }() if job.IsCancelled(ctx) { return nil } if err != nil { return fmt.Errorf("tagging studio '%s': %s", studio.Name, err.Error()) } progress.Increment() } return nil }); err != nil { logger.Errorf("auto-tag error: %v", err) } if job.IsCancelled(ctx) { logger.Info("Stopping studio auto-tag due to user request") return } } } func (j *autoTagJob) autoTagTags(ctx context.Context, progress *job.Progress, paths []string, tagIds []string) { if job.IsCancelled(ctx) { return } r := j.repository tagger := autotag.Tagger{ TxnManager: r.TxnManager, Cache: &j.cache, } for _, tagId := range tagIds { var tags []*models.Tag if err := r.WithDB(ctx, func(ctx context.Context) error { tagQuery := r.Tag ignoreAutoTag := false perPage := -1 if tagId == "*" { var err error tags, _, err = tagQuery.Query(ctx, &models.TagFilterType{ IgnoreAutoTag: &ignoreAutoTag, }, &models.FindFilterType{ PerPage: &perPage, }) if err != nil { return fmt.Errorf("querying tags: %v", err) } } else { tagIdInt, err := strconv.Atoi(tagId) if err != nil { return fmt.Errorf("parsing tag id %s: %s", tagId, err.Error()) } tag, err := tagQuery.Find(ctx, tagIdInt) if err != nil { return fmt.Errorf("finding tag id %s: %s", tagId, err.Error()) } if tag == nil { return fmt.Errorf("tag with id %s not found", tagId) } if tag.IgnoreAutoTag { logger.Infof("Skipping tag %s because auto-tag is disabled", tag.Name) return nil } tags = append(tags, tag) } for _, tag := range tags { if job.IsCancelled(ctx) { return nil } err := func() error { aliases, err := r.Tag.GetAliases(ctx, tag.ID) if err != nil { return fmt.Errorf("getting tag aliases: %w", err) } if err := tagger.TagScenes(ctx, tag, paths, aliases, r.Scene); err != nil { return fmt.Errorf("processing scenes: %w", err) } if err := tagger.TagImages(ctx, tag, paths, aliases, r.Image); err != nil { return fmt.Errorf("processing images: %w", err) } if err := tagger.TagGalleries(ctx, tag, paths, aliases, r.Gallery); err != nil { return fmt.Errorf("processing galleries: %w", err) } return nil }() if job.IsCancelled(ctx) { return nil } if err != nil { return fmt.Errorf("tagging tag '%s': %s", tag.Name, err.Error()) } progress.Increment() } return nil }); err != nil { logger.Errorf("auto-tag error: %v", err) } if job.IsCancelled(ctx) { logger.Info("Stopping tag auto-tag due to user request") return } } } type autoTagFilesTask struct { paths []string performers bool studios bool tags bool progress *job.Progress repository models.Repository cache *match.Cache } func (t *autoTagFilesTask) makeSceneFilter() *models.SceneFilterType { ret := scene.FilterFromPaths(t.paths) organized := false ret.Organized = &organized return ret } func (t *autoTagFilesTask) makeImageFilter() *models.ImageFilterType { ret := &models.ImageFilterType{} or := ret sep := string(filepath.Separator) for _, p := range t.paths { if !strings.HasSuffix(p, sep) { p += sep } if ret.Path == nil { or = ret } else { newOr := &models.ImageFilterType{} or.Or = newOr or = newOr } or.Path = &models.StringCriterionInput{ Modifier: models.CriterionModifierEquals, Value: p + "%", } } organized := false ret.Organized = &organized return ret } func (t *autoTagFilesTask) makeGalleryFilter() *models.GalleryFilterType { ret := &models.GalleryFilterType{} or := ret sep := string(filepath.Separator) if len(t.paths) == 0 { ret.Path = &models.StringCriterionInput{ Modifier: models.CriterionModifierNotNull, } } for _, p := range t.paths { if !strings.HasSuffix(p, sep) { p += sep } if ret.Path == nil { or = ret } else { newOr := &models.GalleryFilterType{} or.Or = newOr or = newOr } or.Path = &models.StringCriterionInput{ Modifier: models.CriterionModifierEquals, Value: p + "%", } } organized := false ret.Organized = &organized return ret } func (t *autoTagFilesTask) getCount(ctx context.Context) (int, error) { r := t.repository pp := 0 findFilter := &models.FindFilterType{ PerPage: &pp, } sceneResults, err := r.Scene.Query(ctx, models.SceneQueryOptions{ QueryOptions: models.QueryOptions{ FindFilter: findFilter, Count: true, }, SceneFilter: t.makeSceneFilter(), }) if err != nil { return 0, fmt.Errorf("getting scene count: %w", err) } sceneCount := sceneResults.Count imageResults, err := r.Image.Query(ctx, models.ImageQueryOptions{ QueryOptions: models.QueryOptions{ FindFilter: findFilter, Count: true, }, ImageFilter: t.makeImageFilter(), }) if err != nil { return 0, fmt.Errorf("getting image count: %w", err) } imageCount := imageResults.Count _, galleryCount, err := r.Gallery.Query(ctx, t.makeGalleryFilter(), findFilter) if err != nil { return 0, fmt.Errorf("getting gallery count: %w", err) } return sceneCount + imageCount + galleryCount, nil } func (t *autoTagFilesTask) processScenes(ctx context.Context) { if job.IsCancelled(ctx) { return } logger.Info("Auto-tagging scenes...") batchSize := 1000 findFilter := models.BatchFindFilter(batchSize) sceneFilter := t.makeSceneFilter() r := t.repository more := true for more { var scenes []*models.Scene if err := r.WithReadTxn(ctx, func(ctx context.Context) error { var err error scenes, err = scene.Query(ctx, r.Scene, sceneFilter, findFilter) return err }); err != nil { if !job.IsCancelled(ctx) { logger.Errorf("error querying scenes for auto-tag: %w", err) } return } for _, ss := range scenes { if job.IsCancelled(ctx) { logger.Info("Stopping auto-tag due to user request") return } tt := autoTagSceneTask{ repository: r, scene: ss, performers: t.performers, studios: t.studios, tags: t.tags, cache: t.cache, } var wg sync.WaitGroup wg.Add(1) go tt.Start(ctx, &wg) wg.Wait() t.progress.Increment() } if len(scenes) != batchSize { more = false } else { *findFilter.Page++ if *findFilter.Page%10 == 1 { logger.Infof("Processed %d scenes...", (*findFilter.Page-1)*batchSize) } } } } func (t *autoTagFilesTask) processImages(ctx context.Context) { if job.IsCancelled(ctx) { return } logger.Info("Auto-tagging images...") batchSize := 1000 findFilter := models.BatchFindFilter(batchSize) imageFilter := t.makeImageFilter() r := t.repository more := true for more { var images []*models.Image if err := r.WithReadTxn(ctx, func(ctx context.Context) error { var err error images, err = image.Query(ctx, r.Image, imageFilter, findFilter) return err }); err != nil { if !job.IsCancelled(ctx) { logger.Errorf("error querying images for auto-tag: %w", err) } return } for _, ss := range images { if job.IsCancelled(ctx) { logger.Info("Stopping auto-tag due to user request") return } tt := autoTagImageTask{ repository: t.repository, image: ss, performers: t.performers, studios: t.studios, tags: t.tags, cache: t.cache, } var wg sync.WaitGroup wg.Add(1) go tt.Start(ctx, &wg) wg.Wait() t.progress.Increment() } if len(images) != batchSize { more = false } else { *findFilter.Page++ if *findFilter.Page%10 == 1 { logger.Infof("Processed %d images...", (*findFilter.Page-1)*batchSize) } } } } func (t *autoTagFilesTask) processGalleries(ctx context.Context) { if job.IsCancelled(ctx) { return } logger.Info("Auto-tagging galleries...") batchSize := 1000 findFilter := models.BatchFindFilter(batchSize) galleryFilter := t.makeGalleryFilter() r := t.repository more := true for more { var galleries []*models.Gallery if err := r.WithReadTxn(ctx, func(ctx context.Context) error { var err error galleries, _, err = r.Gallery.Query(ctx, galleryFilter, findFilter) return err }); err != nil { if !job.IsCancelled(ctx) { logger.Errorf("error querying galleries for auto-tag: %w", err) } return } for _, ss := range galleries { if job.IsCancelled(ctx) { logger.Info("Stopping auto-tag due to user request") return } tt := autoTagGalleryTask{ repository: t.repository, gallery: ss, performers: t.performers, studios: t.studios, tags: t.tags, cache: t.cache, } var wg sync.WaitGroup wg.Add(1) go tt.Start(ctx, &wg) wg.Wait() t.progress.Increment() } if len(galleries) != batchSize { more = false } else { *findFilter.Page++ if *findFilter.Page%10 == 1 { logger.Infof("Processed %d galleries...", (*findFilter.Page-1)*batchSize) } } } } func (t *autoTagFilesTask) process(ctx context.Context) { if err := t.repository.WithReadTxn(ctx, func(ctx context.Context) error { total, err := t.getCount(ctx) if err != nil { return err } t.progress.SetTotal(total) logger.Infof("Starting auto-tag of %d files", total) return nil }); err != nil { if !job.IsCancelled(ctx) { logger.Errorf("error getting file count for auto-tag task: %v", err) } return } t.processScenes(ctx) t.processImages(ctx) t.processGalleries(ctx) } type autoTagSceneTask struct { repository models.Repository scene *models.Scene performers bool studios bool tags bool cache *match.Cache } func (t *autoTagSceneTask) Start(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() r := t.repository if err := r.WithTxn(ctx, func(ctx context.Context) error { if t.scene.Path == "" { // nothing to do return nil } if t.performers { if err := autotag.ScenePerformers(ctx, t.scene, r.Scene, r.Performer, t.cache); err != nil { return fmt.Errorf("tagging scene performers for %s: %v", t.scene.DisplayName(), err) } } if t.studios { if err := autotag.SceneStudios(ctx, t.scene, r.Scene, r.Studio, t.cache); err != nil { return fmt.Errorf("tagging scene studio for %s: %v", t.scene.DisplayName(), err) } } if t.tags { if err := autotag.SceneTags(ctx, t.scene, r.Scene, r.Tag, t.cache); err != nil { return fmt.Errorf("tagging scene tags for %s: %v", t.scene.DisplayName(), err) } } return nil }); err != nil { if !job.IsCancelled(ctx) { logger.Errorf("auto-tag error: %v", err) } } } type autoTagImageTask struct { repository models.Repository image *models.Image performers bool studios bool tags bool cache *match.Cache } func (t *autoTagImageTask) Start(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() r := t.repository if err := r.WithTxn(ctx, func(ctx context.Context) error { if t.performers { if err := autotag.ImagePerformers(ctx, t.image, r.Image, r.Performer, t.cache); err != nil { return fmt.Errorf("tagging image performers for %s: %v", t.image.DisplayName(), err) } } if t.studios { if err := autotag.ImageStudios(ctx, t.image, r.Image, r.Studio, t.cache); err != nil { return fmt.Errorf("tagging image studio for %s: %v", t.image.DisplayName(), err) } } if t.tags { if err := autotag.ImageTags(ctx, t.image, r.Image, r.Tag, t.cache); err != nil { return fmt.Errorf("tagging image tags for %s: %v", t.image.DisplayName(), err) } } return nil }); err != nil { if !job.IsCancelled(ctx) { logger.Errorf("auto-tag error: %v", err) } } } type autoTagGalleryTask struct { repository models.Repository gallery *models.Gallery performers bool studios bool tags bool cache *match.Cache } func (t *autoTagGalleryTask) Start(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() r := t.repository if err := r.WithTxn(ctx, func(ctx context.Context) error { if t.performers { if err := autotag.GalleryPerformers(ctx, t.gallery, r.Gallery, r.Performer, t.cache); err != nil { return fmt.Errorf("tagging gallery performers for %s: %v", t.gallery.DisplayName(), err) } } if t.studios { if err := autotag.GalleryStudios(ctx, t.gallery, r.Gallery, r.Studio, t.cache); err != nil { return fmt.Errorf("tagging gallery studio for %s: %v", t.gallery.DisplayName(), err) } } if t.tags { if err := autotag.GalleryTags(ctx, t.gallery, r.Gallery, r.Tag, t.cache); err != nil { return fmt.Errorf("tagging gallery tags for %s: %v", t.gallery.DisplayName(), err) } } return nil }); err != nil { if !job.IsCancelled(ctx) { logger.Errorf("auto-tag error: %v", err) } } } ================================================ FILE: internal/manager/task_clean.go ================================================ package manager import ( "context" "fmt" "io/fs" "path/filepath" "time" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/scene" ) type cleaner interface { Clean(ctx context.Context, options file.CleanOptions, progress *job.Progress) } type cleanJob struct { cleaner cleaner repository models.Repository input CleanMetadataInput sceneService SceneService imageService ImageService scanSubs *subscriptionManager } func (j *cleanJob) Execute(ctx context.Context, progress *job.Progress) error { logger.Infof("Starting cleaning of tracked files") start := time.Now() if j.input.DryRun { logger.Infof("Running in Dry Mode") } j.cleaner.Clean(ctx, file.CleanOptions{ Paths: j.input.Paths, DryRun: j.input.DryRun, IgnoreZipFileContents: j.input.IgnoreZipFileContents, PathFilter: newCleanFilter(instance.Config), }, progress) if job.IsCancelled(ctx) { logger.Info("Stopping due to user request") return nil } j.cleanEmptyGalleries(ctx) j.scanSubs.notify() elapsed := time.Since(start) logger.Info(fmt.Sprintf("Finished Cleaning (%s)", elapsed)) return nil } func (j *cleanJob) cleanEmptyGalleries(ctx context.Context) { const batchSize = 1000 var toClean []int findFilter := models.BatchFindFilter(batchSize) r := j.repository if err := r.WithTxn(ctx, func(ctx context.Context) error { found := true for found { emptyGalleries, _, err := r.Gallery.Query(ctx, &models.GalleryFilterType{ ImageCount: &models.IntCriterionInput{ Value: 0, Modifier: models.CriterionModifierEquals, }, }, findFilter) if err != nil { return err } found = len(emptyGalleries) > 0 for _, g := range emptyGalleries { if g.Path == "" { continue } if len(j.input.Paths) > 0 && !fsutil.IsPathInDirs(j.input.Paths, g.Path) { continue } logger.Infof("Gallery has 0 images. Marking to clean: %s", g.DisplayName()) toClean = append(toClean, g.ID) } *findFilter.Page++ } return nil }); err != nil { logger.Errorf("Error finding empty galleries: %v", err) return } if !j.input.DryRun { for _, id := range toClean { j.deleteGallery(ctx, id) } } } func (j *cleanJob) deleteGallery(ctx context.Context, id int) { pluginCache := GetInstance().PluginCache r := j.repository if err := r.WithTxn(ctx, func(ctx context.Context) error { qb := r.Gallery g, err := qb.Find(ctx, id) if err != nil { return err } if g == nil { return fmt.Errorf("gallery with id %d not found", id) } if err := g.LoadPrimaryFile(ctx, r.File); err != nil { return err } if err := qb.Destroy(ctx, id); err != nil { return err } pluginCache.RegisterPostHooks(ctx, id, hook.GalleryDestroyPost, plugin.GalleryDestroyInput{ Checksum: g.PrimaryChecksum(), Path: g.Path, }, nil) return nil }); err != nil { logger.Errorf("Error deleting gallery from database: %s", err.Error()) } } type cleanFilter struct { scanFilter } func newCleanFilter(c *config.Config) *cleanFilter { return &cleanFilter{ scanFilter: scanFilter{ extensionConfig: newExtensionConfig(c), stashPaths: c.GetStashPaths(), generatedPath: c.GetGeneratedPath(), videoExcludeRegex: generateRegexps(c.GetExcludes()), imageExcludeRegex: generateRegexps(c.GetImageExcludes()), stashIgnoreFilter: file.NewStashIgnoreFilter(), }, } } func (f *cleanFilter) Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool { // #1102 - clean anything in generated path generatedPath := f.generatedPath var stash *config.StashConfig fileOrFolder := "File" if info.IsDir() { fileOrFolder = "Folder" stash = f.stashPaths.GetStashFromDirPath(path) } else { stash = f.stashPaths.GetStashFromPath(path) } if stash == nil { logger.Infof("%s not in any stash library directories. Marking to clean: %q", fileOrFolder, path) return false } if fsutil.IsPathInDir(generatedPath, path) { logger.Infof("%s is in generated path. Marking to clean: %q", fileOrFolder, path) return false } // Check .stashignore files, bounded to the library root. if !f.stashIgnoreFilter.Accept(ctx, path, info, stash.Path, zipFilePath) { logger.Infof("%s is excluded due to .stashignore. Marking to clean: %q", fileOrFolder, path) return false } if info.IsDir() { return !f.shouldCleanFolder(path, stash) } return !f.shouldCleanFile(path, info, stash) } func (f *cleanFilter) shouldCleanFolder(path string, s *config.StashConfig) bool { // only delete folders where it is excluded from everything pathExcludeTest := path + string(filepath.Separator) if (s.ExcludeVideo || matchFileRegex(pathExcludeTest, f.videoExcludeRegex)) && (s.ExcludeImage || matchFileRegex(pathExcludeTest, f.imageExcludeRegex)) { logger.Infof("Folder is excluded from both video and image. Marking to clean: \"%s\"", path) return true } return false } func (f *cleanFilter) shouldCleanFile(path string, info fs.FileInfo, stash *config.StashConfig) bool { switch { case info.IsDir() || fsutil.MatchExtension(path, f.zipExt): return f.shouldCleanGallery(path, stash) case useAsVideo(path): return f.shouldCleanVideoFile(path, stash) case useAsImage(path): return f.shouldCleanImage(path, stash) default: logger.Infof("File extension does not match any media extensions. Marking to clean: \"%s\"", path) return true } } func (f *cleanFilter) shouldCleanVideoFile(path string, stash *config.StashConfig) bool { if stash.ExcludeVideo { logger.Infof("File in stash library that excludes video. Marking to clean: \"%s\"", path) return true } if matchFileRegex(path, f.videoExcludeRegex) { logger.Infof("File matched regex. Marking to clean: \"%s\"", path) return true } return false } func (f *cleanFilter) shouldCleanGallery(path string, stash *config.StashConfig) bool { if stash.ExcludeImage { logger.Infof("File in stash library that excludes images. Marking to clean: \"%s\"", path) return true } if matchFileRegex(path, f.imageExcludeRegex) { logger.Infof("File matched regex. Marking to clean: \"%s\"", path) return true } return false } func (f *cleanFilter) shouldCleanImage(path string, stash *config.StashConfig) bool { if stash.ExcludeImage { logger.Infof("File in stash library that excludes images. Marking to clean: \"%s\"", path) return true } if matchFileRegex(path, f.imageExcludeRegex) { logger.Infof("File matched regex. Marking to clean: \"%s\"", path) return true } return false } type cleanHandler struct{} func (h *cleanHandler) HandleFile(ctx context.Context, fileDeleter *file.Deleter, fileID models.FileID) error { if err := h.handleRelatedScenes(ctx, fileDeleter, fileID); err != nil { return err } if err := h.handleRelatedGalleries(ctx, fileID); err != nil { return err } if err := h.handleRelatedImages(ctx, fileDeleter, fileID); err != nil { return err } return nil } func (h *cleanHandler) HandleFolder(ctx context.Context, fileDeleter *file.Deleter, folderID models.FolderID) error { return h.deleteRelatedFolderGalleries(ctx, folderID) } func (h *cleanHandler) handleRelatedScenes(ctx context.Context, fileDeleter *file.Deleter, fileID models.FileID) error { mgr := GetInstance() sceneQB := mgr.Repository.Scene scenes, err := sceneQB.FindByFileID(ctx, fileID) if err != nil { return err } fileNamingAlgo := mgr.Config.GetVideoFileNamingAlgorithm() sceneFileDeleter := &scene.FileDeleter{ Deleter: fileDeleter, FileNamingAlgo: fileNamingAlgo, Paths: mgr.Paths, } for _, scene := range scenes { if err := scene.LoadFiles(ctx, sceneQB); err != nil { return err } // only delete if the scene has no other files if len(scene.Files.List()) <= 1 { logger.Infof("Deleting scene %q since it has no other related files", scene.DisplayName()) const deleteGenerated = true const deleteFile = false const destroyFileEntry = false if err := mgr.SceneService.Destroy(ctx, scene, sceneFileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil { return err } mgr.PluginCache.RegisterPostHooks(ctx, scene.ID, hook.SceneDestroyPost, plugin.SceneDestroyInput{ Checksum: scene.Checksum, OSHash: scene.OSHash, Path: scene.Path, }, nil) } else { // set the primary file to a remaining file var newPrimaryID models.FileID for _, f := range scene.Files.List() { if f.ID != fileID { newPrimaryID = f.ID break } } scenePartial := models.NewScenePartial() scenePartial.PrimaryFileID = &newPrimaryID if _, err := mgr.Repository.Scene.UpdatePartial(ctx, scene.ID, scenePartial); err != nil { return err } } } return nil } func (h *cleanHandler) handleRelatedGalleries(ctx context.Context, fileID models.FileID) error { mgr := GetInstance() qb := mgr.Repository.Gallery galleries, err := qb.FindByFileID(ctx, fileID) if err != nil { return err } for _, g := range galleries { if err := g.LoadFiles(ctx, qb); err != nil { return err } // only delete if the gallery has no other files if len(g.Files.List()) <= 1 { logger.Infof("Deleting gallery %q since it has no other related files", g.DisplayName()) if err := qb.Destroy(ctx, g.ID); err != nil { return err } mgr.PluginCache.RegisterPostHooks(ctx, g.ID, hook.GalleryDestroyPost, plugin.GalleryDestroyInput{ Checksum: g.PrimaryChecksum(), Path: g.Path, }, nil) } else { // set the primary file to a remaining file var newPrimaryID models.FileID for _, f := range g.Files.List() { if f.Base().ID != fileID { newPrimaryID = f.Base().ID break } } galleryPartial := models.NewGalleryPartial() galleryPartial.PrimaryFileID = &newPrimaryID if _, err := mgr.Repository.Gallery.UpdatePartial(ctx, g.ID, galleryPartial); err != nil { return err } } } return nil } func (h *cleanHandler) deleteRelatedFolderGalleries(ctx context.Context, folderID models.FolderID) error { mgr := GetInstance() qb := mgr.Repository.Gallery galleries, err := qb.FindByFolderID(ctx, folderID) if err != nil { return err } for _, g := range galleries { logger.Infof("Deleting folder-based gallery %q since the folder no longer exists", g.DisplayName()) if err := qb.Destroy(ctx, g.ID); err != nil { return err } mgr.PluginCache.RegisterPostHooks(ctx, g.ID, hook.GalleryDestroyPost, plugin.GalleryDestroyInput{ // No checksum for folders // Checksum: g.Checksum(), Path: g.Path, }, nil) } return nil } func (h *cleanHandler) handleRelatedImages(ctx context.Context, fileDeleter *file.Deleter, fileID models.FileID) error { mgr := GetInstance() imageQB := mgr.Repository.Image images, err := imageQB.FindByFileID(ctx, fileID) if err != nil { return err } imageFileDeleter := &image.FileDeleter{ Deleter: fileDeleter, Paths: mgr.Paths, } for _, i := range images { if err := i.LoadFiles(ctx, imageQB); err != nil { return err } if len(i.Files.List()) <= 1 { logger.Infof("Deleting image %q since it has no other related files", i.DisplayName()) const deleteGenerated = true const deleteFile = false const destroyFileEntry = false if err := mgr.ImageService.Destroy(ctx, i, imageFileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil { return err } mgr.PluginCache.RegisterPostHooks(ctx, i.ID, hook.ImageDestroyPost, plugin.ImageDestroyInput{ Checksum: i.Checksum, Path: i.Path, }, nil) } else { // set the primary file to a remaining file var newPrimaryID models.FileID for _, f := range i.Files.List() { if f.Base().ID != fileID { newPrimaryID = f.Base().ID break } } imagePartial := models.NewImagePartial() imagePartial.PrimaryFileID = &newPrimaryID if _, err := mgr.Repository.Image.UpdatePartial(ctx, i.ID, imagePartial); err != nil { return err } } } return nil } ================================================ FILE: internal/manager/task_export.go ================================================ package manager import ( "archive/zip" "context" "fmt" "io" "os" "path/filepath" "runtime" "strconv" "sync" "time" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/group" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/paths" "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/savedfilter" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/studio" "github.com/stashapp/stash/pkg/tag" ) type ExportTask struct { repository models.Repository full bool baseDir string json jsonUtils fileNamingAlgorithm models.HashAlgorithm scenes *exportSpec images *exportSpec performers *exportSpec groups *exportSpec tags *exportSpec studios *exportSpec galleries *exportSpec includeDependencies bool DownloadHash string } type ExportObjectTypeInput struct { Ids []string `json:"ids"` All *bool `json:"all"` } type ExportObjectsInput struct { Scenes *ExportObjectTypeInput `json:"scenes"` Images *ExportObjectTypeInput `json:"images"` Studios *ExportObjectTypeInput `json:"studios"` Performers *ExportObjectTypeInput `json:"performers"` Tags *ExportObjectTypeInput `json:"tags"` Groups *ExportObjectTypeInput `json:"groups"` Movies *ExportObjectTypeInput `json:"movies"` // deprecated Galleries *ExportObjectTypeInput `json:"galleries"` IncludeDependencies *bool `json:"includeDependencies"` } type exportSpec struct { IDs []int all bool } func newExportSpec(input *ExportObjectTypeInput) *exportSpec { if input == nil { return &exportSpec{} } ids, _ := stringslice.StringSliceToIntSlice(input.Ids) ret := &exportSpec{ IDs: ids, } if input.All != nil { ret.all = *input.All } return ret } func CreateExportTask(a models.HashAlgorithm, input ExportObjectsInput) *ExportTask { includeDeps := false if input.IncludeDependencies != nil { includeDeps = *input.IncludeDependencies } // handle deprecated Movies field groupSpec := input.Groups if groupSpec == nil && input.Movies != nil { groupSpec = input.Movies } return &ExportTask{ repository: GetInstance().Repository, fileNamingAlgorithm: a, scenes: newExportSpec(input.Scenes), images: newExportSpec(input.Images), performers: newExportSpec(input.Performers), groups: newExportSpec(groupSpec), tags: newExportSpec(input.Tags), studios: newExportSpec(input.Studios), galleries: newExportSpec(input.Galleries), includeDependencies: includeDeps, } } func (t *ExportTask) Start(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() // @manager.total = Scene.count + Gallery.count + Performer.count + Studio.count + Group.count workerCount := runtime.GOMAXPROCS(0) // set worker count to number of cpus available startTime := time.Now() if t.full { t.baseDir = config.GetInstance().GetMetadataPath() } else { var err error t.baseDir, err = instance.Paths.Generated.TempDir("export") if err != nil { logger.Errorf("error creating temporary directory for export: %v", err) return } defer func() { err := fsutil.RemoveDir(t.baseDir) if err != nil { logger.Errorf("error removing directory %s: %v", t.baseDir, err) } }() } if t.baseDir == "" { logger.Errorf("baseDir must not be empty") return } t.json = jsonUtils{ json: *paths.GetJSONPaths(t.baseDir), } paths.EmptyJSONDirs(t.baseDir) paths.EnsureJSONDirs(t.baseDir) txnErr := t.repository.WithTxn(ctx, func(ctx context.Context) error { // include group scenes and gallery images if !t.full { // only include group scenes if includeDependencies is also set if !t.scenes.all && t.includeDependencies { t.populateGroupScenes(ctx) } // always export gallery images if !t.images.all { t.populateGalleryImages(ctx) } } t.ExportScenes(ctx, workerCount) t.ExportImages(ctx, workerCount) t.ExportGalleries(ctx, workerCount) t.ExportGroups(ctx, workerCount) t.ExportPerformers(ctx, workerCount) t.ExportStudios(ctx, workerCount) t.ExportTags(ctx, workerCount) t.ExportSavedFilters(ctx, workerCount) return nil }) if txnErr != nil { logger.Warnf("error while running export transaction: %v", txnErr) } if !t.full { err := t.generateDownload() if err != nil { logger.Errorf("error generating download link: %v", err) return } } logger.Infof("Export complete in %s.", time.Since(startTime)) } func (t *ExportTask) generateDownload() error { // zip the files and register a download link if err := fsutil.EnsureDir(instance.Paths.Generated.Downloads); err != nil { return err } z, err := os.CreateTemp(instance.Paths.Generated.Downloads, "export*.zip") if err != nil { return err } defer z.Close() err = t.zipFiles(z) if err != nil { return err } t.DownloadHash, err = instance.DownloadStore.RegisterFile(z.Name(), "", false) if err != nil { return fmt.Errorf("error registering file for download: %w", err) } logger.Debugf("Generated zip file %s with hash %s", z.Name(), t.DownloadHash) return nil } func (t *ExportTask) zipFiles(w io.Writer) error { z := zip.NewWriter(w) defer z.Close() u := jsonUtils{ json: *paths.GetJSONPaths(""), } walkWarn(t.json.json.Tags, t.zipWalkFunc(u.json.Tags, z)) walkWarn(t.json.json.Galleries, t.zipWalkFunc(u.json.Galleries, z)) walkWarn(t.json.json.Performers, t.zipWalkFunc(u.json.Performers, z)) walkWarn(t.json.json.Studios, t.zipWalkFunc(u.json.Studios, z)) walkWarn(t.json.json.Groups, t.zipWalkFunc(u.json.Groups, z)) walkWarn(t.json.json.Scenes, t.zipWalkFunc(u.json.Scenes, z)) walkWarn(t.json.json.Images, t.zipWalkFunc(u.json.Images, z)) return nil } // like filepath.Walk but issue a warning on error func walkWarn(root string, fn filepath.WalkFunc) { if err := filepath.Walk(root, fn); err != nil { logger.Warnf("error walking structure %v: %v", root, err) } } func (t *ExportTask) zipWalkFunc(outDir string, z *zip.Writer) filepath.WalkFunc { return func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } return t.zipFile(path, outDir, z) } } func (t *ExportTask) zipFile(fn, outDir string, z *zip.Writer) error { bn := filepath.Base(fn) p := filepath.Join(outDir, bn) p = filepath.ToSlash(p) f, err := z.Create(p) if err != nil { return fmt.Errorf("error creating zip entry for %s: %v", fn, err) } i, err := os.Open(fn) if err != nil { return fmt.Errorf("error opening %s: %v", fn, err) } defer i.Close() if _, err := io.Copy(f, i); err != nil { return fmt.Errorf("error writing %s to zip: %v", fn, err) } return nil } func (t *ExportTask) populateGroupScenes(ctx context.Context) { r := t.repository reader := r.Group sceneReader := r.Scene var groups []*models.Group var err error all := t.full || (t.groups != nil && t.groups.all) if all { groups, err = reader.All(ctx) } else if t.groups != nil && len(t.groups.IDs) > 0 { groups, err = reader.FindMany(ctx, t.groups.IDs) } if err != nil { logger.Errorf("[groups] failed to fetch groups: %v", err) } for _, m := range groups { scenes, err := sceneReader.FindByGroupID(ctx, m.ID) if err != nil { logger.Errorf("[groups] <%s> failed to fetch scenes for group: %v", m.Name, err) continue } for _, s := range scenes { t.scenes.IDs = sliceutil.AppendUnique(t.scenes.IDs, s.ID) } } } func (t *ExportTask) populateGalleryImages(ctx context.Context) { r := t.repository reader := r.Gallery imageReader := r.Image var galleries []*models.Gallery var err error all := t.full || (t.galleries != nil && t.galleries.all) if all { galleries, err = reader.All(ctx) } else if t.galleries != nil && len(t.galleries.IDs) > 0 { galleries, err = reader.FindMany(ctx, t.galleries.IDs) } if err != nil { logger.Errorf("[galleries] failed to fetch galleries: %v", err) } for _, g := range galleries { if err := g.LoadFiles(ctx, reader); err != nil { logger.Errorf("[galleries] <%s> failed to fetch files for gallery: %v", g.DisplayName(), err) continue } images, err := imageReader.FindByGalleryID(ctx, g.ID) if err != nil { logger.Errorf("[galleries] <%s> failed to fetch images for gallery: %v", g.DisplayName(), err) continue } for _, i := range images { t.images.IDs = sliceutil.AppendUnique(t.images.IDs, i.ID) } } } func (t *ExportTask) ExportScenes(ctx context.Context, workers int) { var scenesWg sync.WaitGroup sceneReader := t.repository.Scene var scenes []*models.Scene var err error all := t.full || (t.scenes != nil && t.scenes.all) if all { scenes, err = sceneReader.All(ctx) } else if t.scenes != nil && len(t.scenes.IDs) > 0 { scenes, err = sceneReader.FindMany(ctx, t.scenes.IDs) } if err != nil { logger.Errorf("[scenes] failed to fetch scenes: %v", err) } jobCh := make(chan *models.Scene, workers*2) // make a buffered channel to feed workers logger.Info("[scenes] exporting") startTime := time.Now() for w := 0; w < workers; w++ { // create export Scene workers scenesWg.Add(1) go t.exportScene(ctx, &scenesWg, jobCh) } for i, scene := range scenes { index := i + 1 if (i % 100) == 0 { // make progress easier to read logger.Progressf("[scenes] %d of %d", index, len(scenes)) } jobCh <- scene // feed workers } close(jobCh) // close channel so that workers will know no more jobs are available scenesWg.Wait() logger.Infof("[scenes] export complete in %s. %d workers used.", time.Since(startTime), workers) } func (t *ExportTask) exportFile(f models.File) { newFileJSON := fileToJSON(f) fn := newFileJSON.Filename() if err := t.json.saveFile(fn, newFileJSON); err != nil { logger.Errorf("[files] <%s> failed to save json: %v", fn, err) } } func fileToJSON(f models.File) jsonschema.DirEntry { bf := f.Base() base := jsonschema.BaseFile{ BaseDirEntry: jsonschema.BaseDirEntry{ Type: jsonschema.DirEntryTypeFile, ModTime: json.JSONTime{Time: bf.ModTime}, Path: bf.Path, CreatedAt: json.JSONTime{Time: bf.CreatedAt}, UpdatedAt: json.JSONTime{Time: bf.UpdatedAt}, }, Size: bf.Size, } if bf.ZipFile != nil { base.ZipFile = bf.ZipFile.Base().Path } for _, fp := range bf.Fingerprints { base.Fingerprints = append(base.Fingerprints, jsonschema.Fingerprint{ Type: fp.Type, Fingerprint: fp.Fingerprint, }) } switch ff := f.(type) { case *models.VideoFile: base.Type = jsonschema.DirEntryTypeVideo return jsonschema.VideoFile{ BaseFile: &base, Format: ff.Format, Width: ff.Width, Height: ff.Height, Duration: ff.Duration, VideoCodec: ff.VideoCodec, AudioCodec: ff.AudioCodec, FrameRate: ff.FrameRate, BitRate: ff.BitRate, Interactive: ff.Interactive, InteractiveSpeed: ff.InteractiveSpeed, } case *models.ImageFile: base.Type = jsonschema.DirEntryTypeImage return jsonschema.ImageFile{ BaseFile: &base, Format: ff.Format, Width: ff.Width, Height: ff.Height, } } return &base } func (t *ExportTask) exportFolder(f models.Folder) { newFileJSON := folderToJSON(f) fn := newFileJSON.Filename() if err := t.json.saveFile(fn, newFileJSON); err != nil { logger.Errorf("[files] <%s> failed to save json: %v", fn, err) } } func folderToJSON(f models.Folder) jsonschema.DirEntry { base := jsonschema.BaseDirEntry{ Type: jsonschema.DirEntryTypeFolder, ModTime: json.JSONTime{Time: f.ModTime}, Path: f.Path, CreatedAt: json.JSONTime{Time: f.CreatedAt}, UpdatedAt: json.JSONTime{Time: f.UpdatedAt}, } if f.ZipFile != nil { base.ZipFile = f.ZipFile.Base().Path } return &base } func (t *ExportTask) exportScene(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.Scene) { defer wg.Done() r := t.repository sceneReader := r.Scene studioReader := r.Studio groupReader := r.Group galleryReader := r.Gallery performerReader := r.Performer tagReader := r.Tag sceneMarkerReader := r.SceneMarker for s := range jobChan { sceneHash := s.GetHash(t.fileNamingAlgorithm) if err := s.LoadRelationships(ctx, sceneReader); err != nil { logger.Errorf("[scenes] <%s> error loading scene relationships: %v", sceneHash, err) } newSceneJSON, err := scene.ToBasicJSON(ctx, sceneReader, s) if err != nil { logger.Errorf("[scenes] <%s> error getting scene JSON: %v", sceneHash, err) continue } // export files for _, f := range s.Files.List() { t.exportFile(f) } newSceneJSON.Studio, err = scene.GetStudioName(ctx, studioReader, s) if err != nil { logger.Errorf("[scenes] <%s> error getting scene studio name: %v", sceneHash, err) continue } galleries, err := galleryReader.FindBySceneID(ctx, s.ID) if err != nil { logger.Errorf("[scenes] <%s> error getting scene gallery checksums: %v", sceneHash, err) continue } for _, g := range galleries { if err := g.LoadFiles(ctx, galleryReader); err != nil { logger.Errorf("[scenes] <%s> error getting scene gallery files: %v", sceneHash, err) continue } } newSceneJSON.Galleries = gallery.GetRefs(galleries) newSceneJSON.ResumeTime = s.ResumeTime newSceneJSON.PlayDuration = s.PlayDuration performers, err := performerReader.FindBySceneID(ctx, s.ID) if err != nil { logger.Errorf("[scenes] <%s> error getting scene performer names: %v", sceneHash, err) continue } newSceneJSON.Performers = performer.GetNames(performers) newSceneJSON.Tags, err = scene.GetTagNames(ctx, tagReader, s) if err != nil { logger.Errorf("[scenes] <%s> error getting scene tag names: %v", sceneHash, err) continue } newSceneJSON.Markers, err = scene.GetSceneMarkersJSON(ctx, sceneMarkerReader, tagReader, s) if err != nil { logger.Errorf("[scenes] <%s> error getting scene markers JSON: %v", sceneHash, err) continue } newSceneJSON.Groups, err = scene.GetSceneGroupsJSON(ctx, groupReader, s) if err != nil { logger.Errorf("[scenes] <%s> error getting scene groups JSON: %v", sceneHash, err) continue } if t.includeDependencies { if s.StudioID != nil { t.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *s.StudioID) } t.galleries.IDs = sliceutil.AppendUniques(t.galleries.IDs, gallery.GetIDs(galleries)) tagIDs, err := scene.GetDependentTagIDs(ctx, tagReader, sceneMarkerReader, s) if err != nil { logger.Errorf("[scenes] <%s> error getting scene tags: %v", sceneHash, err) continue } t.tags.IDs = sliceutil.AppendUniques(t.tags.IDs, tagIDs) groupIDs, err := scene.GetDependentGroupIDs(ctx, s) if err != nil { logger.Errorf("[scenes] <%s> error getting scene groups: %v", sceneHash, err) continue } t.groups.IDs = sliceutil.AppendUniques(t.groups.IDs, groupIDs) t.performers.IDs = sliceutil.AppendUniques(t.performers.IDs, performer.GetIDs(performers)) } basename := filepath.Base(s.Path) hash := s.OSHash fn := newSceneJSON.Filename(s.ID, basename, hash) if err := t.json.saveScene(fn, newSceneJSON); err != nil { logger.Errorf("[scenes] <%s> failed to save json: %v", sceneHash, err) } } } func (t *ExportTask) ExportImages(ctx context.Context, workers int) { var imagesWg sync.WaitGroup r := t.repository imageReader := r.Image var images []*models.Image var err error all := t.full || (t.images != nil && t.images.all) if all { images, err = imageReader.All(ctx) } else if t.images != nil && len(t.images.IDs) > 0 { images, err = imageReader.FindMany(ctx, t.images.IDs) } if err != nil { logger.Errorf("[images] failed to fetch images: %v", err) } jobCh := make(chan *models.Image, workers*2) // make a buffered channel to feed workers logger.Info("[images] exporting") startTime := time.Now() for w := 0; w < workers; w++ { // create export Image workers imagesWg.Add(1) go t.exportImage(ctx, &imagesWg, jobCh) } for i, image := range images { index := i + 1 if (i % 100) == 0 { // make progress easier to read logger.Progressf("[images] %d of %d", index, len(images)) } jobCh <- image // feed workers } close(jobCh) // close channel so that workers will know no more jobs are available imagesWg.Wait() logger.Infof("[images] export complete in %s. %d workers used.", time.Since(startTime), workers) } func (t *ExportTask) exportImage(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.Image) { defer wg.Done() r := t.repository studioReader := r.Studio galleryReader := r.Gallery performerReader := r.Performer tagReader := r.Tag imageReader := r.Image for s := range jobChan { imageHash := s.Checksum if err := s.LoadFiles(ctx, r.Image); err != nil { logger.Errorf("[images] <%s> error getting image files: %v", imageHash, err) continue } if err := s.LoadURLs(ctx, r.Image); err != nil { logger.Errorf("[images] <%s> error getting image urls: %v", imageHash, err) continue } newImageJSON, err := image.ToBasicJSON(ctx, imageReader, s) if err != nil { logger.Errorf("[images] <%s> error converting image to JSON: %v", imageHash, err) continue } // export files for _, f := range s.Files.List() { t.exportFile(f) } newImageJSON.Studio, err = image.GetStudioName(ctx, studioReader, s) if err != nil { logger.Errorf("[images] <%s> error getting image studio name: %v", imageHash, err) continue } imageGalleries, err := galleryReader.FindByImageID(ctx, s.ID) if err != nil { logger.Errorf("[images] <%s> error getting image galleries: %v", imageHash, err) continue } for _, g := range imageGalleries { if err := g.LoadFiles(ctx, galleryReader); err != nil { logger.Errorf("[images] <%s> error getting image gallery files: %v", imageHash, err) continue } } newImageJSON.Galleries = gallery.GetRefs(imageGalleries) performers, err := performerReader.FindByImageID(ctx, s.ID) if err != nil { logger.Errorf("[images] <%s> error getting image performer names: %v", imageHash, err) continue } newImageJSON.Performers = performer.GetNames(performers) tags, err := tagReader.FindByImageID(ctx, s.ID) if err != nil { logger.Errorf("[images] <%s> error getting image tag names: %v", imageHash, err) continue } newImageJSON.Tags = tag.GetNames(tags) if t.includeDependencies { if s.StudioID != nil { t.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *s.StudioID) } t.galleries.IDs = sliceutil.AppendUniques(t.galleries.IDs, gallery.GetIDs(imageGalleries)) t.tags.IDs = sliceutil.AppendUniques(t.tags.IDs, tag.GetIDs(tags)) t.performers.IDs = sliceutil.AppendUniques(t.performers.IDs, performer.GetIDs(performers)) } fn := newImageJSON.Filename(filepath.Base(s.Path), s.Checksum) if err := t.json.saveImage(fn, newImageJSON); err != nil { logger.Errorf("[images] <%s> failed to save json: %v", imageHash, err) } } } func (t *ExportTask) ExportGalleries(ctx context.Context, workers int) { var galleriesWg sync.WaitGroup reader := t.repository.Gallery var galleries []*models.Gallery var err error all := t.full || (t.galleries != nil && t.galleries.all) if all { galleries, err = reader.All(ctx) } else if t.galleries != nil && len(t.galleries.IDs) > 0 { galleries, err = reader.FindMany(ctx, t.galleries.IDs) } if err != nil { logger.Errorf("[galleries] failed to fetch galleries: %v", err) } jobCh := make(chan *models.Gallery, workers*2) // make a buffered channel to feed workers logger.Info("[galleries] exporting") startTime := time.Now() for w := 0; w < workers; w++ { // create export Scene workers galleriesWg.Add(1) go t.exportGallery(ctx, &galleriesWg, jobCh) } for i, gallery := range galleries { index := i + 1 if (i % 100) == 0 { // make progress easier to read logger.Progressf("[galleries] %d of %d", index, len(galleries)) } jobCh <- gallery } close(jobCh) // close channel so that workers will know no more jobs are available galleriesWg.Wait() logger.Infof("[galleries] export complete in %s. %d workers used.", time.Since(startTime), workers) } func (t *ExportTask) exportGallery(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.Gallery) { defer wg.Done() r := t.repository studioReader := r.Studio performerReader := r.Performer tagReader := r.Tag galleryReader := r.Gallery galleryChapterReader := r.GalleryChapter for g := range jobChan { if err := g.LoadFiles(ctx, r.Gallery); err != nil { logger.Errorf("[galleries] <%s> error getting gallery files: %v", g.DisplayName(), err) continue } if err := g.LoadURLs(ctx, r.Gallery); err != nil { logger.Errorf("[galleries] <%s> error getting gallery urls: %v", g.DisplayName(), err) continue } newGalleryJSON, err := gallery.ToBasicJSON(g) if err != nil { logger.Errorf("[galleries] <%s> error getting gallery JSON: %v", g.DisplayName(), err) continue } // export files for _, f := range g.Files.List() { t.exportFile(f) } // export folder if necessary if g.FolderID != nil { folder, err := r.Folder.Find(ctx, *g.FolderID) if err != nil { logger.Errorf("[galleries] <%s> error getting gallery folder: %v", g.DisplayName(), err) continue } if folder == nil { logger.Errorf("[galleries] <%s> unable to find gallery folder", g.DisplayName()) continue } t.exportFolder(*folder) } newGalleryJSON.Studio, err = gallery.GetStudioName(ctx, studioReader, g) if err != nil { logger.Errorf("[galleries] <%s> error getting gallery studio name: %v", g.DisplayName(), err) continue } performers, err := performerReader.FindByGalleryID(ctx, g.ID) if err != nil { logger.Errorf("[galleries] <%s> error getting gallery performer names: %v", g.DisplayName(), err) continue } newGalleryJSON.Performers = performer.GetNames(performers) tags, err := tagReader.FindByGalleryID(ctx, g.ID) if err != nil { logger.Errorf("[galleries] <%s> error getting gallery tag names: %v", g.DisplayName(), err) continue } newGalleryJSON.Chapters, err = gallery.GetGalleryChaptersJSON(ctx, galleryChapterReader, g) if err != nil { logger.Errorf("[galleries] <%s> error getting gallery chapters JSON: %v", g.DisplayName(), err) continue } newGalleryJSON.Tags = tag.GetNames(tags) newGalleryJSON.CustomFields, err = galleryReader.GetCustomFields(ctx, g.ID) if err != nil { logger.Errorf("[galleries] <%s> error getting gallery custom fields: %v", g.DisplayName(), err) continue } if t.includeDependencies { if g.StudioID != nil { t.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *g.StudioID) } t.tags.IDs = sliceutil.AppendUniques(t.tags.IDs, tag.GetIDs(tags)) t.performers.IDs = sliceutil.AppendUniques(t.performers.IDs, performer.GetIDs(performers)) } basename := "" // use id in case multiple galleries with the same basename hash := strconv.Itoa(g.ID) switch { case g.Path != "": basename = filepath.Base(g.Path) default: basename = g.Title } fn := newGalleryJSON.Filename(basename, hash) if err := t.json.saveGallery(fn, newGalleryJSON); err != nil { logger.Errorf("[galleries] <%s> failed to save json: %v", g.DisplayName(), err) } } } func (t *ExportTask) ExportPerformers(ctx context.Context, workers int) { var performersWg sync.WaitGroup reader := t.repository.Performer var performers []*models.Performer var err error all := t.full || (t.performers != nil && t.performers.all) if all { performers, err = reader.All(ctx) } else if t.performers != nil && len(t.performers.IDs) > 0 { performers, err = reader.FindMany(ctx, t.performers.IDs) } if err != nil { logger.Errorf("[performers] failed to fetch performers: %v", err) } jobCh := make(chan *models.Performer, workers*2) // make a buffered channel to feed workers logger.Info("[performers] exporting") startTime := time.Now() for w := 0; w < workers; w++ { // create export Performer workers performersWg.Add(1) go t.exportPerformer(ctx, &performersWg, jobCh) } for i, performer := range performers { index := i + 1 logger.Progressf("[performers] %d of %d", index, len(performers)) jobCh <- performer // feed workers } close(jobCh) // close channel so workers will know that no more jobs are available performersWg.Wait() logger.Infof("[performers] export complete in %s. %d workers used.", time.Since(startTime), workers) } func (t *ExportTask) exportPerformer(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.Performer) { defer wg.Done() r := t.repository performerReader := r.Performer for p := range jobChan { newPerformerJSON, err := performer.ToJSON(ctx, performerReader, p) if err != nil { logger.Errorf("[performers] <%s> error getting performer JSON: %v", p.Name, err) continue } tags, err := r.Tag.FindByPerformerID(ctx, p.ID) if err != nil { logger.Errorf("[performers] <%s> error getting performer tags: %v", p.Name, err) continue } newPerformerJSON.Tags = tag.GetNames(tags) if t.includeDependencies { t.tags.IDs = sliceutil.AppendUniques(t.tags.IDs, tag.GetIDs(tags)) } fn := newPerformerJSON.Filename() if err := t.json.savePerformer(fn, newPerformerJSON); err != nil { logger.Errorf("[performers] <%s> failed to save json: %v", p.Name, err) } } } func (t *ExportTask) ExportStudios(ctx context.Context, workers int) { var studiosWg sync.WaitGroup reader := t.repository.Studio var studios []*models.Studio var err error all := t.full || (t.studios != nil && t.studios.all) if all { studios, err = reader.All(ctx) } else if t.studios != nil && len(t.studios.IDs) > 0 { studios, err = reader.FindMany(ctx, t.studios.IDs) } if err != nil { logger.Errorf("[studios] failed to fetch studios: %v", err) } logger.Info("[studios] exporting") startTime := time.Now() jobCh := make(chan *models.Studio, workers*2) // make a buffered channel to feed workers for w := 0; w < workers; w++ { // create export Studio workers studiosWg.Add(1) go t.exportStudio(ctx, &studiosWg, jobCh) } for i, studio := range studios { index := i + 1 logger.Progressf("[studios] %d of %d", index, len(studios)) jobCh <- studio // feed workers } close(jobCh) studiosWg.Wait() logger.Infof("[studios] export complete in %s. %d workers used.", time.Since(startTime), workers) } func (t *ExportTask) exportStudio(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.Studio) { defer wg.Done() r := t.repository studioReader := t.repository.Studio for s := range jobChan { newStudioJSON, err := studio.ToJSON(ctx, studioReader, s) if err != nil { logger.Errorf("[studios] <%s> error getting studio JSON: %v", s.Name, err) continue } tags, err := r.Tag.FindByStudioID(ctx, s.ID) if err != nil { logger.Errorf("[studios] <%s> error getting studio tags: %s", s.Name, err.Error()) continue } newStudioJSON.Tags = tag.GetNames(tags) if t.includeDependencies { t.tags.IDs = sliceutil.AppendUniques(t.tags.IDs, tag.GetIDs(tags)) } fn := newStudioJSON.Filename() if err := t.json.saveStudio(fn, newStudioJSON); err != nil { logger.Errorf("[studios] <%s> failed to save json: %v", s.Name, err) } } } func (t *ExportTask) ExportTags(ctx context.Context, workers int) { var tagsWg sync.WaitGroup reader := t.repository.Tag var tags []*models.Tag var err error all := t.full || (t.tags != nil && t.tags.all) if all { tags, err = reader.All(ctx) } else if t.tags != nil && len(t.tags.IDs) > 0 { tags, err = reader.FindMany(ctx, t.tags.IDs) } if err != nil { logger.Errorf("[tags] failed to fetch tags: %v", err) } logger.Info("[tags] exporting") startTime := time.Now() tagIdx := 0 if t.tags != nil { tagIdx = len(t.tags.IDs) } for { jobCh := make(chan *models.Tag, workers*2) // make a buffered channel to feed workers for w := 0; w < workers; w++ { // create export Tag workers tagsWg.Add(1) go t.exportTag(ctx, &tagsWg, jobCh) } for i, tag := range tags { index := i + 1 + tagIdx logger.Progressf("[tags] %d of %d", index, len(tags)+tagIdx) jobCh <- tag // feed workers } close(jobCh) tagsWg.Wait() // if more tags were added, we need to export those too if t.tags == nil || len(t.tags.IDs) == tagIdx { break } newTags, err := reader.FindMany(ctx, t.tags.IDs[tagIdx:]) if err != nil { logger.Errorf("[tags] failed to fetch tags: %v", err) } tags = newTags tagIdx = len(t.tags.IDs) } logger.Infof("[tags] export complete in %s. %d workers used.", time.Since(startTime), workers) } func (t *ExportTask) exportTag(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.Tag) { defer wg.Done() tagReader := t.repository.Tag for thisTag := range jobChan { newTagJSON, err := tag.ToJSON(ctx, tagReader, thisTag) if err != nil { logger.Errorf("[tags] <%s> error getting tag JSON: %v", thisTag.Name, err) continue } if t.includeDependencies { tagIDs, err := tag.GetDependentTagIDs(ctx, tagReader, thisTag) if err != nil { logger.Errorf("[tags] <%s> error getting dependent tags: %v", thisTag.Name, err) continue } t.tags.IDs = sliceutil.AppendUniques(t.tags.IDs, tagIDs) } fn := newTagJSON.Filename() if err := t.json.saveTag(fn, newTagJSON); err != nil { logger.Errorf("[tags] <%s> failed to save json: %v", fn, err) } } } func (t *ExportTask) ExportGroups(ctx context.Context, workers int) { var groupsWg sync.WaitGroup reader := t.repository.Group var groups []*models.Group var err error all := t.full || (t.groups != nil && t.groups.all) if all { groups, err = reader.All(ctx) } else if t.groups != nil && len(t.groups.IDs) > 0 { groups, err = reader.FindMany(ctx, t.groups.IDs) } if err != nil { logger.Errorf("[groups] failed to fetch groups: %v", err) } logger.Info("[groups] exporting") startTime := time.Now() jobCh := make(chan *models.Group, workers*2) // make a buffered channel to feed workers for w := 0; w < workers; w++ { // create export Studio workers groupsWg.Add(1) go t.exportGroup(ctx, &groupsWg, jobCh) } for i, group := range groups { index := i + 1 logger.Progressf("[groups] %d of %d", index, len(groups)) jobCh <- group // feed workers } close(jobCh) groupsWg.Wait() logger.Infof("[groups] export complete in %s. %d workers used.", time.Since(startTime), workers) } func (t *ExportTask) exportGroup(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.Group) { defer wg.Done() r := t.repository groupReader := r.Group studioReader := r.Studio tagReader := r.Tag for m := range jobChan { if err := m.LoadURLs(ctx, r.Group); err != nil { logger.Errorf("[groups] <%s> error getting group urls: %v", m.Name, err) continue } if err := m.LoadSubGroupIDs(ctx, r.Group); err != nil { logger.Errorf("[groups] <%s> error getting group sub-groups: %v", m.Name, err) continue } newGroupJSON, err := group.ToJSON(ctx, groupReader, studioReader, m) if err != nil { logger.Errorf("[groups] <%s> error getting tag JSON: %v", m.Name, err) continue } tags, err := tagReader.FindByGroupID(ctx, m.ID) if err != nil { logger.Errorf("[groups] <%s> error getting image tag names: %v", m.Name, err) continue } newGroupJSON.Tags = tag.GetNames(tags) subGroups := m.SubGroups.List() if err := func() error { for _, sg := range subGroups { subGroup, err := groupReader.Find(ctx, sg.GroupID) if err != nil { return fmt.Errorf("error getting sub group: %v", err) } newGroupJSON.SubGroups = append(newGroupJSON.SubGroups, jsonschema.SubGroupDescription{ // TODO - this won't be unique Group: subGroup.Name, Description: sg.Description, }) } return nil }(); err != nil { logger.Errorf("[groups] <%s> %v", m.Name, err) } if t.includeDependencies { if m.StudioID != nil { t.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *m.StudioID) } } fn := newGroupJSON.Filename() if err := t.json.saveGroup(fn, newGroupJSON); err != nil { logger.Errorf("[groups] <%s> failed to save json: %v", m.Name, err) } } } func (t *ExportTask) ExportSavedFilters(ctx context.Context, workers int) { // don't export saved filters unless we're doing a full export if !t.full { return } var wg sync.WaitGroup reader := t.repository.SavedFilter var filters []*models.SavedFilter var err error filters, err = reader.All(ctx) if err != nil { logger.Errorf("[saved filters] failed to fetch saved filters: %v", err) } logger.Info("[saved filters] exporting") startTime := time.Now() jobCh := make(chan *models.SavedFilter, workers*2) // make a buffered channel to feed workers for w := 0; w < workers; w++ { // create export Saved Filter workers wg.Add(1) go t.exportSavedFilter(ctx, &wg, jobCh) } for i, savedFilter := range filters { index := i + 1 logger.Progressf("[saved filters] %d of %d", index, len(filters)) jobCh <- savedFilter // feed workers } close(jobCh) wg.Wait() logger.Infof("[saved filters] export complete in %s. %d workers used.", time.Since(startTime), workers) } func (t *ExportTask) exportSavedFilter(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.SavedFilter) { defer wg.Done() for thisFilter := range jobChan { newJSON, err := savedfilter.ToJSON(ctx, thisFilter) if err != nil { logger.Errorf("[saved filter] <%s> error getting saved filter JSON: %v", thisFilter.Name, err) continue } fn := newJSON.Filename() if err := t.json.saveSavedFilter(fn, newJSON); err != nil { logger.Errorf("[saved filter] <%s> failed to save json: %v", fn, err) } } } ================================================ FILE: internal/manager/task_generate.go ================================================ package manager import ( "context" "fmt" "time" "github.com/remeh/sizedwaitgroup" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/scene/generate" "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) type GenerateMetadataInput struct { Covers bool `json:"covers"` Sprites bool `json:"sprites"` Previews bool `json:"previews"` ImagePreviews bool `json:"imagePreviews"` PreviewOptions *GeneratePreviewOptionsInput `json:"previewOptions"` Markers bool `json:"markers"` MarkerImagePreviews bool `json:"markerImagePreviews"` MarkerScreenshots bool `json:"markerScreenshots"` Transcodes bool `json:"transcodes"` // Generate transcodes even if not required ForceTranscodes bool `json:"forceTranscodes"` Phashes bool `json:"phashes"` ImagePhashes bool `json:"imagePhashes"` InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"` ClipPreviews bool `json:"clipPreviews"` ImageThumbnails bool `json:"imageThumbnails"` // scene ids to generate for SceneIDs []string `json:"sceneIDs"` // marker ids to generate for MarkerIDs []string `json:"markerIDs"` // image ids to generate for ImageIDs []string `json:"imageIDs"` // gallery ids to generate for GalleryIDs []string `json:"galleryIDs"` // overwrite existing media Overwrite bool `json:"overwrite"` // paths to run generate on, in addition to the other ID lists Paths []string `json:"paths"` } type GeneratePreviewOptionsInput struct { // Number of segments in a preview file PreviewSegments *int `json:"previewSegments"` // Preview segment duration, in seconds PreviewSegmentDuration *float64 `json:"previewSegmentDuration"` // Duration of start of video to exclude when generating previews PreviewExcludeStart *string `json:"previewExcludeStart"` // Duration of end of video to exclude when generating previews PreviewExcludeEnd *string `json:"previewExcludeEnd"` // Preset when generating preview PreviewPreset *models.PreviewPreset `json:"previewPreset"` } const generateQueueSize = 200000 type GenerateJob struct { repository models.Repository input GenerateMetadataInput overwrite bool fileNamingAlgo models.HashAlgorithm totals totalsGenerate } type totalsGenerate struct { covers int64 sprites int64 previews int64 imagePreviews int64 markers int64 transcodes int64 phashes int64 imagePhashes int64 interactiveHeatmapSpeeds int64 clipPreviews int64 imageThumbnails int64 tasks int } func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error { var scenes []*models.Scene var markers []*models.SceneMarker var images []*models.Image var err error j.overwrite = j.input.Overwrite j.fileNamingAlgo = config.GetInstance().GetVideoFileNamingAlgorithm() config := config.GetInstance() parallelTasks := config.GetParallelTasksWithAutoDetection() logger.Infof("Generate started with %d parallel tasks", parallelTasks) queue := make(chan Task, generateQueueSize) go func() { defer close(queue) sceneIDs, err := stringslice.StringSliceToIntSlice(j.input.SceneIDs) if err != nil { logger.Error(err.Error()) } markerIDs, err := stringslice.StringSliceToIntSlice(j.input.MarkerIDs) if err != nil { logger.Error(err.Error()) } imageIDs, err := stringslice.StringSliceToIntSlice(j.input.ImageIDs) if err != nil { logger.Error(err.Error()) } galleryIDs, err := stringslice.StringSliceToIntSlice(j.input.GalleryIDs) if err != nil { logger.Error(err.Error()) } g := &generate.Generator{ Encoder: instance.FFMpeg, FFMpegConfig: instance.Config, LockManager: instance.ReadLockManager, MarkerPaths: instance.Paths.SceneMarkers, ScenePaths: instance.Paths.Scene, Overwrite: j.overwrite, } r := j.repository if err := r.WithReadTxn(ctx, func(ctx context.Context) error { qb := r.Scene if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 && len(j.input.ImageIDs) == 0 && len(j.input.GalleryIDs) == 0 && len(j.input.Paths) == 0 { j.queueTasks(ctx, g, nil, queue) } else { if len(j.input.SceneIDs) > 0 { scenes, err = qb.FindMany(ctx, sceneIDs) for _, s := range scenes { if err := s.LoadFiles(ctx, qb); err != nil { return err } j.queueSceneJobs(ctx, g, s, queue) } } if len(j.input.MarkerIDs) > 0 { markers, err = r.SceneMarker.FindMany(ctx, markerIDs) if err != nil { return err } for _, m := range markers { j.queueMarkerJob(g, m, queue) } } if len(j.input.ImageIDs) > 0 { images, err = r.Image.FindMany(ctx, imageIDs) for _, i := range images { if err := i.LoadFiles(ctx, r.Image); err != nil { return err } j.queueImageJob(g, i, queue) } } if len(j.input.GalleryIDs) > 0 { for _, galleryID := range galleryIDs { imgs, err := r.Image.FindByGalleryID(ctx, galleryID) if err != nil { return err } for _, img := range imgs { if err := img.LoadFiles(ctx, r.Image); err != nil { return err } j.queueImageJob(g, img, queue) } } } if len(j.input.Paths) > 0 { paths := filterStashPaths(j.input.Paths) j.queueTasks(ctx, g, paths, queue) } } return nil }); err != nil && ctx.Err() == nil { logger.Error(err.Error()) return } totals := j.totals logMsg := "Generating" if j.input.Covers { logMsg += fmt.Sprintf(" %d covers", totals.covers) } if j.input.Sprites { logMsg += fmt.Sprintf(" %d sprites", totals.sprites) } if j.input.Previews { logMsg += fmt.Sprintf(" %d previews", totals.previews) } if j.input.ImagePreviews { logMsg += fmt.Sprintf(" %d image previews", totals.imagePreviews) } if j.input.Markers { logMsg += fmt.Sprintf(" %d markers", totals.markers) } if j.input.Transcodes { logMsg += fmt.Sprintf(" %d transcodes", totals.transcodes) } if j.input.Phashes { logMsg += fmt.Sprintf(" %d phashes", totals.phashes) } if j.input.ImagePhashes { logMsg += fmt.Sprintf(" %d image phashes", totals.imagePhashes) } if j.input.InteractiveHeatmapsSpeeds { logMsg += fmt.Sprintf(" %d heatmaps & speeds", totals.interactiveHeatmapSpeeds) } if j.input.ClipPreviews { logMsg += fmt.Sprintf(" %d image clip previews", totals.clipPreviews) } if j.input.ImageThumbnails { logMsg += fmt.Sprintf(" %d image thumbnails", totals.imageThumbnails) } if logMsg == "Generating" { logMsg = "Nothing selected to generate" } logger.Infof(logMsg) progress.SetTotal(int(totals.tasks)) }() wg := sizedwaitgroup.New(parallelTasks) // Start measuring how long the generate has taken. (consider moving this up) start := time.Now() if err = instance.Paths.Generated.EnsureTmpDir(); err != nil { logger.Warnf("could not create temporary directory: %v", err) } defer func() { if err := instance.Paths.Generated.EmptyTmpDir(); err != nil { logger.Warnf("failure emptying temporary directory: %v", err) } }() for f := range queue { if job.IsCancelled(ctx) { break } wg.Add() // #1879 - need to make a copy of f - otherwise there is a race condition // where f is changed when the goroutine runs localTask := f go progress.ExecuteTask(localTask.GetDescription(), func() { localTask.Start(ctx) wg.Done() progress.Increment() }) } wg.Wait() if job.IsCancelled(ctx) { logger.Info("Stopping due to user request") return nil } elapsed := time.Since(start) logger.Info(fmt.Sprintf("Generate finished (%s)", elapsed)) return nil } func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, paths []string, queue chan<- Task) { j.totals = totalsGenerate{} j.queueScenesTasks(ctx, g, paths, queue) j.queueImagesTasks(ctx, g, paths, queue) } func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generator, paths []string, queue chan<- Task) { const batchSize = 1000 findFilter := models.BatchFindFilter(batchSize) sceneFilter := scene.FilterFromPaths(paths) r := j.repository for more := true; more; { if job.IsCancelled(ctx) { return } scenes, err := scene.Query(ctx, r.Scene, sceneFilter, findFilter) if err != nil { logger.Errorf("Error encountered queuing files to scan: %s", err.Error()) return } for _, ss := range scenes { if job.IsCancelled(ctx) { return } if err := ss.LoadFiles(ctx, r.Scene); err != nil { logger.Errorf("Error encountered queuing files to scan: %s", err.Error()) return } j.queueSceneJobs(ctx, g, ss, queue) } if len(scenes) != batchSize { more = false } else { *findFilter.Page++ } } } func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generator, paths []string, queue chan<- Task) { const batchSize = 1000 findFilter := models.BatchFindFilter(batchSize) imageFilter := image.FilterFromPaths(paths) r := j.repository for more := j.input.ClipPreviews || j.input.ImageThumbnails || j.input.ImagePhashes; more; { if job.IsCancelled(ctx) { return } images, err := image.Query(ctx, r.Image, imageFilter, findFilter) if err != nil { logger.Errorf("Error encountered queuing files to scan: %s", err.Error()) return } for _, ss := range images { if job.IsCancelled(ctx) { return } if err := ss.LoadFiles(ctx, r.Image); err != nil { logger.Errorf("Error encountered queuing files to scan: %s", err.Error()) return } j.queueImageJob(g, ss, queue) } if len(images) != batchSize { more = false } else { *findFilter.Page++ } } } func getGeneratePreviewOptions(optionsInput GeneratePreviewOptionsInput) generate.PreviewOptions { config := config.GetInstance() ret := generate.PreviewOptions{ Segments: config.GetPreviewSegments(), SegmentDuration: config.GetPreviewSegmentDuration(), ExcludeStart: config.GetPreviewExcludeStart(), ExcludeEnd: config.GetPreviewExcludeEnd(), Preset: config.GetPreviewPreset().String(), Audio: config.GetPreviewAudio(), } if optionsInput.PreviewSegments != nil { ret.Segments = *optionsInput.PreviewSegments } if optionsInput.PreviewSegmentDuration != nil { ret.SegmentDuration = *optionsInput.PreviewSegmentDuration } if optionsInput.PreviewExcludeStart != nil { ret.ExcludeStart = *optionsInput.PreviewExcludeStart } if optionsInput.PreviewExcludeEnd != nil { ret.ExcludeEnd = *optionsInput.PreviewExcludeEnd } if optionsInput.PreviewPreset != nil { ret.Preset = optionsInput.PreviewPreset.String() } return ret } func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, scene *models.Scene, queue chan<- Task) { r := j.repository if j.input.Covers { task := &GenerateCoverTask{ repository: r, Scene: *scene, Overwrite: j.overwrite, } if task.required(ctx) { j.totals.covers++ j.totals.tasks++ queue <- task } } if j.input.Sprites { task := &GenerateSpriteTask{ Scene: *scene, Overwrite: j.overwrite, fileNamingAlgorithm: j.fileNamingAlgo, } if task.required() { j.totals.sprites++ j.totals.tasks++ queue <- task } } generatePreviewOptions := j.input.PreviewOptions if generatePreviewOptions == nil { generatePreviewOptions = &GeneratePreviewOptionsInput{} } options := getGeneratePreviewOptions(*generatePreviewOptions) if j.input.Previews { task := &GeneratePreviewTask{ Scene: *scene, ImagePreview: j.input.ImagePreviews, Options: options, Overwrite: j.overwrite, fileNamingAlgorithm: j.fileNamingAlgo, generator: g, } if task.required() { if task.videoPreviewRequired() { j.totals.previews++ } if task.imagePreviewRequired() { j.totals.imagePreviews++ } j.totals.tasks++ queue <- task } } if j.input.Markers || j.input.MarkerImagePreviews || j.input.MarkerScreenshots { task := &GenerateMarkersTask{ repository: r, Scene: scene, Overwrite: j.overwrite, fileNamingAlgorithm: j.fileNamingAlgo, VideoPreview: j.input.Markers, ImagePreview: j.input.MarkerImagePreviews, Screenshot: j.input.MarkerScreenshots, generator: g, } markers := task.markersNeeded(ctx) if markers > 0 { j.totals.markers += int64(markers) j.totals.tasks++ queue <- task } } if j.input.Transcodes { forceTranscode := j.input.ForceTranscodes task := &GenerateTranscodeTask{ Scene: *scene, Overwrite: j.overwrite, Force: forceTranscode, fileNamingAlgorithm: j.fileNamingAlgo, g: g, } if task.required() { j.totals.transcodes++ j.totals.tasks++ queue <- task } } if j.input.Phashes { // generate for all files in scene for _, f := range scene.Files.List() { task := &GeneratePhashTask{ repository: r, File: f, fileNamingAlgorithm: j.fileNamingAlgo, Overwrite: j.overwrite, } if task.required() { j.totals.phashes++ j.totals.tasks++ queue <- task } } } if j.input.InteractiveHeatmapsSpeeds { task := &GenerateInteractiveHeatmapSpeedTask{ repository: r, Scene: *scene, Overwrite: j.overwrite, fileNamingAlgorithm: j.fileNamingAlgo, } if task.required() { j.totals.interactiveHeatmapSpeeds++ j.totals.tasks++ queue <- task } } } func (j *GenerateJob) queueMarkerJob(g *generate.Generator, marker *models.SceneMarker, queue chan<- Task) { task := &GenerateMarkersTask{ repository: j.repository, Marker: marker, Overwrite: j.overwrite, fileNamingAlgorithm: j.fileNamingAlgo, VideoPreview: j.input.Markers, ImagePreview: j.input.MarkerImagePreviews, Screenshot: j.input.MarkerScreenshots, generator: g, } j.totals.markers++ j.totals.tasks++ queue <- task } func (j *GenerateJob) queueImageJob(g *generate.Generator, image *models.Image, queue chan<- Task) { if j.input.ImageThumbnails { task := &GenerateImageThumbnailTask{ Image: *image, Overwrite: j.overwrite, } if task.required() { j.totals.imageThumbnails++ j.totals.tasks++ queue <- task } } if j.input.ClipPreviews { task := &GenerateClipPreviewTask{ Image: *image, Overwrite: j.overwrite, } if task.required() { j.totals.clipPreviews++ j.totals.tasks++ queue <- task } } if j.input.ImagePhashes { // generate for all files in image for _, f := range image.Files.List() { if imageFile, ok := f.(*models.ImageFile); ok { task := &GenerateImagePhashTask{ repository: j.repository, File: imageFile, Overwrite: j.overwrite, } if task.required() { j.totals.imagePhashes++ j.totals.tasks++ queue <- task } } } } } ================================================ FILE: internal/manager/task_generate_clip_preview.go ================================================ package manager import ( "context" "fmt" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) type GenerateClipPreviewTask struct { Image models.Image Overwrite bool } func (t *GenerateClipPreviewTask) GetDescription() string { return fmt.Sprintf("Generating Preview for image Clip %s", t.Image.Path) } func (t *GenerateClipPreviewTask) Start(ctx context.Context) { if !t.required() { return } prevPath := GetInstance().Paths.Generated.GetClipPreviewPath(t.Image.Checksum, models.DefaultGthumbWidth) filePath := t.Image.Files.Primary().Base().Path clipPreviewOptions := image.ClipPreviewOptions{ InputArgs: GetInstance().Config.GetTranscodeInputArgs(), OutputArgs: GetInstance().Config.GetTranscodeOutputArgs(), Preset: GetInstance().Config.GetPreviewPreset().String(), } encoder := image.NewThumbnailEncoder(GetInstance().FFMpeg, GetInstance().FFProbe, clipPreviewOptions) err := encoder.GetPreview(filePath, prevPath, models.DefaultGthumbWidth) if err != nil { logger.Errorf("getting preview for image %s: %w", filePath, err) return } } func (t *GenerateClipPreviewTask) required() bool { _, ok := t.Image.Files.Primary().(*models.VideoFile) if !ok { return false } if t.Overwrite { return true } prevPath := GetInstance().Paths.Generated.GetClipPreviewPath(t.Image.Checksum, models.DefaultGthumbWidth) if exists, _ := fsutil.FileExists(prevPath); exists { return false } return true } ================================================ FILE: internal/manager/task_generate_image_phash.go ================================================ package manager import ( "context" "fmt" "github.com/stashapp/stash/pkg/hash/imagephash" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) type GenerateImagePhashTask struct { repository models.Repository File *models.ImageFile Overwrite bool } func (t *GenerateImagePhashTask) GetDescription() string { return fmt.Sprintf("Generating phash for %s", t.File.Path) } func (t *GenerateImagePhashTask) Start(ctx context.Context) { if !t.required() { return } var hash int64 set := false // #4393 - if there is a file with the same md5, we can use the same phash // only use this if we're not overwriting if !t.Overwrite { existing, err := t.findExistingPhash(ctx) if err != nil { logger.Warnf("Error finding existing phash: %v", err) } else if existing != nil { logger.Infof("Using existing phash for %s", t.File.Path) hash = existing.(int64) set = true } } if !set { generated, err := imagephash.Generate(instance.FFMpeg, t.File) if err != nil { logger.Errorf("Error generating phash for %q: %v", t.File.Path, err) logErrorOutput(err) return } hash = int64(*generated) } r := t.repository if err := r.WithTxn(ctx, func(ctx context.Context) error { t.File.Fingerprints = t.File.Fingerprints.AppendUnique(models.Fingerprint{ Type: models.FingerprintTypePhash, Fingerprint: hash, }) return r.File.Update(ctx, t.File) }); err != nil && ctx.Err() == nil { logger.Errorf("Error setting phash: %v", err) } } func (t *GenerateImagePhashTask) findExistingPhash(ctx context.Context) (interface{}, error) { r := t.repository var ret interface{} if err := r.WithReadTxn(ctx, func(ctx context.Context) error { md5 := t.File.Fingerprints.Get(models.FingerprintTypeMD5) // find other files with the same md5 files, err := r.File.FindByFingerprint(ctx, models.Fingerprint{ Type: models.FingerprintTypeMD5, Fingerprint: md5, }) if err != nil { return fmt.Errorf("finding files by md5: %w", err) } // find the first file with a phash for _, file := range files { if phash := file.Base().Fingerprints.Get(models.FingerprintTypePhash); phash != nil { ret = phash return nil } } return nil }); err != nil { return nil, err } return ret, nil } func (t *GenerateImagePhashTask) required() bool { if t.Overwrite { return true } return t.File.Fingerprints.Get(models.FingerprintTypePhash) == nil } ================================================ FILE: internal/manager/task_generate_image_thumbnail.go ================================================ package manager import ( "context" "errors" "fmt" "os/exec" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) type GenerateImageThumbnailTask struct { Image models.Image Overwrite bool } func (t *GenerateImageThumbnailTask) GetDescription() string { return fmt.Sprintf("Generating Thumbnail for image %s", t.Image.Path) } func (t *GenerateImageThumbnailTask) logStderr(err error) { var exitErr *exec.ExitError if errors.As(err, &exitErr) { logger.Debugf("[generator] error output: %s", exitErr.Stderr) } } func (t *GenerateImageThumbnailTask) Start(ctx context.Context) { if !t.required() { return } thumbPath := GetInstance().Paths.Generated.GetThumbnailPath(t.Image.Checksum, models.DefaultGthumbWidth) f := t.Image.Files.Primary() path := f.Base().Path logger.Debugf("Generating thumbnail for %s", path) mgr := GetInstance() c := mgr.Config clipPreviewOptions := image.ClipPreviewOptions{ InputArgs: c.GetTranscodeInputArgs(), OutputArgs: c.GetTranscodeOutputArgs(), Preset: c.GetPreviewPreset().String(), } encoder := image.NewThumbnailEncoder(mgr.FFMpeg, mgr.FFProbe, clipPreviewOptions) data, err := encoder.GetThumbnail(f, models.DefaultGthumbWidth) if err != nil { // don't log for animated images if !errors.Is(err, image.ErrNotSupportedForThumbnail) { logger.Errorf("[generator] getting thumbnail for image %s: %s", path, err.Error()) t.logStderr(err) } return } err = fsutil.WriteFile(thumbPath, data) if err != nil { logger.Errorf("[generator] writing thumbnail for image %s: %s", path, err.Error()) return } } func (t *GenerateImageThumbnailTask) required() bool { vf, ok := t.Image.Files.Primary().(models.VisualFile) if !ok { return false } if vf.GetHeight() <= models.DefaultGthumbWidth && vf.GetWidth() <= models.DefaultGthumbWidth { return false } if t.Overwrite { return true } thumbPath := GetInstance().Paths.Generated.GetThumbnailPath(t.Image.Checksum, models.DefaultGthumbWidth) exists, _ := fsutil.FileExists(thumbPath) return !exists } ================================================ FILE: internal/manager/task_generate_interactive_heatmap_speed.go ================================================ package manager import ( "context" "fmt" "github.com/stashapp/stash/pkg/file/video" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) type GenerateInteractiveHeatmapSpeedTask struct { repository models.Repository Scene models.Scene Overwrite bool fileNamingAlgorithm models.HashAlgorithm } func (t *GenerateInteractiveHeatmapSpeedTask) GetDescription() string { return fmt.Sprintf("Generating heatmap and speed for %s", t.Scene.Path) } func (t *GenerateInteractiveHeatmapSpeedTask) Start(ctx context.Context) { if !t.required() { return } videoChecksum := t.Scene.GetHash(t.fileNamingAlgorithm) funscriptPath := video.GetFunscriptPath(t.Scene.Path) heatmapPath := instance.Paths.Scene.GetInteractiveHeatmapPath(videoChecksum) drawRange := instance.Config.GetDrawFunscriptHeatmapRange() generator := NewInteractiveHeatmapSpeedGenerator(drawRange) err := generator.Generate(funscriptPath, heatmapPath, t.Scene.Files.Primary().Duration) if err != nil { logger.Errorf("error generating heatmap for %s: %s", t.Scene.Path, err.Error()) return } median := generator.InteractiveSpeed r := t.repository if err := r.WithTxn(ctx, func(ctx context.Context) error { primaryFile := t.Scene.Files.Primary() primaryFile.InteractiveSpeed = &median if err := r.File.Update(ctx, primaryFile); err != nil { return fmt.Errorf("updating interactive speed for %s: %w", primaryFile.Path, err) } // update the scene UpdatedAt field // NewScenePartial sets the UpdatedAt field to the current time if _, err := r.Scene.UpdatePartial(ctx, t.Scene.ID, models.NewScenePartial()); err != nil { return fmt.Errorf("updating UpdatedAt field for scene %d: %w", t.Scene.ID, err) } return nil }); err != nil && ctx.Err() == nil { logger.Error(err.Error()) } } func (t *GenerateInteractiveHeatmapSpeedTask) required() bool { primaryFile := t.Scene.Files.Primary() if primaryFile == nil || !primaryFile.Interactive { return false } if t.Overwrite { return true } sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) return !t.doesHeatmapExist(sceneHash) || primaryFile.InteractiveSpeed == nil } func (t *GenerateInteractiveHeatmapSpeedTask) doesHeatmapExist(sceneChecksum string) bool { if sceneChecksum == "" { return false } imageExists, _ := fsutil.FileExists(instance.Paths.Scene.GetInteractiveHeatmapPath(sceneChecksum)) return imageExists } ================================================ FILE: internal/manager/task_generate_markers.go ================================================ package manager import ( "context" "fmt" "path/filepath" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene/generate" ) type GenerateMarkersTask struct { repository models.Repository Scene *models.Scene Marker *models.SceneMarker Overwrite bool fileNamingAlgorithm models.HashAlgorithm VideoPreview bool ImagePreview bool Screenshot bool generator *generate.Generator } func (t *GenerateMarkersTask) GetDescription() string { if t.Scene != nil { return fmt.Sprintf("Generating markers for %s", t.Scene.Path) } else if t.Marker != nil { return fmt.Sprintf("Generating marker preview for marker ID %d", t.Marker.ID) } return "Generating markers" } func (t *GenerateMarkersTask) Start(ctx context.Context) { if t.Scene != nil { t.generateSceneMarkers(ctx) } if t.Marker != nil { var scene *models.Scene r := t.repository if err := r.WithReadTxn(ctx, func(ctx context.Context) error { var err error scene, err = r.Scene.Find(ctx, t.Marker.SceneID) if err != nil { return err } if scene == nil { return fmt.Errorf("scene with id %d not found", t.Marker.SceneID) } return scene.LoadPrimaryFile(ctx, r.File) }); err != nil { logger.Errorf("error finding scene for marker generation: %v", err) return } videoFile := scene.Files.Primary() if videoFile == nil { // nothing to do return } t.generateMarker(videoFile, scene, t.Marker) } } func (t *GenerateMarkersTask) generateSceneMarkers(ctx context.Context) { var sceneMarkers []*models.SceneMarker r := t.repository if err := r.WithReadTxn(ctx, func(ctx context.Context) error { var err error sceneMarkers, err = r.SceneMarker.FindBySceneID(ctx, t.Scene.ID) return err }); err != nil { logger.Errorf("error getting scene markers: %s", err.Error()) return } videoFile := t.Scene.Files.Primary() if len(sceneMarkers) == 0 || videoFile == nil { return } sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) // Make the folder for the scenes markers markersFolder := filepath.Join(instance.Paths.Generated.Markers, sceneHash) if err := fsutil.EnsureDir(markersFolder); err != nil { logger.Warnf("could not create the markers folder (%v): %v", markersFolder, err) } for i, sceneMarker := range sceneMarkers { index := i + 1 logger.Progressf("[generator] <%s> scene marker %d of %d", sceneHash, index, len(sceneMarkers)) t.generateMarker(videoFile, t.Scene, sceneMarker) } } func (t *GenerateMarkersTask) generateMarker(videoFile *models.VideoFile, scene *models.Scene, sceneMarker *models.SceneMarker) { sceneHash := scene.GetHash(t.fileNamingAlgorithm) seconds := float64(sceneMarker.Seconds) // check if marker past duration if seconds > float64(videoFile.Duration) { logger.Warnf("[generator] scene marker at %.2f seconds exceeds video duration of %.2f seconds, skipping", seconds, float64(videoFile.Duration)) return } g := t.generator if t.VideoPreview { if err := g.MarkerPreviewVideo(context.TODO(), videoFile.Path, sceneHash, seconds, sceneMarker.EndSeconds, instance.Config.GetPreviewAudio()); err != nil { logger.Errorf("[generator] failed to generate marker video: %v", err) logErrorOutput(err) } } if t.ImagePreview { if err := g.SceneMarkerWebp(context.TODO(), videoFile.Path, sceneHash, seconds); err != nil { logger.Errorf("[generator] failed to generate marker image: %v", err) logErrorOutput(err) } } if t.Screenshot { if err := g.SceneMarkerScreenshot(context.TODO(), videoFile.Path, sceneHash, seconds, videoFile.Width); err != nil { logger.Errorf("[generator] failed to generate marker screenshot: %v", err) logErrorOutput(err) } } } func (t *GenerateMarkersTask) markersNeeded(ctx context.Context) int { markers := 0 sceneMarkers, err := t.repository.SceneMarker.FindBySceneID(ctx, t.Scene.ID) if err != nil { logger.Errorf("error finding scene markers: %s", err.Error()) return 0 } if len(sceneMarkers) == 0 || t.Scene.Files.Primary() == nil { return 0 } sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) for _, sceneMarker := range sceneMarkers { seconds := int(sceneMarker.Seconds) if t.Overwrite || !t.markerExists(sceneHash, seconds) { markers++ } } return markers } func (t *GenerateMarkersTask) markerExists(sceneChecksum string, seconds int) bool { if sceneChecksum == "" { return false } videoExists := !t.VideoPreview || t.videoExists(sceneChecksum, seconds) imageExists := !t.ImagePreview || t.imageExists(sceneChecksum, seconds) screenshotExists := !t.Screenshot || t.screenshotExists(sceneChecksum, seconds) return videoExists && imageExists && screenshotExists } func (t *GenerateMarkersTask) videoExists(sceneChecksum string, seconds int) bool { if sceneChecksum == "" { return false } videoPath := instance.Paths.SceneMarkers.GetVideoPreviewPath(sceneChecksum, seconds) videoExists, _ := fsutil.FileExists(videoPath) return videoExists } func (t *GenerateMarkersTask) imageExists(sceneChecksum string, seconds int) bool { if sceneChecksum == "" { return false } imagePath := instance.Paths.SceneMarkers.GetWebpPreviewPath(sceneChecksum, seconds) imageExists, _ := fsutil.FileExists(imagePath) return imageExists } func (t *GenerateMarkersTask) screenshotExists(sceneChecksum string, seconds int) bool { if sceneChecksum == "" { return false } screenshotPath := instance.Paths.SceneMarkers.GetScreenshotPath(sceneChecksum, seconds) screenshotExists, _ := fsutil.FileExists(screenshotPath) return screenshotExists } ================================================ FILE: internal/manager/task_generate_phash.go ================================================ package manager import ( "context" "fmt" "github.com/stashapp/stash/pkg/hash/videophash" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) type GeneratePhashTask struct { repository models.Repository File *models.VideoFile Overwrite bool fileNamingAlgorithm models.HashAlgorithm } func (t *GeneratePhashTask) GetDescription() string { return fmt.Sprintf("Generating phash for %s", t.File.Path) } func (t *GeneratePhashTask) Start(ctx context.Context) { if !t.required() { return } var hash int64 set := false // #4393 - if there is a file with the same oshash, we can use the same phash // only use this if we're not overwriting if !t.Overwrite { existing, err := t.findExistingPhash(ctx) if err != nil { logger.Warnf("Error finding existing phash: %v", err) } else if existing != nil { logger.Infof("Using existing phash for %s", t.File.Path) hash = existing.(int64) set = true } } if !set { generated, err := videophash.Generate(instance.FFMpeg, t.File) if err != nil { logger.Errorf("Error generating phash for %q: %v", t.File.Path, err) logErrorOutput(err) return } hash = int64(*generated) } r := t.repository if err := r.WithTxn(ctx, func(ctx context.Context) error { t.File.Fingerprints = t.File.Fingerprints.AppendUnique(models.Fingerprint{ Type: models.FingerprintTypePhash, Fingerprint: hash, }) return r.File.Update(ctx, t.File) }); err != nil && ctx.Err() == nil { logger.Errorf("Error setting phash: %v", err) } } func (t *GeneratePhashTask) findExistingPhash(ctx context.Context) (interface{}, error) { r := t.repository var ret interface{} if err := r.WithReadTxn(ctx, func(ctx context.Context) error { oshash := t.File.Fingerprints.Get(models.FingerprintTypeOshash) // find other files with the same oshash files, err := r.File.FindByFingerprint(ctx, models.Fingerprint{ Type: models.FingerprintTypeOshash, Fingerprint: oshash, }) if err != nil { return fmt.Errorf("finding files by oshash: %w", err) } // find the first file with a phash for _, file := range files { if phash := file.Base().Fingerprints.Get(models.FingerprintTypePhash); phash != nil { ret = phash return nil } } return nil }); err != nil { return nil, err } return ret, nil } func (t *GeneratePhashTask) required() bool { if t.Overwrite { return true } return t.File.Fingerprints.Get(models.FingerprintTypePhash) == nil } ================================================ FILE: internal/manager/task_generate_preview.go ================================================ package manager import ( "context" "fmt" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene/generate" ) type GeneratePreviewTask struct { Scene models.Scene ImagePreview bool Options generate.PreviewOptions Overwrite bool fileNamingAlgorithm models.HashAlgorithm generator *generate.Generator videoPreviewExists *bool imagePreviewExists *bool } func (t *GeneratePreviewTask) GetDescription() string { return fmt.Sprintf("Generating preview for %s", t.Scene.Path) } func (t *GeneratePreviewTask) Start(ctx context.Context) { videoChecksum := t.Scene.GetHash(t.fileNamingAlgorithm) if t.videoPreviewRequired() { ffprobe := instance.FFProbe videoFile, err := ffprobe.NewVideoFile(t.Scene.Path) if err != nil { logger.Errorf("error reading video file: %v", err) return } if err := t.generateVideo(videoChecksum, videoFile.VideoStreamDuration, videoFile.FrameRate); err != nil { logger.Errorf("error generating preview: %v", err) logErrorOutput(err) return } } if t.imagePreviewRequired() { if err := t.generateWebp(videoChecksum); err != nil { logger.Errorf("error generating preview webp: %v", err) logErrorOutput(err) } } } func (t *GeneratePreviewTask) generateVideo(videoChecksum string, videoDuration float64, videoFrameRate float64) error { videoFilename := t.Scene.Path useVsync2 := false if videoFrameRate <= 0.01 { logger.Errorf("[generator] Video framerate very low/high (%f) most likely vfr so using -vsync 2", videoFrameRate) useVsync2 = true } if err := t.generator.PreviewVideo(context.TODO(), videoFilename, videoDuration, videoChecksum, t.Options, false, useVsync2); err != nil { logger.Warnf("[generator] failed generating scene preview, trying fallback") if err := t.generator.PreviewVideo(context.TODO(), videoFilename, videoDuration, videoChecksum, t.Options, true, useVsync2); err != nil { return err } } return nil } func (t *GeneratePreviewTask) generateWebp(videoChecksum string) error { videoFilename := t.Scene.Path return t.generator.PreviewWebp(context.TODO(), videoFilename, videoChecksum) } func (t *GeneratePreviewTask) required() bool { return t.videoPreviewRequired() || t.imagePreviewRequired() } func (t *GeneratePreviewTask) videoPreviewRequired() bool { if t.Scene.Path == "" { return false } if t.Overwrite { return true } sceneChecksum := t.Scene.GetHash(t.fileNamingAlgorithm) if sceneChecksum == "" { return false } if t.videoPreviewExists == nil { videoExists, _ := fsutil.FileExists(instance.Paths.Scene.GetVideoPreviewPath(sceneChecksum)) t.videoPreviewExists = &videoExists } return !*t.videoPreviewExists } func (t *GeneratePreviewTask) imagePreviewRequired() bool { if !t.ImagePreview { return false } if t.Scene.Path == "" { return false } if t.Overwrite { return true } sceneChecksum := t.Scene.GetHash(t.fileNamingAlgorithm) if sceneChecksum == "" { return false } if t.imagePreviewExists == nil { imageExists, _ := fsutil.FileExists(instance.Paths.Scene.GetWebpPreviewPath(sceneChecksum)) t.imagePreviewExists = &imageExists } return !*t.imagePreviewExists } ================================================ FILE: internal/manager/task_generate_screenshot.go ================================================ package manager import ( "context" "fmt" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene/generate" ) type GenerateCoverTask struct { repository models.Repository Scene models.Scene ScreenshotAt *float64 Overwrite bool } func (t *GenerateCoverTask) GetDescription() string { return fmt.Sprintf("Generating cover for %s", t.Scene.GetTitle()) } func (t *GenerateCoverTask) Start(ctx context.Context) { scenePath := t.Scene.Path r := t.repository var required bool if err := r.WithReadTxn(ctx, func(ctx context.Context) error { required = t.required(ctx) return t.Scene.LoadPrimaryFile(ctx, r.File) }); err != nil { logger.Error(err) return } if !required { return } videoFile := t.Scene.Files.Primary() if videoFile == nil { return } var at float64 if t.ScreenshotAt == nil { at = float64(videoFile.Duration) * 0.2 } else { at = *t.ScreenshotAt } // we'll generate the screenshot, grab the generated data and set it // in the database. logger.Debugf("Creating screenshot for %s", scenePath) g := generate.Generator{ Encoder: instance.FFMpeg, FFMpegConfig: instance.Config, LockManager: instance.ReadLockManager, ScenePaths: instance.Paths.Scene, Overwrite: true, } coverImageData, err := g.Screenshot(context.TODO(), videoFile.Path, videoFile.Width, videoFile.Duration, generate.ScreenshotOptions{ At: &at, }) if err != nil { logger.Errorf("Error generating screenshot: %v", err) logErrorOutput(err) return } if err := r.WithTxn(ctx, func(ctx context.Context) error { qb := r.Scene scenePartial := models.NewScenePartial() // update the scene cover table if err := qb.UpdateCover(ctx, t.Scene.ID, coverImageData); err != nil { return fmt.Errorf("error setting screenshot: %v", err) } // update the scene with the update date _, err = qb.UpdatePartial(ctx, t.Scene.ID, scenePartial) if err != nil { return fmt.Errorf("error updating scene: %v", err) } return nil }); err != nil && ctx.Err() == nil { logger.Error(err.Error()) } } // required returns true if the sprite needs to be generated // assumes in a transaction func (t *GenerateCoverTask) required(ctx context.Context) bool { if t.Scene.Path == "" { return false } if t.Overwrite { return true } // if the scene has a cover, then we don't need to generate it hasCover, err := t.repository.Scene.HasCover(ctx, t.Scene.ID) if err != nil { logger.Errorf("Error getting cover: %v", err) return false } return !hasCover } ================================================ FILE: internal/manager/task_generate_sprite.go ================================================ package manager import ( "context" "fmt" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) type GenerateSpriteTask struct { Scene models.Scene Overwrite bool fileNamingAlgorithm models.HashAlgorithm } func (t *GenerateSpriteTask) GetDescription() string { return fmt.Sprintf("Generating sprites for %s", t.Scene.Path) } func (t *GenerateSpriteTask) Start(ctx context.Context) { if !t.required() { return } ffprobe := instance.FFProbe videoFile, err := ffprobe.NewVideoFile(t.Scene.Path) if err != nil { logger.Errorf("error reading video file: %s", err.Error()) return } sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) imagePath := instance.Paths.Scene.GetSpriteImageFilePath(sceneHash) vttPath := instance.Paths.Scene.GetSpriteVttFilePath(sceneHash) cfg := DefaultSpriteGeneratorConfig cfg.SpriteSize = instance.Config.GetSpriteScreenshotSize() if instance.Config.GetUseCustomSpriteInterval() { cfg.MinimumSprites = instance.Config.GetMinimumSprites() cfg.MaximumSprites = instance.Config.GetMaximumSprites() cfg.SpriteInterval = instance.Config.GetSpriteInterval() } generator, err := NewSpriteGenerator(*videoFile, sceneHash, imagePath, vttPath, cfg) if err != nil { logger.Errorf("error creating sprite generator: %s", err.Error()) return } generator.Overwrite = t.Overwrite if err := generator.Generate(); err != nil { logger.Errorf("error generating sprite: %s", err.Error()) logErrorOutput(err) return } } // required returns true if the sprite needs to be generated func (t GenerateSpriteTask) required() bool { if t.Scene.Path == "" { return false } if t.Overwrite { return true } sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) return !t.doesSpriteExist(sceneHash) } func (t *GenerateSpriteTask) doesSpriteExist(sceneChecksum string) bool { if sceneChecksum == "" { return false } imageExists, _ := fsutil.FileExists(instance.Paths.Scene.GetSpriteImageFilePath(sceneChecksum)) vttExists, _ := fsutil.FileExists(instance.Paths.Scene.GetSpriteVttFilePath(sceneChecksum)) return imageExists && vttExists } ================================================ FILE: internal/manager/task_identify.go ================================================ package manager import ( "context" "errors" "fmt" "strings" "github.com/stashapp/stash/internal/identify" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/scraper" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/stashbox" "github.com/stashapp/stash/pkg/txn" ) var ErrInput = errors.New("invalid request input") type IdentifyJob struct { postHookExecutor identify.SceneUpdatePostHookExecutor input identify.Options stashBoxes []*models.StashBox progress *job.Progress } func CreateIdentifyJob(input identify.Options) *IdentifyJob { return &IdentifyJob{ postHookExecutor: instance.PluginCache, input: input, stashBoxes: instance.Config.GetStashBoxes(), } } func (j *IdentifyJob) Execute(ctx context.Context, progress *job.Progress) error { j.progress = progress // if no sources provided - just return if len(j.input.Sources) == 0 { return nil } sources, err := j.getSources() if err != nil { return err } // if scene ids provided, use those // otherwise, batch query for all scenes - ordering by path // don't use a transaction to query scenes r := instance.Repository if err := r.WithDB(ctx, func(ctx context.Context) error { if len(j.input.SceneIDs) == 0 { return j.identifyAllScenes(ctx, sources) } sceneIDs, err := stringslice.StringSliceToIntSlice(j.input.SceneIDs) if err != nil { return fmt.Errorf("invalid scene IDs: %w", err) } progress.SetTotal(len(sceneIDs)) for _, id := range sceneIDs { if job.IsCancelled(ctx) { break } // find the scene var err error scene, err := r.Scene.Find(ctx, id) if err != nil { return fmt.Errorf("finding scene id %d: %w", id, err) } if scene == nil { return fmt.Errorf("scene with id %d not found", id) } j.identifyScene(ctx, scene, sources) } return nil }); err != nil { return fmt.Errorf("error encountered while identifying scenes: %w", err) } return nil } func (j *IdentifyJob) identifyAllScenes(ctx context.Context, sources []identify.ScraperSource) error { r := instance.Repository // exclude organised organised := false sceneFilter := scene.FilterFromPaths(j.input.Paths) sceneFilter.Organized = &organised sort := "path" findFilter := &models.FindFilterType{ Sort: &sort, } // get the count pp := 0 findFilter.PerPage = &pp countResult, err := r.Scene.Query(ctx, models.SceneQueryOptions{ QueryOptions: models.QueryOptions{ FindFilter: findFilter, Count: true, }, SceneFilter: sceneFilter, }) if err != nil { return fmt.Errorf("error getting scene count: %w", err) } j.progress.SetTotal(countResult.Count) return scene.BatchProcess(ctx, r.Scene, sceneFilter, findFilter, func(scene *models.Scene) error { if job.IsCancelled(ctx) { return nil } j.identifyScene(ctx, scene, sources) return nil }) } func (j *IdentifyJob) identifyScene(ctx context.Context, s *models.Scene, sources []identify.ScraperSource) { if job.IsCancelled(ctx) { return } var taskError error j.progress.ExecuteTask("Identifying "+s.Path, func() { r := instance.Repository task := identify.SceneIdentifier{ TxnManager: r.TxnManager, SceneReaderUpdater: r.Scene, StudioReaderWriter: r.Studio, PerformerCreator: r.Performer, TagFinderCreator: r.Tag, DefaultOptions: j.input.Options, Sources: sources, SceneUpdatePostHookExecutor: j.postHookExecutor, } taskError = task.Identify(ctx, s) }) if taskError != nil { logger.Errorf("Error encountered identifying %s: %v", s.Path, taskError) } j.progress.Increment() } func (j *IdentifyJob) getSources() ([]identify.ScraperSource, error) { var ret []identify.ScraperSource for _, source := range j.input.Sources { // get scraper source stashBox, err := j.getStashBox(source.Source) if err != nil { return nil, err } var src identify.ScraperSource if stashBox != nil { matcher := match.SceneRelationships{ PerformerFinder: instance.Repository.Performer, TagFinder: instance.Repository.Tag, StudioFinder: instance.Repository.Studio, } src = identify.ScraperSource{ Name: "stash-box: " + stashBox.Endpoint, Scraper: stashboxSource{ Client: stashbox.NewClient(*stashBox, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns())), endpoint: stashBox.Endpoint, txnManager: instance.Repository.TxnManager, sceneFingerprintGetter: instance.SceneService, matcher: matcher, }, RemoteSite: stashBox.Endpoint, } } else { scraperID := *source.Source.ScraperID s := instance.ScraperCache.GetScraper(scraperID) if s == nil { return nil, fmt.Errorf("%w: scraper with id %q", models.ErrNotFound, scraperID) } src = identify.ScraperSource{ Name: s.Name, Scraper: scraperSource{ cache: instance.ScraperCache, scraperID: scraperID, }, } } src.Options = source.Options ret = append(ret, src) } return ret, nil } func (j *IdentifyJob) getStashBox(src *scraper.Source) (*models.StashBox, error) { if src.ScraperID != nil { return nil, nil } // must be stash-box if src.StashBoxIndex == nil && src.StashBoxEndpoint == nil { return nil, fmt.Errorf("%w: stash_box_index or stash_box_endpoint or scraper_id must be set", ErrInput) } return resolveStashBox(j.stashBoxes, *src) } func resolveStashBox(sb []*models.StashBox, source scraper.Source) (*models.StashBox, error) { if source.StashBoxIndex != nil { index := source.StashBoxIndex if *index < 0 || *index >= len(sb) { return nil, fmt.Errorf("%w: invalid stash_box_index: %d", models.ErrScraperSource, index) } return sb[*index], nil } if source.StashBoxEndpoint != nil { var ret *models.StashBox endpoint := *source.StashBoxEndpoint for _, b := range sb { if strings.EqualFold(endpoint, b.Endpoint) { ret = b } } if ret == nil { return nil, fmt.Errorf(`%w: stash-box with endpoint "%s"`, models.ErrNotFound, endpoint) } return ret, nil } // neither stash-box inputs were provided, so assume it is a scraper return nil, nil } type stashboxSource struct { *stashbox.Client endpoint string txnManager models.TxnManager sceneFingerprintGetter sceneFingerprintGetter matcher match.SceneRelationships } type sceneFingerprintGetter interface { GetScenesFingerprints(ctx context.Context, ids []int) ([]models.Fingerprints, error) } func (s stashboxSource) ScrapeScenes(ctx context.Context, sceneID int) ([]*models.ScrapedScene, error) { var fps []models.Fingerprints if err := txn.WithReadTxn(ctx, s.txnManager, func(ctx context.Context) error { var err error fps, err = s.sceneFingerprintGetter.GetScenesFingerprints(ctx, []int{sceneID}) return err }); err != nil { return nil, fmt.Errorf("error getting scene fingerprints: %w", err) } results, err := s.FindSceneByFingerprints(ctx, fps[0]) if err != nil { return nil, fmt.Errorf("error querying stash-box using scene ID %d: %w", sceneID, err) } if err := txn.WithReadTxn(ctx, s.txnManager, func(ctx context.Context) error { for _, ret := range results { if err := s.matcher.MatchRelationships(ctx, ret, s.endpoint); err != nil { return err } } return nil }); err != nil { return nil, fmt.Errorf("error matching scene relationships: %w", err) } if len(results) > 0 { return results, nil } return nil, nil } func (s stashboxSource) String() string { return fmt.Sprintf("stash-box %s", s.endpoint) } type scraperSource struct { cache *scraper.Cache scraperID string } func (s scraperSource) ScrapeScenes(ctx context.Context, sceneID int) ([]*models.ScrapedScene, error) { content, err := s.cache.ScrapeID(ctx, s.scraperID, sceneID, scraper.ScrapeContentTypeScene) if err != nil { return nil, err } // don't try to convert nil return value if content == nil { return nil, nil } if scene, ok := content.(models.ScrapedScene); ok { return []*models.ScrapedScene{&scene}, nil } return nil, errors.New("could not convert content to scene") } func (s scraperSource) String() string { return fmt.Sprintf("scraper %s", s.scraperID) } ================================================ FILE: internal/manager/task_import.go ================================================ package manager import ( "archive/zip" "context" "errors" "fmt" "io" "os" "path/filepath" "github.com/99designs/gqlgen/graphql" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/group" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/paths" "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/savedfilter" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/studio" "github.com/stashapp/stash/pkg/tag" ) type Resetter interface { Reset() error } type ImportTask struct { repository models.Repository resetter Resetter json jsonUtils BaseDir string TmpZip string Reset bool DuplicateBehaviour ImportDuplicateEnum MissingRefBehaviour models.ImportMissingRefEnum fileNamingAlgorithm models.HashAlgorithm } type ImportObjectsInput struct { File graphql.Upload `json:"file"` DuplicateBehaviour ImportDuplicateEnum `json:"duplicateBehaviour"` MissingRefBehaviour models.ImportMissingRefEnum `json:"missingRefBehaviour"` } func CreateImportTask(a models.HashAlgorithm, input ImportObjectsInput) (*ImportTask, error) { baseDir, err := instance.Paths.Generated.TempDir("import") if err != nil { logger.Errorf("error creating temporary directory for import: %v", err) return nil, err } tmpZip := "" if input.File.File != nil { tmpZip = filepath.Join(baseDir, "import.zip") out, err := os.Create(tmpZip) if err != nil { return nil, err } _, err = io.Copy(out, input.File.File) out.Close() if err != nil { return nil, err } } mgr := GetInstance() return &ImportTask{ repository: mgr.Repository, resetter: mgr.Database, BaseDir: baseDir, TmpZip: tmpZip, Reset: false, DuplicateBehaviour: input.DuplicateBehaviour, MissingRefBehaviour: input.MissingRefBehaviour, fileNamingAlgorithm: a, }, nil } func (t *ImportTask) GetDescription() string { return "Importing..." } func (t *ImportTask) Start(ctx context.Context) { if t.TmpZip != "" { defer func() { err := fsutil.RemoveDir(t.BaseDir) if err != nil { logger.Errorf("error removing directory %s: %v", t.BaseDir, err) } }() if err := t.unzipFile(); err != nil { logger.Errorf("error unzipping provided file for import: %v", err) return } } t.json = jsonUtils{ json: *paths.GetJSONPaths(t.BaseDir), } // set default behaviour if not provided if !t.DuplicateBehaviour.IsValid() { t.DuplicateBehaviour = ImportDuplicateEnumFail } if !t.MissingRefBehaviour.IsValid() { t.MissingRefBehaviour = models.ImportMissingRefEnumFail } if t.Reset { err := t.resetter.Reset() if err != nil { logger.Errorf("Error resetting database: %v", err) return } } t.ImportSavedFilters(ctx) t.ImportTags(ctx) t.ImportPerformers(ctx) t.ImportStudios(ctx) t.ImportGroups(ctx) t.ImportFiles(ctx) t.ImportGalleries(ctx) t.ImportScenes(ctx) t.ImportImages(ctx) } func (t *ImportTask) unzipFile() error { defer func() { err := os.Remove(t.TmpZip) if err != nil { logger.Errorf("error removing temporary zip file %s: %v", t.TmpZip, err) } }() // now we can read the zip file r, err := zip.OpenReader(t.TmpZip) if err != nil { return err } defer r.Close() for _, f := range r.File { fn := filepath.Join(t.BaseDir, f.Name) if f.FileInfo().IsDir() { if err := os.MkdirAll(fn, os.ModePerm); err != nil { logger.Warnf("couldn't create directory %v while unzipping import file: %v", fn, err) } continue } if err := os.MkdirAll(filepath.Dir(fn), os.ModePerm); err != nil { return err } o, err := os.OpenFile(fn, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) if err != nil { return err } i, err := f.Open() if err != nil { o.Close() return err } if _, err := io.Copy(o, i); err != nil { o.Close() i.Close() return err } o.Close() i.Close() } return nil } func (t *ImportTask) ImportPerformers(ctx context.Context) { logger.Info("[performers] importing") path := t.json.json.Performers files, err := os.ReadDir(path) if err != nil { if !errors.Is(err, os.ErrNotExist) { logger.Errorf("[performers] failed to read performers directory: %v", err) } return } r := t.repository for i, fi := range files { index := i + 1 performerJSON, err := jsonschema.LoadPerformerFile(filepath.Join(path, fi.Name())) if err != nil { logger.Errorf("[performers] failed to read json: %v", err) continue } logger.Progressf("[performers] %d of %d", index, len(files)) if err := r.WithTxn(ctx, func(ctx context.Context) error { importer := &performer.Importer{ ReaderWriter: r.Performer, TagWriter: r.Tag, Input: *performerJSON, } return performImport(ctx, importer, t.DuplicateBehaviour) }); err != nil { logger.Errorf("[performers] <%s> import failed: %v", fi.Name(), err) } } logger.Info("[performers] import complete") } func (t *ImportTask) ImportStudios(ctx context.Context) { pendingParent := make(map[string][]*jsonschema.Studio) logger.Info("[studios] importing") path := t.json.json.Studios files, err := os.ReadDir(path) if err != nil { if !errors.Is(err, os.ErrNotExist) { logger.Errorf("[studios] failed to read studios directory: %v", err) } return } r := t.repository for i, fi := range files { index := i + 1 studioJSON, err := jsonschema.LoadStudioFile(filepath.Join(path, fi.Name())) if err != nil { logger.Errorf("[studios] failed to read json: %v", err) continue } logger.Progressf("[studios] %d of %d", index, len(files)) if err := r.WithTxn(ctx, func(ctx context.Context) error { return t.importStudio(ctx, studioJSON, pendingParent) }); err != nil { if errors.Is(err, studio.ErrParentStudioNotExist) { // add to the pending parent list so that it is created after the parent s := pendingParent[studioJSON.ParentStudio] s = append(s, studioJSON) pendingParent[studioJSON.ParentStudio] = s continue } logger.Errorf("[studios] <%s> failed to create: %v", fi.Name(), err) continue } } // create the leftover studios, warning for missing parents if len(pendingParent) > 0 { logger.Warnf("[studios] importing studios with missing parents") for _, s := range pendingParent { for _, orphanStudioJSON := range s { if err := r.WithTxn(ctx, func(ctx context.Context) error { return t.importStudio(ctx, orphanStudioJSON, nil) }); err != nil { logger.Errorf("[studios] <%s> failed to create: %v", orphanStudioJSON.Name, err) continue } } } } logger.Info("[studios] import complete") } func (t *ImportTask) importStudio(ctx context.Context, studioJSON *jsonschema.Studio, pendingParent map[string][]*jsonschema.Studio) error { r := t.repository importer := &studio.Importer{ ReaderWriter: t.repository.Studio, TagWriter: r.Tag, Input: *studioJSON, MissingRefBehaviour: t.MissingRefBehaviour, } // first phase: return error if parent does not exist if pendingParent != nil { importer.MissingRefBehaviour = models.ImportMissingRefEnumFail } if err := performImport(ctx, importer, t.DuplicateBehaviour); err != nil { return err } // now create the studios pending this studios creation s := pendingParent[studioJSON.Name] for _, childStudioJSON := range s { // map is nil since we're not checking parent studios at this point if err := t.importStudio(ctx, childStudioJSON, nil); err != nil { return fmt.Errorf("failed to create child studio <%s>: %v", childStudioJSON.Name, err) } } // delete the entry from the map so that we know its not left over delete(pendingParent, studioJSON.Name) return nil } func (t *ImportTask) ImportGroups(ctx context.Context) { logger.Info("[groups] importing") pendingSubs := make(map[string][]*jsonschema.Group) path := t.json.json.Groups files, err := os.ReadDir(path) if err != nil { if !errors.Is(err, os.ErrNotExist) { logger.Errorf("[groups] failed to read movies directory: %v", err) } return } r := t.repository for i, fi := range files { index := i + 1 groupJSON, err := jsonschema.LoadGroupFile(filepath.Join(path, fi.Name())) if err != nil { logger.Errorf("[groups] failed to read json: %v", err) continue } logger.Progressf("[groups] %d of %d", index, len(files)) if err := r.WithTxn(ctx, func(ctx context.Context) error { return t.importGroup(ctx, groupJSON, pendingSubs, false) }); err != nil { var subError group.SubGroupNotExistError if errors.As(err, &subError) { missingSub := subError.MissingSubGroup() pendingSubs[missingSub] = append(pendingSubs[missingSub], groupJSON) continue } logger.Errorf("[groups] <%s> failed to import: %v", fi.Name(), err) continue } } for _, s := range pendingSubs { for _, orphanGroupJSON := range s { if err := r.WithTxn(ctx, func(ctx context.Context) error { return t.importGroup(ctx, orphanGroupJSON, nil, true) }); err != nil { logger.Errorf("[groups] <%s> failed to create: %v", orphanGroupJSON.Name, err) continue } } } logger.Info("[groups] import complete") } func (t *ImportTask) importGroup(ctx context.Context, groupJSON *jsonschema.Group, pendingSub map[string][]*jsonschema.Group, fail bool) error { r := t.repository importer := &group.Importer{ ReaderWriter: r.Group, StudioWriter: r.Studio, TagWriter: r.Tag, Input: *groupJSON, MissingRefBehaviour: t.MissingRefBehaviour, } // first phase: return error if parent does not exist if !fail { importer.MissingRefBehaviour = models.ImportMissingRefEnumFail } if err := performImport(ctx, importer, t.DuplicateBehaviour); err != nil { return err } for _, containingGroupJSON := range pendingSub[groupJSON.Name] { if err := t.importGroup(ctx, containingGroupJSON, pendingSub, fail); err != nil { var subError group.SubGroupNotExistError if errors.As(err, &subError) { missingSub := subError.MissingSubGroup() pendingSub[missingSub] = append(pendingSub[missingSub], containingGroupJSON) continue } return fmt.Errorf("failed to create containing group <%s>: %v", containingGroupJSON.Name, err) } } delete(pendingSub, groupJSON.Name) return nil } func (t *ImportTask) ImportFiles(ctx context.Context) { logger.Info("[files] importing") path := t.json.json.Files files, err := os.ReadDir(path) if err != nil { if !errors.Is(err, os.ErrNotExist) { logger.Errorf("[files] failed to read files directory: %v", err) } return } r := t.repository pendingParent := make(map[string][]jsonschema.DirEntry) for i, fi := range files { index := i + 1 fileJSON, err := jsonschema.LoadFileFile(filepath.Join(path, fi.Name())) if err != nil { logger.Errorf("[files] failed to read json: %v", err) continue } logger.Progressf("[files] %d of %d", index, len(files)) if err := r.WithTxn(ctx, func(ctx context.Context) error { return t.importFile(ctx, fileJSON, pendingParent) }); err != nil { if errors.Is(err, file.ErrZipFileNotExist) { // add to the pending parent list so that it is created after the parent s := pendingParent[fileJSON.DirEntry().ZipFile] s = append(s, fileJSON) pendingParent[fileJSON.DirEntry().ZipFile] = s continue } logger.Errorf("[files] <%s> failed to create: %v", fi.Name(), err) continue } } // create the leftover studios, warning for missing parents if len(pendingParent) > 0 { logger.Warnf("[files] importing files with missing zip files") for _, s := range pendingParent { for _, orphanFileJSON := range s { if err := r.WithTxn(ctx, func(ctx context.Context) error { return t.importFile(ctx, orphanFileJSON, nil) }); err != nil { logger.Errorf("[files] <%s> failed to create: %v", orphanFileJSON.DirEntry().Path, err) continue } } } } logger.Info("[files] import complete") } func (t *ImportTask) importFile(ctx context.Context, fileJSON jsonschema.DirEntry, pendingParent map[string][]jsonschema.DirEntry) error { r := t.repository fileImporter := &file.Importer{ ReaderWriter: r.File, FolderStore: r.Folder, Input: fileJSON, } // ignore duplicate files - don't overwrite if err := performImport(ctx, fileImporter, ImportDuplicateEnumIgnore); err != nil { return err } // now create the files pending this file's creation s := pendingParent[fileJSON.DirEntry().Path] for _, childFileJSON := range s { // map is nil since we're not checking parent studios at this point if err := t.importFile(ctx, childFileJSON, nil); err != nil { return fmt.Errorf("failed to create child file <%s>: %v", childFileJSON.DirEntry().Path, err) } } // delete the entry from the map so that we know its not left over delete(pendingParent, fileJSON.DirEntry().Path) return nil } func (t *ImportTask) ImportGalleries(ctx context.Context) { logger.Info("[galleries] importing") path := t.json.json.Galleries files, err := os.ReadDir(path) if err != nil { if !errors.Is(err, os.ErrNotExist) { logger.Errorf("[galleries] failed to read galleries directory: %v", err) } return } r := t.repository for i, fi := range files { index := i + 1 galleryJSON, err := jsonschema.LoadGalleryFile(filepath.Join(path, fi.Name())) if err != nil { logger.Errorf("[galleries] failed to read json: %v", err) continue } logger.Progressf("[galleries] %d of %d", index, len(files)) if err := r.WithTxn(ctx, func(ctx context.Context) error { galleryImporter := &gallery.Importer{ ReaderWriter: r.Gallery, FolderFinder: r.Folder, FileFinder: r.File, PerformerWriter: r.Performer, StudioWriter: r.Studio, TagWriter: r.Tag, Input: *galleryJSON, MissingRefBehaviour: t.MissingRefBehaviour, } if err := performImport(ctx, galleryImporter, t.DuplicateBehaviour); err != nil { return err } // import the gallery chapters for _, m := range galleryJSON.Chapters { chapterImporter := &gallery.ChapterImporter{ GalleryID: galleryImporter.ID, Input: m, MissingRefBehaviour: t.MissingRefBehaviour, ReaderWriter: r.GalleryChapter, } if err := performImport(ctx, chapterImporter, t.DuplicateBehaviour); err != nil { return err } } return nil }); err != nil { logger.Errorf("[galleries] <%s> import failed to commit: %v", fi.Name(), err) continue } } logger.Info("[galleries] import complete") } func (t *ImportTask) ImportTags(ctx context.Context) { pendingParent := make(map[string][]*jsonschema.Tag) logger.Info("[tags] importing") path := t.json.json.Tags files, err := os.ReadDir(path) if err != nil { if !errors.Is(err, os.ErrNotExist) { logger.Errorf("[tags] failed to read tags directory: %v", err) } return } r := t.repository for i, fi := range files { index := i + 1 tagJSON, err := jsonschema.LoadTagFile(filepath.Join(path, fi.Name())) if err != nil { logger.Errorf("[tags] failed to read json: %v", err) continue } logger.Progressf("[tags] %d of %d", index, len(files)) if err := r.WithTxn(ctx, func(ctx context.Context) error { return t.importTag(ctx, tagJSON, pendingParent, false) }); err != nil { var parentError tag.ParentTagNotExistError if errors.As(err, &parentError) { pendingParent[parentError.MissingParent()] = append(pendingParent[parentError.MissingParent()], tagJSON) continue } logger.Errorf("[tags] <%s> failed to import: %v", fi.Name(), err) continue } } for _, s := range pendingParent { for _, orphanTagJSON := range s { if err := r.WithTxn(ctx, func(ctx context.Context) error { return t.importTag(ctx, orphanTagJSON, nil, true) }); err != nil { logger.Errorf("[tags] <%s> failed to create: %v", orphanTagJSON.Name, err) continue } } } logger.Info("[tags] import complete") } func (t *ImportTask) importTag(ctx context.Context, tagJSON *jsonschema.Tag, pendingParent map[string][]*jsonschema.Tag, fail bool) error { importer := &tag.Importer{ ReaderWriter: t.repository.Tag, Input: *tagJSON, MissingRefBehaviour: t.MissingRefBehaviour, } // first phase: return error if parent does not exist if !fail { importer.MissingRefBehaviour = models.ImportMissingRefEnumFail } if err := performImport(ctx, importer, t.DuplicateBehaviour); err != nil { return err } for _, childTagJSON := range pendingParent[tagJSON.Name] { if err := t.importTag(ctx, childTagJSON, pendingParent, fail); err != nil { var parentError tag.ParentTagNotExistError if errors.As(err, &parentError) { pendingParent[parentError.MissingParent()] = append(pendingParent[parentError.MissingParent()], childTagJSON) continue } return fmt.Errorf("failed to create child tag <%s>: %v", childTagJSON.Name, err) } } delete(pendingParent, tagJSON.Name) return nil } func (t *ImportTask) ImportScenes(ctx context.Context) { logger.Info("[scenes] importing") path := t.json.json.Scenes files, err := os.ReadDir(path) if err != nil { if !errors.Is(err, os.ErrNotExist) { logger.Errorf("[scenes] failed to read scenes directory: %v", err) } return } r := t.repository for i, fi := range files { index := i + 1 logger.Progressf("[scenes] %d of %d", index, len(files)) sceneJSON, err := jsonschema.LoadSceneFile(filepath.Join(path, fi.Name())) if err != nil { logger.Infof("[scenes] <%s> json parse failure: %v", fi.Name(), err) continue } if err := r.WithTxn(ctx, func(ctx context.Context) error { sceneImporter := &scene.Importer{ ReaderWriter: r.Scene, Input: *sceneJSON, FileFinder: r.File, FileNamingAlgorithm: t.fileNamingAlgorithm, MissingRefBehaviour: t.MissingRefBehaviour, GalleryFinder: r.Gallery, GroupWriter: r.Group, PerformerWriter: r.Performer, StudioWriter: r.Studio, TagWriter: r.Tag, } if err := performImport(ctx, sceneImporter, t.DuplicateBehaviour); err != nil { return err } // skip importing markers if the scene was not created if sceneImporter.ID == 0 { return nil } // import the scene markers for _, m := range sceneJSON.Markers { markerImporter := &scene.MarkerImporter{ SceneID: sceneImporter.ID, Input: m, MissingRefBehaviour: t.MissingRefBehaviour, ReaderWriter: r.SceneMarker, TagWriter: r.Tag, } if err := performImport(ctx, markerImporter, t.DuplicateBehaviour); err != nil { return err } } return nil }); err != nil { logger.Errorf("[scenes] <%s> import failed: %v", fi.Name(), err) } } logger.Info("[scenes] import complete") } func (t *ImportTask) ImportImages(ctx context.Context) { logger.Info("[images] importing") path := t.json.json.Images files, err := os.ReadDir(path) if err != nil { if !errors.Is(err, os.ErrNotExist) { logger.Errorf("[images] failed to read images directory: %v", err) } return } r := t.repository for i, fi := range files { index := i + 1 logger.Progressf("[images] %d of %d", index, len(files)) imageJSON, err := jsonschema.LoadImageFile(filepath.Join(path, fi.Name())) if err != nil { logger.Infof("[images] <%s> json parse failure: %v", fi.Name(), err) continue } if err := r.WithTxn(ctx, func(ctx context.Context) error { imageImporter := &image.Importer{ ReaderWriter: r.Image, FileFinder: r.File, Input: *imageJSON, MissingRefBehaviour: t.MissingRefBehaviour, GalleryFinder: r.Gallery, PerformerWriter: r.Performer, StudioWriter: r.Studio, TagWriter: r.Tag, } return performImport(ctx, imageImporter, t.DuplicateBehaviour) }); err != nil { logger.Errorf("[images] <%s> import failed: %v", fi.Name(), err) } } logger.Info("[images] import complete") } func (t *ImportTask) ImportSavedFilters(ctx context.Context) { logger.Info("[saved filters] importing") path := t.json.json.SavedFilters files, err := os.ReadDir(path) if err != nil { if !errors.Is(err, os.ErrNotExist) { logger.Errorf("[saved filters] failed to read saved filters directory: %v", err) } return } r := t.repository for i, fi := range files { index := i + 1 savedFilterJSON, err := jsonschema.LoadSavedFilterFile(filepath.Join(path, fi.Name())) if err != nil { logger.Errorf("[saved filters] failed to read json: %v", err) continue } logger.Progressf("[saved filters] %d of %d", index, len(files)) if err := r.WithTxn(ctx, func(ctx context.Context) error { return t.importSavedFilter(ctx, savedFilterJSON) }); err != nil { logger.Errorf("[saved filters] <%s> failed to import: %v", fi.Name(), err) continue } } logger.Info("[saved filters] import complete") } func (t *ImportTask) importSavedFilter(ctx context.Context, savedFilterJSON *jsonschema.SavedFilter) error { importer := &savedfilter.Importer{ ReaderWriter: t.repository.SavedFilter, Input: *savedFilterJSON, MissingRefBehaviour: t.MissingRefBehaviour, } if err := performImport(ctx, importer, t.DuplicateBehaviour); err != nil { return err } return nil } ================================================ FILE: internal/manager/task_migrate_hash.go ================================================ package manager import ( "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" ) // MigrateHashTask renames generated files between oshash and MD5 based on the // value of the fileNamingAlgorithm flag. type MigrateHashTask struct { Scene *models.Scene fileNamingAlgorithm models.HashAlgorithm } // Start starts the task. func (t *MigrateHashTask) Start() { if t.Scene.OSHash == "" || t.Scene.Checksum == "" { // nothing to do return } oshash := t.Scene.OSHash checksum := t.Scene.Checksum oldHash := oshash newHash := checksum if t.fileNamingAlgorithm == models.HashAlgorithmOshash { oldHash = checksum newHash = oshash } scene.MigrateHash(instance.Paths, oldHash, newHash) } ================================================ FILE: internal/manager/task_optimise.go ================================================ package manager import ( "context" "fmt" "time" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" ) type Optimiser interface { Analyze(ctx context.Context) error Vacuum(ctx context.Context) error } type OptimiseDatabaseJob struct { Optimiser Optimiser } func (j *OptimiseDatabaseJob) Execute(ctx context.Context, progress *job.Progress) error { logger.Info("Optimising database") progress.SetTotal(2) start := time.Now() var err error progress.ExecuteTask("Analyzing database", func() { err = j.Optimiser.Analyze(ctx) progress.Increment() }) if job.IsCancelled(ctx) { logger.Info("Stopping due to user request") return nil } if err != nil { return fmt.Errorf("Error analyzing database: %w", err) } progress.ExecuteTask("Vacuuming database", func() { err = j.Optimiser.Vacuum(ctx) progress.Increment() }) if job.IsCancelled(ctx) { logger.Info("Stopping due to user request") return nil } if err != nil { return fmt.Errorf("error vacuuming database: %w", err) } elapsed := time.Since(start) logger.Infof("Finished optimising database after %s", elapsed) return nil } ================================================ FILE: internal/manager/task_plugin.go ================================================ package manager import ( "context" "fmt" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/plugin" ) func (s *Manager) RunPluginTask( ctx context.Context, pluginID string, taskName *string, description *string, args plugin.OperationInput, ) int { j := job.MakeJobExec(func(jobCtx context.Context, progress *job.Progress) error { pluginProgress := make(chan float64) task, err := s.PluginCache.CreateTask(ctx, pluginID, taskName, args, pluginProgress) if err != nil { return fmt.Errorf("Error creating plugin task: %w", err) } err = task.Start() if err != nil { return fmt.Errorf("Error running plugin task: %w", err) } done := make(chan bool) go func() { defer close(done) task.Wait() output := task.GetResult() if output == nil { logger.Debug("Plugin returned no result") } else { if output.Error != nil { logger.Errorf("Plugin returned error: %s", *output.Error) } else if output.Output != nil { logger.Debugf("Plugin returned: %v", output.Output) } } }() for { select { case <-done: return nil case p := <-pluginProgress: progress.SetPercent(p) case <-jobCtx.Done(): if err := task.Stop(); err != nil { logger.Errorf("Error stopping plugin operation: %s", err.Error()) } return nil } } }) displayName := pluginID if taskName != nil { displayName = *taskName } if description != nil { displayName = *description } return s.JobManager.Add(ctx, fmt.Sprintf("Running plugin task: %s", displayName), j) } ================================================ FILE: internal/manager/task_scan.go ================================================ package manager import ( "context" "errors" "fmt" "io/fs" "path/filepath" "regexp" "runtime/debug" "sync" "time" "github.com/99designs/gqlgen/graphql/handler/lru" "github.com/remeh/sizedwaitgroup" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/file/video" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/paths" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/scene/generate" "github.com/stashapp/stash/pkg/txn" "github.com/stashapp/stash/pkg/utils" ) type ScanJob struct { scanner *file.Scanner input ScanMetadataInput subscriptions *subscriptionManager fileQueue chan file.ScannedFile count int unmatchedCaptionFiles utils.MutexField[[]string] } func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error { cfg := config.GetInstance() input := j.input if job.IsCancelled(ctx) { logger.Info("Stopping due to user request") return nil } sp := getScanPaths(input.Paths) paths := make([]string, len(sp)) for i, p := range sp { paths[i] = p.Path } mgr := GetInstance() c := mgr.Config repo := mgr.Repository start := time.Now() nTasks := cfg.GetParallelTasksWithAutoDetection() const taskQueueSize = 200000 taskQueue := job.NewTaskQueue(ctx, progress, taskQueueSize, nTasks) var minModTime time.Time if j.input.Filter != nil && j.input.Filter.MinModTime != nil { minModTime = *j.input.Filter.MinModTime } // HACK - these should really be set in the scanner initialization j.scanner.FileHandlers = getScanHandlers(j.input, taskQueue, progress) j.scanner.ScanFilters = []file.PathFilter{newScanFilter(c, repo, minModTime)} j.scanner.HandlerRequiredFilters = []file.Filter{newHandlerRequiredFilter(cfg, repo)} logger.Infof("Starting scan of %d paths with %d parallel tasks", len(paths), nTasks) j.runJob(ctx, paths, nTasks, progress) taskQueue.Close() if job.IsCancelled(ctx) { logger.Info("Stopping due to user request") return nil } elapsed := time.Since(start) logger.Infof("Scan finished (%s)", elapsed) j.subscriptions.notify() return nil } func (j *ScanJob) runJob(ctx context.Context, paths []string, nTasks int, progress *job.Progress) { var wg sync.WaitGroup wg.Add(1) j.fileQueue = make(chan file.ScannedFile, scanQueueSize) go func() { defer func() { wg.Done() // handle panics in goroutine if p := recover(); p != nil { logger.Errorf("panic while queuing files for scan: %v", p) logger.Errorf(string(debug.Stack())) } }() if err := j.queueFiles(ctx, paths, progress); err != nil { if errors.Is(err, context.Canceled) { return } logger.Errorf("error queuing files for scan: %v", err) return } logger.Infof("Finished adding files to queue. %d files queued", j.count) }() defer wg.Wait() j.processQueue(ctx, nTasks, progress) } const scanQueueSize = 200000 func (j *ScanJob) queueFiles(ctx context.Context, paths []string, progress *job.Progress) error { fs := &file.OsFS{} defer func() { close(j.fileQueue) progress.AddTotal(j.count) progress.Definite() }() var err error progress.ExecuteTask("Walking directory tree", func() { for _, p := range paths { err = file.SymWalk(fs, p, j.queueFileFunc(ctx, fs, nil, progress)) if err != nil { return } } }) return err } func (j *ScanJob) queueFileFunc(ctx context.Context, f models.FS, zipFile *file.ScannedFile, progress *job.Progress) fs.WalkDirFunc { return func(path string, d fs.DirEntry, err error) error { if err != nil { // don't let errors prevent scanning logger.Errorf("error scanning %s: %v", path, err) return nil } if err = ctx.Err(); err != nil { return err } info, err := d.Info() if err != nil { logger.Errorf("reading info for %q: %v", path, err) return nil } zipFilePath := "" if zipFile != nil { zipFilePath = zipFile.Path } if !j.scanner.AcceptEntry(ctx, path, info, zipFilePath) { if info.IsDir() { logger.Debugf("Skipping directory %s", path) return fs.SkipDir } // we don't include caption files in the file scan, but we do need // to handle them if fsutil.MatchExtension(path, video.CaptionExts) { fileRepo := j.scanner.Repository.File matched := video.AssociateCaptions(ctx, path, j.scanner.Repository.TxnManager, fileRepo, fileRepo) if !matched { logger.Debugf("No matching video file found for caption file %s", path) j.unmatchedCaptionFiles.SetFunc(func(files []string) []string { return append(files, path) }) } return nil } logger.Debugf("Skipping file %s", path) return nil } size, err := file.GetFileSize(f, path, info) if err != nil { return err } ff := file.ScannedFile{ BaseFile: &models.BaseFile{ DirEntry: models.DirEntry{ ModTime: file.ModTime(info), }, Path: path, Basename: filepath.Base(path), Size: size, }, FS: f, Info: info, } if zipFile != nil { ff.ZipFileID = &zipFile.ID ff.ZipFile = zipFile } if info.IsDir() { // handle folders immediately if err := j.handleFolder(ctx, ff, progress); err != nil { if !errors.Is(err, context.Canceled) { logger.Errorf("error processing %q: %v", path, err) } // skip the directory since we won't be able to process the files anyway return fs.SkipDir } return nil } // if zip file is present, we handle immediately if zipFile != nil { progress.ExecuteTask("Scanning "+path, func() { // don't increment progress in zip files if err := j.handleFile(ctx, ff, nil); err != nil { if !errors.Is(err, context.Canceled) { logger.Errorf("error processing %q: %v", path, err) } // don't return an error, just skip the file } }) return nil } logger.Tracef("Queueing file %s for scanning", path) j.fileQueue <- ff j.count++ return nil } } func (j *ScanJob) processQueue(ctx context.Context, parallelTasks int, progress *job.Progress) { if parallelTasks < 1 { parallelTasks = 1 } wg := sizedwaitgroup.New(parallelTasks) func() { defer func() { wg.Wait() // handle panics in goroutine if p := recover(); p != nil { logger.Errorf("panic while scanning files: %v", p) logger.Errorf(string(debug.Stack())) } }() for f := range j.fileQueue { logger.Tracef("Processing queued file %s", f.Path) if err := ctx.Err(); err != nil { return } wg.Add() ff := f go func() { defer wg.Done() j.processQueueItem(ctx, ff, progress) }() } }() } func (j *ScanJob) processQueueItem(ctx context.Context, f file.ScannedFile, progress *job.Progress) { progress.ExecuteTask("Scanning "+f.Path, func() { var err error if f.Info.IsDir() { err = j.handleFolder(ctx, f, progress) } else { err = j.handleFile(ctx, f, progress) } if err != nil && !errors.Is(err, context.Canceled) { logger.Errorf("error processing %q: %v", f.Path, err) } }) } func (j *ScanJob) handleFolder(ctx context.Context, f file.ScannedFile, progress *job.Progress) error { if progress != nil { defer progress.Increment() } _, err := j.scanner.ScanFolder(ctx, f) if err != nil { return err } return nil } func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress *job.Progress) error { if progress != nil { defer progress.Increment() } r, err := j.scanner.ScanFile(ctx, f) if err != nil { return err } // if this is a new video file, match it with any unmatched caption files if r.New && len(j.unmatchedCaptionFiles.Get()) > 0 { videoFile, _ := r.File.(*models.VideoFile) if videoFile != nil { // try to match any unmatched caption files to this video file for _, captionPath := range j.unmatchedCaptionFiles.Get() { if video.MatchesCaption(videoFile.Path, captionPath) { video.AssociateCaptions(ctx, captionPath, j.scanner.Repository.TxnManager, j.scanner.Repository.File, j.scanner.Repository.File) // remove from the unmatched list j.unmatchedCaptionFiles.SetFunc(func(files []string) []string { newFiles := make([]string, 0, len(files)-1) for _, f := range files { if f != captionPath { newFiles = append(newFiles, f) } } return newFiles }) } } } } // clean captions - scene handler handles this as well, but // unchanged files aren't processed by the scene handler if r.IsUnchanged() { videoFile, _ := r.File.(*models.VideoFile) if videoFile != nil { txnMgr := j.scanner.Repository.TxnManager fileRepo := j.scanner.Repository.File if err := txn.WithDatabase(ctx, txnMgr, func(ctx context.Context) error { return video.CleanCaptions(ctx, videoFile, txnMgr, fileRepo) }); err != nil { logger.Errorf("Error cleaning captions: %v", err) } } } // handle rename should have already handled the contents of the zip file // so shouldn't need to scan it again. // Only scan zip contents if the file is new, the fingerprint changed, // or if a force rescan was requested. if j.scanner.IsZipFile(f.Info.Name()) && (r.New || r.FingerprintChanged || j.scanner.Rescan) { ff := r.File f.BaseFile = ff.Base() // scan zip files with a different context that is not cancellable // cancelling while scanning zip file contents results in the scan // contents being partially completed zipCtx := context.WithoutCancel(ctx) if err := j.scanZipFile(zipCtx, f, progress); err != nil { logger.Errorf("Error scanning zip file %q: %v", f.Path, err) } } else if r.Updated && j.scanner.IsZipFile(f.Info.Name()) { logger.Debugf("Skipping zip file scan for %q: fingerprint unchanged", f.Path) } return nil } func (j *ScanJob) scanZipFile(ctx context.Context, f file.ScannedFile, progress *job.Progress) error { zipFS, err := f.FS.OpenZip(f.Path, f.Size) if err != nil { if errors.Is(err, file.ErrNotReaderAt) { // can't walk the zip file // just return logger.Debugf("Skipping zip file %q as it cannot be opened for walking", f.Path) return nil } return err } defer zipFS.Close() return file.SymWalk(zipFS, f.Path, j.queueFileFunc(ctx, zipFS, &f, progress)) } type extensionConfig struct { vidExt []string imgExt []string zipExt []string } func newExtensionConfig(c *config.Config) extensionConfig { return extensionConfig{ vidExt: c.GetVideoExtensions(), imgExt: c.GetImageExtensions(), zipExt: c.GetGalleryExtensions(), } } type fileCounter interface { CountByFileID(ctx context.Context, fileID models.FileID) (int, error) } type galleryFinder interface { fileCounter FindByFolderID(ctx context.Context, folderID models.FolderID) ([]*models.Gallery, error) } type sceneFinder interface { fileCounter FindByPrimaryFileID(ctx context.Context, fileID models.FileID) ([]*models.Scene, error) } // handlerRequiredFilter returns true if a File's handler needs to be executed despite the file not being updated. type handlerRequiredFilter struct { extensionConfig txnManager txn.Manager SceneFinder sceneFinder ImageFinder fileCounter GalleryFinder galleryFinder FolderCache *lru.LRU[bool] videoFileNamingAlgorithm models.HashAlgorithm } func newHandlerRequiredFilter(c *config.Config, repo models.Repository) *handlerRequiredFilter { processes := c.GetParallelTasksWithAutoDetection() return &handlerRequiredFilter{ extensionConfig: newExtensionConfig(c), txnManager: repo.TxnManager, SceneFinder: repo.Scene, ImageFinder: repo.Image, GalleryFinder: repo.Gallery, FolderCache: lru.New[bool](processes * 2), videoFileNamingAlgorithm: c.GetVideoFileNamingAlgorithm(), } } func (f *handlerRequiredFilter) Accept(ctx context.Context, ff models.File) bool { path := ff.Base().Path isVideoFile := useAsVideo(path) isImageFile := useAsImage(path) isZipFile := fsutil.MatchExtension(path, f.zipExt) var counter fileCounter switch { case isVideoFile: // return true if there are no scenes associated counter = f.SceneFinder case isImageFile: counter = f.ImageFinder case isZipFile: counter = f.GalleryFinder } if counter == nil { return false } n, err := counter.CountByFileID(ctx, ff.Base().ID) if err != nil { // just ignore return false } // execute handler if there are no related objects if n == 0 { return true } // if create galleries from folder is enabled and the file is not in a zip // file, then check if there is a folder-based gallery for the file's // directory // #4611 - also check for .forcegallery if isImageFile && ff.Base().ZipFileID == nil { // only do this for the first time it encounters the folder // the first instance should create the gallery _, found := f.FolderCache.Get(ctx, ff.Base().ParentFolderID.String()) if found { // should already be handled return false } f.FolderCache.Add(ctx, ff.Base().ParentFolderID.String(), true) createGallery := instance.Config.GetCreateGalleriesFromFolders() if !createGallery { // check for presence of .forcegallery forceGalleryPath := filepath.Join(filepath.Dir(path), ".forcegallery") if exists, _ := fsutil.FileExists(forceGalleryPath); exists { createGallery = true } } if !createGallery { return false } g, _ := f.GalleryFinder.FindByFolderID(ctx, ff.Base().ParentFolderID) if len(g) == 0 { // no folder gallery. Return true so that it creates one. return true } } return false } type scanFilter struct { extensionConfig txnManager txn.Manager stashPaths config.StashConfigs generatedPath string videoExcludeRegex []*regexp.Regexp imageExcludeRegex []*regexp.Regexp minModTime time.Time stashIgnoreFilter *file.StashIgnoreFilter } func newScanFilter(c *config.Config, repo models.Repository, minModTime time.Time) *scanFilter { return &scanFilter{ extensionConfig: newExtensionConfig(c), txnManager: repo.TxnManager, stashPaths: c.GetStashPaths(), generatedPath: c.GetGeneratedPath(), videoExcludeRegex: generateRegexps(c.GetExcludes()), imageExcludeRegex: generateRegexps(c.GetImageExcludes()), minModTime: minModTime, stashIgnoreFilter: file.NewStashIgnoreFilter(), } } func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool { if fsutil.IsPathInDir(f.generatedPath, path) { logger.Warnf("Skipping %q as it overlaps with the generated folder", path) return false } // exit early on cutoff if info.Mode().IsRegular() && info.ModTime().Before(f.minModTime) { return false } s := f.stashPaths.GetStashFromDirPath(path) if s == nil { logger.Debugf("Skipping %s as it is not in the stash library", path) return false } // Check .stashignore files, bounded to the library root. if !f.stashIgnoreFilter.Accept(ctx, path, info, s.Path, zipFilePath) { logger.Debugf("Skipping %s due to .stashignore", path) return false } isVideoFile := useAsVideo(path) isImageFile := useAsImage(path) isZipFile := fsutil.MatchExtension(path, f.zipExt) if !info.IsDir() && !isVideoFile && !isImageFile && !isZipFile { logger.Debugf("Skipping %s as it does not match any known file extensions", path) return false } // #1756 - skip zero length files if !info.IsDir() && info.Size() == 0 { logger.Infof("Skipping zero-length file: %s", path) return false } // shortcut: skip the directory entirely if it matches both exclusion patterns // add a trailing separator so that it correctly matches against patterns like path/.* pathExcludeTest := path + string(filepath.Separator) if (matchFileRegex(pathExcludeTest, f.videoExcludeRegex)) && (s.ExcludeImage || matchFileRegex(pathExcludeTest, f.imageExcludeRegex)) { logger.Debugf("Skipping directory %s as it matches video and image exclusion patterns", path) return false } if isVideoFile && (s.ExcludeVideo || matchFileRegex(path, f.videoExcludeRegex)) { logger.Debugf("Skipping %s as it matches video exclusion patterns", path) return false } else if (isImageFile || isZipFile) && (s.ExcludeImage || matchFileRegex(path, f.imageExcludeRegex)) { logger.Debugf("Skipping %s as it matches image exclusion patterns", path) return false } return true } type scanConfig struct { isGenerateThumbnails bool isGenerateClipPreviews bool createGalleriesFromFolders bool } func (c *scanConfig) GetCreateGalleriesFromFolders() bool { return c.createGalleriesFromFolders } func videoFileFilter(ctx context.Context, f models.File) bool { return useAsVideo(f.Base().Path) } func imageFileFilter(ctx context.Context, f models.File) bool { return useAsImage(f.Base().Path) } func galleryFileFilter(ctx context.Context, f models.File) bool { return isZip(f.Base().Basename) } func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progress *job.Progress) []file.Handler { mgr := GetInstance() c := mgr.Config r := mgr.Repository pluginCache := mgr.PluginCache return []file.Handler{ &file.FilteredHandler{ Filter: file.FilterFunc(imageFileFilter), Handler: &image.ScanHandler{ CreatorUpdater: r.Image, GalleryFinder: r.Gallery, SceneFinderUpdater: r.Scene, ScanGenerator: &imageGenerators{ input: options, taskQueue: taskQueue, progress: progress, paths: mgr.Paths, sequentialScanning: c.GetSequentialScanning(), }, ScanConfig: &scanConfig{ isGenerateThumbnails: options.ScanGenerateThumbnails, isGenerateClipPreviews: options.ScanGenerateClipPreviews, createGalleriesFromFolders: c.GetCreateGalleriesFromFolders(), }, PluginCache: pluginCache, Paths: instance.Paths, }, }, &file.FilteredHandler{ Filter: file.FilterFunc(galleryFileFilter), Handler: &gallery.ScanHandler{ CreatorUpdater: r.Gallery, SceneFinderUpdater: r.Scene, ImageFinderUpdater: r.Image, PluginCache: pluginCache, }, }, &file.FilteredHandler{ Filter: file.FilterFunc(videoFileFilter), Handler: &scene.ScanHandler{ CreatorUpdater: r.Scene, GalleryFinderUpdater: r.Gallery, CaptionUpdater: r.File, PluginCache: pluginCache, ScanGenerator: &sceneGenerators{ input: options, taskQueue: taskQueue, progress: progress, paths: mgr.Paths, fileNamingAlgorithm: c.GetVideoFileNamingAlgorithm(), sequentialScanning: c.GetSequentialScanning(), }, FileNamingAlgorithm: c.GetVideoFileNamingAlgorithm(), Paths: mgr.Paths, }, }, } } type imageGenerators struct { input ScanMetadataInput taskQueue *job.TaskQueue progress *job.Progress paths *paths.Paths sequentialScanning bool } func (g *imageGenerators) Generate(ctx context.Context, i *models.Image, f models.File) error { const overwrite = false progress := g.progress t := g.input path := f.Base().Path // this is a bit of a hack: the task requires files to be loaded, but // we don't really need to since we already have the file ii := *i ii.Files = models.NewRelatedFiles([]models.File{f}) if t.ScanGenerateThumbnails { // this should be quick, so always generate sequentially taskThumbnail := GenerateImageThumbnailTask{ Image: ii, Overwrite: overwrite, } taskThumbnail.Start(ctx) } // avoid adding a task if the file isn't a video file _, isVideo := f.(*models.VideoFile) if isVideo && t.ScanGenerateClipPreviews { progress.AddTotal(1) previewsFn := func(ctx context.Context) { taskPreview := GenerateClipPreviewTask{ Image: ii, Overwrite: overwrite, } taskPreview.Start(ctx) progress.Increment() } if g.sequentialScanning { previewsFn(ctx) } else { g.taskQueue.Add(fmt.Sprintf("Generating preview for %s", path), previewsFn) } } if t.ScanGenerateImagePhashes { progress.AddTotal(1) phashFn := func(ctx context.Context) { mgr := GetInstance() // Only generate phash for image files, not video files if imageFile, ok := f.(*models.ImageFile); ok { taskPhash := GenerateImagePhashTask{ repository: mgr.Repository, File: imageFile, Overwrite: overwrite, } taskPhash.Start(ctx) } progress.Increment() } if g.sequentialScanning { phashFn(ctx) } else { g.taskQueue.Add(fmt.Sprintf("Generating phash for %s", path), phashFn) } } return nil } type sceneGenerators struct { input ScanMetadataInput taskQueue *job.TaskQueue progress *job.Progress paths *paths.Paths fileNamingAlgorithm models.HashAlgorithm sequentialScanning bool } func (g *sceneGenerators) Generate(ctx context.Context, s *models.Scene, f *models.VideoFile) error { const overwrite = false progress := g.progress t := g.input path := f.Path mgr := GetInstance() if t.ScanGenerateSprites { progress.AddTotal(1) spriteFn := func(ctx context.Context) { taskSprite := GenerateSpriteTask{ Scene: *s, Overwrite: overwrite, fileNamingAlgorithm: g.fileNamingAlgorithm, } taskSprite.Start(ctx) progress.Increment() } if g.sequentialScanning { spriteFn(ctx) } else { g.taskQueue.Add(fmt.Sprintf("Generating sprites for %s", path), spriteFn) } } if t.ScanGeneratePhashes { progress.AddTotal(1) phashFn := func(ctx context.Context) { taskPhash := GeneratePhashTask{ repository: mgr.Repository, File: f, Overwrite: overwrite, fileNamingAlgorithm: g.fileNamingAlgorithm, } taskPhash.Start(ctx) progress.Increment() } if g.sequentialScanning { phashFn(ctx) } else { g.taskQueue.Add(fmt.Sprintf("Generating phash for %s", path), phashFn) } } if t.ScanGeneratePreviews { progress.AddTotal(1) previewsFn := func(ctx context.Context) { options := getGeneratePreviewOptions(GeneratePreviewOptionsInput{}) generator := &generate.Generator{ Encoder: mgr.FFMpeg, FFMpegConfig: mgr.Config, LockManager: mgr.ReadLockManager, MarkerPaths: g.paths.SceneMarkers, ScenePaths: g.paths.Scene, Overwrite: overwrite, } taskPreview := GeneratePreviewTask{ Scene: *s, ImagePreview: t.ScanGenerateImagePreviews, Options: options, Overwrite: overwrite, fileNamingAlgorithm: g.fileNamingAlgorithm, generator: generator, } taskPreview.Start(ctx) progress.Increment() } if g.sequentialScanning { previewsFn(ctx) } else { g.taskQueue.Add(fmt.Sprintf("Generating preview for %s", path), previewsFn) } } if t.ScanGenerateCovers { progress.AddTotal(1) g.taskQueue.Add(fmt.Sprintf("Generating cover for %s", path), func(ctx context.Context) { taskCover := GenerateCoverTask{ repository: mgr.Repository, Scene: *s, Overwrite: overwrite, } taskCover.Start(ctx) progress.Increment() }) } return nil } ================================================ FILE: internal/manager/task_stash_box_tag.go ================================================ package manager import ( "context" "fmt" "strconv" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/stashbox" "github.com/stashapp/stash/pkg/studio" "github.com/stashapp/stash/pkg/tag" ) // stashBoxBatchPerformerTagTask is used to tag or create performers from stash-box. // // Two modes of operation: // - Update existing performer: set performer to update from stash-box data // - Create new performer: set name or stashID to search stash-box and create locally type stashBoxBatchPerformerTagTask struct { box *models.StashBox name *string stashID *string performer *models.Performer excludedFields []string } func (t *stashBoxBatchPerformerTagTask) getName() string { switch { case t.name != nil: return *t.name case t.stashID != nil: return *t.stashID case t.performer != nil: return t.performer.Name default: return "" } } func (t *stashBoxBatchPerformerTagTask) Start(ctx context.Context) { performer, err := t.findStashBoxPerformer(ctx) if err != nil { logger.Errorf("Error fetching performer data from stash-box: %v", err) return } excluded := map[string]bool{} for _, field := range t.excludedFields { excluded[field] = true } if performer != nil { t.processMatchedPerformer(ctx, performer, excluded) } else { logger.Infof("No match found for %s", t.getName()) } } func (t *stashBoxBatchPerformerTagTask) GetDescription() string { return fmt.Sprintf("Tagging performer %s from stash-box", t.getName()) } func (t *stashBoxBatchPerformerTagTask) findStashBoxPerformer(ctx context.Context) (*models.ScrapedPerformer, error) { var performer *models.ScrapedPerformer var err error r := instance.Repository client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns())) switch { case t.name != nil: performer, err = client.FindPerformerByName(ctx, *t.name) case t.stashID != nil: performer, err = client.FindPerformerByID(ctx, *t.stashID) if performer != nil && performer.RemoteMergedIntoId != nil { mergedPerformer, err := t.handleMergedPerformer(ctx, performer, client) if err != nil { return nil, err } if mergedPerformer != nil { logger.Infof("Performer id %s merged into %s, updating local performer", *t.stashID, *performer.RemoteMergedIntoId) performer = mergedPerformer } } case t.performer != nil: // tagging or updating existing performer var remoteID string if err := r.WithReadTxn(ctx, func(ctx context.Context) error { qb := r.Performer if !t.performer.StashIDs.Loaded() { err = t.performer.LoadStashIDs(ctx, qb) if err != nil { return err } } for _, id := range t.performer.StashIDs.List() { if id.Endpoint == t.box.Endpoint { remoteID = id.StashID } } return nil }); err != nil { return nil, err } if remoteID != "" { performer, err = client.FindPerformerByID(ctx, remoteID) if performer != nil && performer.RemoteMergedIntoId != nil { mergedPerformer, err := t.handleMergedPerformer(ctx, performer, client) if err != nil { return nil, err } if mergedPerformer != nil { logger.Infof("Performer id %s merged into %s, updating local performer", remoteID, *performer.RemoteMergedIntoId) performer = mergedPerformer } } } else { // find by performer name instead performer, err = client.FindPerformerByName(ctx, t.performer.Name) } } if performer != nil { if err := r.WithReadTxn(ctx, func(ctx context.Context) error { return match.ScrapedPerformer(ctx, r.Performer, performer, t.box.Endpoint) }); err != nil { return nil, err } } return performer, err } func (t *stashBoxBatchPerformerTagTask) handleMergedPerformer(ctx context.Context, performer *models.ScrapedPerformer, client *stashbox.Client) (mergedPerformer *models.ScrapedPerformer, err error) { mergedPerformer, err = client.FindPerformerByID(ctx, *performer.RemoteMergedIntoId) if err != nil { return nil, fmt.Errorf("loading merged performer %s from stashbox", *performer.RemoteMergedIntoId) } if mergedPerformer.StoredID != nil && *mergedPerformer.StoredID != *performer.StoredID { logger.Warnf("Performer %s merged into %s, but both exist locally, not merging", *performer.StoredID, *mergedPerformer.StoredID) return nil, nil } mergedPerformer.StoredID = performer.StoredID return mergedPerformer, nil } func (t *stashBoxBatchPerformerTagTask) processMatchedPerformer(ctx context.Context, p *models.ScrapedPerformer, excluded map[string]bool) { if t.performer != nil { storedID, _ := strconv.Atoi(*p.StoredID) image, err := p.GetImage(ctx, excluded) if err != nil { logger.Errorf("Error processing scraped performer image for %s: %v", *p.Name, err) return } r := instance.Repository err = r.WithTxn(ctx, func(ctx context.Context) error { qb := r.Performer existingStashIDs, err := qb.GetStashIDs(ctx, storedID) if err != nil { return err } partial := p.ToPartial(t.box.Endpoint, excluded, existingStashIDs) // if we're setting the performer's aliases, and not the name, then filter out the name // from the aliases to avoid duplicates // add the name to the aliases if it's not already there if partial.Aliases != nil && !partial.Name.Set { partial.Aliases.Values = sliceutil.Filter(partial.Aliases.Values, func(s string) bool { return s != t.performer.Name }) if p.Name != nil && t.performer.Name != *p.Name { partial.Aliases.Values = sliceutil.AppendUnique(partial.Aliases.Values, *p.Name) } } if err := performer.ValidateUpdate(ctx, t.performer.ID, partial, qb); err != nil { return err } if _, err := qb.UpdatePartial(ctx, t.performer.ID, partial); err != nil { return err } if len(image) > 0 { if err := qb.UpdateImage(ctx, t.performer.ID, image); err != nil { return err } } return nil }) if err != nil { logger.Errorf("Failed to update performer %s: %v", *p.Name, err) } else { logger.Infof("Updated performer %s", *p.Name) } } else { // no existing performer, create a new one newPerformer := p.ToPerformer(t.box.Endpoint, excluded) image, err := p.GetImage(ctx, excluded) if err != nil { logger.Errorf("Error processing scraped performer image for %s: %v", *p.Name, err) return } r := instance.Repository err = r.WithTxn(ctx, func(ctx context.Context) error { qb := r.Performer if err := performer.ValidateCreate(ctx, *newPerformer, qb); err != nil { return err } if err := qb.Create(ctx, &models.CreatePerformerInput{Performer: newPerformer}); err != nil { return err } if len(image) > 0 { if err := qb.UpdateImage(ctx, newPerformer.ID, image); err != nil { return err } } return nil }) if err != nil { logger.Errorf("Failed to create performer %s: %v", *p.Name, err) } else { logger.Infof("Created performer %s", *p.Name) } } } // stashBoxBatchStudioTagTask is used to tag or create studios from stash-box. // // Two modes of operation: // - Update existing studio: set studio to update from stash-box data // - Create new studio: set name or stashID to search stash-box and create locally type stashBoxBatchStudioTagTask struct { box *models.StashBox name *string stashID *string studio *models.Studio createParent bool excludedFields []string } func (t *stashBoxBatchStudioTagTask) getName() string { switch { case t.name != nil: return *t.name case t.stashID != nil: return *t.stashID case t.studio != nil: return t.studio.Name default: return "" } } func (t *stashBoxBatchStudioTagTask) Start(ctx context.Context) { // Skip organized studios if t.studio != nil && t.studio.Organized { logger.Infof("Skipping organized studio %s", t.studio.Name) return } studio, err := t.findStashBoxStudio(ctx) if err != nil { logger.Errorf("Error fetching studio data from stash-box: %v", err) return } excluded := map[string]bool{} for _, field := range t.excludedFields { excluded[field] = true } if studio != nil { t.processMatchedStudio(ctx, studio, excluded) } else { logger.Infof("No match found for %s", t.getName()) } } func (t *stashBoxBatchStudioTagTask) GetDescription() string { return fmt.Sprintf("Tagging studio %s from stash-box", t.getName()) } func (t *stashBoxBatchStudioTagTask) findStashBoxStudio(ctx context.Context) (*models.ScrapedStudio, error) { var studio *models.ScrapedStudio var err error r := instance.Repository client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns())) switch { case t.name != nil: studio, err = client.FindStudio(ctx, *t.name) case t.stashID != nil: studio, err = client.FindStudio(ctx, *t.stashID) case t.studio != nil: var remoteID string if err := r.WithReadTxn(ctx, func(ctx context.Context) error { if !t.studio.StashIDs.Loaded() { err = t.studio.LoadStashIDs(ctx, r.Studio) if err != nil { return err } } for _, id := range t.studio.StashIDs.List() { if id.Endpoint == t.box.Endpoint { remoteID = id.StashID } } return nil }); err != nil { return nil, err } if remoteID != "" { studio, err = client.FindStudio(ctx, remoteID) } else { // find by studio name instead studio, err = client.FindStudio(ctx, t.studio.Name) } } if err := r.WithReadTxn(ctx, func(ctx context.Context) error { if studio != nil { if err := match.ScrapedStudioHierarchy(ctx, r.Studio, studio, t.box.Endpoint); err != nil { return err } } return nil }); err != nil { return nil, err } return studio, err } func (t *stashBoxBatchStudioTagTask) processMatchedStudio(ctx context.Context, s *models.ScrapedStudio, excluded map[string]bool) { if t.studio != nil { storedID, _ := strconv.Atoi(*s.StoredID) if s.Parent != nil && t.createParent { err := t.processParentStudio(ctx, s.Parent, excluded) if err != nil { return } } image, err := s.GetImage(ctx, excluded) if err != nil { logger.Errorf("Error processing scraped studio image for %s: %v", s.Name, err) return } r := instance.Repository err = r.WithTxn(ctx, func(ctx context.Context) error { qb := r.Studio existingStashIDs, err := qb.GetStashIDs(ctx, storedID) if err != nil { return err } partial := s.ToPartial(*s.StoredID, t.box.Endpoint, excluded, existingStashIDs) if err := studio.ValidateModify(ctx, partial, qb); err != nil { return err } if _, err := qb.UpdatePartial(ctx, partial); err != nil { return err } if len(image) > 0 { if err := qb.UpdateImage(ctx, partial.ID, image); err != nil { return err } } return nil }) if err != nil { logger.Errorf("Failed to update studio %s: %v", s.Name, err) } else { logger.Infof("Updated studio %s", s.Name) } } else if s.Name != "" { // no existing studio, create a new one if s.Parent != nil && t.createParent { err := t.processParentStudio(ctx, s.Parent, excluded) if err != nil { return } } newStudio := s.ToStudio(t.box.Endpoint, excluded) studioImage, err := s.GetImage(ctx, excluded) if err != nil { logger.Errorf("Error processing scraped studio image for %s: %v", s.Name, err) return } r := instance.Repository err = r.WithTxn(ctx, func(ctx context.Context) error { qb := r.Studio if err := studio.ValidateCreate(ctx, *newStudio, qb); err != nil { return err } if err := qb.Create(ctx, newStudio); err != nil { return err } if len(studioImage) > 0 { if err := qb.UpdateImage(ctx, newStudio.ID, studioImage); err != nil { return err } } return nil }) if err != nil { logger.Errorf("Failed to create studio %s: %v", s.Name, err) } else { logger.Infof("Created studio %s", s.Name) } } } func (t *stashBoxBatchStudioTagTask) processParentStudio(ctx context.Context, parent *models.ScrapedStudio, excluded map[string]bool) error { if parent.StoredID == nil { newParentStudio := parent.ToStudio(t.box.Endpoint, excluded) image, err := parent.GetImage(ctx, excluded) if err != nil { logger.Errorf("Error processing scraped studio image for %s: %v", parent.Name, err) return err } r := instance.Repository err = r.WithTxn(ctx, func(ctx context.Context) error { qb := r.Studio if err := qb.Create(ctx, newParentStudio); err != nil { return err } if len(image) > 0 { if err := qb.UpdateImage(ctx, newParentStudio.ID, image); err != nil { return err } } storedId := strconv.Itoa(newParentStudio.ID) parent.StoredID = &storedId return nil }) if err != nil { logger.Errorf("Failed to create studio %s: %v", parent.Name, err) } else { logger.Infof("Created studio %s", parent.Name) } return err } else { storedID, _ := strconv.Atoi(*parent.StoredID) image, err := parent.GetImage(ctx, excluded) if err != nil { logger.Errorf("Error processing scraped studio image for %s: %v", parent.Name, err) return err } r := instance.Repository err = r.WithTxn(ctx, func(ctx context.Context) error { qb := r.Studio existingStashIDs, err := qb.GetStashIDs(ctx, storedID) if err != nil { return err } partial := parent.ToPartial(*parent.StoredID, t.box.Endpoint, excluded, existingStashIDs) if err := studio.ValidateModify(ctx, partial, qb); err != nil { return err } if _, err := qb.UpdatePartial(ctx, partial); err != nil { return err } if len(image) > 0 { if err := qb.UpdateImage(ctx, partial.ID, image); err != nil { return err } } return nil }) if err != nil { logger.Errorf("Failed to update studio %s: %v", parent.Name, err) } else { logger.Infof("Updated studio %s", parent.Name) } return err } } // stashBoxBatchTagTagTask is used to tag or create tags from stash-box. // // Two modes of operation: // - Update existing tag: set tag to update from stash-box data // - Create new tag: set name or stashID to search stash-box and create locally type stashBoxBatchTagTagTask struct { box *models.StashBox name *string stashID *string tag *models.Tag createParent bool excludedFields []string } func (t *stashBoxBatchTagTagTask) getName() string { switch { case t.name != nil: return *t.name case t.stashID != nil: return *t.stashID case t.tag != nil: return t.tag.Name default: return "" } } func (t *stashBoxBatchTagTagTask) Start(ctx context.Context) { scrapedTag, err := t.findStashBoxTag(ctx) if err != nil { logger.Errorf("Error fetching tag data from stash-box: %v", err) return } excluded := map[string]bool{} for _, field := range t.excludedFields { excluded[field] = true } if scrapedTag != nil { t.processMatchedTag(ctx, scrapedTag, excluded) } else { logger.Infof("No match found for %s", t.getName()) } } func (t *stashBoxBatchTagTagTask) GetDescription() string { return fmt.Sprintf("Tagging tag %s from stash-box", t.getName()) } func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.ScrapedTag, error) { var results []*models.ScrapedTag var err error r := instance.Repository client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns())) switch { case t.name != nil: results, err = client.QueryTag(ctx, *t.name) case t.stashID != nil: results, err = client.QueryTag(ctx, *t.stashID) case t.tag != nil: var remoteID string if err := r.WithReadTxn(ctx, func(ctx context.Context) error { if !t.tag.StashIDs.Loaded() { err = t.tag.LoadStashIDs(ctx, r.Tag) if err != nil { return err } } for _, id := range t.tag.StashIDs.List() { if id.Endpoint == t.box.Endpoint { remoteID = id.StashID } } return nil }); err != nil { return nil, err } if remoteID != "" { results, err = client.QueryTag(ctx, remoteID) } else { results, err = client.QueryTag(ctx, t.tag.Name) } } if err != nil { return nil, err } if len(results) == 0 { return nil, nil } result := results[0] if err := r.WithReadTxn(ctx, func(ctx context.Context) error { return match.ScrapedTagHierarchy(ctx, r.Tag, result, t.box.Endpoint) }); err != nil { return nil, err } return result, nil } func (t *stashBoxBatchTagTagTask) processParentTag(ctx context.Context, parent *models.ScrapedTag, excluded map[string]bool) error { if parent.StoredID == nil { // Create new parent tag newParentTag := parent.ToTag(t.box.Endpoint, excluded) r := instance.Repository err := r.WithTxn(ctx, func(ctx context.Context) error { qb := r.Tag if err := tag.ValidateCreate(ctx, *newParentTag, qb); err != nil { return err } if err := qb.Create(ctx, &models.CreateTagInput{Tag: newParentTag}); err != nil { return err } storedID := strconv.Itoa(newParentTag.ID) parent.StoredID = &storedID return nil }) if err != nil { logger.Errorf("Failed to create parent tag %s: %v", parent.Name, err) } else { logger.Infof("Created parent tag %s", parent.Name) } return err } // Parent already exists — nothing to update for categories return nil } func (t *stashBoxBatchTagTagTask) processMatchedTag(ctx context.Context, s *models.ScrapedTag, excluded map[string]bool) { // Determine the tag ID to update — either from the task's tag or from the // StoredID set by match.ScrapedTag (when batch adding by name and the tag // already exists locally). tagID := 0 if t.tag != nil { tagID = t.tag.ID } else if s.StoredID != nil { tagID, _ = strconv.Atoi(*s.StoredID) } if s.Parent != nil && t.createParent { if err := t.processParentTag(ctx, s.Parent, excluded); err != nil { return } } if tagID > 0 { r := instance.Repository err := r.WithTxn(ctx, func(ctx context.Context) error { qb := r.Tag existingStashIDs, err := qb.GetStashIDs(ctx, tagID) if err != nil { return err } storedID := strconv.Itoa(tagID) partial := s.ToPartial(storedID, t.box.Endpoint, excluded, existingStashIDs) if err := tag.ValidateUpdate(ctx, tagID, partial, qb); err != nil { return err } if _, err := qb.UpdatePartial(ctx, tagID, partial); err != nil { return err } return nil }) if err != nil { logger.Errorf("Failed to update tag %s: %v", s.Name, err) } else { logger.Infof("Updated tag %s", s.Name) } } else if s.Name != "" { // no existing tag, create a new one newTag := s.ToTag(t.box.Endpoint, excluded) r := instance.Repository err := r.WithTxn(ctx, func(ctx context.Context) error { qb := r.Tag if err := tag.ValidateCreate(ctx, *newTag, qb); err != nil { return err } if err := qb.Create(ctx, &models.CreateTagInput{Tag: newTag}); err != nil { return err } return nil }) if err != nil { logger.Errorf("Failed to create tag %s: %v", s.Name, err) } else { logger.Infof("Created tag %s", s.Name) } } } ================================================ FILE: internal/manager/task_transcode.go ================================================ package manager import ( "context" "fmt" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene/generate" ) type GenerateTranscodeTask struct { Scene models.Scene Overwrite bool fileNamingAlgorithm models.HashAlgorithm // is true, generate even if video is browser-supported Force bool g *generate.Generator } func (t *GenerateTranscodeTask) GetDescription() string { return fmt.Sprintf("Generating transcode for %s", t.Scene.Path) } func (t *GenerateTranscodeTask) Start(ctx context.Context) { hasTranscode := HasTranscode(&t.Scene, t.fileNamingAlgorithm) if !t.Overwrite && hasTranscode { return } f := t.Scene.Files.Primary() ffprobe := instance.FFProbe var container ffmpeg.Container var err error container, err = GetVideoFileContainer(f) if err != nil { logger.Errorf("[transcode] error getting scene container: %s", err.Error()) return } var videoCodec string if f.VideoCodec != "" { videoCodec = f.VideoCodec } audioCodec := ffmpeg.MissingUnsupported if f.AudioCodec != "" { audioCodec = ffmpeg.ProbeAudioCodec(f.AudioCodec) } if !t.Force && ffmpeg.IsStreamable(videoCodec, audioCodec, container) == nil { return } // TODO - move transcode generation logic elsewhere videoFile, err := ffprobe.NewVideoFile(f.Path) if err != nil { logger.Errorf("[transcode] error reading video file: %s", err.Error()) return } sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) transcodeSize := config.GetInstance().GetMaxTranscodeSize() w, h := videoFile.TranscodeScale(transcodeSize.GetMaxResolution()) // if scale is being set, then we can't use stream copy scaleSet := w == 0 && h == 0 if scaleSet && videoCodec == ffmpeg.H264 { // for non supported h264 files stream copy the video part if audioCodec == ffmpeg.MissingUnsupported { err = t.g.TranscodeCopyVideo(ctx, videoFile.Path, sceneHash) } else { err = t.g.TranscodeAudio(ctx, videoFile.Path, sceneHash) } } else { options := generate.TranscodeOptions{ Width: w, Height: h, } if audioCodec == ffmpeg.MissingUnsupported { // ffmpeg fails if it tries to transcode an unsupported audio codec err = t.g.TranscodeVideo(ctx, videoFile.Path, sceneHash, options) } else { err = t.g.Transcode(ctx, videoFile.Path, sceneHash, options) } } if err != nil { logger.Errorf("[transcode] error generating transcode: %v", err) return } } // return true if transcode is needed // used only when counting files to generate, doesn't affect the actual transcode generation // if container is missing from DB it is treated as non supported in order not to delay the user func (t *GenerateTranscodeTask) required() bool { f := t.Scene.Files.Primary() if f == nil { return false } hasTranscode := HasTranscode(&t.Scene, t.fileNamingAlgorithm) if !t.Overwrite && hasTranscode { return false } if t.Force { return true } var videoCodec string if f.VideoCodec != "" { videoCodec = f.VideoCodec } container := "" audioCodec := ffmpeg.MissingUnsupported if f.AudioCodec != "" { audioCodec = ffmpeg.ProbeAudioCodec(f.AudioCodec) } if f.Format != "" { container = f.Format } if ffmpeg.IsStreamable(videoCodec, audioCodec, ffmpeg.Container(container)) == nil { return false } return true } ================================================ FILE: internal/static/embed.go ================================================ // Package static provides the static files embedded in the application. package static import ( "embed" "fmt" "io" "io/fs" ) //go:embed performer performer_male performer_sfw scene image gallery tag studio group var data embed.FS const ( Performer = "performer" PerformerMale = "performer_male" DefaultSFWPerformerImage = "performer_sfw/performer.svg" Scene = "scene" DefaultSceneImage = "scene/scene.svg" Image = "image" DefaultImageImage = "image/image.svg" Gallery = "gallery" DefaultGalleryImage = "gallery/gallery.svg" Tag = "tag" DefaultTagImage = "tag/tag.svg" Studio = "studio" DefaultStudioImage = "studio/studio.svg" Group = "group" DefaultGroupImage = "group/group.svg" ) // Sub returns an FS rooted at path, using fs.Sub. // It will panic if an error occurs. func Sub(path string) fs.FS { ret, err := fs.Sub(data, path) if err != nil { panic(fmt.Sprintf("creating static SubFS: %v", err)) } return ret } // Open opens the file at path for reading. // It will panic if an error occurs. func Open(path string) fs.File { f, err := data.Open(path) if err != nil { panic(fmt.Sprintf("opening static file: %v", err)) } return f } // ReadAll returns the contents of the file at path. // It will panic if an error occurs. func ReadAll(path string) []byte { f := Open(path) ret, err := io.ReadAll(f) if err != nil { panic(fmt.Sprintf("reading static file: %v", err)) } return ret } ================================================ FILE: internal/static/performer/attribution.md ================================================ NoName02.svg - "[Exotic dancer silhouette](https://freesvg.org/exotic-dancer-silhouette)" by OpenClipart-Vectors under CC0 License NoName05.svg - "[Fashion girl silhouette](https://creazilla.com/media/silhouette/76433/fashion-girl)" by Creazilla under CC0 License NoName06.png - "[Woman, Female, Girl](https://pixabay.com/illustrations/woman-female-girl-lady-silhouette-163525/)" by No-longer-here under Pixabay License NoName07.svg - "[Woman Silhouette 11](https://openclipart.org/detail/14083/woman-silhouette-11)" by nicubunu under CC0 License NoName09.svg - "[Girl, Pose, Posing](https://pixabay.com/vectors/girl-pose-posing-female-woman-311535/)" by Clker-Free-Vector-Images under CC0 License NoName11.png - "[Alpha Mask, Silhouette, Woman](https://pixabay.com/illustrations/alpha-mask-silhouette-woman-girl-3072470/)" by Wolfgang Eckert under Pixabay License NoName12.svg - "[Dance, Dancer, Dancing](https://pixabay.com/vectors/dance-dancer-dancing-female-girl-2023863/)" by OpenClipart-Vectors under CC0 License NoName13.svg - "[Dress, Silhouette, Woman](https://pixabay.com/vectors/dress-silhouette-woman-female-148745/)" by OpenClipart-Vectors under CC0 License NoName14.svg - "[Woman in long dress silhouette](https://freesvg.org/woman-in-long-dress-silhouette)" by OpenClipart-Vectors under CC0 License NoName17.svg - "[Female Model silhouette](https://creazilla.com/media/silhouette/2495/female-model)" by Natasha Sinegina under CC-BY-4.0 NoName19.svg - "[Female, Girl, Heel](https://pixabay.com/vectors/female-girl-heel-silhouette-woman-2023898/)" by OpenClipart-Vectors under CC0 License NoName21.svg - "[Lady, Silhouette, Woman](https://pixabay.com/vectors/lady-silhouette-woman-pink-296698/)" by Clker-Free-Vector-Images under CC0 License NoName22.svg - "[Female, Girl, Heel](https://pixabay.com/vectors/female-girl-heel-silhouette-woman-2023856/)" by OpenClipart-Vectors under CC0 License NoName23.svg - "[Woman, Female, Figure](https://pixabay.com/vectors/woman-female-figure-slender-slim-149723/)" by OpenClipart-Vectors under CC0 License NoName24.svg - "[Silhouette, Woman, Bunny](https://pixabay.com/illustrations/silhouette-woman-bunny-girl-female-3196716/)" by Wolfgang Eckert under Pixabay License NoName25.svg - "[Female, Girl, Silhouette](https://pixabay.com/vectors/female-girl-silhouette-woman-2023857/)" by OpenClipart-Vectors under CC0 License NoName26.svg - "[Female, Girl, Silhouette](https://pixabay.com/vectors/female-girl-silhouette-woman-2024047/)" by OpenClipart-Vectors under CC0 License NoName27.svg - "[Woman, School Clothes, Uniform](https://pixabay.com/illustrations/woman-school-clothes-uniform-644569/)" by Silvia under Pixabay License NoName28.svg - "[Girl, Woman, Feminine](https://pixabay.com/illustrations/girl-woman-feminine-sensual-1369733/)" by Calzas under Pixabay License NoName29.png - "[Alpha Mask, Silhouette, Woman](https://pixabay.com/illustrations/alpha-mask-silhouette-woman-girl-3066005/)" by Wolfgang Eckert under Pixabay License NoName30.svg - "[Architetto](https://openclipart.org/detail/68047)" by Emilie Rollandin under CC0 License NoName31.svg - "[Model silhouette](https://creazilla.com/media/silhouette/1785/model)" by Bob Comix under CC-BY-4.0 License NoName32.svg - "[Fashion, Female, Girl](https://pixabay.com/vectors/fashion-female-girl-heel-model-2023859/)" by OpenClipart-Vectors under CC0 License NoName33.png - "[Silhouette Donna 6](https://www.publicdomainpictures.net/view-image.php?image=82268)" by Tammy Sue under CC0 License NoName34.svg - "[Donna in piedi 01](https://openclipart.org/detail/33139)" by Emilie Rollandin under CC0 License NoName35.png - "[Silhouette, Woman, Young](https://pixabay.com/illustrations/silhouette-woman-young-move-female-3104942/)" by Wolfgang Eckert under Pixabay License NoName36.svg - "[Fashion Model silhouette](https://creazilla.com/media/silhouette/2506/fashion-model)" by Natasha Sinegina under CC-BY-4.0 License NoName37.svg - "[Female, Woman, Standing](https://pixabay.com/vectors/female-woman-standing-confident-2816234/)" by Mohamed Hassan under Pixabay License NoName38.svg - "[Dress, Silhouette, Women](https://pixabay.com/vectors/dress-silhouette-women-dance-lady-3360422/)" by Mohamed Hassan under Pixabay License NoName39.svg - "[Woman, Female, Lady](https://pixabay.com/illustrations/woman-female-lady-business-woman-220260/)" by No-longer-here under Pixabay License CC0 License: https://creativecommons.org/publicdomain/zero/1.0/ CC-BY-4.0 License: https://creativecommons.org/licenses/by/4.0/ Pixabay License: https://pixabay.com/service/license-summary/ ================================================ FILE: internal/static/performer_male/attribution.md ================================================ Male01.svg - "[Man Silhouette](https://freesvg.org/1528398040)" by "OpenClipart" under CC0 License Male02.svg - "[Male pose silhouette](https://freesvg.org/male-pose-silhouette)" by OpenClipart under CC0 License Male03.svg - "[Bald man walking in a suit silhouette vector image](https://freesvg.org/bald-man-walking-in-a-suit-silhouette-vector-image)" by OpenClipart under CC0 License Male04.svg - "[Man silhouette vector clip art](https://freesvg.org/man-silhouette-vector-clip-art) by OpenClipart under CC0 License Male05.svg - "[Man, Walking, Confident](https://pixabay.com/vectors/man-walking-confident-silhouette-2759950/)" by Mohamed Hassan under Pixabay License CC0 Licence: https://creativecommons.org/public-domain/cc0/ Pixabay License: https://pixabay.com/service/license-summary/ ================================================ FILE: pkg/exec/command.go ================================================ // Package exec provides functions that wrap os/exec functions. These functions prevent external commands from opening windows on the Windows platform. package exec import ( "context" "os/exec" ) // Command wraps the exec.Command function, preventing Windows from opening a window when starting. func Command(name string, arg ...string) *exec.Cmd { ret := exec.Command(name, arg...) hideExecShell(ret) return ret } // CommandContext wraps the exec.CommandContext function, preventing Windows from opening a window when starting. func CommandContext(ctx context.Context, name string, arg ...string) *exec.Cmd { ret := exec.CommandContext(ctx, name, arg...) hideExecShell(ret) return ret } ================================================ FILE: pkg/exec/shell_nonwindows.go ================================================ //go:build linux || darwin || !windows // +build linux darwin !windows package exec import "os/exec" // hideExecShell does nothing on non-Windows platforms. func hideExecShell(cmd *exec.Cmd) { } ================================================ FILE: pkg/exec/shell_windows.go ================================================ //go:build windows // +build windows package exec import ( "os/exec" "syscall" "golang.org/x/sys/windows" ) // hideExecShell hides the windows when executing on Windows. func hideExecShell(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.DETACHED_PROCESS & windows.CREATE_NO_WINDOW} } ================================================ FILE: pkg/ffmpeg/browser.go ================================================ package ffmpeg import ( "errors" "fmt" ) // only support H264 by default, since Safari does not support VP8/VP9 var defaultSupportedCodecs = []string{H264, H265} var validForH264Mkv = []Container{Mp4, Matroska} var validForH264 = []Container{Mp4} var validForH265Mkv = []Container{Mp4, Matroska} var validForH265 = []Container{Mp4} var validForVp8 = []Container{Webm} var validForVp9Mkv = []Container{Webm, Matroska} var validForVp9 = []Container{Webm} var validForHevcMkv = []Container{Mp4, Matroska} var validForHevc = []Container{Mp4} var validAudioForMkv = []ProbeAudioCodec{Aac, Mp3, Vorbis, Opus} var validAudioForWebm = []ProbeAudioCodec{Vorbis, Opus} var validAudioForMp4 = []ProbeAudioCodec{Aac, Mp3, Opus} var ( // ErrUnsupportedVideoCodecForBrowser is returned when the video codec is not supported for browser streaming. ErrUnsupportedVideoCodecForBrowser = errors.New("unsupported video codec for browser") // ErrUnsupportedVideoCodecContainer is returned when the video codec/container combination is not supported for browser streaming. ErrUnsupportedVideoCodecContainer = errors.New("video codec/container combination is unsupported for browser streaming") // ErrUnsupportedAudioCodecContainer is returned when the audio codec/container combination is not supported for browser streaming. ErrUnsupportedAudioCodecContainer = errors.New("audio codec/container combination is unsupported for browser streaming") ) // IsStreamable returns nil if the file is streamable, or an error if it is not. func IsStreamable(videoCodec string, audioCodec ProbeAudioCodec, container Container) error { supportedVideoCodecs := defaultSupportedCodecs // check if the video codec matches the supported codecs if !isValidCodec(videoCodec, supportedVideoCodecs) { return fmt.Errorf("%w: %s", ErrUnsupportedVideoCodecForBrowser, videoCodec) } if !isValidCombo(videoCodec, container, supportedVideoCodecs) { return fmt.Errorf("%w: %s/%s", ErrUnsupportedVideoCodecContainer, videoCodec, container) } if !IsValidAudioForContainer(audioCodec, container) { return fmt.Errorf("%w: %s/%s", ErrUnsupportedAudioCodecContainer, audioCodec, container) } return nil } func isValidCodec(codecName string, supportedCodecs []string) bool { for _, c := range supportedCodecs { if c == codecName { return true } } return false } func isValidAudio(audio ProbeAudioCodec, validCodecs []ProbeAudioCodec) bool { // if audio codec is missing or unsupported by ffmpeg we can't do anything about it // report it as valid so that the file can at least be streamed directly if the video codec is supported if audio == MissingUnsupported { return true } for _, c := range validCodecs { if c == audio { return true } } return false } // IsValidAudioForContainer returns true if the audio codec is valid for the container. func IsValidAudioForContainer(audio ProbeAudioCodec, format Container) bool { switch format { case Matroska: return isValidAudio(audio, validAudioForMkv) case Webm: return isValidAudio(audio, validAudioForWebm) case Mp4: return isValidAudio(audio, validAudioForMp4) } return false } // isValidCombo checks if a codec/container combination is valid. // Returns true on validity, false otherwise func isValidCombo(codecName string, format Container, supportedVideoCodecs []string) bool { supportMKV := isValidCodec(Mkv, supportedVideoCodecs) supportHEVC := isValidCodec(Hevc, supportedVideoCodecs) switch codecName { case H264: if supportMKV { return isValidForContainer(format, validForH264Mkv) } return isValidForContainer(format, validForH264) case H265: if supportMKV { return isValidForContainer(format, validForH265Mkv) } return isValidForContainer(format, validForH265) case Vp8: return isValidForContainer(format, validForVp8) case Vp9: if supportMKV { return isValidForContainer(format, validForVp9Mkv) } return isValidForContainer(format, validForVp9) case Hevc: if supportHEVC { if supportMKV { return isValidForContainer(format, validForHevcMkv) } return isValidForContainer(format, validForHevc) } } return false } func isValidForContainer(format Container, validContainers []Container) bool { for _, fmt := range validContainers { if fmt == format { return true } } return false } ================================================ FILE: pkg/ffmpeg/codec.go ================================================ package ffmpeg type VideoCodec struct { Name string // The full name of the codec including profile/quality CodeName string // The core codec name without profile/quality suffix } func makeVideoCodec(name string, codename string) VideoCodec { return VideoCodec{name, codename} } func (c VideoCodec) Args() []string { if c.CodeName == "" { return nil } return []string{"-c:v", string(c.CodeName)} } var ( // Software codec's VideoCodecLibX264 = makeVideoCodec("x264", "libx264") VideoCodecLibWebP = makeVideoCodec("WebP", "libwebp") VideoCodecBMP = makeVideoCodec("BMP", "bmp") VideoCodecMJpeg = makeVideoCodec("Jpeg", "mjpeg") VideoCodecVP9 = makeVideoCodec("VPX-VP9", "libvpx-vp9") VideoCodecVPX = makeVideoCodec("VPX-VP8", "libvpx") VideoCodecLibX265 = makeVideoCodec("x265", "libx265") VideoCodecCopy = makeVideoCodec("Copy", "copy") ) type AudioCodec string func (c AudioCodec) Args() []string { if c == "" { return nil } return []string{"-c:a", string(c)} } var ( AudioCodecAAC AudioCodec = "aac" AudioCodecLibOpus AudioCodec = "libopus" AudioCodecCopy AudioCodec = "copy" ) ================================================ FILE: pkg/ffmpeg/codec_hardware.go ================================================ package ffmpeg import ( "bytes" "context" "fmt" "math" "os" "regexp" "strconv" "strings" "time" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) var ( // Hardware codec's VideoCodecN264 = makeVideoCodec("H264 NVENC", "h264_nvenc") VideoCodecN264H = makeVideoCodec("H264 NVENC HQ profile", "h264_nvenc") VideoCodecI264 = makeVideoCodec("H264 Intel Quick Sync Video (QSV)", "h264_qsv") VideoCodecI264C = makeVideoCodec("H264 Intel Quick Sync Video (QSV) Compatibility profile", "h264_qsv") VideoCodecA264 = makeVideoCodec("H264 Advanced Media Framework (AMF)", "h264_amf") VideoCodecM264 = makeVideoCodec("H264 VideoToolbox", "h264_videotoolbox") VideoCodecV264 = makeVideoCodec("H264 VAAPI", "h264_vaapi") VideoCodecR264 = makeVideoCodec("H264 V4L2M2M", "h264_v4l2m2m") VideoCodecO264 = makeVideoCodec("H264 OMX", "h264_omx") VideoCodecIVP9 = makeVideoCodec("VP9 Intel Quick Sync Video (QSV)", "vp9_qsv") VideoCodecVVP9 = makeVideoCodec("VP9 VAAPI", "vp9_vaapi") VideoCodecVVPX = makeVideoCodec("VP8 VAAPI", "vp8_vaapi") VideoCodecRK264 = makeVideoCodec("H264 Rockchip MPP (rkmpp)", "h264_rkmpp") ) const minHeight int = 480 // Tests all (given) hardware codec's func (f *FFMpeg) InitHWSupport(ctx context.Context) { // do the hardware codec tests in a separate goroutine to avoid blocking done := make(chan struct{}) go func() { f.initHWSupport(ctx) close(done) }() // log if the initialization takes too long const hwInitLogTimeoutSecondsDefault = 5 hwInitLogTimeoutSeconds := hwInitLogTimeoutSecondsDefault * time.Second timer := time.NewTimer(hwInitLogTimeoutSeconds) go func() { select { case <-timer.C: logger.Warnf("[InitHWSupport] Hardware codec initialization is taking longer than %s...", hwInitLogTimeoutSeconds) logger.Info("[InitHWSupport] Hardware encoding will not be available until initialization is complete.") case <-done: if !timer.Stop() { <-timer.C } } }() } func (f *FFMpeg) initHWSupport(ctx context.Context) { var hwCodecSupport []VideoCodec // Note that the first compatible codec is returned, so order is important for _, codec := range []VideoCodec{ VideoCodecN264H, VideoCodecN264, VideoCodecI264, VideoCodecI264C, VideoCodecV264, VideoCodecR264, VideoCodecRK264, VideoCodecIVP9, VideoCodecVVP9, VideoCodecM264, } { var args Args args = append(args, "-hide_banner") args = args.LogLevel(LogLevelWarning) args = f.hwDeviceInit(args, codec, false) args = args.Format("lavfi") vFile := &models.VideoFile{Width: 1280, Height: 720} args = args.Input(fmt.Sprintf("color=c=red:s=%dx%d", vFile.Width, vFile.Height)) args = args.Duration(0.1) // Test scaling videoFilter := f.hwMaxResFilter(codec, vFile, minHeight, false) args = append(args, CodecInit(codec)...) args = args.VideoFilter(videoFilter) args = args.Format("null") args = args.Output("-") // #6064 - add timeout to context to prevent hangs const hwTestTimeoutSecondsDefault = 10 hwTestTimeoutSeconds := hwTestTimeoutSecondsDefault * time.Second // allow timeout to be overridden with environment variable if timeout := os.Getenv("STASH_HW_TEST_TIMEOUT"); timeout != "" { if seconds, err := strconv.Atoi(timeout); err == nil { hwTestTimeoutSeconds = time.Duration(seconds) * time.Second } } testCtx, cancel := context.WithTimeout(ctx, hwTestTimeoutSeconds) defer cancel() cmd := f.Command(testCtx, args) cmd.WaitDelay = time.Second logger.Tracef("[InitHWSupport] Testing codec %s: %v", codec, cmd.Args) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { if testCtx.Err() != nil { logger.Debugf("[InitHWSupport] Codec %s test timed out after %s", codec, hwTestTimeoutSeconds) continue } errOutput := stderr.String() if len(errOutput) == 0 { errOutput = err.Error() } logger.Debugf("[InitHWSupport] Codec %s not supported. Error output:\n%s", codec, errOutput) } else { hwCodecSupport = append(hwCodecSupport, codec) } } outstr := fmt.Sprintf("[InitHWSupport] Supported HW codecs [%d]:\n", len(hwCodecSupport)) for _, codec := range hwCodecSupport { outstr += fmt.Sprintf("\t%s - %s\n", codec.Name, codec.CodeName) } logger.Info(outstr) f.hwCodecSupportMutex.Lock() defer f.hwCodecSupportMutex.Unlock() f.hwCodecSupport = hwCodecSupport } func (f *FFMpeg) hwCanFullHWTranscode(ctx context.Context, codec VideoCodec, vf *models.VideoFile, reqHeight int) bool { if codec == VideoCodecCopy { return false } var args Args args = append(args, "-hide_banner") args = args.LogLevel(LogLevelWarning) args = args.XError() args = f.hwDeviceInit(args, codec, true) args = args.Input(vf.Path) args = args.Duration(1) videoFilter := f.hwMaxResFilter(codec, vf, reqHeight, true) args = append(args, CodecInit(codec)...) args = args.VideoFilter(videoFilter) args = args.Format("null") args = args.Output("-") cmd := f.Command(ctx, args) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { errOutput := stderr.String() if len(errOutput) == 0 { errOutput = err.Error() } logger.Debugf("[InitHWSupport] Full hardware transcode for file %s not supported. Error output:\n%s", vf.Basename, errOutput) return false } return true } // Prepend input for hardware encoding only func (f *FFMpeg) hwDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args { switch toCodec { case VideoCodecN264, VideoCodecN264H: args = append(args, "-hwaccel_device") args = append(args, "0") if fullhw { args = append(args, "-threads") args = append(args, "1") args = append(args, "-hwaccel") args = append(args, "cuda") args = append(args, "-hwaccel_output_format") args = append(args, "cuda") } case VideoCodecV264, VideoCodecVVP9: args = append(args, "-vaapi_device") args = append(args, "/dev/dri/renderD128") if fullhw { args = append(args, "-hwaccel") args = append(args, "vaapi") args = append(args, "-hwaccel_output_format") args = append(args, "vaapi") } case VideoCodecI264, VideoCodecI264C, VideoCodecIVP9: if fullhw { args = append(args, "-hwaccel") args = append(args, "qsv") args = append(args, "-hwaccel_output_format") args = append(args, "qsv") } else { args = append(args, "-init_hw_device") args = append(args, "qsv=hw") args = append(args, "-filter_hw_device") args = append(args, "hw") } case VideoCodecM264: if fullhw { args = append(args, "-hwaccel") args = append(args, "videotoolbox") args = append(args, "-hwaccel_output_format") args = append(args, "videotoolbox_vld") } else { args = append(args, "-init_hw_device") args = append(args, "videotoolbox=vt") } case VideoCodecRK264: // Rockchip: always create rkmpp device and make it the filter device, so // scale_rkrga and subsequent hwupload/hwmap operate in the right context. args = append(args, "-init_hw_device") args = append(args, "rkmpp=rk") args = append(args, "-filter_hw_device") args = append(args, "rk") if fullhw { args = append(args, "-hwaccel") args = append(args, "rkmpp") args = append(args, "-hwaccel_output_format") args = append(args, "drm_prime") } } return args } // Initialise a video filter for HW encoding func (f *FFMpeg) hwFilterInit(toCodec VideoCodec, fullhw bool) VideoFilter { var videoFilter VideoFilter switch toCodec { case VideoCodecV264, VideoCodecVVP9: if !fullhw { videoFilter = videoFilter.Append("format=nv12") videoFilter = videoFilter.Append("hwupload") } case VideoCodecN264, VideoCodecN264H: if !fullhw { videoFilter = videoFilter.Append("format=nv12") videoFilter = videoFilter.Append("hwupload_cuda") } case VideoCodecI264, VideoCodecI264C, VideoCodecIVP9: if !fullhw { videoFilter = videoFilter.Append("hwupload=extra_hw_frames=64") videoFilter = videoFilter.Append("format=qsv") } case VideoCodecM264: if !fullhw { videoFilter = videoFilter.Append("format=nv12") videoFilter = videoFilter.Append("hwupload") } case VideoCodecRK264: // For Rockchip full-hw, do NOT pre-map to rkrga here. scale_rkrga can // consume DRM_PRIME frames directly when filter_hw_device is set. // For non-fullhw, keep a sane software format. if !fullhw { videoFilter = videoFilter.Append("format=nv12") videoFilter = videoFilter.Append("hwupload") } } return videoFilter } var scaler_re = regexp.MustCompile(`scale=(?P([-\d]+):([-\d]+))`) func templateReplaceScale(input string, template string, match []int, vf *models.VideoFile, minusonehack bool) string { result := []byte{} if minusonehack { // Parse width and height w, err := strconv.Atoi(input[match[4]:match[5]]) if err != nil { logger.Error("failed to parse width") return input } h, err := strconv.Atoi(input[match[6]:match[7]]) if err != nil { logger.Error("failed to parse height") return input } // Calculate ratio ratio := float64(vf.Width) / float64(vf.Height) if w < 0 { w = int(math.Round(float64(h) * ratio)) } else if h < 0 { h = int(math.Round(float64(w) / ratio)) } // Fix not divisible by 2 errors if w%2 != 0 { w++ } if h%2 != 0 { h++ } template = strings.ReplaceAll(template, "$value", fmt.Sprintf("%d:%d", w, h)) } res := string(scaler_re.ExpandString(result, template, input, match)) matchStart := match[0] matchEnd := match[1] return input[0:matchStart] + res + input[matchEnd:] } // Replace video filter scaling with hardware scaling for full hardware transcoding (also fixes the format) func (f *FFMpeg) hwCodecFilter(args VideoFilter, codec VideoCodec, vf *models.VideoFile, fullhw bool) VideoFilter { sargs := string(args) match := scaler_re.FindStringSubmatchIndex(sargs) if match == nil { return f.hwApplyFullHWFilter(args, codec, fullhw) } return f.hwApplyScaleTemplate(sargs, codec, match, vf, fullhw) } // Apply format switching if applicable func (f *FFMpeg) hwApplyFullHWFilter(args VideoFilter, codec VideoCodec, fullhw bool) VideoFilter { switch codec { case VideoCodecN264, VideoCodecN264H: if fullhw && f.version.Gteq(Version{major: 5}) { // Added in FFMpeg 5 args = args.Append("scale_cuda=format=yuv420p") } case VideoCodecV264, VideoCodecVVP9: if fullhw && f.version.Gteq(Version{major: 3, minor: 1}) { // Added in FFMpeg 3.1 args = args.Append("scale_vaapi=format=nv12") } case VideoCodecI264, VideoCodecI264C, VideoCodecIVP9: if fullhw && f.version.Gteq(Version{major: 3, minor: 3}) { // Added in FFMpeg 3.3 args = args.Append("scale_qsv=format=nv12") } case VideoCodecRK264: // Full-hw decode on 10-bit sources often produces DRM_PRIME with sw_pix_fmt=nv15. // h264_rkmpp does NOT accept nv15, so we must force a conversion to nv12 if fullhw { args = args.Append("scale_rkrga=w=iw:h=ih:format=nv12") } } return args } // Switch scaler func (f *FFMpeg) hwApplyScaleTemplate(sargs string, codec VideoCodec, match []int, vf *models.VideoFile, fullhw bool) VideoFilter { var template string switch codec { case VideoCodecN264, VideoCodecN264H: template = "scale_cuda=$value" if fullhw && f.version.Gteq(Version{major: 5}) { // Added in FFMpeg 5 template += ":format=yuv420p" } case VideoCodecV264, VideoCodecVVP9: template = "scale_vaapi=$value" if fullhw && f.version.Gteq(Version{major: 3, minor: 1}) { // Added in FFMpeg 3.1 template += ":format=nv12" } case VideoCodecI264, VideoCodecI264C, VideoCodecIVP9: template = "scale_qsv=$value" if fullhw && f.version.Gteq(Version{major: 3, minor: 3}) { // Added in FFMpeg 3.3 template += ":format=nv12" } case VideoCodecM264: template = "scale_vt=$value" case VideoCodecRK264: // The original filter chain is a fallback for maximum compatibility: // "scale_rkrga=$value:format=nv12,hwdownload,format=nv12,hwupload" // It avoids hwmap(rkrga→rkmpp) failures (-38/-12) seen on some builds // by downloading the scaled frame to system RAM and re-uploading it. // The filter chain below uses a zero-copy approach, passing the hardware-scaled // frame directly to the encoder. This is more efficient but may be less stable. template = "scale_rkrga=$value:format=nv12" default: return VideoFilter(sargs) } // BUG: [scale_qsv]: Size values less than -1 are not acceptable. isIntel := codec == VideoCodecI264 || codec == VideoCodecI264C || codec == VideoCodecIVP9 // BUG: scale_vt doesn't call ff_scale_adjust_dimensions, thus cant accept negative size values isApple := codec == VideoCodecM264 // Rockchip's scale_rkrga supports -1/-2; don't apply minus-one hack here. return VideoFilter(templateReplaceScale(sargs, template, match, vf, isIntel || isApple)) } // Returns the max resolution for a given codec, or a default func (f *FFMpeg) hwCodecMaxRes(codec VideoCodec) (int, int) { switch codec { case VideoCodecRK264: return 8192, 8192 case VideoCodecN264, VideoCodecN264H, VideoCodecI264, VideoCodecI264C: return 4096, 4096 } return 0, 0 } // Return a maxres filter func (f *FFMpeg) hwMaxResFilter(toCodec VideoCodec, vf *models.VideoFile, reqHeight int, fullhw bool) VideoFilter { if vf.Width == 0 || vf.Height == 0 { return "" } videoFilter := f.hwFilterInit(toCodec, fullhw) maxWidth, maxHeight := f.hwCodecMaxRes(toCodec) videoFilter = videoFilter.ScaleMaxLM(vf.Width, vf.Height, reqHeight, maxWidth, maxHeight) return f.hwCodecFilter(videoFilter, toCodec, vf, fullhw) } // Return if a hardware accelerated for HLS is available func (f *FFMpeg) hwCodecHLSCompatible() *VideoCodec { for _, element := range f.getHWCodecSupport() { switch element { case VideoCodecN264, VideoCodecN264H, VideoCodecI264, VideoCodecI264C, VideoCodecV264, VideoCodecR264, VideoCodecM264, // Note that the Apple encoder sucks at startup, thus HLS quality is crap VideoCodecRK264: return &element } } return nil } // Return if a hardware accelerated codec for MP4 is available func (f *FFMpeg) hwCodecMP4Compatible() *VideoCodec { for _, element := range f.getHWCodecSupport() { switch element { case VideoCodecN264, VideoCodecN264H, VideoCodecI264, VideoCodecI264C, VideoCodecM264, VideoCodecRK264: return &element } } return nil } // Return if a hardware accelerated codec for WebM is available func (f *FFMpeg) hwCodecWEBMCompatible() *VideoCodec { for _, element := range f.getHWCodecSupport() { switch element { case VideoCodecIVP9, VideoCodecVVP9: return &element } } return nil } ================================================ FILE: pkg/ffmpeg/container.go ================================================ package ffmpeg type Container string type ProbeAudioCodec string const ( Mp4 Container = "mp4" M4v Container = "m4v" Mov Container = "mov" Wmv Container = "wmv" Webm Container = "webm" Matroska Container = "matroska" Avi Container = "avi" Flv Container = "flv" Mpegts Container = "mpegts" Aac ProbeAudioCodec = "aac" Mp3 ProbeAudioCodec = "mp3" Opus ProbeAudioCodec = "opus" Vorbis ProbeAudioCodec = "vorbis" MissingUnsupported ProbeAudioCodec = "" Mp4Ffmpeg string = "mov,mp4,m4a,3gp,3g2,mj2" // browsers support all of them M4vFfmpeg string = "mov,mp4,m4a,3gp,3g2,mj2" // so we don't care that ffmpeg MovFfmpeg string = "mov,mp4,m4a,3gp,3g2,mj2" // can't differentiate between them WmvFfmpeg string = "asf" WebmFfmpeg string = "matroska,webm" MatroskaFfmpeg string = "matroska,webm" AviFfmpeg string = "avi" FlvFfmpeg string = "flv" MpegtsFfmpeg string = "mpegts" H264 string = "h264" H265 string = "h265" // found in rare cases from a faulty encoder Hevc string = "hevc" Vp8 string = "vp8" Vp9 string = "vp9" Mkv string = "mkv" // only used from the browser to indicate mkv support Hls string = "hls" // only used from the browser to indicate hls support ) var ffprobeToContainer = map[string]Container{ Mp4Ffmpeg: Mp4, WmvFfmpeg: Wmv, AviFfmpeg: Avi, FlvFfmpeg: Flv, MpegtsFfmpeg: Mpegts, MatroskaFfmpeg: Matroska, } func MatchContainer(format string, filePath string) (Container, error) { // match ffprobe string to our Container container := ffprobeToContainer[format] if container == Matroska { return magicContainer(filePath) // use magic number instead of ffprobe for matroska,webm } if container == "" { // if format is not in our Container list leave it as ffprobes reported format_name container = Container(format) } return container, nil } ================================================ FILE: pkg/ffmpeg/downloader.go ================================================ package ffmpeg import ( "runtime" ) func GetFFmpegURL() []string { var urls []string switch runtime.GOOS { case "darwin": urls = []string{"https://evermeet.cx/ffmpeg/getrelease/zip", "https://evermeet.cx/ffmpeg/getrelease/ffprobe/zip"} case "linux": switch runtime.GOARCH { case "amd64": urls = []string{"https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v6.1/ffmpeg-6.1-linux-64.zip", "https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v6.1/ffprobe-6.1-linux-64.zip"} case "arm": urls = []string{"https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v6.1/ffmpeg-6.1-linux-armhf-32.zip", "https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v6.1/ffprobe-6.1-linux-armhf-32.zip"} case "arm64": urls = []string{"https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v6.1/ffmpeg-6.1-linux-arm-64.zip", "https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v6.1/ffprobe-6.1-linux-arm-64.zip"} } case "windows": urls = []string{"https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip"} default: urls = []string{""} } return urls } func getFFMpegFilename() string { if runtime.GOOS == "windows" { return "ffmpeg.exe" } return "ffmpeg" } func getFFProbeFilename() string { if runtime.GOOS == "windows" { return "ffprobe.exe" } return "ffprobe" } ================================================ FILE: pkg/ffmpeg/ffmpeg.go ================================================ // Package ffmpeg provides a wrapper around the ffmpeg and ffprobe executables. package ffmpeg import ( "bytes" "context" "errors" "fmt" "os/exec" "regexp" "strconv" "strings" "sync" stashExec "github.com/stashapp/stash/pkg/exec" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" ) func ffmpegHelp(ffmpegPath string) (string, error) { cmd := stashExec.Command(ffmpegPath, "-h") bytes, err := cmd.CombinedOutput() output := string(bytes) if err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { return "", fmt.Errorf("error running ffmpeg: %v", output) } return "", fmt.Errorf("error running ffmpeg: %v", err) } return output, nil } func ValidateFFMpeg(ffmpegPath string) error { _, err := ffmpegHelp(ffmpegPath) return err } func ValidateFFMpegCodecSupport(ffmpegPath string) error { output, err := ffmpegHelp(ffmpegPath) if err != nil { return err } var missingSupport []string if !strings.Contains(output, "--enable-libopus") { missingSupport = append(missingSupport, "libopus") } if !strings.Contains(output, "--enable-libvpx") { missingSupport = append(missingSupport, "libvpx") } if !strings.Contains(output, "--enable-libx264") { missingSupport = append(missingSupport, "libx264") } if !strings.Contains(output, "--enable-libx265") { missingSupport = append(missingSupport, "libx265") } if !strings.Contains(output, "--enable-libwebp") { missingSupport = append(missingSupport, "libwebp") } if len(missingSupport) > 0 { return fmt.Errorf("ffmpeg missing codec support: %v", missingSupport) } return nil } func LookPathFFMpeg() string { ret, _ := exec.LookPath(getFFMpegFilename()) if ret != "" { // ensure ffmpeg has the correct flags if err := ValidateFFMpeg(ret); err != nil { logger.Warnf("ffmpeg found (%s), could not be executed: %v", ret, err) ret = "" } } return ret } func FindFFMpeg(path string) string { ret := fsutil.FindInPaths([]string{path}, getFFMpegFilename()) if ret != "" { // ensure ffmpeg has the correct flags if err := ValidateFFMpeg(ret); err != nil { logger.Warnf("ffmpeg found (%s), could not be executed: %v", ret, err) ret = "" } } return ret } // ResolveFFMpeg attempts to resolve the path to the ffmpeg executable. // It first looks in the provided path, then resolves from the environment, and finally looks in the fallback path. // It will prefer an ffmpeg binary that has the required codec support. // Returns an empty string if a valid ffmpeg cannot be found. func ResolveFFMpeg(path string, fallbackPath string) string { var ret string // look in the provided path first pathFound := FindFFMpeg(path) if pathFound != "" { err := ValidateFFMpegCodecSupport(pathFound) if err == nil { return pathFound } logger.Warnf("ffmpeg found (%s), but it is missing required flags: %v", pathFound, err) ret = pathFound } // then resolve from the environment envFound := LookPathFFMpeg() if envFound != "" { err := ValidateFFMpegCodecSupport(envFound) if err == nil { return envFound } logger.Warnf("ffmpeg found (%s), but it is missing required flags: %v", envFound, err) if ret == "" { ret = envFound } } // finally, look in the fallback path fallbackFound := FindFFMpeg(fallbackPath) if fallbackFound != "" { err := ValidateFFMpegCodecSupport(fallbackFound) if err == nil { return fallbackFound } logger.Warnf("ffmpeg found (%s), but it is missing required flags: %v", fallbackFound, err) if ret == "" { ret = fallbackFound } } return ret } var version_re = regexp.MustCompile(`ffmpeg version n?((\d+)\.(\d+)(?:\.(\d+))?)`) func (f *FFMpeg) getVersion() error { var args Args args = append(args, "-version") cmd := f.Command(context.Background(), args) var stdout bytes.Buffer cmd.Stdout = &stdout var err error if err = cmd.Run(); err != nil { return err } stdoutStr := stdout.String() match := version_re.FindStringSubmatchIndex(stdoutStr) if match == nil { return errors.New("version string malformed") } majorS := stdoutStr[match[4]:match[5]] minorS := stdoutStr[match[6]:match[7]] // patch is optional var patchS string if match[8] != -1 && match[9] != -1 { patchS = stdoutStr[match[8]:match[9]] } if i, err := strconv.Atoi(majorS); err == nil { f.version.major = i } if i, err := strconv.Atoi(minorS); err == nil { f.version.minor = i } if i, err := strconv.Atoi(patchS); err == nil { f.version.patch = i } logger.Debugf("FFMpeg version %s detected", f.version.String()) return nil } // FFMpeg version params type Version struct { major int minor int patch int } // Gteq returns true if the version is greater than or equal to the other version. func (v Version) Gteq(other Version) bool { if v.major > other.major { return true } if v.major == other.major && v.minor > other.minor { return true } if v.major == other.major && v.minor == other.minor && v.patch >= other.patch { return true } return false } func (v Version) String() string { return fmt.Sprintf("%d.%d.%d", v.major, v.minor, v.patch) } // FFMpeg provides an interface to ffmpeg. type FFMpeg struct { ffmpeg string version Version hwCodecSupport []VideoCodec hwCodecSupportMutex sync.RWMutex } // Creates a new FFMpeg encoder func NewEncoder(ffmpegPath string) *FFMpeg { ret := &FFMpeg{ ffmpeg: ffmpegPath, } if err := ret.getVersion(); err != nil { logger.Warnf("FFMpeg version not detected %v", err) } return ret } // Returns an exec.Cmd that can be used to run ffmpeg using args. func (f *FFMpeg) Command(ctx context.Context, args []string) *exec.Cmd { return stashExec.CommandContext(ctx, string(f.ffmpeg), args...) } func (f *FFMpeg) Path() string { return f.ffmpeg } func (f *FFMpeg) getHWCodecSupport() []VideoCodec { f.hwCodecSupportMutex.RLock() defer f.hwCodecSupportMutex.RUnlock() return f.hwCodecSupport } ================================================ FILE: pkg/ffmpeg/ffmpeg_test.go ================================================ // Package ffmpeg provides a wrapper around the ffmpeg and ffprobe executables. package ffmpeg import "testing" func TestFFMpegVersion_GreaterThan(t *testing.T) { tests := []struct { name string this Version other Version want bool }{ { "major greater, minor equal, patch equal", Version{2, 0, 0}, Version{1, 0, 0}, true, }, { "major greater, minor less, patch less", Version{2, 1, 1}, Version{1, 0, 0}, true, }, { "major equal, minor greater, patch equal", Version{1, 1, 0}, Version{1, 0, 0}, true, }, { "major equal, minor equal, patch greater", Version{1, 0, 1}, Version{1, 0, 0}, true, }, { "major equal, minor equal, patch equal", Version{1, 0, 0}, Version{1, 0, 0}, true, }, { "major less, minor equal, patch equal", Version{1, 0, 0}, Version{2, 0, 0}, false, }, { "major equal, minor less, patch equal", Version{1, 0, 0}, Version{1, 1, 0}, false, }, { "major equal, minor equal, patch less", Version{1, 0, 0}, Version{1, 0, 1}, false, }, { "major less, minor less, patch less", Version{1, 0, 0}, Version{2, 1, 1}, false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.this.Gteq(tt.other); got != tt.want { t.Errorf("FFMpegVersion.GreaterThan() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: pkg/ffmpeg/ffprobe.go ================================================ package ffmpeg import ( "bytes" "encoding/json" "errors" "fmt" "math" "os" "os/exec" "regexp" "strconv" "strings" "time" stashExec "github.com/stashapp/stash/pkg/exec" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" ) const minimumFFProbeVersion = 5 func ValidateFFProbe(ffprobePath string) error { cmd := stashExec.Command(ffprobePath, "-h") bytes, err := cmd.CombinedOutput() output := string(bytes) if err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { return fmt.Errorf("error running ffprobe: %v", output) } return fmt.Errorf("error running ffprobe: %v", err) } return nil } func LookPathFFProbe() string { ret, _ := exec.LookPath(getFFProbeFilename()) if ret != "" { if err := ValidateFFProbe(ret); err != nil { logger.Warnf("ffprobe found in PATH (%s), but it is missing required flags: %v", ret, err) ret = "" } } return ret } func FindFFProbe(path string) string { ret := fsutil.FindInPaths([]string{path}, getFFProbeFilename()) if ret != "" { if err := ValidateFFProbe(ret); err != nil { logger.Warnf("ffprobe found (%s), but it is missing required flags: %v", ret, err) ret = "" } } return ret } // ResolveFFMpeg attempts to resolve the path to the ffmpeg executable. // It first looks in the provided path, then resolves from the environment, and finally looks in the fallback path. // Returns an empty string if a valid ffmpeg cannot be found. func ResolveFFProbe(path string, fallbackPath string) string { // look in the provided path first ret := FindFFProbe(path) if ret != "" { return ret } // then resolve from the environment ret = LookPathFFProbe() if ret != "" { return ret } // finally, look in the fallback path ret = FindFFProbe(fallbackPath) return ret } // VideoFile represents the ffprobe output for a video file. type VideoFile struct { JSON FFProbeJSON AudioStream *FFProbeStream VideoStream *FFProbeStream Path string Title string Comment string Container string // FileDuration is the declared (meta-data) duration of the *file*. // In most cases (sprites, previews, etc.) we actually care about the duration of the video stream specifically, // because those two can differ slightly (e.g. audio stream longer than the video stream, making the whole file // longer). FileDuration float64 VideoStreamDuration float64 StartTime float64 Bitrate int64 Size int64 CreationTime time.Time VideoCodec string VideoBitrate int64 Width int Height int FrameRate float64 Rotation int64 FrameCount int64 AudioCodec string } // TranscodeScale calculates the dimension scaling for a transcode, where maxSize is the maximum size of the longest dimension of the input video. // If no scaling is required, then returns 0, 0. // Returns -2 for the dimension that will scale to maintain aspect ratio. func (v *VideoFile) TranscodeScale(maxSize int) (int, int) { // get the smaller dimension of the video file videoSize := v.Height if v.Width < videoSize { videoSize = v.Width } // if our streaming resolution is larger than the video dimension // or we are streaming the original resolution, then just set the // input width if maxSize >= videoSize || maxSize == 0 { return 0, 0 } // we're setting either the width or height // we'll set the smaller dimesion if v.Width > v.Height { // set the height return -2, maxSize } return maxSize, -2 } // FFProbe provides an interface to the ffprobe executable. type FFProbe struct { path string version Version } func (f *FFProbe) Path() string { return f.path } var ffprobeVersionRE = regexp.MustCompile(`ffprobe version n?((\d+)\.(\d+)(?:\.(\d+))?)`) func (f *FFProbe) getVersion() error { var args []string args = append(args, "-version") cmd := stashExec.Command(f.path, args...) var stdout bytes.Buffer cmd.Stdout = &stdout var err error if err = cmd.Run(); err != nil { return err } stdoutStr := stdout.String() match := ffprobeVersionRE.FindStringSubmatchIndex(stdoutStr) if match == nil { return errors.New("version string malformed") } majorS := stdoutStr[match[4]:match[5]] minorS := stdoutStr[match[6]:match[7]] // patch is optional var patchS string if match[8] != -1 && match[9] != -1 { patchS = stdoutStr[match[8]:match[9]] } if i, err := strconv.Atoi(majorS); err == nil { f.version.major = i } if i, err := strconv.Atoi(minorS); err == nil { f.version.minor = i } if i, err := strconv.Atoi(patchS); err == nil { f.version.patch = i } logger.Debugf("FFProbe version %s detected", f.version.String()) return nil } // Creates a new FFProbe instance. func NewFFProbe(path string) *FFProbe { ret := &FFProbe{ path: path, } if err := ret.getVersion(); err != nil { logger.Warnf("FFProbe version not detected %v", err) } if ret.version.major != 0 && ret.version.major < minimumFFProbeVersion { logger.Warnf("FFProbe version %d.%d.%d detected, but %d.x or later is required", ret.version.major, ret.version.minor, ret.version.patch, minimumFFProbeVersion) } return ret } // NewVideoFile runs ffprobe on the given path and returns a VideoFile. func (f *FFProbe) NewVideoFile(videoPath string) (*VideoFile, error) { args := []string{ "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "-show_error", } // show_entries stream_side_data=rotation requires 5.x or later ffprobe if f.version.major >= 5 { args = append(args, "-show_entries", "stream_side_data=rotation") } args = append(args, videoPath) cmd := stashExec.Command(f.path, args...) out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", videoPath, string(out), err.Error()) } probeJSON := &FFProbeJSON{} if err := json.Unmarshal(out, probeJSON); err != nil { return nil, fmt.Errorf("error unmarshalling video data for <%s>: %s", videoPath, err.Error()) } return parse(videoPath, probeJSON) } // GetReadFrameCount counts the actual frames of the video file. // Used when the frame count is missing or incorrect. func (f *FFProbe) GetReadFrameCount(path string) (int64, error) { args := []string{"-v", "quiet", "-print_format", "json", "-count_frames", "-show_format", "-show_streams", "-show_error", path} out, err := stashExec.Command(f.path, args...).Output() if err != nil { return 0, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", path, string(out), err.Error()) } probeJSON := &FFProbeJSON{} if err := json.Unmarshal(out, probeJSON); err != nil { return 0, fmt.Errorf("error unmarshalling video data for <%s>: %s", path, err.Error()) } fc, err := parse(path, probeJSON) return fc.FrameCount, err } func parse(filePath string, probeJSON *FFProbeJSON) (*VideoFile, error) { if probeJSON == nil { return nil, fmt.Errorf("failed to get ffprobe json for <%s>", filePath) } result := &VideoFile{} result.JSON = *probeJSON if result.JSON.Error.Code != 0 { return nil, fmt.Errorf("ffprobe error code %d: %s", result.JSON.Error.Code, result.JSON.Error.String) } result.Path = filePath result.Title = probeJSON.Format.Tags.Title result.Comment = probeJSON.Format.Tags.Comment result.Bitrate, _ = strconv.ParseInt(probeJSON.Format.BitRate, 10, 64) result.Container = probeJSON.Format.FormatName duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64) result.FileDuration = math.Round(duration*100) / 100 fileStat, err := os.Stat(filePath) if err != nil { statErr := fmt.Errorf("error statting file <%s>: %w", filePath, err) logger.Errorf("%v", statErr) return nil, statErr } result.Size = fileStat.Size() result.StartTime, _ = strconv.ParseFloat(probeJSON.Format.StartTime, 64) result.CreationTime = probeJSON.Format.Tags.CreationTime.Time audioStream := result.getAudioStream() if audioStream != nil { result.AudioCodec = audioStream.CodecName result.AudioStream = audioStream } videoStream := result.getVideoStream() if videoStream != nil { result.VideoStream = videoStream result.VideoCodec = videoStream.CodecName result.FrameCount, _ = strconv.ParseInt(videoStream.NbFrames, 10, 64) if videoStream.NbReadFrames != "" { // if ffprobe counted the frames use that instead fc, _ := strconv.ParseInt(videoStream.NbReadFrames, 10, 64) if fc > 0 { result.FrameCount, _ = strconv.ParseInt(videoStream.NbReadFrames, 10, 64) } else { logger.Debugf("[ffprobe] <%s> invalid Read Frames count", videoStream.NbReadFrames) } } result.VideoBitrate, _ = strconv.ParseInt(videoStream.BitRate, 10, 64) var framerate float64 if strings.Contains(videoStream.AvgFrameRate, "/") { frameRateSplit := strings.Split(videoStream.AvgFrameRate, "/") numerator, _ := strconv.ParseFloat(frameRateSplit[0], 64) denominator, _ := strconv.ParseFloat(frameRateSplit[1], 64) framerate = numerator / denominator } else { framerate, _ = strconv.ParseFloat(videoStream.AvgFrameRate, 64) } if math.IsNaN(framerate) { framerate = 0 } result.FrameRate = math.Round(framerate*100) / 100 result.Width = videoStream.Width result.Height = videoStream.Height if isRotated(videoStream) { result.Width = videoStream.Height result.Height = videoStream.Width } result.VideoStreamDuration, err = strconv.ParseFloat(videoStream.Duration, 64) if err != nil { // Revert to the historical behaviour, which is still correct in the vast majority of cases. result.VideoStreamDuration = result.FileDuration } } return result, nil } func isRotated(s *FFProbeStream) bool { rotate, _ := strconv.ParseInt(s.Tags.Rotate, 10, 64) if rotate != 180 && rotate != 0 { return true } for _, sd := range s.SideDataList { r := sd.Rotation if r < 0 { r = -r } if r != 0 && r != 180 { return true } } return false } func (v *VideoFile) getAudioStream() *FFProbeStream { index := v.getStreamIndex("audio", v.JSON) if index != -1 { return &v.JSON.Streams[index] } return nil } func (v *VideoFile) getVideoStream() *FFProbeStream { index := v.getStreamIndex("video", v.JSON) if index != -1 { return &v.JSON.Streams[index] } return nil } func (v *VideoFile) getStreamIndex(fileType string, probeJSON FFProbeJSON) int { ret := -1 for i, stream := range probeJSON.Streams { // skip cover art/thumbnails if stream.CodecType == fileType && stream.Disposition.AttachedPic == 0 { // prefer default stream if stream.Disposition.Default == 1 { return i } // backwards compatible behaviour - fallback to first matching stream if ret == -1 { ret = i } } } return ret } ================================================ FILE: pkg/ffmpeg/filter.go ================================================ package ffmpeg import ( "fmt" ) // VideoFilter represents video filter parameters to be passed to ffmpeg. type VideoFilter string // Args converts the video filter parameters to a slice of arguments to be passed to ffmpeg. // Returns an empty slice if the filter is empty. func (f VideoFilter) Args() []string { if f == "" { return nil } return []string{"-vf", string(f)} } // ScaleWidth returns a VideoFilter scaling the width to the given width, maintaining aspect ratio and a height as a multiple of 2. func (f VideoFilter) ScaleWidth(w int) VideoFilter { return f.ScaleDimensions(w, -2) } func (f VideoFilter) ScaleHeight(h int) VideoFilter { return f.ScaleDimensions(-2, h) } // ScaleDimesions returns a VideoFilter scaling using w and h. Use -n to maintain aspect ratio and maintain as multiple of n. func (f VideoFilter) ScaleDimensions(w, h int) VideoFilter { return f.Append(fmt.Sprintf("scale=%v:%v", w, h)) } // ScaleMaxSize returns a VideoFilter scaling to maxDimensions, maintaining aspect ratio using force_original_aspect_ratio=decrease. func (f VideoFilter) ScaleMaxSize(maxDimensions int) VideoFilter { return f.Append(fmt.Sprintf("scale=%v:%v:force_original_aspect_ratio=decrease", maxDimensions, maxDimensions)) } // ScaleMax returns a VideoFilter scaling to maxSize. It will scale width if it is larger than height, otherwise it will scale height. func (f VideoFilter) ScaleMax(inputWidth, inputHeight, maxSize int) VideoFilter { // get the smaller dimension of the input videoSize := inputHeight if inputWidth < videoSize { videoSize = inputWidth } // if maxSize is larger than the video dimension, then no-op if maxSize >= videoSize || maxSize == 0 { return f } // we're setting either the width or height // we'll set the smaller dimesion if inputWidth > inputHeight { // set the height return f.ScaleDimensions(-2, maxSize) } return f.ScaleDimensions(maxSize, -2) } // ScaleMaxLM scales an image to fit within specified maximum dimensions while maintaining its aspect ratio. func (f VideoFilter) ScaleMaxLM(width int, height int, reqHeight int, maxWidth int, maxHeight int) VideoFilter { if maxWidth == 0 || maxHeight == 0 { return f.ScaleMax(width, height, reqHeight) } aspectRatio := float64(width) / float64(height) desiredHeight := reqHeight if desiredHeight == 0 { desiredHeight = height } desiredWidth := int(float64(desiredHeight) * aspectRatio) if desiredHeight <= maxHeight && desiredWidth <= maxWidth { return f.ScaleMax(width, height, reqHeight) } if float64(desiredHeight-maxHeight) > float64(desiredWidth-maxWidth) { return f.ScaleDimensions(-2, maxHeight) } else { return f.ScaleDimensions(maxWidth, -2) } } // Fps returns a VideoFilter setting the frames per second. func (f VideoFilter) Fps(fps int) VideoFilter { return f.Append(fmt.Sprintf("fps=%v", fps)) } // Select returns a VideoFilter to select the given frame. func (f VideoFilter) Select(frame int) VideoFilter { return f.Append(fmt.Sprintf("select=eq(n\\,%d)", frame)) } // Append returns a VideoFilter appending the given string. func (f VideoFilter) Append(s string) VideoFilter { // if filter is empty, then just set if f == "" { return VideoFilter(s) } return VideoFilter(fmt.Sprintf("%s,%s", f, s)) } ================================================ FILE: pkg/ffmpeg/format.go ================================================ package ffmpeg // Format represents the input/output format for ffmpeg. type Format string // Args converts the Format to a slice of arguments to be passed to ffmpeg. func (f Format) Args() []string { if f == "" { return nil } return []string{"-f", string(f)} } var ( FormatConcat Format = "concat" FormatImage2 Format = "image2" FormatRawVideo Format = "rawvideo" FormatMpegTS Format = "mpegts" FormatMP4 Format = "mp4" FormatWebm Format = "webm" FormatMatroska Format = "matroska" ) // ImageFormat represents the input format for an image for ffmpeg. type ImageFormat string // Args converts the ImageFormat to a slice of arguments to be passed to ffmpeg. func (f ImageFormat) Args() []string { if f == "" { return nil } return []string{"-f", string(f)} } var ( ImageFormatJpeg ImageFormat = "mjpeg" ImageFormatPng ImageFormat = "png_pipe" ImageFormatWebp ImageFormat = "webp_pipe" ImageFormatImage2Pipe ImageFormat = "image2pipe" ) ================================================ FILE: pkg/ffmpeg/frame_rate.go ================================================ package ffmpeg import ( "bytes" "context" "math" "regexp" "strconv" ) // FrameInfo contains the number of frames and the frame rate for a video file. type FrameInfo struct { FrameRate float64 NumberOfFrames int } // CalculateFrameRate calculates the frame rate and number of frames of the video file. // Used where the frame rate or NbFrames is missing or invalid in the ffprobe output. func (f *FFMpeg) CalculateFrameRate(ctx context.Context, v *VideoFile) (*FrameInfo, error) { var args Args args = append(args, "-nostats") args = args.Input(v.Path). VideoCodec(VideoCodecCopy). Format(FormatRawVideo). Overwrite(). NullOutput() command := f.Command(ctx, args) var stdErrBuffer bytes.Buffer command.Stderr = &stdErrBuffer // Frames go to stderr rather than stdout err := command.Run() if err == nil { var ret FrameInfo stdErrString := stdErrBuffer.String() ret.NumberOfFrames = getFrameFromRegex(stdErrString) time := getTimeFromRegex(stdErrString) ret.FrameRate = math.Round((float64(ret.NumberOfFrames)/time)*100) / 100 return &ret, nil } return nil, err } var timeRegex = regexp.MustCompile(`time=\s*(\d+):(\d+):(\d+.\d+)`) var frameRegex = regexp.MustCompile(`frame=\s*([0-9]+)`) func getTimeFromRegex(str string) float64 { regexResult := timeRegex.FindStringSubmatch(str) // Bail early if we don't have the results we expect if len(regexResult) != 4 { return 0 } h, _ := strconv.ParseFloat(regexResult[1], 64) m, _ := strconv.ParseFloat(regexResult[2], 64) s, _ := strconv.ParseFloat(regexResult[3], 64) hours := h * 3600 minutes := m * 60 seconds := s return hours + minutes + seconds } func getFrameFromRegex(str string) int { regexResult := frameRegex.FindStringSubmatch(str) // Bail early if we don't have the results we expect if len(regexResult) < 2 { return 0 } result, _ := strconv.Atoi(regexResult[1]) return result } ================================================ FILE: pkg/ffmpeg/generate.go ================================================ package ffmpeg import ( "bytes" "context" "errors" "fmt" "io" "os/exec" "strings" ) // Generate runs ffmpeg with the given args and waits for it to finish. // Returns an error if the command fails. If the command fails, the return // value will be of type *exec.ExitError. func (f *FFMpeg) Generate(ctx context.Context, args Args) error { cmd := f.Command(ctx, args) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Start(); err != nil { return fmt.Errorf("error starting command: %w", err) } if err := cmd.Wait(); err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { exitErr.Stderr = stderr.Bytes() err = exitErr } return fmt.Errorf("error running ffmpeg command <%s>: %w", strings.Join(args, " "), err) } return nil } // GenerateOutput runs ffmpeg with the given args and returns it standard output. func (f *FFMpeg) GenerateOutput(ctx context.Context, args []string, stdin io.Reader) ([]byte, error) { cmd := f.Command(ctx, args) cmd.Stdin = stdin ret, err := cmd.Output() if err != nil { return nil, fmt.Errorf("error running ffmpeg command <%s>: %w", strings.Join(args, " "), err) } return ret, nil } ================================================ FILE: pkg/ffmpeg/media_detection.go ================================================ package ffmpeg import ( "bytes" "os" ) // detect file format from magic file number // https://github.com/lex-r/filetype/blob/73c10ad714e3b8ecf5cd1564c882ed6d440d5c2d/matchers/video.go func mkv(buf []byte) bool { return len(buf) > 3 && buf[0] == 0x1A && buf[1] == 0x45 && buf[2] == 0xDF && buf[3] == 0xA3 && containsMatroskaSignature(buf, []byte{'m', 'a', 't', 'r', 'o', 's', 'k', 'a'}) } func webm(buf []byte) bool { return len(buf) > 3 && buf[0] == 0x1A && buf[1] == 0x45 && buf[2] == 0xDF && buf[3] == 0xA3 && containsMatroskaSignature(buf, []byte{'w', 'e', 'b', 'm'}) } func containsMatroskaSignature(buf, subType []byte) bool { limit := 4096 if len(buf) < limit { limit = len(buf) } index := bytes.Index(buf[:limit], subType) if index < 3 { return false } return buf[index-3] == 0x42 && buf[index-2] == 0x82 } // magicContainer returns the container type of a file path. // Returns the zero-value on errors or no-match. Implements mkv or // webm only, as ffprobe can't distinguish between them and not all // browsers support mkv func magicContainer(filePath string) (Container, error) { file, err := os.Open(filePath) if err != nil { return "", err } defer file.Close() buf := make([]byte, 4096) _, err = file.Read(buf) if err != nil { return "", err } if webm(buf) { return Webm, nil } if mkv(buf) { return Matroska, nil } return "", nil } ================================================ FILE: pkg/ffmpeg/options.go ================================================ package ffmpeg import ( "fmt" "runtime" ) // Arger is an interface that can be used to append arguments to an Args slice. type Arger interface { Args() []string } // Args represents a slice of arguments to be passed to ffmpeg. type Args []string // LogLevel sets the LogLevel to l and returns the result. func (a Args) LogLevel(l LogLevel) Args { if l == "" { return a } return append(a, l.Args()...) } // XError adds the -xerror flag and returns the result. func (a Args) XError() Args { return append(a, "-xerror") } // Overwrite adds the overwrite flag (-y) and returns the result. func (a Args) Overwrite() Args { return append(a, "-y") } // Seek adds a seek (-ss) to the given seconds and returns the result. func (a Args) Seek(seconds float64) Args { return append(a, "-ss", fmt.Sprint(seconds)) } // Duration sets the duration (-t) to the given seconds and returns the result. func (a Args) Duration(seconds float64) Args { return append(a, "-t", fmt.Sprint(seconds)) } // Input adds the input (-i) and returns the result. func (a Args) Input(i string) Args { return append(a, "-i", i) } // Output adds the output o and returns the result. func (a Args) Output(o string) Args { return append(a, o) } // NullOutput adds a null output and returns the result. // On Windows, this outputs to NUL, on everything else, /dev/null. func (a Args) NullOutput() Args { var output string if runtime.GOOS == "windows" { output = "nul" // https://stackoverflow.com/questions/313111/is-there-a-dev-null-on-windows } else { output = "/dev/null" } return a.Output(output) } // VideoFrames adds the -frames:v with f and returns the result. func (a Args) VideoFrames(f int) Args { return append(a, "-frames:v", fmt.Sprint(f)) } // FixedQualityScaleVideo adds the -q:v argument with q and returns the result. func (a Args) FixedQualityScaleVideo(q int) Args { return append(a, "-q:v", fmt.Sprint(q)) } // VideoFilter adds the vf video filter and returns the result. func (a Args) VideoFilter(vf VideoFilter) Args { return append(a, vf.Args()...) } // VSync adds the VsyncMethod and returns the result. func (a Args) VSync(m VSyncMethod) Args { return append(a, m.Args()...) } // AudioBitrate adds the -b:a argument with b and returns the result. func (a Args) AudioBitrate(b string) Args { return append(a, "-b:a", b) } // MaxMuxingQueueSize adds the -max_muxing_queue_size argument with s and returns the result. func (a Args) MaxMuxingQueueSize(s int) Args { // https://trac.ffmpeg.org/ticket/6375 return append(a, "-max_muxing_queue_size", fmt.Sprint(s)) } // SkipAudio adds the skip audio flag (-an) and returns the result. func (a Args) SkipAudio() Args { return append(a, "-an") } // VideoCodec adds the given video codec and returns the result. func (a Args) VideoCodec(c VideoCodec) Args { return append(a, c.Args()...) } // AudioCodec adds the given audio codec and returns the result. func (a Args) AudioCodec(c AudioCodec) Args { return append(a, c.Args()...) } // Format adds the format flag with f and returns the result. func (a Args) Format(f Format) Args { return append(a, f.Args()...) } // ImageFormat adds the image format (using -f) and returns the result. func (a Args) ImageFormat(f ImageFormat) Args { return append(a, f.Args()...) } // AppendArgs appends the given Arger to the Args and returns the result. func (a Args) AppendArgs(o Arger) Args { return append(a, o.Args()...) } // Args returns a string slice of the arguments. func (a Args) Args() []string { return []string(a) } // LogLevel represents the log level of ffmpeg. type LogLevel string // Args returns the arguments to set the log level in ffmpeg. func (l LogLevel) Args() []string { if l == "" { return nil } return []string{"-v", string(l)} } // LogLevels for ffmpeg. See -v entry under https://ffmpeg.org/ffmpeg.html#Generic-options var ( LogLevelQuiet LogLevel = "quiet" LogLevelPanic LogLevel = "panic" LogLevelFatal LogLevel = "fatal" LogLevelError LogLevel = "error" LogLevelWarning LogLevel = "warning" LogLevelInfo LogLevel = "info" LogLevelVerbose LogLevel = "verbose" LogLevelDebug LogLevel = "debug" LogLevelTrace LogLevel = "trace" ) // VSyncMethod represents the vsync method of ffmpeg. type VSyncMethod string // Args returns the arguments to set the vsync method in ffmpeg. func (m VSyncMethod) Args() []string { if m == "" { return nil } return []string{"-vsync", string(m)} } // Video sync methods for ffmpeg. See -vsync entry under https://ffmpeg.org/ffmpeg.html#Advanced-options var ( VSyncMethodPassthrough VSyncMethod = "0" VSyncMethodCFR VSyncMethod = "1" VSyncMethodVFR VSyncMethod = "2" VSyncMethodDrop VSyncMethod = "drop" VSyncMethodAuto VSyncMethod = "-1" ) ================================================ FILE: pkg/ffmpeg/stream.go ================================================ package ffmpeg import ( "context" "net/http" "sync" "time" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) const ( MimeWebmVideo string = "video/webm" MimeWebmAudio string = "audio/webm" MimeMkvVideo string = "video/x-matroska" MimeMkvAudio string = "audio/x-matroska" MimeMp4Video string = "video/mp4" MimeMp4Audio string = "audio/mp4" ) type StreamManager struct { cacheDir string encoder *FFMpeg ffprobe *FFProbe config StreamManagerConfig lockManager *fsutil.ReadLockManager context context.Context cancelFunc context.CancelFunc runningStreams map[string]*runningStream streamsMutex sync.Mutex } type StreamManagerConfig interface { GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum GetLiveTranscodeInputArgs() []string GetLiveTranscodeOutputArgs() []string GetTranscodeHardwareAcceleration() bool } func NewStreamManager(cacheDir string, encoder *FFMpeg, ffprobe *FFProbe, config StreamManagerConfig, lockManager *fsutil.ReadLockManager) *StreamManager { if cacheDir == "" { logger.Warn("cache directory is not set. Live HLS/DASH transcoding will be disabled") } ctx, cancel := context.WithCancel(context.Background()) ret := &StreamManager{ cacheDir: cacheDir, encoder: encoder, ffprobe: ffprobe, config: config, lockManager: lockManager, context: ctx, cancelFunc: cancel, runningStreams: make(map[string]*runningStream), } go func() { for { select { case <-time.After(monitorInterval): ret.monitorStreams() case <-ctx.Done(): return } } }() return ret } // Shutdown shuts down the stream manager, killing any running transcoding processes and removing all cached files. func (sm *StreamManager) Shutdown() { sm.cancelFunc() sm.stopAndRemoveAll() } type StreamRequestContext struct { context.Context ResponseWriter http.ResponseWriter } func NewStreamRequestContext(w http.ResponseWriter, r *http.Request) *StreamRequestContext { return &StreamRequestContext{ Context: r.Context(), ResponseWriter: w, } } func (c *StreamRequestContext) Cancel() { hj, ok := (c.ResponseWriter).(http.Hijacker) if !ok { return } // hijack and close the connection conn, bw, _ := hj.Hijack() if conn != nil { if bw != nil { // notify end of stream _, err := bw.WriteString("0\r\n") if err != nil { logger.Warnf("unable to write end of stream: %v", err) } _, err = bw.WriteString("\r\n") if err != nil { logger.Warnf("unable to write end of stream: %v", err) } // flush the buffer, but don't wait indefinitely timeout := make(chan struct{}, 1) go func() { _ = bw.Flush() close(timeout) }() const waitTime = time.Second select { case <-timeout: case <-time.After(waitTime): logger.Warnf("unable to flush buffer - closing connection") } } conn.Close() } } ================================================ FILE: pkg/ffmpeg/stream_segmented.go ================================================ package ffmpeg import ( "bytes" "context" "errors" "fmt" "io" "math" "net/http" "net/url" "os" "os/exec" "path/filepath" "strconv" "strings" "sync/atomic" "time" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" "github.com/zencoder/go-dash/v3/mpd" ) const ( MimeHLS string = "application/vnd.apple.mpegurl" MimeMpegTS string = "video/MP2T" MimeDASH string = "application/dash+xml" segmentLength = 2 maxSegmentWait = 15 * time.Second monitorInterval = 200 * time.Millisecond // segment gap before counting a request as a seek and // restarting the transcode process at the requested segment maxSegmentGap = 5 // maximum number of segments to generate // ahead of the currently streaming segment maxSegmentBuffer = 15 // maximum idle time between segment requests before // stopping transcode and deleting cache folder maxIdleTime = 30 * time.Second resolutionParamKey = "resolution" // TODO - setting the apikey in here isn't ideal apiKeyParamKey = "apikey" ) type StreamType struct { Name string SegmentType *SegmentType ServeManifest func(sm *StreamManager, w http.ResponseWriter, r *http.Request, vf *models.VideoFile, resolution string) Args func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) Args } var ( StreamTypeHLS = &StreamType{ Name: "hls", SegmentType: SegmentTypeTS, ServeManifest: serveHLSManifest, Args: func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) { args = CodecInit(codec) args = append(args, "-flags", "+cgop", "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", segmentLength), ) args = args.VideoFilter(videoFilter) if videoOnly { args = append(args, "-an") } else { args = append(args, "-c:a", "aac", "-ac", "2", ) } args = append(args, "-sn", "-copyts", "-avoid_negative_ts", "disabled", "-f", "hls", "-start_number", fmt.Sprint(segment), "-hls_time", fmt.Sprint(segmentLength), "-hls_flags", "split_by_time", "-hls_segment_type", "mpegts", "-hls_playlist_type", "vod", "-hls_segment_filename", filepath.Join(outputDir, ".%d.ts"), filepath.Join(outputDir, "manifest.m3u8"), ) return }, } StreamTypeHLSCopy = &StreamType{ Name: "hls-copy", SegmentType: SegmentTypeTS, ServeManifest: serveHLSManifest, Args: func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) { args = CodecInit(codec) if videoOnly { args = append(args, "-an") } else { args = append(args, "-c:a", "aac", "-ac", "2", ) } args = append(args, "-sn", "-copyts", "-avoid_negative_ts", "disabled", "-f", "hls", "-start_number", fmt.Sprint(segment), "-hls_time", fmt.Sprint(segmentLength), "-hls_flags", "split_by_time", "-hls_segment_type", "mpegts", "-hls_playlist_type", "vod", "-hls_segment_filename", filepath.Join(outputDir, ".%d.ts"), filepath.Join(outputDir, "manifest.m3u8"), ) return }, } StreamTypeDASHVideo = &StreamType{ Name: "dash-v", SegmentType: SegmentTypeWEBMVideo, ServeManifest: serveDASHManifest, Args: func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) { // only generate the actual init segment (init_v.webm) // when generating the first segment init := ".init" if segment == 0 { init = "init" } args = CodecInit(codec) args = append(args, "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", segmentLength), ) args = args.VideoFilter(videoFilter) args = append(args, "-copyts", "-avoid_negative_ts", "disabled", "-map", "0:v:0", "-f", "webm_chunk", "-chunk_start_index", fmt.Sprint(segment), "-header", filepath.Join(outputDir, init+"_v.webm"), filepath.Join(outputDir, ".%d_v.webm"), ) return }, } StreamTypeDASHAudio = &StreamType{ Name: "dash-a", SegmentType: SegmentTypeWEBMAudio, ServeManifest: serveDASHManifest, Args: func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) { // only generate the actual init segment (init_a.webm) // when generating the first segment init := ".init" if segment == 0 { init = "init" } args = append(args, "-c:a", "libopus", "-b:a", "96000", "-ar", "48000", "-copyts", "-avoid_negative_ts", "disabled", "-map", "0:a:0", "-f", "webm_chunk", "-chunk_start_index", fmt.Sprint(segment), "-audio_chunk_duration", fmt.Sprint(segmentLength*1000), "-header", filepath.Join(outputDir, init+"_a.webm"), filepath.Join(outputDir, ".%d_a.webm"), ) return }, } ) type SegmentType struct { Format string MimeType string MakeFilename func(segment int) string ParseSegment func(str string) (int, error) } var ( SegmentTypeTS = &SegmentType{ Format: "%d.ts", MimeType: MimeMpegTS, MakeFilename: func(segment int) string { return fmt.Sprintf("%d.ts", segment) }, ParseSegment: func(str string) (int, error) { segment, err := strconv.Atoi(str) if err != nil || segment < 0 { err = ErrInvalidSegment } return segment, err }, } SegmentTypeWEBMVideo = &SegmentType{ Format: "%d_v.webm", MimeType: MimeWebmVideo, MakeFilename: func(segment int) string { if segment == -1 { return "init_v.webm" } else { return fmt.Sprintf("%d_v.webm", segment) } }, ParseSegment: func(str string) (int, error) { if str == "init" { return -1, nil } else { segment, err := strconv.Atoi(str) if err != nil || segment < 0 { err = ErrInvalidSegment } return segment, err } }, } SegmentTypeWEBMAudio = &SegmentType{ Format: "%d_a.webm", MimeType: MimeWebmAudio, MakeFilename: func(segment int) string { if segment == -1 { return "init_a.webm" } else { return fmt.Sprintf("%d_a.webm", segment) } }, ParseSegment: func(str string) (int, error) { if str == "init" { return -1, nil } else { segment, err := strconv.Atoi(str) if err != nil || segment < 0 { err = ErrInvalidSegment } return segment, err } }, } ) var ErrInvalidSegment = errors.New("invalid segment") type StreamOptions struct { StreamType *StreamType VideoFile *models.VideoFile Resolution string Hash string Segment string } type transcodeProcess struct { cmd *exec.Cmd context context.Context cancel context.CancelFunc cancelled bool outputDir string segmentType *SegmentType segment int } type waitingSegment struct { segmentType *SegmentType idx int file string path string accessed time.Time available chan error done atomic.Bool } type runningStream struct { dir string streamType *StreamType vf *models.VideoFile maxTranscodeSize int outputDir string waitingSegments []*waitingSegment tp *transcodeProcess lastAccessed time.Time lastSegment int } func (t StreamType) String() string { return t.Name } func (t StreamType) FileDir(hash string, maxTranscodeSize int) string { if maxTranscodeSize == 0 { return fmt.Sprintf("%s_%s", hash, t) } else { return fmt.Sprintf("%s_%s_%d", hash, t, maxTranscodeSize) } } func HLSGetCodec(sm *StreamManager, name string) (codec VideoCodec) { switch name { case "hls": codec = VideoCodecLibX264 if hwcodec := sm.encoder.hwCodecHLSCompatible(); hwcodec != nil && sm.config.GetTranscodeHardwareAcceleration() { codec = *hwcodec } case "dash-v": codec = VideoCodecVP9 if hwcodec := sm.encoder.hwCodecWEBMCompatible(); hwcodec != nil && sm.config.GetTranscodeHardwareAcceleration() { codec = *hwcodec } case "hls-copy": codec = VideoCodecCopy } return codec } func (s *runningStream) makeStreamArgs(sm *StreamManager, segment int) Args { extraInputArgs := sm.config.GetLiveTranscodeInputArgs() extraOutputArgs := sm.config.GetLiveTranscodeOutputArgs() args := Args{"-hide_banner"} args = args.LogLevel(LogLevelError) codec := HLSGetCodec(sm, s.streamType.Name) fullhw := sm.config.GetTranscodeHardwareAcceleration() && sm.encoder.hwCanFullHWTranscode(sm.context, codec, s.vf, s.maxTranscodeSize) args = sm.encoder.hwDeviceInit(args, codec, fullhw) args = append(args, extraInputArgs...) if segment > 0 { args = args.Seek(float64(segment * segmentLength)) } args = args.Input(s.vf.Path) videoOnly := ProbeAudioCodec(s.vf.AudioCodec) == MissingUnsupported videoFilter := sm.encoder.hwMaxResFilter(codec, s.vf, s.maxTranscodeSize, fullhw) args = append(args, s.streamType.Args(codec, segment, videoFilter, videoOnly, s.outputDir)...) args = append(args, extraOutputArgs...) return args } // checkSegments renames temp segments that have been completely generated. // existing segments are not replaced - if a segment is generated // multiple times, then only the first one is kept. func (tp *transcodeProcess) checkSegments() { doSegment := func(filename string) { if filename != "" { oldPath := filepath.Join(tp.outputDir, filename) newPath := filepath.Join(tp.outputDir, filename[1:]) if !segmentExists(newPath) { _ = os.Rename(oldPath, newPath) } else { os.Remove(oldPath) } } } processState := tp.cmd.ProcessState var lastFilename string for i := tp.segment; ; i++ { filename := fmt.Sprintf("."+tp.segmentType.Format, i) if segmentExists(filepath.Join(tp.outputDir, filename)) { // this segment exists so the previous segment is valid doSegment(lastFilename) } else { // if the transcode process has exited then // we need to do something with the last segment if processState != nil { if processState.Success() { // if the process exited successfully then // count the last segment as valid doSegment(lastFilename) } else if lastFilename != "" { // if the process exited unsuccessfully then just delete // the last segment, it's probably incomplete os.Remove(filepath.Join(tp.outputDir, lastFilename)) } } break } lastFilename = filename tp.segment = i } } func lastSegment(vf *models.VideoFile) int { return int(math.Ceil(vf.Duration/segmentLength)) - 1 } func segmentExists(path string) bool { exists, _ := fsutil.FileExists(path) return exists } // serveHLSManifest serves a generated HLS playlist. The URLs for the segments // are of the form {r.URL}/%d.ts{?urlQuery} where %d is the segment index. func serveHLSManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request, vf *models.VideoFile, resolution string) { if sm.cacheDir == "" { logger.Error("[transcode] cannot live transcode with HLS because cache dir is unset") http.Error(w, "cannot live transcode with HLS because cache dir is unset", http.StatusServiceUnavailable) return } probeResult, err := sm.ffprobe.NewVideoFile(vf.Path) if err != nil { logger.Warnf("[transcode] error generating HLS manifest: %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } prefix := r.Header.Get("X-Forwarded-Prefix") baseUrl := *r.URL baseUrl.RawQuery = "" baseURL := prefix + baseUrl.String() urlQuery := url.Values{} apikey := r.URL.Query().Get(apiKeyParamKey) if resolution != "" { urlQuery.Set(resolutionParamKey, resolution) } // TODO - this needs to be handled outside of this package if apikey != "" { urlQuery.Set(apiKeyParamKey, apikey) } urlQueryString := "" if len(urlQuery) > 0 { urlQueryString = "?" + urlQuery.Encode() } var buf bytes.Buffer fmt.Fprint(&buf, "#EXTM3U\n") fmt.Fprint(&buf, "#EXT-X-VERSION:3\n") fmt.Fprint(&buf, "#EXT-X-MEDIA-SEQUENCE:0\n") fmt.Fprintf(&buf, "#EXT-X-TARGETDURATION:%d\n", segmentLength) fmt.Fprint(&buf, "#EXT-X-PLAYLIST-TYPE:VOD\n") leftover := probeResult.FileDuration segment := 0 for leftover > 0 { thisLength := float64(segmentLength) if leftover < thisLength { thisLength = leftover } fmt.Fprintf(&buf, "#EXTINF:%f,\n", thisLength) fmt.Fprintf(&buf, "%s/%d.ts%s\n", baseURL, segment, urlQueryString) leftover -= thisLength segment++ } fmt.Fprint(&buf, "#EXT-X-ENDLIST\n") w.Header().Set("Content-Type", MimeHLS) utils.ServeStaticContent(w, r, buf.Bytes()) } // serveDASHManifest serves a generated DASH manifest. func serveDASHManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request, vf *models.VideoFile, resolution string) { if sm.cacheDir == "" { logger.Error("[transcode] cannot live transcode with DASH because cache dir is unset") http.Error(w, "cannot live transcode files with DASH because cache dir is unset", http.StatusServiceUnavailable) return } probeResult, err := sm.ffprobe.NewVideoFile(vf.Path) if err != nil { logger.Warnf("[transcode] error generating DASH manifest: %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } var framerate string var videoWidth int var videoHeight int videoStream := probeResult.VideoStream if videoStream != nil { framerate = videoStream.AvgFrameRate videoWidth = videoStream.Width videoHeight = videoStream.Height } else { // extract the framerate fraction from the file framerate // framerates 0.1% below round numbers are common, // attempt to infer when this is the case fileFramerate := vf.FrameRate rate1001, off1001 := math.Modf(fileFramerate * 1.001) var numerator int var denominator int switch { case off1001 < 0.005: numerator = int(rate1001) * 1000 denominator = 1001 case off1001 > 0.995: numerator = (int(rate1001) + 1) * 1000 denominator = 1001 default: numerator = int(fileFramerate * 1000) denominator = 1000 } framerate = fmt.Sprintf("%d/%d", numerator, denominator) videoHeight = vf.Height videoWidth = vf.Width } urlQuery := url.Values{} // TODO - this needs to be handled outside of this package apikey := r.URL.Query().Get(apiKeyParamKey) if apikey != "" { urlQuery.Set(apiKeyParamKey, apikey) } maxTranscodeSize := sm.config.GetMaxStreamingTranscodeSize().GetMaxResolution() if resolution != "" { maxTranscodeSize = models.StreamingResolutionEnum(resolution).GetMaxResolution() urlQuery.Set(resolutionParamKey, resolution) } if maxTranscodeSize != 0 { videoSize := videoHeight if videoWidth < videoSize { videoSize = videoWidth } if maxTranscodeSize < videoSize { scaleFactor := float64(maxTranscodeSize) / float64(videoSize) videoWidth = int(float64(videoWidth) * scaleFactor) videoHeight = int(float64(videoHeight) * scaleFactor) } } urlQueryString := "" if len(urlQuery) > 0 { urlQueryString = "?" + urlQuery.Encode() } mediaDuration := mpd.Duration(time.Duration(probeResult.FileDuration * float64(time.Second))) m := mpd.NewMPD(mpd.DASH_PROFILE_LIVE, mediaDuration.String(), "PT4.0S") prefix := r.Header.Get("X-Forwarded-Prefix") baseUrl := r.URL.JoinPath("/") baseUrl.RawQuery = "" m.BaseURL = prefix + baseUrl.String() video, _ := m.AddNewAdaptationSetVideo(MimeWebmVideo, "progressive", true, 1) _, _ = video.SetNewSegmentTemplate(2, "init_v.webm"+urlQueryString, "$Number$_v.webm"+urlQueryString, 0, 1) _, _ = video.AddNewRepresentationVideo(200000, "vp09.00.40.08", "0", framerate, int64(videoWidth), int64(videoHeight)) if ProbeAudioCodec(vf.AudioCodec) != MissingUnsupported { audio, _ := m.AddNewAdaptationSetAudio(MimeWebmAudio, true, 1, "und") _, _ = audio.SetNewSegmentTemplate(2, "init_a.webm"+urlQueryString, "$Number$_a.webm"+urlQueryString, 0, 1) _, _ = audio.AddNewRepresentationAudio(48000, 96000, "opus", "1") } var buf bytes.Buffer _ = m.Write(&buf) w.Header().Set("Content-Type", MimeDASH) utils.ServeStaticContent(w, r, buf.Bytes()) } func (sm *StreamManager) ServeManifest(w http.ResponseWriter, r *http.Request, streamType *StreamType, vf *models.VideoFile, resolution string) { streamType.ServeManifest(sm, w, r, vf, resolution) } func (sm *StreamManager) serveWaitingSegment(w http.ResponseWriter, r *http.Request, segment *waitingSegment) { select { case <-r.Context().Done(): break case err := <-segment.available: if err == nil { logger.Tracef("[transcode] streaming segment file %s", segment.file) w.Header().Set("Content-Type", segment.segmentType.MimeType) utils.ServeStaticFile(w, r, segment.path) } else if !errors.Is(err, context.Canceled) { http.Error(w, err.Error(), http.StatusInternalServerError) } } segment.done.Store(true) } func (sm *StreamManager) ServeSegment(w http.ResponseWriter, r *http.Request, options StreamOptions) { if sm.cacheDir == "" { logger.Error("[transcode] cannot live transcode files because cache dir is unset") http.Error(w, "cannot live transcode files because cache dir is unset", http.StatusServiceUnavailable) return } if options.Hash == "" { http.Error(w, "invalid hash", http.StatusBadRequest) return } streamType := options.StreamType segment, err := streamType.SegmentType.ParseSegment(options.Segment) // error if segment is past the end of the video if err != nil || segment > lastSegment(options.VideoFile) { http.Error(w, "invalid segment", http.StatusBadRequest) return } maxTranscodeSize := sm.config.GetMaxStreamingTranscodeSize().GetMaxResolution() if options.Resolution != "" { maxTranscodeSize = models.StreamingResolutionEnum(options.Resolution).GetMaxResolution() } dir := options.StreamType.FileDir(options.Hash, maxTranscodeSize) outputDir := filepath.Join(sm.cacheDir, dir) name := streamType.SegmentType.MakeFilename(segment) file := filepath.Join(dir, name) sm.streamsMutex.Lock() stream := sm.runningStreams[dir] if stream == nil { stream = &runningStream{ dir: dir, streamType: options.StreamType, vf: options.VideoFile, maxTranscodeSize: maxTranscodeSize, outputDir: outputDir, // initialize to cap 10 to avoid reallocations waitingSegments: make([]*waitingSegment, 0, 10), } sm.runningStreams[dir] = stream } now := time.Now() stream.lastAccessed = now if segment != -1 { stream.lastSegment = segment } waitingSegment := &waitingSegment{ segmentType: streamType.SegmentType, idx: segment, file: file, path: filepath.Join(sm.cacheDir, file), accessed: now, available: make(chan error, 1), } stream.waitingSegments = append(stream.waitingSegments, waitingSegment) sm.streamsMutex.Unlock() sm.serveWaitingSegment(w, r, waitingSegment) } // assume lock is held func (sm *StreamManager) startTranscode(stream *runningStream, segment int, done chan<- error) { // generate segment 0 if init segment requested if segment == -1 { segment = 0 } logger.Debugf("[transcode] starting transcode for %s at segment #%d", stream.dir, segment) if err := os.MkdirAll(stream.outputDir, os.ModePerm); err != nil { logger.Errorf("[transcode] %v", err) done <- err return } lockCtx := sm.lockManager.ReadLock(sm.context, stream.vf.Path) args := stream.makeStreamArgs(sm, segment) cmd := sm.encoder.Command(lockCtx, args) stderr, err := cmd.StderrPipe() if err != nil { logger.Errorf("[transcode] ffmpeg stderr not available: %v", err) } stdout, err := cmd.StdoutPipe() if nil != err { logger.Errorf("[transcode] ffmpeg stdout not available: %v", err) } logger.Tracef("[transcode] running %s", cmd) if err := cmd.Start(); err != nil { lockCtx.Cancel() err = fmt.Errorf("error starting transcode process: %w", err) logger.Errorf("[transcode] %v", err) done <- err return } tp := &transcodeProcess{ cmd: cmd, context: lockCtx, cancel: lockCtx.Cancel, outputDir: stream.outputDir, segmentType: stream.streamType.SegmentType, segment: segment, } stream.tp = tp go func() { errStr, _ := io.ReadAll(stderr) outStr, _ := io.ReadAll(stdout) errCmd := cmd.Wait() var err error // don't log error if cancelled if !tp.cancelled { e := string(errStr) if e == "" { e = string(outStr) } if e != "" { err = errors.New(e) } else { err = errCmd } if err != nil { err = fmt.Errorf("ffmpeg error when running command <%s>: %w", strings.Join(cmd.Args, " "), err) var exitError *exec.ExitError if !errors.As(err, &exitError) { logger.Errorf("[transcode] %v", err) } } } sm.streamsMutex.Lock() // make sure that cancel is called to prevent memory leaks tp.cancel() // clear remaining segments after ffmpeg exit tp.checkSegments() if stream.tp == tp { stream.tp = nil } sm.streamsMutex.Unlock() if err != nil { done <- err } }() } // assume lock is held func (sm *StreamManager) stopTranscode(stream *runningStream) { tp := stream.tp if tp != nil { tp.cancel() tp.cancelled = true } } func (sm *StreamManager) checkTranscode(stream *runningStream, now time.Time) { if len(stream.waitingSegments) == 0 && stream.lastAccessed.Add(maxIdleTime).Before(now) { // Stream expired. Cancel the transcode process and delete the files logger.Debugf("[transcode] stream for %s not accessed recently. Cancelling transcode and removing files", stream.dir) sm.stopTranscode(stream) sm.removeTranscodeFiles(stream) delete(sm.runningStreams, stream.dir) return } if stream.tp != nil { segmentType := stream.streamType.SegmentType segment := stream.lastSegment // if all segments up to maxSegmentBuffer exist, stop transcode for i := segment; i < segment+maxSegmentBuffer; i++ { if !segmentExists(filepath.Join(stream.outputDir, segmentType.MakeFilename(i))) { return } } logger.Debugf("[transcode] stopping transcode for %s, buffer is full", stream.dir) sm.stopTranscode(stream) } } func (s *waitingSegment) checkAvailable(now time.Time) bool { if segmentExists(s.path) { s.available <- nil return true } else if s.accessed.Add(maxSegmentWait).Before(now) { err := fmt.Errorf("timed out waiting for segment file %s to be generated", s.file) logger.Errorf("[transcode] %v", err) s.available <- err return true } return false } // ensureTranscode will start a new transcode process if the transcode // is more than maxSegmentGap behind the requested segment func (sm *StreamManager) ensureTranscode(stream *runningStream, segment *waitingSegment) bool { segmentIdx := segment.idx tp := stream.tp if tp == nil { sm.startTranscode(stream, segmentIdx, segment.available) return true } else if segmentIdx < tp.segment || tp.segment+maxSegmentGap < segmentIdx { // only stop the transcode process here - it will be restarted only // after the old process exits as stream.tp will then be nil. sm.stopTranscode(stream) return true } return false } // runs every monitorInterval func (sm *StreamManager) monitorStreams() { sm.streamsMutex.Lock() defer sm.streamsMutex.Unlock() now := time.Now() for _, stream := range sm.runningStreams { if stream.tp != nil { stream.tp.checkSegments() } transcodeStarted := false temp := stream.waitingSegments[:0] for _, segment := range stream.waitingSegments { remove := false if segment.done.Load() || segment.checkAvailable(now) { remove = true } else if !transcodeStarted { transcodeStarted = sm.ensureTranscode(stream, segment) } if !remove { temp = append(temp, segment) } } stream.waitingSegments = temp if !transcodeStarted { sm.checkTranscode(stream, now) } } } // assume lock is held func (sm *StreamManager) removeTranscodeFiles(stream *runningStream) { path := stream.outputDir if err := os.RemoveAll(path); err != nil { logger.Warnf("[transcode] error removing segment directory %s: %v", path, err) } } // stopAndRemoveAll stops all current streams and removes all cache files func (sm *StreamManager) stopAndRemoveAll() { sm.streamsMutex.Lock() defer sm.streamsMutex.Unlock() for _, stream := range sm.runningStreams { for _, segment := range stream.waitingSegments { if len(segment.available) == 0 { segment.available <- context.Canceled } } sm.stopTranscode(stream) sm.removeTranscodeFiles(stream) } // ensure nothing else can use the map sm.runningStreams = nil } ================================================ FILE: pkg/ffmpeg/stream_transcode.go ================================================ package ffmpeg import ( "context" "errors" "io" "net/http" "os/exec" "strings" "syscall" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) type StreamFormat struct { MimeType string Args func(codec VideoCodec, videoFilter VideoFilter, videoOnly bool) Args } func CodecInit(codec VideoCodec) (args Args) { args = args.VideoCodec(codec) switch codec { // CPU Codecs case VideoCodecLibX264: args = append(args, "-pix_fmt", "yuv420p", "-preset", "veryfast", "-crf", "25", "-sc_threshold", "0", ) case VideoCodecVP9: args = append(args, "-pix_fmt", "yuv420p", "-deadline", "realtime", "-cpu-used", "5", "-row-mt", "1", "-crf", "30", "-b:v", "0", ) // HW Codecs case VideoCodecN264: args = append(args, "-rc", "vbr", "-cq", "15", ) case VideoCodecN264H: args = append(args, "-profile", "p7", "-tune", "hq", "-profile", "high", "-rc", "vbr", "-rc-lookahead", "60", "-surfaces", "64", "-spatial-aq", "1", "-aq-strength", "15", "-cq", "15", "-coder", "cabac", "-b_ref_mode", "middle", ) case VideoCodecI264, VideoCodecIVP9: args = append(args, "-global_quality", "20", "-preset", "faster", ) case VideoCodecI264C: args = append(args, "-q", "20", "-preset", "faster", ) case VideoCodecV264, VideoCodecVVP9: args = append(args, "-qp", "20", ) case VideoCodecA264: args = append(args, "-quality", "speed", ) case VideoCodecM264: args = append(args, "-realtime", "1", ) case VideoCodecO264: args = append(args, "-preset", "superfast", "-crf", "25", ) } return args } var ( StreamTypeMP4 = StreamFormat{ MimeType: MimeMp4Video, Args: func(codec VideoCodec, videoFilter VideoFilter, videoOnly bool) (args Args) { args = CodecInit(codec) args = append(args, "-movflags", "frag_keyframe+empty_moov") args = args.VideoFilter(videoFilter) if videoOnly { args = args.SkipAudio() } else { args = append(args, "-ac", "2") } args = args.Format(FormatMP4) return }, } StreamTypeWEBM = StreamFormat{ MimeType: MimeWebmVideo, Args: func(codec VideoCodec, videoFilter VideoFilter, videoOnly bool) (args Args) { args = CodecInit(codec) args = args.VideoFilter(videoFilter) if videoOnly { args = args.SkipAudio() } else { args = append(args, "-ac", "2") } args = args.Format(FormatWebm) return }, } StreamTypeMKV = StreamFormat{ MimeType: MimeMkvVideo, Args: func(codec VideoCodec, videoFilter VideoFilter, videoOnly bool) (args Args) { args = CodecInit(codec) if videoOnly { args = args.SkipAudio() } else { args = args.AudioCodec(AudioCodecLibOpus) args = append(args, "-b:a", "96k", "-vbr", "on", "-ac", "2", ) } args = args.Format(FormatMatroska) return }, } ) type TranscodeOptions struct { StreamType StreamFormat VideoFile *models.VideoFile Resolution string StartTime float64 } func (o TranscodeOptions) FileGetCodec(sm *StreamManager, maxTranscodeSize int) (codec VideoCodec) { needsResize := false if maxTranscodeSize != 0 { if o.VideoFile.Width > o.VideoFile.Height { needsResize = o.VideoFile.Width > maxTranscodeSize } else { needsResize = o.VideoFile.Height > maxTranscodeSize } } switch o.StreamType.MimeType { case MimeMp4Video: if !needsResize && o.VideoFile.VideoCodec == H264 { return VideoCodecCopy } codec = VideoCodecLibX264 if hwcodec := sm.encoder.hwCodecMP4Compatible(); hwcodec != nil && sm.config.GetTranscodeHardwareAcceleration() { codec = *hwcodec } case MimeWebmVideo: if !needsResize && (o.VideoFile.VideoCodec == Vp8 || o.VideoFile.VideoCodec == Vp9) { return VideoCodecCopy } codec = VideoCodecVP9 if hwcodec := sm.encoder.hwCodecWEBMCompatible(); hwcodec != nil && sm.config.GetTranscodeHardwareAcceleration() { codec = *hwcodec } case MimeMkvVideo: codec = VideoCodecCopy } return codec } func (o TranscodeOptions) makeStreamArgs(sm *StreamManager) Args { maxTranscodeSize := sm.config.GetMaxStreamingTranscodeSize().GetMaxResolution() if o.Resolution != "" { maxTranscodeSize = models.StreamingResolutionEnum(o.Resolution).GetMaxResolution() } extraInputArgs := sm.config.GetLiveTranscodeInputArgs() extraOutputArgs := sm.config.GetLiveTranscodeOutputArgs() args := Args{"-hide_banner"} args = args.LogLevel(LogLevelError) codec := o.FileGetCodec(sm, maxTranscodeSize) fullhw := sm.config.GetTranscodeHardwareAcceleration() && sm.encoder.hwCanFullHWTranscode(sm.context, codec, o.VideoFile, maxTranscodeSize) args = sm.encoder.hwDeviceInit(args, codec, fullhw) args = append(args, extraInputArgs...) if o.StartTime != 0 { args = args.Seek(o.StartTime) } args = args.Input(o.VideoFile.Path) videoOnly := ProbeAudioCodec(o.VideoFile.AudioCodec) == MissingUnsupported videoFilter := sm.encoder.hwMaxResFilter(codec, o.VideoFile, maxTranscodeSize, fullhw) args = append(args, o.StreamType.Args(codec, videoFilter, videoOnly)...) args = append(args, extraOutputArgs...) args = args.Output("pipe:") return args } func (sm *StreamManager) ServeTranscode(w http.ResponseWriter, r *http.Request, options TranscodeOptions) { streamRequestCtx := NewStreamRequestContext(w, r) lockCtx := sm.lockManager.ReadLock(streamRequestCtx, options.VideoFile.Path) // hijacking and closing the connection here causes video playback to hang in Chrome // due to ERR_INCOMPLETE_CHUNKED_ENCODING // We trust that the request context will be closed, so we don't need to call Cancel on the returned context here. handler, err := sm.getTranscodeStream(lockCtx, options) if err != nil { // don't log context canceled errors if !errors.Is(err, context.Canceled) { logger.Errorf("[transcode] error transcoding video file: %v", err) } w.WriteHeader(http.StatusBadRequest) if _, err := w.Write([]byte(err.Error())); err != nil { logger.Warnf("[transcode] error writing response: %v", err) } return } handler(w, r) } func (sm *StreamManager) getTranscodeStream(ctx *fsutil.LockContext, options TranscodeOptions) (http.HandlerFunc, error) { args := options.makeStreamArgs(sm) cmd := sm.encoder.Command(ctx, args) stdout, err := cmd.StdoutPipe() if nil != err { logger.Errorf("[transcode] ffmpeg stdout not available: %v", err) return nil, err } stderr, err := cmd.StderrPipe() if nil != err { logger.Errorf("[transcode] ffmpeg stderr not available: %v", err) return nil, err } if err = cmd.Start(); err != nil { return nil, err } ctx.AttachCommand(cmd) // stderr must be consumed or the process deadlocks go func() { errStr, _ := io.ReadAll(stderr) errCmd := cmd.Wait() var err error e := string(errStr) if e != "" { err = errors.New(e) } else { err = errCmd } // ignore ExitErrors, the process is always forcibly killed var exitError *exec.ExitError if err != nil && !errors.As(err, &exitError) { logger.Errorf("[transcode] ffmpeg error when running command <%s>: %v", strings.Join(cmd.Args, " "), err) } }() mimeType := options.StreamType.MimeType handler := func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", mimeType) w.WriteHeader(http.StatusOK) // process killing should be handled by command context _, err := io.Copy(w, stdout) if err != nil && !errors.Is(err, syscall.EPIPE) && !errors.Is(err, syscall.ECONNRESET) { logger.Errorf("[transcode] error serving transcoded video file: %v", err) } w.(http.Flusher).Flush() } return handler, nil } ================================================ FILE: pkg/ffmpeg/transcoder/image.go ================================================ package transcoder import ( "errors" "github.com/stashapp/stash/pkg/ffmpeg" ) var ErrUnsupportedFormat = errors.New("unsupported image format") type ImageThumbnailOptions struct { InputFormat ffmpeg.ImageFormat OutputFormat ffmpeg.ImageFormat OutputPath string MaxDimensions int Quality int } func ImageThumbnail(input string, options ImageThumbnailOptions) ffmpeg.Args { var videoFilter ffmpeg.VideoFilter videoFilter = videoFilter.ScaleMaxSize(options.MaxDimensions) var args ffmpeg.Args args = append(args, "-hide_banner") args = args.LogLevel(ffmpeg.LogLevelError) args = args.Overwrite(). ImageFormat(options.InputFormat). Input(input). VideoFilter(videoFilter). VideoCodec(ffmpeg.VideoCodecMJpeg) args = append(args, "-frames:v", "1") if options.Quality > 0 { args = args.FixedQualityScaleVideo(options.Quality) } args = args.ImageFormat(ffmpeg.ImageFormatImage2Pipe). Output(options.OutputPath). ImageFormat(options.OutputFormat) return args } ================================================ FILE: pkg/ffmpeg/transcoder/screenshot.go ================================================ package transcoder import "github.com/stashapp/stash/pkg/ffmpeg" type ScreenshotOptions struct { OutputPath string OutputType ScreenshotOutputType // Quality is the quality scale. See https://ffmpeg.org/ffmpeg.html#Main-options Quality int // Width is the width to scale the screenshot to. If 0, no scaling will be applied. Width int // Height is the height to scale the screenshot to. If 0, no scaling will be applied. // Not used if Width is set. Height int // Verbosity is the logging verbosity. Defaults to LogLevelError if not set. Verbosity ffmpeg.LogLevel UseSelectFilter bool } func (o *ScreenshotOptions) setDefaults() { if o.Verbosity == "" { o.Verbosity = ffmpeg.LogLevelError } } type ScreenshotOutputType struct { codec *ffmpeg.VideoCodec format ffmpeg.Format } func (t ScreenshotOutputType) Args() []string { var ret []string if t.codec != nil { ret = append(ret, t.codec.Args()...) } if t.format != "" { ret = append(ret, t.format.Args()...) } return ret } var ( ScreenshotOutputTypeImage2 = ScreenshotOutputType{ format: "image2", } ScreenshotOutputTypeBMP = ScreenshotOutputType{ codec: &ffmpeg.VideoCodecBMP, format: "rawvideo", } ) func ScreenshotTime(input string, t float64, options ScreenshotOptions) ffmpeg.Args { options.setDefaults() var args ffmpeg.Args args = args.LogLevel(options.Verbosity) args = args.Overwrite() args = args.Seek(t) args = args.Input(input) args = args.VideoFrames(1) if options.Quality > 0 { args = args.FixedQualityScaleVideo(options.Quality) } var vf ffmpeg.VideoFilter if options.Width > 0 { vf = vf.ScaleWidth(options.Width) args = args.VideoFilter(vf) } else if options.Height > 0 { vf = vf.ScaleHeight(options.Height) args = args.VideoFilter(vf) } args = args.AppendArgs(options.OutputType) args = args.Output(options.OutputPath) return args } // ScreenshotFrame uses the select filter to get a single frame from the video. // It is very slow and should only be used for files with very small duration in secs / frame count. func ScreenshotFrame(input string, frame int, options ScreenshotOptions) ffmpeg.Args { options.setDefaults() var args ffmpeg.Args args = args.LogLevel(options.Verbosity) args = args.Overwrite() args = args.Input(input) args = args.VideoFrames(1) args = args.VSync(ffmpeg.VSyncMethodPassthrough) var vf ffmpeg.VideoFilter // keep only frame number options.Frame) vf = vf.Select(frame) if options.Width > 0 { vf = vf.ScaleWidth(options.Width) } args = args.VideoFilter(vf) args = args.AppendArgs(options.OutputType) args = args.Output(options.OutputPath) return args } ================================================ FILE: pkg/ffmpeg/transcoder/splice.go ================================================ package transcoder import ( "runtime" "strings" "github.com/stashapp/stash/pkg/ffmpeg" ) type SpliceOptions struct { OutputPath string Format ffmpeg.Format VideoCodec *ffmpeg.VideoCodec VideoArgs ffmpeg.Args AudioCodec ffmpeg.AudioCodec AudioArgs ffmpeg.Args // Verbosity is the logging verbosity. Defaults to LogLevelError if not set. Verbosity ffmpeg.LogLevel } func (o *SpliceOptions) setDefaults() { if o.Verbosity == "" { o.Verbosity = ffmpeg.LogLevelError } } // fixWindowsPath replaces \ with / in the given path because the \ isn't recognized as valid on windows ffmpeg func fixWindowsPath(str string) string { if runtime.GOOS == "windows" { return strings.ReplaceAll(str, `\`, "/") } return str } func Splice(concatFile string, options SpliceOptions) ffmpeg.Args { options.setDefaults() var args ffmpeg.Args args = args.LogLevel(options.Verbosity) args = args.Format(ffmpeg.FormatConcat) args = args.Input(fixWindowsPath(concatFile)) args = args.Overwrite() // if video codec is not provided, then use copy if options.VideoCodec == nil { options.VideoCodec = &ffmpeg.VideoCodecCopy } args = args.VideoCodec(*options.VideoCodec) args = args.AppendArgs(options.VideoArgs) // if audio codec is not provided, then use copy if options.AudioCodec == "" { options.AudioCodec = ffmpeg.AudioCodecCopy } args = args.AudioCodec(options.AudioCodec) args = args.AppendArgs(options.AudioArgs) args = args.Format(options.Format) args = args.Output(options.OutputPath) return args } ================================================ FILE: pkg/ffmpeg/transcoder/transcode.go ================================================ package transcoder import "github.com/stashapp/stash/pkg/ffmpeg" type TranscodeOptions struct { OutputPath string Format ffmpeg.Format VideoCodec ffmpeg.VideoCodec VideoArgs ffmpeg.Args AudioCodec ffmpeg.AudioCodec AudioArgs ffmpeg.Args // if XError is true, then ffmpeg will fail on warnings XError bool StartTime float64 SlowSeek bool Duration float64 // Verbosity is the logging verbosity. Defaults to LogLevelError if not set. Verbosity ffmpeg.LogLevel // arguments added before the input argument ExtraInputArgs []string // arguments added before the output argument ExtraOutputArgs []string } func (o *TranscodeOptions) setDefaults() { if o.Verbosity == "" { o.Verbosity = ffmpeg.LogLevelError } } func Transcode(input string, options TranscodeOptions) ffmpeg.Args { options.setDefaults() // TODO - this should probably be generalised and applied to all operations. Need to verify impact on phash algorithm. const fallbackMinSlowSeek = 20.0 var fastSeek float64 var slowSeek float64 if !options.SlowSeek { fastSeek = options.StartTime slowSeek = 0 } else { // In slowseek mode, try a combination of fast/slow seek instead of just fastseek // Commonly with avi/wmv ffmpeg doesn't seem to always predict the right start point to begin decoding when // using fast seek. If you force ffmpeg to decode more, it avoids the "blocky green artifact" issue. if options.StartTime > fallbackMinSlowSeek { // Handle seeks longer than fallbackMinSlowSeek with fast/slow seeks // Allow for at least fallbackMinSlowSeek seconds of slow seek fastSeek = options.StartTime - fallbackMinSlowSeek slowSeek = fallbackMinSlowSeek } else { // Handle seeks shorter than fallbackMinSlowSeek with only slow seeks. slowSeek = options.StartTime fastSeek = 0 } } var args ffmpeg.Args args = args.LogLevel(options.Verbosity).Overwrite() args = append(args, options.ExtraInputArgs...) if options.XError { args = args.XError() } if fastSeek > 0 { args = args.Seek(fastSeek) } args = args.Input(input) if slowSeek > 0 { args = args.Seek(slowSeek) } if options.Duration > 0 { args = args.Duration(options.Duration) } // https://trac.ffmpeg.org/ticket/6375 args = args.MaxMuxingQueueSize(1024) args = args.VideoCodec(options.VideoCodec) args = args.AppendArgs(options.VideoArgs) // if audio codec is not provided, then skip it if options.AudioCodec == "" { args = args.SkipAudio() } else { args = args.AudioCodec(options.AudioCodec) } args = args.AppendArgs(options.AudioArgs) args = append(args, options.ExtraOutputArgs...) args = args.Format(options.Format) args = args.Output(options.OutputPath) return args } ================================================ FILE: pkg/ffmpeg/types.go ================================================ package ffmpeg import ( "github.com/stashapp/stash/pkg/models/json" ) // FFProbeJSON is the JSON output of ffprobe. type FFProbeJSON struct { Format struct { BitRate string `json:"bit_rate"` Duration string `json:"duration"` Filename string `json:"filename"` FormatLongName string `json:"format_long_name"` FormatName string `json:"format_name"` NbPrograms int `json:"nb_programs"` NbStreams int `json:"nb_streams"` ProbeScore int `json:"probe_score"` Size string `json:"size"` StartTime string `json:"start_time"` Tags struct { CompatibleBrands string `json:"compatible_brands"` CreationTime json.JSONTime `json:"creation_time"` Encoder string `json:"encoder"` MajorBrand string `json:"major_brand"` MinorVersion string `json:"minor_version"` Title string `json:"title"` Comment string `json:"comment"` } `json:"tags"` } `json:"format"` Streams []FFProbeStream `json:"streams"` Error struct { Code int `json:"code"` String string `json:"string"` } `json:"error"` } // FFProbeStream is a JSON representation of an ffmpeg stream. type FFProbeStream struct { AvgFrameRate string `json:"avg_frame_rate"` BitRate string `json:"bit_rate"` BitsPerRawSample string `json:"bits_per_raw_sample,omitempty"` ChromaLocation string `json:"chroma_location,omitempty"` CodecLongName string `json:"codec_long_name"` CodecName string `json:"codec_name"` CodecTag string `json:"codec_tag"` CodecTagString string `json:"codec_tag_string"` CodecTimeBase string `json:"codec_time_base"` CodecType string `json:"codec_type"` CodedHeight int `json:"coded_height,omitempty"` CodedWidth int `json:"coded_width,omitempty"` DisplayAspectRatio string `json:"display_aspect_ratio,omitempty"` Disposition struct { AttachedPic int `json:"attached_pic"` CleanEffects int `json:"clean_effects"` Comment int `json:"comment"` Default int `json:"default"` Dub int `json:"dub"` Forced int `json:"forced"` HearingImpaired int `json:"hearing_impaired"` Karaoke int `json:"karaoke"` Lyrics int `json:"lyrics"` Original int `json:"original"` TimedThumbnails int `json:"timed_thumbnails"` VisualImpaired int `json:"visual_impaired"` } `json:"disposition"` Duration string `json:"duration"` DurationTs int64 `json:"duration_ts"` HasBFrames int `json:"has_b_frames,omitempty"` Height int `json:"height,omitempty"` Index int `json:"index"` IsAvc string `json:"is_avc,omitempty"` Level int `json:"level,omitempty"` NalLengthSize string `json:"nal_length_size,omitempty"` NbFrames string `json:"nb_frames"` NbReadFrames string `json:"nb_read_frames"` PixFmt string `json:"pix_fmt,omitempty"` Profile string `json:"profile"` RFrameRate string `json:"r_frame_rate"` Refs int `json:"refs,omitempty"` SampleAspectRatio string `json:"sample_aspect_ratio,omitempty"` StartPts int64 `json:"start_pts"` StartTime string `json:"start_time"` Tags struct { CreationTime json.JSONTime `json:"creation_time"` HandlerName string `json:"handler_name"` Language string `json:"language"` Rotate string `json:"rotate"` } `json:"tags"` TimeBase string `json:"time_base"` Width int `json:"width,omitempty"` BitsPerSample int `json:"bits_per_sample,omitempty"` ChannelLayout string `json:"channel_layout,omitempty"` Channels int `json:"channels,omitempty"` MaxBitRate string `json:"max_bit_rate,omitempty"` SampleFmt string `json:"sample_fmt,omitempty"` SampleRate string `json:"sample_rate,omitempty"` SideDataList []struct { Rotation int `json:"rotation"` } `json:"side_data_list"` } ================================================ FILE: pkg/file/clean.go ================================================ package file import ( "context" "errors" "fmt" "io/fs" "os" "path/filepath" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) // Cleaner scans through stored file and folder instances and removes those that are no longer present on disk. type Cleaner struct { FS models.FS Repository Repository Handlers []CleanHandler TrashPath string } type cleanJob struct { *Cleaner progress *job.Progress options CleanOptions } // CleanOptions provides options for scanning files. type CleanOptions struct { Paths []string // IgnoreZipFileContents will skip checking the contents of zip files when determining whether to clean a file. // This can significantly speed up the clean process, but will potentially miss removed files within zip files. // Where users do not modify zip files contents directly, this should be safe to use. IgnoreZipFileContents bool // Do a dry run. Don't delete any files DryRun bool // PathFilter are used to determine if a file should be included. // Excluded files are marked for cleaning. PathFilter PathFilter } // Clean starts the clean process. func (s *Cleaner) Clean(ctx context.Context, options CleanOptions, progress *job.Progress) { j := &cleanJob{ Cleaner: s, progress: progress, options: options, } if err := j.execute(ctx); err != nil { logger.Errorf("error cleaning files: %v", err) return } } type fileOrFolder struct { fileID models.FileID folderID models.FolderID } type deleteSet struct { orderedList []fileOrFolder fileIDSet map[models.FileID]string folderIDSet map[models.FolderID]string } func newDeleteSet() deleteSet { return deleteSet{ fileIDSet: make(map[models.FileID]string), folderIDSet: make(map[models.FolderID]string), } } func (s *deleteSet) add(id models.FileID, path string) { if _, ok := s.fileIDSet[id]; !ok { s.orderedList = append(s.orderedList, fileOrFolder{fileID: id}) s.fileIDSet[id] = path } } func (s *deleteSet) has(id models.FileID) bool { _, ok := s.fileIDSet[id] return ok } func (s *deleteSet) addFolder(id models.FolderID, path string) { if _, ok := s.folderIDSet[id]; !ok { s.orderedList = append(s.orderedList, fileOrFolder{folderID: id}) s.folderIDSet[id] = path } } func (s *deleteSet) hasFolder(id models.FolderID) bool { _, ok := s.folderIDSet[id] return ok } func (s *deleteSet) len() int { return len(s.orderedList) } func (j *cleanJob) execute(ctx context.Context) error { progress := j.progress toDelete := newDeleteSet() var ( fileCount int folderCount int ) r := j.Repository if err := r.WithReadTxn(ctx, func(ctx context.Context) error { var err error fileCount, err = r.File.CountAllInPaths(ctx, j.options.Paths) if err != nil { return err } folderCount, err = r.Folder.CountAllInPaths(ctx, j.options.Paths) if err != nil { return err } return nil }); err != nil { return err } progress.AddTotal(fileCount + folderCount) progress.Definite() if err := j.assessFiles(ctx, &toDelete); err != nil { return err } if err := j.assessFolders(ctx, &toDelete); err != nil { return err } if j.options.DryRun && toDelete.len() > 0 { // add progress for files that would've been deleted progress.AddProcessed(toDelete.len()) return nil } progress.ExecuteTask(fmt.Sprintf("Cleaning %d files and folders", toDelete.len()), func() { for _, ff := range toDelete.orderedList { if job.IsCancelled(ctx) { return } if ff.fileID != 0 { j.deleteFile(ctx, ff.fileID, toDelete.fileIDSet[ff.fileID]) } if ff.folderID != 0 { j.deleteFolder(ctx, ff.folderID, toDelete.folderIDSet[ff.folderID]) } progress.Increment() } }) return nil } func (j *cleanJob) assessFiles(ctx context.Context, toDelete *deleteSet) error { const batchSize = 1000 offset := 0 progress := j.progress more := true r := j.Repository includeZipContents := !j.options.IgnoreZipFileContents if err := r.WithReadTxn(ctx, func(ctx context.Context) error { for more { if job.IsCancelled(ctx) { return nil } files, err := r.File.FindAllInPaths(ctx, j.options.Paths, includeZipContents, batchSize, offset) if err != nil { return fmt.Errorf("error querying for files: %w", err) } for _, f := range files { path := f.Base().Path err = nil fileID := f.Base().ID // short-cut, don't assess if already added if toDelete.has(fileID) { continue } progress.ExecuteTask(fmt.Sprintf("Assessing file %s for clean", path), func() { if j.shouldClean(ctx, f) { err = j.flagFileForDelete(ctx, toDelete, f) } else { // increment progress, no further processing progress.Increment() } }) if err != nil { return err } } if len(files) != batchSize { more = false } else { offset += batchSize } } return nil }); err != nil { return err } return nil } // flagFolderForDelete adds folders to the toDelete set, with the leaf folders added first func (j *cleanJob) flagFileForDelete(ctx context.Context, toDelete *deleteSet, f models.File) error { r := j.Repository // add contained files first containedFiles, err := r.File.FindByZipFileID(ctx, f.Base().ID) if err != nil { return fmt.Errorf("error finding contained files for %q: %w", f.Base().Path, err) } for _, cf := range containedFiles { logger.Infof("Marking contained file %q to clean", cf.Base().Path) toDelete.add(cf.Base().ID, cf.Base().Path) } // add contained folders as well containedFolders, err := r.Folder.FindByZipFileID(ctx, f.Base().ID) if err != nil { return fmt.Errorf("error finding contained folders for %q: %w", f.Base().Path, err) } for _, cf := range containedFolders { logger.Infof("Marking contained folder %q to clean", cf.Path) toDelete.addFolder(cf.ID, cf.Path) } toDelete.add(f.Base().ID, f.Base().Path) return nil } func (j *cleanJob) assessFolders(ctx context.Context, toDelete *deleteSet) error { const batchSize = 1000 offset := 0 progress := j.progress includeZipContents := !j.options.IgnoreZipFileContents more := true r := j.Repository if err := r.WithReadTxn(ctx, func(ctx context.Context) error { for more { if job.IsCancelled(ctx) { return nil } folders, err := r.Folder.FindAllInPaths(ctx, j.options.Paths, includeZipContents, batchSize, offset) if err != nil { return fmt.Errorf("error querying for folders: %w", err) } for _, f := range folders { path := f.Path folderID := f.ID // short-cut, don't assess if already added if toDelete.hasFolder(folderID) { continue } err = nil progress.ExecuteTask(fmt.Sprintf("Assessing folder %s for clean", path), func() { if j.shouldCleanFolder(ctx, f) { if err = j.flagFolderForDelete(ctx, toDelete, f); err != nil { return } } else { // increment progress, no further processing progress.Increment() } }) if err != nil { return err } } if len(folders) != batchSize { more = false } else { offset += batchSize } } return nil }); err != nil { return err } return nil } func (j *cleanJob) flagFolderForDelete(ctx context.Context, toDelete *deleteSet, folder *models.Folder) error { // it is possible that child folders may be included while parent folders are not // so we need to check child folders separately toDelete.addFolder(folder.ID, folder.Path) return nil } func isNotFound(err error) bool { // ErrInvalid can occur in zip files where the zip file path changed // and the underlying folder did not // #3877 - fs.PathError can occur if the network share no longer exists var pathErr *fs.PathError return err != nil && (errors.Is(err, fs.ErrNotExist) || errors.Is(err, fs.ErrInvalid) || errors.As(err, &pathErr)) } func (j *cleanJob) shouldClean(ctx context.Context, f models.File) bool { path := f.Base().Path info, err := f.Base().Info(j.FS) if err != nil && !isNotFound(err) { logger.Errorf("error getting file info for %q, not cleaning: %v", path, err) return false } if info == nil { // info is nil - file not exist logger.Infof("File not found. Marking to clean: \"%s\"", path) return true } // run through path filter, if returns false then the file should be cleaned filter := j.options.PathFilter // need to get the zip file path if present zipFilePath := "" if f.Base().ZipFile != nil { zipFilePath = f.Base().ZipFile.Base().Path } // don't log anything - assume filter will have logged the reason return !filter.Accept(ctx, path, info, zipFilePath) } func (j *cleanJob) shouldCleanFolder(ctx context.Context, f *models.Folder) bool { path := f.Path info, err := f.Info(j.FS) if err != nil && !isNotFound(err) { logger.Errorf("error getting folder info for %q, not cleaning: %v", path, err) return false } if info == nil { // info is nil - file not exist logger.Infof("Folder not found. Marking to clean: \"%s\"", path) return true } // #3261 - handle symlinks if info.Mode()&os.ModeSymlink == os.ModeSymlink { finalPath, err := filepath.EvalSymlinks(path) if err != nil { // don't bail out if symlink is invalid logger.Infof("Invalid symlink. Marking to clean: \"%s\"", path) return true } info, err = j.FS.Lstat(finalPath) if err != nil && !isNotFound(err) { logger.Errorf("error getting file info for %q (-> %s), not cleaning: %v", path, finalPath, err) return false } } // run through path filter, if returns false then the file should be cleaned filter := j.options.PathFilter // need to get the zip file path if present zipFilePath := "" if f.ZipFile != nil { zipFilePath = f.ZipFile.Base().Path } // don't log anything - assume filter will have logged the reason return !filter.Accept(ctx, path, info, zipFilePath) } func (j *cleanJob) deleteFile(ctx context.Context, fileID models.FileID, fn string) { // delete associated objects fileDeleter := NewDeleterWithTrash(j.TrashPath) r := j.Repository if err := r.WithTxn(ctx, func(ctx context.Context) error { fileDeleter.RegisterHooks(ctx) if err := j.fireHandlers(ctx, fileDeleter, fileID); err != nil { return err } return r.File.Destroy(ctx, fileID) }); err != nil { logger.Errorf("Error deleting file %q from database: %s", fn, err.Error()) return } } func (j *cleanJob) deleteFolder(ctx context.Context, folderID models.FolderID, fn string) { // delete associated objects fileDeleter := NewDeleterWithTrash(j.TrashPath) r := j.Repository if err := r.WithTxn(ctx, func(ctx context.Context) error { fileDeleter.RegisterHooks(ctx) if err := j.fireFolderHandlers(ctx, fileDeleter, folderID); err != nil { return err } return r.Folder.Destroy(ctx, folderID) }); err != nil { logger.Errorf("Error deleting folder %q from database: %s", fn, err.Error()) return } } func (j *cleanJob) fireHandlers(ctx context.Context, fileDeleter *Deleter, fileID models.FileID) error { for _, h := range j.Handlers { if err := h.HandleFile(ctx, fileDeleter, fileID); err != nil { return err } } return nil } func (j *cleanJob) fireFolderHandlers(ctx context.Context, fileDeleter *Deleter, folderID models.FolderID) error { for _, h := range j.Handlers { if err := h.HandleFolder(ctx, fileDeleter, folderID); err != nil { return err } } return nil } ================================================ FILE: pkg/file/delete.go ================================================ package file import ( "context" "errors" "fmt" "io/fs" "os" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/txn" ) const deleteFileSuffix = ".delete" // RenamerRemover provides access to the Rename and Remove functions. type RenamerRemover interface { Renamer Remove(name string) error RemoveAll(path string) error Statter } type renamerRemoverImpl struct { RenameFn func(oldpath, newpath string) error RemoveFn func(name string) error RemoveAllFn func(path string) error StatFn func(path string) (fs.FileInfo, error) } func (r renamerRemoverImpl) Rename(oldpath, newpath string) error { return r.RenameFn(oldpath, newpath) } func (r renamerRemoverImpl) Remove(name string) error { return r.RemoveFn(name) } func (r renamerRemoverImpl) RemoveAll(path string) error { return r.RemoveAllFn(path) } func (r renamerRemoverImpl) Stat(path string) (fs.FileInfo, error) { return r.StatFn(path) } func newRenamerRemoverImpl() renamerRemoverImpl { return renamerRemoverImpl{ // use fsutil.SafeMove to support cross-device moves RenameFn: fsutil.SafeMove, RemoveFn: os.Remove, RemoveAllFn: os.RemoveAll, StatFn: os.Stat, } } // Deleter is used to safely delete files and directories from the filesystem. // During a transaction, files and directories are marked for deletion using // the Files and Dirs methods. If TrashPath is set, files are moved to trash // immediately. Otherwise, they are renamed with a .delete suffix. If the // transaction is rolled back, then the files/directories can be restored to // their original state with the Rollback method. If the transaction is // committed, the marked files are then deleted from the filesystem using the // Commit method. type Deleter struct { RenamerRemover RenamerRemover files []string dirs []string TrashPath string // if set, files will be moved to this directory instead of being permanently deleted trashedPaths map[string]string // map of original path -> trash path (only used when TrashPath is set) } func NewDeleter() *Deleter { return &Deleter{ RenamerRemover: newRenamerRemoverImpl(), TrashPath: "", trashedPaths: make(map[string]string), } } func NewDeleterWithTrash(trashPath string) *Deleter { return &Deleter{ RenamerRemover: newRenamerRemoverImpl(), TrashPath: trashPath, trashedPaths: make(map[string]string), } } // RegisterHooks registers post-commit and post-rollback hooks. func (d *Deleter) RegisterHooks(ctx context.Context) { txn.AddPostCommitHook(ctx, func(ctx context.Context) { d.Commit() }) txn.AddPostRollbackHook(ctx, func(ctx context.Context) { d.Rollback() }) } // Files designates files to be deleted. Each file marked will be renamed to add // a `.delete` suffix. An error is returned if a file could not be renamed. // Note that if an error is returned, then some files may be left renamed. // Abort should be called to restore marked files if this function returns an // error. func (d *Deleter) Files(paths []string) error { return d.filesInternal(paths, false) } // FilesWithoutTrash designates files to be deleted, bypassing the trash directory. // Files will be permanently deleted even if TrashPath is configured. // This is useful for deleting generated files that can be easily recreated. func (d *Deleter) FilesWithoutTrash(paths []string) error { return d.filesInternal(paths, true) } func (d *Deleter) filesInternal(paths []string, bypassTrash bool) error { for _, p := range paths { // fail silently if the file does not exist if _, err := d.RenamerRemover.Stat(p); err != nil { if errors.Is(err, fs.ErrNotExist) { logger.Warnf("File %q does not exist and therefore cannot be deleted. Ignoring.", p) continue } return fmt.Errorf("check file %q exists: %w", p, err) } if err := d.renameForDelete(p, bypassTrash); err != nil { return fmt.Errorf("marking file %q for deletion: %w", p, err) } d.files = append(d.files, p) } return nil } // Dirs designates directories to be deleted. Each directory marked will be renamed to add // a `.delete` suffix. An error is returned if a directory could not be renamed. // Note that if an error is returned, then some directories may be left renamed. // Abort should be called to restore marked files/directories if this function returns an // error. func (d *Deleter) Dirs(paths []string) error { return d.dirsInternal(paths, false) } // DirsWithoutTrash designates directories to be deleted, bypassing the trash directory. // Directories will be permanently deleted even if TrashPath is configured. // This is useful for deleting generated directories that can be easily recreated. func (d *Deleter) DirsWithoutTrash(paths []string) error { return d.dirsInternal(paths, true) } func (d *Deleter) dirsInternal(paths []string, bypassTrash bool) error { for _, p := range paths { // fail silently if the file does not exist if _, err := d.RenamerRemover.Stat(p); err != nil { if errors.Is(err, fs.ErrNotExist) { logger.Warnf("Directory %q does not exist and therefore cannot be deleted. Ignoring.", p) continue } return fmt.Errorf("check directory %q exists: %w", p, err) } if err := d.renameForDelete(p, bypassTrash); err != nil { return fmt.Errorf("marking directory %q for deletion: %w", p, err) } d.dirs = append(d.dirs, p) } return nil } // Rollback tries to rename all marked files and directories back to their // original names and clears the marked list. Any errors encountered are // logged. All files will be attempted regardless of any errors occurred. func (d *Deleter) Rollback() { for _, f := range append(d.files, d.dirs...) { if err := d.renameForRestore(f); err != nil { logger.Warnf("Error restoring %q: %v", f, err) } } d.files = nil d.dirs = nil d.trashedPaths = make(map[string]string) } // Commit deletes all files marked for deletion and clears the marked list. // When using trash, files have already been moved during renameForDelete, so // this just clears the tracking. Otherwise, permanently delete the .delete files. // Any errors encountered are logged. All files will be attempted, regardless // of the errors encountered. func (d *Deleter) Commit() { if d.TrashPath != "" { // Files were already moved to trash during renameForDelete, just clear tracking logger.Debugf("Commit: %d files and %d directories already in trash, clearing tracking", len(d.files), len(d.dirs)) } else { // Permanently delete files and directories marked with .delete suffix for _, f := range d.files { if err := d.RenamerRemover.Remove(f + deleteFileSuffix); err != nil { logger.Warnf("Error deleting file %q: %v", f+deleteFileSuffix, err) } } for _, f := range d.dirs { if err := d.RenamerRemover.RemoveAll(f + deleteFileSuffix); err != nil { logger.Warnf("Error deleting directory %q: %v", f+deleteFileSuffix, err) } } } d.files = nil d.dirs = nil d.trashedPaths = make(map[string]string) } func (d *Deleter) renameForDelete(path string, bypassTrash bool) error { if d.TrashPath != "" && !bypassTrash { // Move file to trash immediately trashDest, err := fsutil.MoveToTrash(path, d.TrashPath) if err != nil { return err } d.trashedPaths[path] = trashDest logger.Infof("Moved %q to trash at %s", path, trashDest) return nil } // Standard behavior: rename with .delete suffix (or when bypassing trash) return d.RenamerRemover.Rename(path, path+deleteFileSuffix) } func (d *Deleter) renameForRestore(path string) error { if d.TrashPath != "" { // Restore file from trash trashPath, ok := d.trashedPaths[path] if !ok { return fmt.Errorf("no trash path found for %q", path) } return d.RenamerRemover.Rename(trashPath, path) } // Standard behavior: restore from .delete suffix return d.RenamerRemover.Rename(path+deleteFileSuffix, path) } func Destroy(ctx context.Context, destroyer models.FileDestroyer, f models.File, fileDeleter *Deleter, deleteFile bool) error { if err := destroyer.Destroy(ctx, f.Base().ID); err != nil { return err } // don't delete files in zip files if deleteFile && f.Base().ZipFileID == nil { if err := fileDeleter.Files([]string{f.Base().Path}); err != nil { return err } } return nil } type ZipDestroyer struct { FileDestroyer models.FileFinderDestroyer FolderDestroyer models.FolderFinderDestroyer } func (d *ZipDestroyer) DestroyZip(ctx context.Context, f models.File, fileDeleter *Deleter, deleteFile bool) error { // destroy contained files files, err := d.FileDestroyer.FindByZipFileID(ctx, f.Base().ID) if err != nil { return err } for _, ff := range files { if err := d.FileDestroyer.Destroy(ctx, ff.Base().ID); err != nil { return err } } // destroy contained folders folders, err := d.FolderDestroyer.FindByZipFileID(ctx, f.Base().ID) if err != nil { return err } for _, ff := range folders { if err := d.FolderDestroyer.Destroy(ctx, ff.ID); err != nil { return err } } if err := d.FileDestroyer.Destroy(ctx, f.Base().ID); err != nil { return err } if deleteFile { if err := fileDeleter.Files([]string{f.Base().Path}); err != nil { return err } } return nil } ================================================ FILE: pkg/file/file.go ================================================ // Package file provides functionality for managing, scanning and cleaning files and folders. package file import ( "context" "fmt" "io/fs" "os" "time" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/txn" ) // Repository provides access to storage methods for files and folders. type Repository struct { TxnManager models.TxnManager File models.FileReaderWriter Folder models.FolderReaderWriter } func NewRepository(repo models.Repository) Repository { return Repository{ TxnManager: repo.TxnManager, File: repo.File, Folder: repo.Folder, } } func (r *Repository) WithTxn(ctx context.Context, fn txn.TxnFunc) error { return txn.WithTxn(ctx, r.TxnManager, fn) } func (r *Repository) WithReadTxn(ctx context.Context, fn txn.TxnFunc) error { return txn.WithReadTxn(ctx, r.TxnManager, fn) } func (r *Repository) WithDB(ctx context.Context, fn txn.TxnFunc) error { return txn.WithDatabase(ctx, r.TxnManager, fn) } // ModTime returns the modification time truncated to seconds. func ModTime(info fs.FileInfo) time.Time { // truncate to seconds, since we don't store beyond that in the database return info.ModTime().Truncate(time.Second) } // GetFileSize gets the size of the file, taking into account symlinks. func GetFileSize(f models.FS, path string, info fs.FileInfo) (int64, error) { // #2196/#3042 - replace size with target size if file is a symlink if info.Mode()&os.ModeSymlink == os.ModeSymlink { targetInfo, err := f.Stat(path) if err != nil { return 0, fmt.Errorf("reading info for symlink %q: %w", path, err) } return targetInfo.Size(), nil } return info.Size(), nil } ================================================ FILE: pkg/file/folder.go ================================================ package file import ( "context" "fmt" "path/filepath" "slices" "strings" "time" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) // GetOrCreateFolderHierarchy gets the folder for the given path, or creates a folder hierarchy for the given path if one if no existing folder is found. // Creates folder entries for each level of the hierarchy that doesn't already exist, up to the provided root paths. // Does not create any folders in the file system. func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreator, path string, rootPaths []string) (*models.Folder, error) { // get or create folder hierarchy // assume case sensitive when searching for the folder const caseSensitive = true folder, err := fc.FindByPath(ctx, path, caseSensitive) if err != nil { return nil, err } if folder == nil { var parentID *models.FolderID if !slices.Contains(rootPaths, path) { parentPath := filepath.Dir(path) // safety check - don't allow parent path to be the same as the current path, // otherwise we could end up in an infinite loop if parentPath == path { // #6618 - log a warning and return nil for the parent ID, // which will cause the folder to be created with no parent logger.Warnf("parent path is the same as the current path: %s", path) return nil, nil } parent, err := GetOrCreateFolderHierarchy(ctx, fc, parentPath, rootPaths) if err != nil { return nil, err } parentID = &parent.ID } now := time.Now() folder = &models.Folder{ Path: path, ParentFolderID: parentID, DirEntry: models.DirEntry{ // leave mod time empty for now - it will be updated when the folder is scanned }, CreatedAt: now, UpdatedAt: now, } logger.Infof("%s doesn't exist. Creating new folder entry...", path) if err = fc.Create(ctx, folder); err != nil { return nil, fmt.Errorf("creating folder %s: %w", path, err) } } return folder, nil } type zipHierarchyMover struct { folderStore models.FolderReaderWriter files models.FileFinderUpdater rootPaths []string } func (m zipHierarchyMover) transferZipHierarchy(ctx context.Context, zipFileID models.FileID, oldPath string, newPath string) error { if err := m.transferZipFolderHierarchy(ctx, zipFileID, oldPath, newPath); err != nil { return fmt.Errorf("moving folder hierarchy for file %s: %w", oldPath, err) } if err := m.transferZipFileEntries(ctx, zipFileID, oldPath, newPath); err != nil { return fmt.Errorf("moving zip file contents for file %s: %w", oldPath, err) } return nil } // transferZipFolderHierarchy creates the folder hierarchy for zipFileID under newPath, and removes // ZipFileID from folders under oldPath. func (m zipHierarchyMover) transferZipFolderHierarchy(ctx context.Context, zipFileID models.FileID, oldPath string, newPath string) error { zipFolders, err := m.folderStore.FindByZipFileID(ctx, zipFileID) if err != nil { return err } for _, oldFolder := range zipFolders { oldZfPath := oldFolder.Path // sanity check - ignore folders which aren't under oldPath if !strings.HasPrefix(oldZfPath, oldPath) { continue } relZfPath, err := filepath.Rel(oldPath, oldZfPath) if err != nil { return err } newZfPath := filepath.Join(newPath, relZfPath) newFolder, err := GetOrCreateFolderHierarchy(ctx, m.folderStore, newZfPath, m.rootPaths) if err != nil { return err } // add ZipFileID to new folder logger.Debugf("adding zip file %s to folder %s", zipFileID, newFolder.Path) newFolder.ZipFileID = &zipFileID if err = m.folderStore.Update(ctx, newFolder); err != nil { return err } // remove ZipFileID from old folder logger.Debugf("removing zip file %s from folder %s", zipFileID, oldFolder.Path) oldFolder.ZipFileID = nil if err = m.folderStore.Update(ctx, oldFolder); err != nil { return err } } return nil } func (m zipHierarchyMover) transferZipFileEntries(ctx context.Context, zipFileID models.FileID, oldPath, newPath string) error { // move contained files if file is a zip file zipFiles, err := m.files.FindByZipFileID(ctx, zipFileID) if err != nil { return fmt.Errorf("finding contained files in file %s: %w", oldPath, err) } for _, zf := range zipFiles { zfBase := zf.Base() oldZfPath := zfBase.Path oldZfDir := filepath.Dir(oldZfPath) // sanity check - ignore files which aren't under oldPath if !strings.HasPrefix(oldZfPath, oldPath) { continue } relZfDir, err := filepath.Rel(oldPath, oldZfDir) if err != nil { return fmt.Errorf("moving contained file %s: %w", zfBase.ID, err) } newZfDir := filepath.Join(newPath, relZfDir) // folder should have been created by transferZipFolderHierarchy newZfFolder, err := GetOrCreateFolderHierarchy(ctx, m.folderStore, newZfDir, m.rootPaths) if err != nil { return fmt.Errorf("getting or creating folder hierarchy: %w", err) } // update file parent folder zfBase.ParentFolderID = newZfFolder.ID logger.Debugf("moving %s to folder %s", zfBase.Path, newZfFolder.Path) if err := m.files.Update(ctx, zf); err != nil { return fmt.Errorf("updating file %s: %w", oldZfPath, err) } } return nil } ================================================ FILE: pkg/file/folder_rename_detect.go ================================================ package file import ( "context" "fmt" "io/fs" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) type folderRenameCandidate struct { folder *models.Folder found int files int } type folderRenameDetector struct { // candidates is a map of folder id to the number of files that match candidates map[models.FolderID]folderRenameCandidate // rejects is a set of folder ids which were found to still exist rejects map[models.FolderID]struct{} } func (d *folderRenameDetector) isReject(id models.FolderID) bool { _, ok := d.rejects[id] return ok } func (d *folderRenameDetector) getCandidate(id models.FolderID) *folderRenameCandidate { c, ok := d.candidates[id] if !ok { return nil } return &c } func (d *folderRenameDetector) setCandidate(c folderRenameCandidate) { d.candidates[c.folder.ID] = c } func (d *folderRenameDetector) reject(id models.FolderID) { d.rejects[id] = struct{}{} } // bestCandidate returns the folder that is the best candidate for a rename. // This is the folder that has the largest number of its original files that // are still present in the new location. func (d *folderRenameDetector) bestCandidate() *models.Folder { if len(d.candidates) == 0 { return nil } var best *folderRenameCandidate for _, c := range d.candidates { // ignore folders that have less than 50% of their original files if c.found < c.files/2 { continue } // prefer the folder with the most files if the ratio is the same if best == nil || c.found > best.found { cc := c best = &cc } } if best == nil { return nil } return best.folder } func (s *Scanner) detectFolderMove(ctx context.Context, file ScannedFile) (*models.Folder, error) { // in order for a folder to be considered moved, the existing folder must be // missing, and the majority of the old folder's files must be present, unchanged, // in the new folder. detector := folderRenameDetector{ candidates: make(map[models.FolderID]folderRenameCandidate), rejects: make(map[models.FolderID]struct{}), } // rejects is a set of folder ids which were found to still exist r := s.Repository zipFilePath := "" if file.ZipFile != nil { zipFilePath = file.ZipFile.Base().Path } if err := SymWalk(file.FS, file.Path, func(path string, d fs.DirEntry, err error) error { if err != nil { // don't let errors prevent scanning logger.Errorf("error scanning %s: %v", path, err) return nil } // ignore root if path == file.Path { return nil } // ignore directories if d.IsDir() { return fs.SkipDir } info, err := d.Info() if err != nil { logger.Errorf("reading info for %q: %v", path, err) return nil } if !s.AcceptEntry(ctx, path, info, zipFilePath) { return nil } size, err := GetFileSize(file.FS, path, info) if err != nil { return fmt.Errorf("getting file size for %q: %w", path, err) } // check if the file exists in the database based on basename, size and mod time existing, err := r.File.FindByFileInfo(ctx, info, size) if err != nil { return fmt.Errorf("checking for existing file %q: %w", path, err) } for _, e := range existing { // ignore files in zip files if e.Base().ZipFileID != nil { continue } parentFolderID := e.Base().ParentFolderID if detector.isReject(parentFolderID) { // folder was found to still exist, not a candidate continue } c := detector.getCandidate(parentFolderID) if c == nil { // need to check if the folder exists in the filesystem pf, err := r.Folder.Find(ctx, e.Base().ParentFolderID) if err != nil { return fmt.Errorf("getting parent folder %d: %w", e.Base().ParentFolderID, err) } if pf == nil { // shouldn't happen, but just in case continue } // parent folder must be missing _, err = file.FS.Lstat(pf.Path) if err == nil { // parent folder exists, not a candidate detector.reject(parentFolderID) continue } // treat any error as missing folder // parent folder is missing, possible candidate // count the total number of files in the existing folder count, err := r.File.CountByFolderID(ctx, parentFolderID) if err != nil { return fmt.Errorf("counting files in folder %d: %w", parentFolderID, err) } if count == 0 { // no files in the folder, not a candidate detector.reject(parentFolderID) continue } c = &folderRenameCandidate{ folder: pf, found: 0, files: count, } } // increment the count and set it in the map c.found++ detector.setCandidate(*c) } return nil }); err != nil { return nil, fmt.Errorf("walking filesystem for folder rename detection: %w", err) } return detector.bestCandidate(), nil } ================================================ FILE: pkg/file/fs.go ================================================ package file import ( "io" "io/fs" "os" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/models" ) // Opener provides an interface to open a file. type Opener interface { Open() (io.ReadCloser, error) } type fsOpener struct { fs models.FS name string } func (o *fsOpener) Open() (io.ReadCloser, error) { return o.fs.Open(o.name) } // OsFS is a file system backed by the OS. type OsFS struct{} func (f *OsFS) Create(name string) (*os.File, error) { return os.Create(name) } func (f *OsFS) MkdirAll(path string, perm fs.FileMode) error { return os.MkdirAll(path, perm) } func (f *OsFS) Remove(name string) error { return os.Remove(name) } func (f *OsFS) Rename(oldpath, newpath string) error { return os.Rename(oldpath, newpath) } func (f *OsFS) RemoveAll(path string) error { return os.RemoveAll(path) } func (f *OsFS) Stat(name string) (fs.FileInfo, error) { return os.Stat(name) } func (f *OsFS) Lstat(name string) (fs.FileInfo, error) { return os.Lstat(name) } func (f *OsFS) Open(name string) (fs.ReadDirFile, error) { return os.Open(name) } func (f *OsFS) OpenZip(name string, size int64) (models.ZipFS, error) { return newZipFS(f, name, size) } func (f *OsFS) IsPathCaseSensitive(path string) (bool, error) { return fsutil.IsFsPathCaseSensitive(path) } ================================================ FILE: pkg/file/handler.go ================================================ package file import ( "context" "io/fs" "github.com/stashapp/stash/pkg/models" ) // PathFilter provides a filter function for paths. type PathFilter interface { Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool } type PathFilterFunc func(path string) bool func (pff PathFilterFunc) Accept(path string) bool { return pff(path) } // Filter provides a filter function for Files. type Filter interface { Accept(ctx context.Context, f models.File) bool } type FilterFunc func(ctx context.Context, f models.File) bool func (ff FilterFunc) Accept(ctx context.Context, f models.File) bool { return ff(ctx, f) } // Handler provides a handler for Files. type Handler interface { Handle(ctx context.Context, f models.File, oldFile models.File) error } // FilteredHandler is a Handler runs only if the filter accepts the file. type FilteredHandler struct { Handler Filter } // Handle runs the handler if the filter accepts the file. func (h *FilteredHandler) Handle(ctx context.Context, f models.File, oldFile models.File) error { if h.Accept(ctx, f) { return h.Handler.Handle(ctx, f, oldFile) } return nil } // CleanHandler provides a handler for cleaning Files and Folders. type CleanHandler interface { HandleFile(ctx context.Context, fileDeleter *Deleter, fileID models.FileID) error HandleFolder(ctx context.Context, fileDeleter *Deleter, folderID models.FolderID) error } ================================================ FILE: pkg/file/image/orientation.go ================================================ package image import ( "errors" "fmt" "io" "strings" "github.com/rwcarlsen/goexif/exif" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) func adjustForOrientation(fs models.FS, path string, f *models.ImageFile) { isFlipped, err := areDimensionsFlipped(fs, path) if err != nil { logger.Warnf("Error determining image orientation for %s: %v", path, err) // isFlipped is false by default } if isFlipped { f.Width, f.Height = f.Height, f.Width } } // areDimensionsFlipped returns true if the image dimensions are flipped. // This is determined by the EXIF orientation tag. func areDimensionsFlipped(fs models.FS, path string) (bool, error) { r, err := fs.Open(path) if err != nil { return false, fmt.Errorf("reading image file %q: %w", path, err) } defer r.Close() x, err := exif.Decode(r) if err != nil { if errors.Is(err, io.EOF) || strings.Contains(err.Error(), "failed to find exif") { // no exif data return false, nil } return false, fmt.Errorf("decoding exif data: %w", err) } o, err := x.Get(exif.Orientation) if err != nil { // assume not present return false, nil } oo, err := o.Int(0) if err != nil { return false, fmt.Errorf("decoding orientation: %w", err) } return isOrientationDimensionsFlipped(oo), nil } // isOrientationDimensionsFlipped returns true if the image orientation is flipped based on the input orientation EXIF value. // From https://sirv.com/help/articles/rotate-photos-to-be-upright/ // 1 = 0 degrees: the correct orientation, no adjustment is required. // 2 = 0 degrees, mirrored: image has been flipped back-to-front. // 3 = 180 degrees: image is upside down. // 4 = 180 degrees, mirrored: image has been flipped back-to-front and is upside down. // 5 = 90 degrees: image has been flipped back-to-front and is on its side. // 6 = 90 degrees, mirrored: image is on its side. // 7 = 270 degrees: image has been flipped back-to-front and is on its far side. // 8 = 270 degrees, mirrored: image is on its far side. func isOrientationDimensionsFlipped(o int) bool { switch o { case 5, 6, 7, 8: return true default: return false } } ================================================ FILE: pkg/file/image/scan.go ================================================ package image import ( "context" "errors" "fmt" "image" "path/filepath" "strings" _ "image/gif" _ "image/jpeg" _ "image/png" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/file/video" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" _ "golang.org/x/image/webp" ) var ErrUnsupportedAVIFInZip = errors.New("AVIF images in zip files is unsupported") // Decorator adds image specific fields to a File. type Decorator struct { FFProbe *ffmpeg.FFProbe } func (d *Decorator) Decorate(ctx context.Context, fs models.FS, f models.File) (models.File, error) { base := f.Base() // ignore clips in non-OsFS filesystems as ffprobe cannot read them // TODO - copy to temp file if not an OsFS if _, isOs := fs.(*file.OsFS); !isOs { // AVIF images inside zip files are not supported if strings.ToLower(filepath.Ext(base.Path)) == ".avif" { return nil, fmt.Errorf("%w: %s", ErrUnsupportedAVIFInZip, base.Path) } logger.Debugf("assuming ImageFile for non-OsFS file %q", base.Path) return decorateFallback(fs, f) } probe, err := d.FFProbe.NewVideoFile(base.Path) if err != nil { logger.Warnf("File %q could not be read with ffprobe: %s, assuming ImageFile", base.Path, err) return decorateFallback(fs, f) } // Fallback to catch non-animated avif images that FFProbe detects as video files if probe.Bitrate == 0 && probe.VideoCodec == "av1" { return &models.ImageFile{ BaseFile: base, Format: "avif", Width: probe.Width, Height: probe.Height, }, nil } isClip := true // This list is derived from ffmpegImageThumbnail in pkg/image/thumbnail. If one gets updated, the other should be as well for _, item := range []string{"png", "mjpeg", "webp", "bmp", "jpegxl"} { if item == probe.VideoCodec { isClip = false } } if isClip { videoFileDecorator := video.Decorator{FFProbe: d.FFProbe} return videoFileDecorator.Decorate(ctx, fs, f) } ret := &models.ImageFile{ BaseFile: base, Format: probe.VideoCodec, Width: probe.Width, Height: probe.Height, } // FFprobe has a known bug where it returns 0x0 dimensions for some animated WebP files // Fall back to image.DecodeConfig in this case. // See: https://trac.ffmpeg.org/ticket/4907 if ret.Width == 0 || ret.Height == 0 { logger.Warnf("FFprobe returned invalid dimensions (%dx%d) for %q, trying fallback decoder", ret.Width, ret.Height, base.Path) c, format, err := decodeConfig(fs, base.Path) if err != nil { logger.Warnf("Fallback decoder failed for %q: %s. Proceeding with original FFprobe result", base.Path, err) } else { ret.Width = c.Width ret.Height = c.Height // Update format if it differs (fallback decoder may be more accurate) if format != "" && format != ret.Format { logger.Debugf("Updating format from %q to %q for %q", ret.Format, format, base.Path) ret.Format = format } } } adjustForOrientation(fs, base.Path, ret) return ret, nil } func decodeConfig(fs models.FS, path string) (config image.Config, format string, err error) { r, err := fs.Open(path) if err != nil { err = fmt.Errorf("reading image file %q: %w", path, err) return } defer r.Close() config, format, err = image.DecodeConfig(r) if err != nil { err = fmt.Errorf("decoding image file %q: %w", path, err) return } return } func decorateFallback(fs models.FS, f models.File) (models.File, error) { base := f.Base() path := base.Path c, format, err := decodeConfig(fs, path) if err != nil { return f, err } ret := &models.ImageFile{ BaseFile: base, Format: format, Width: c.Width, Height: c.Height, } adjustForOrientation(fs, path, ret) return ret, nil } func (d *Decorator) IsMissingMetadata(ctx context.Context, fs models.FS, f models.File) bool { const ( unsetString = "unset" unsetNumber = -1 ) imf, isImage := f.(*models.ImageFile) vf, isVideo := f.(*models.VideoFile) switch { case isImage: return imf.Format == unsetString || imf.Width == unsetNumber || imf.Height == unsetNumber case isVideo: videoFileDecorator := video.Decorator{FFProbe: d.FFProbe} return videoFileDecorator.IsMissingMetadata(ctx, fs, vf) default: return true } } ================================================ FILE: pkg/file/import.go ================================================ package file import ( "context" "errors" "fmt" "path/filepath" "time" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" ) var ErrZipFileNotExist = errors.New("zip file does not exist") type Importer struct { ReaderWriter models.FileFinderCreator FolderStore models.FolderFinderCreator Input jsonschema.DirEntry file models.File folder *models.Folder } func (i *Importer) PreImport(ctx context.Context) error { var err error switch ff := i.Input.(type) { case *jsonschema.BaseDirEntry: i.folder, err = i.folderJSONToFolder(ctx, ff) default: i.file, err = i.fileJSONToFile(ctx, i.Input) } return err } func (i *Importer) folderJSONToFolder(ctx context.Context, baseJSON *jsonschema.BaseDirEntry) (*models.Folder, error) { ret := models.Folder{ DirEntry: models.DirEntry{ ModTime: baseJSON.ModTime.GetTime(), }, Path: baseJSON.Path, CreatedAt: baseJSON.CreatedAt.GetTime(), UpdatedAt: baseJSON.CreatedAt.GetTime(), } if err := i.populateZipFileID(ctx, &ret.DirEntry); err != nil { return nil, err } // set parent folder id during the creation process return &ret, nil } func (i *Importer) fileJSONToFile(ctx context.Context, fileJSON jsonschema.DirEntry) (models.File, error) { switch ff := fileJSON.(type) { case *jsonschema.VideoFile: baseFile, err := i.baseFileJSONToBaseFile(ctx, ff.BaseFile) if err != nil { return nil, err } return &models.VideoFile{ BaseFile: baseFile, Format: ff.Format, Width: ff.Width, Height: ff.Height, Duration: ff.Duration, VideoCodec: ff.VideoCodec, AudioCodec: ff.AudioCodec, FrameRate: ff.FrameRate, BitRate: ff.BitRate, Interactive: ff.Interactive, InteractiveSpeed: ff.InteractiveSpeed, }, nil case *jsonschema.ImageFile: baseFile, err := i.baseFileJSONToBaseFile(ctx, ff.BaseFile) if err != nil { return nil, err } return &models.ImageFile{ BaseFile: baseFile, Format: ff.Format, Width: ff.Width, Height: ff.Height, }, nil case *jsonschema.BaseFile: return i.baseFileJSONToBaseFile(ctx, ff) } return nil, errors.New("unknown file type") } func (i *Importer) baseFileJSONToBaseFile(ctx context.Context, baseJSON *jsonschema.BaseFile) (*models.BaseFile, error) { baseFile := models.BaseFile{ DirEntry: models.DirEntry{ ModTime: baseJSON.ModTime.GetTime(), }, Basename: filepath.Base(baseJSON.Path), Size: baseJSON.Size, CreatedAt: baseJSON.CreatedAt.GetTime(), UpdatedAt: baseJSON.CreatedAt.GetTime(), } for _, fp := range baseJSON.Fingerprints { baseFile.Fingerprints = append(baseFile.Fingerprints, models.Fingerprint{ Type: fp.Type, Fingerprint: fp.Fingerprint, }) } if err := i.populateZipFileID(ctx, &baseFile.DirEntry); err != nil { return nil, err } return &baseFile, nil } func (i *Importer) populateZipFileID(ctx context.Context, f *models.DirEntry) error { zipFilePath := i.Input.DirEntry().ZipFile if zipFilePath != "" { zf, err := i.ReaderWriter.FindByPath(ctx, zipFilePath, true) if err != nil { return fmt.Errorf("error finding file by path %q: %v", zipFilePath, err) } if zf == nil { return ErrZipFileNotExist } id := zf.Base().ID f.ZipFileID = &id } return nil } func (i *Importer) PostImport(ctx context.Context, id int) error { return nil } func (i *Importer) Name() string { return i.Input.DirEntry().Path } func (i *Importer) FindExistingID(ctx context.Context) (*int, error) { path := i.Input.DirEntry().Path existing, err := i.ReaderWriter.FindByPath(ctx, path, true) if err != nil { return nil, err } if existing != nil { id := int(existing.Base().ID) return &id, nil } return nil, nil } func (i *Importer) createFolderHierarchy(ctx context.Context, p string) (*models.Folder, error) { parentPath := filepath.Dir(p) if parentPath == p { // get or create this folder return i.getOrCreateFolder(ctx, p, nil) } parent, err := i.createFolderHierarchy(ctx, parentPath) if err != nil { return nil, err } return i.getOrCreateFolder(ctx, p, parent) } func (i *Importer) getOrCreateFolder(ctx context.Context, path string, parent *models.Folder) (*models.Folder, error) { folder, err := i.FolderStore.FindByPath(ctx, path, true) if err != nil { return nil, err } if folder != nil { return folder, nil } now := time.Now() folder = &models.Folder{ Path: path, CreatedAt: now, UpdatedAt: now, } if parent != nil { folder.ZipFileID = parent.ZipFileID folder.ParentFolderID = &parent.ID } if err := i.FolderStore.Create(ctx, folder); err != nil { return nil, err } return folder, nil } func (i *Importer) Create(ctx context.Context) (*int, error) { // create folder hierarchy and set parent folder id path := i.Input.DirEntry().Path path = filepath.Dir(path) folder, err := i.createFolderHierarchy(ctx, path) if err != nil { return nil, fmt.Errorf("creating folder hierarchy for %q: %w", path, err) } if i.folder != nil { return i.createFolder(ctx, folder) } return i.createFile(ctx, folder) } func (i *Importer) createFile(ctx context.Context, parentFolder *models.Folder) (*int, error) { if parentFolder != nil { i.file.Base().ParentFolderID = parentFolder.ID } if err := i.ReaderWriter.Create(ctx, i.file); err != nil { return nil, fmt.Errorf("error creating file: %w", err) } id := int(i.file.Base().ID) return &id, nil } func (i *Importer) createFolder(ctx context.Context, parentFolder *models.Folder) (*int, error) { if parentFolder != nil { i.folder.ParentFolderID = &parentFolder.ID } if err := i.FolderStore.Create(ctx, i.folder); err != nil { return nil, fmt.Errorf("error creating folder: %w", err) } id := int(i.folder.ID) return &id, nil } func (i *Importer) Update(ctx context.Context, id int) error { // update not supported return nil } ================================================ FILE: pkg/file/move.go ================================================ package file import ( "context" "errors" "fmt" "io/fs" "os" "path/filepath" "time" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/txn" ) type Renamer interface { Rename(oldpath, newpath string) error } type Statter interface { Stat(name string) (fs.FileInfo, error) } type DirMakerStatRenamer interface { Statter Renamer Mkdir(name string, perm os.FileMode) error Remove(name string) error } type folderCreatorStatRenamerImpl struct { renamerRemoverImpl mkDirFn func(name string, perm os.FileMode) error } func (r folderCreatorStatRenamerImpl) Mkdir(name string, perm os.FileMode) error { return r.mkDirFn(name, perm) } type Mover struct { Renamer DirMakerStatRenamer Files models.FileFinderUpdater Folders models.FolderReaderWriter moved map[string]string foldersCreated []string // needed for creating folder hierarchy when moving zip file entries rootPaths []string } func NewMover(fileStore models.FileFinderUpdater, folderStore models.FolderReaderWriter, rootPaths []string) *Mover { return &Mover{ Files: fileStore, Folders: folderStore, Renamer: &folderCreatorStatRenamerImpl{ renamerRemoverImpl: newRenamerRemoverImpl(), mkDirFn: os.Mkdir, }, rootPaths: rootPaths, } } // Move moves the file to the given folder and basename. If basename is empty, then the existing basename is used. // Assumes that the parent folder exists in the filesystem. func (m *Mover) Move(ctx context.Context, f models.File, folder *models.Folder, basename string) error { fBase := f.Base() // don't allow moving files in zip files if fBase.ZipFileID != nil { return fmt.Errorf("cannot move file %s, is in a zip file", fBase.Path) } if basename == "" { basename = fBase.Basename } // modify the database first oldPath := fBase.Path if folder.ID == fBase.ParentFolderID && (basename == "" || basename == fBase.Basename) { // nothing to do return nil } // ensure that the new path doesn't already exist newPath := filepath.Join(folder.Path, basename) if _, err := m.Renamer.Stat(newPath); !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("file %s already exists", newPath) } zipMover := zipHierarchyMover{ folderStore: m.Folders, files: m.Files, rootPaths: m.rootPaths, } if err := zipMover.transferZipHierarchy(ctx, fBase.ID, oldPath, newPath); err != nil { return fmt.Errorf("moving folder hierarchy for file %s: %w", fBase.Path, err) } fBase.ParentFolderID = folder.ID fBase.Basename = basename fBase.UpdatedAt = time.Now() // leave ModTime as is. It may or may not be changed by this operation if err := m.Files.Update(ctx, f); err != nil { return fmt.Errorf("updating file %s: %w", oldPath, err) } // then move the file return m.moveFile(oldPath, newPath) } func (m *Mover) CreateFolderHierarchy(path string) error { info, err := m.Renamer.Stat(path) if err != nil { if errors.Is(err, os.ErrNotExist) { // create the parent folder parentPath := filepath.Dir(path) if err := m.CreateFolderHierarchy(parentPath); err != nil { return err } // create the folder if err := m.Renamer.Mkdir(path, 0755); err != nil { return fmt.Errorf("creating folder %s: %w", path, err) } m.foldersCreated = append(m.foldersCreated, path) } else { return fmt.Errorf("getting info for %s: %w", path, err) } } else { if !info.IsDir() { return fmt.Errorf("%s is not a directory", path) } } return nil } func (m *Mover) moveFile(oldPath, newPath string) error { if err := m.Renamer.Rename(oldPath, newPath); err != nil { return fmt.Errorf("renaming file %s to %s: %w", oldPath, newPath, err) } if m.moved == nil { m.moved = make(map[string]string) } m.moved[newPath] = oldPath return nil } func (m *Mover) RegisterHooks(ctx context.Context) { txn.AddPostCommitHook(ctx, func(ctx context.Context) { m.commit() }) txn.AddPostRollbackHook(ctx, func(ctx context.Context) { m.rollback() }) } func (m *Mover) commit() { m.moved = nil m.foldersCreated = nil } func (m *Mover) rollback() { // move files back to their original location for newPath, oldPath := range m.moved { if err := m.Renamer.Rename(newPath, oldPath); err != nil { logger.Errorf("error moving file %s back to %s: %s", newPath, oldPath, err.Error()) } } // remove folders created in reverse order for i := len(m.foldersCreated) - 1; i >= 0; i-- { folder := m.foldersCreated[i] if err := m.Renamer.Remove(folder); err != nil { logger.Errorf("error removing folder %s: %s", folder, err.Error()) } } } // correctSubFolderHierarchy sets the path of all contained folders to be relative to the given folder. // It does not move the folder hierarchy in the filesystem. func correctSubFolderHierarchy(ctx context.Context, rw models.FolderReaderWriter, folder *models.Folder) error { folders, err := rw.FindByParentFolderID(ctx, folder.ID) if err != nil { return fmt.Errorf("finding contained folders in folder %s: %w", folder.Path, err) } folderPath := folder.Path for _, f := range folders { oldPath := f.Path folderBasename := filepath.Base(f.Path) correctPath := filepath.Join(folderPath, folderBasename) logger.Debugf("updating folder %s to %s", oldPath, correctPath) // #6427 - ensure folder entry with new path doesn't already exist const caseSensitive = true existing, err := rw.FindByPath(ctx, correctPath, caseSensitive) if err != nil { return fmt.Errorf("finding folder by path %s: %w", correctPath, err) } if existing != nil { // this should no longer be possible, but if it does happen, log a warning // and skip updating this folder and its subfolders logger.Warnf("folder with path %s already exists, setting parent_folder_id of %s to NULL and skipping", correctPath, oldPath) f.ParentFolderID = nil if err := rw.Update(ctx, f); err != nil { return fmt.Errorf("updating folder parent id to NULL for folder %s: %w", oldPath, err) } continue } f.Path = correctPath if err := rw.Update(ctx, f); err != nil { return fmt.Errorf("updating folder path %s -> %s: %w", oldPath, f.Path, err) } // recurse if err := correctSubFolderHierarchy(ctx, rw, f); err != nil { return err } } return nil } ================================================ FILE: pkg/file/scan.go ================================================ package file import ( "context" "fmt" "io/fs" "path/filepath" "slices" "strings" "sync" "time" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/txn" ) // Scanner scans files into the database. // // The scan process works using two goroutines. The first walks through the provided paths // in the filesystem. It runs each directory entry through the provided ScanFilters. If none // of the filter Accept methods return true, then the file/directory is ignored. // Any folders found are handled immediately. Files inside zip files are also handled immediately. // All other files encountered are sent to the second goroutine queue. // // Folders are handled by checking if the folder exists in the database, by its full path. // If a folder entry already exists, then its mod time is updated (if applicable). // If the folder does not exist in the database, then a new folder entry its created. // // Files are handled by first querying for the file by its path. If the file entry exists in the // database, then the mod time is compared to the value in the database. If the mod time is different // then file is marked as updated - it recalculates any fingerprints and fires decorators, then // the file entry is updated and any applicable handlers are fired. // // If the file entry does not exist in the database, then fingerprints are calculated for the file. // It then determines if the file is a rename of an existing file by querying for file entries with // the same fingerprint. If any are found, it checks each to see if any are missing in the file // system. If one is, then the file is treated as renamed and its path is updated. If none are missing, // or many are, then the file is treated as a new file. // // If the file is not a renamed file, then the decorators are fired and the file is created, then // the applicable handlers are fired. type Scanner struct { FS models.FS Repository Repository FingerprintCalculator FingerprintCalculator // ZipFileExtensions is a list of file extensions that are considered zip files. // Extension does not include the . character. ZipFileExtensions []string // ScanFilters are used to determine if a file should be scanned. ScanFilters []PathFilter // HandlerRequiredFilters are used to determine if an unchanged file needs to be handled HandlerRequiredFilters []Filter // FileDecorators are applied to files as they are scanned. FileDecorators []Decorator // handlers are called after a file has been scanned. FileHandlers []Handler // RootPaths form the top-level paths for the library. // Used to determine the root of the folder hierarchy when creating folders. RootPaths []string // Rescan indicates whether files should be rescanned even if they haven't changed. Rescan bool folderPathToID sync.Map } // FingerprintCalculator calculates a fingerprint for the provided file. type FingerprintCalculator interface { CalculateFingerprints(f *models.BaseFile, o Opener, useExisting bool) ([]models.Fingerprint, error) } // Decorator wraps the Decorate method to add additional functionality while scanning files. type Decorator interface { Decorate(ctx context.Context, fs models.FS, f models.File) (models.File, error) IsMissingMetadata(ctx context.Context, fs models.FS, f models.File) bool } type FilteredDecorator struct { Decorator Filter } // Decorate runs the decorator if the filter accepts the file. func (d *FilteredDecorator) Decorate(ctx context.Context, fs models.FS, f models.File) (models.File, error) { if d.Accept(ctx, f) { return d.Decorator.Decorate(ctx, fs, f) } return f, nil } func (d *FilteredDecorator) IsMissingMetadata(ctx context.Context, fs models.FS, f models.File) bool { if d.Accept(ctx, f) { return d.Decorator.IsMissingMetadata(ctx, fs, f) } return false } // ScannedFile represents a file being scanned. type ScannedFile struct { *models.BaseFile FS models.FS Info fs.FileInfo } // AcceptEntry determines if the file entry should be accepted for scanning func (s *Scanner) AcceptEntry(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool { // always accept if there's no filters accept := len(s.ScanFilters) == 0 for _, filter := range s.ScanFilters { // accept if any filter accepts the file if filter.Accept(ctx, path, info, zipFilePath) { accept = true break } } return accept } func (s *Scanner) getFolderID(ctx context.Context, path string) (*models.FolderID, error) { // check the folder cache first if f, ok := s.folderPathToID.Load(path); ok { v := f.(models.FolderID) return &v, nil } // assume case sensitive when searching for the folder const caseSensitive = true ret, err := s.Repository.Folder.FindByPath(ctx, path, caseSensitive) if err != nil { return nil, err } if ret == nil { return nil, nil } s.folderPathToID.Store(path, ret.ID) return &ret.ID, nil } // ScanFolder scans the provided folder into the database, returning the folder entry. // If the folder already exists, it is updated if necessary. func (s *Scanner) ScanFolder(ctx context.Context, file ScannedFile) (*models.Folder, error) { var f *models.Folder var err error path := file.Path err = s.Repository.WithTxn(ctx, func(ctx context.Context) error { // determine if folder already exists in data store (by path) // assume case sensitive by default f, err = s.Repository.Folder.FindByPath(ctx, path, true) if err != nil { return fmt.Errorf("checking for existing folder %q: %w", path, err) } // #1426 / #6326 - if folder is in a case-insensitive filesystem, then try // case insensitive searching // assume case sensitive if in zip if f == nil && file.ZipFileID == nil { caseSensitive, _ := file.FS.IsPathCaseSensitive(file.Path) if !caseSensitive { f, err = s.Repository.Folder.FindByPath(ctx, path, false) if err != nil { return fmt.Errorf("checking for existing folder %q: %w", path, err) } } } // if folder not exists, create it if f == nil { f, err = s.onNewFolder(ctx, file) } else { f, err = s.onExistingFolder(ctx, file, f) } if err != nil { return err } if f != nil { s.folderPathToID.Store(f.Path, f.ID) } return nil }) return f, err } func (s *Scanner) isRootPath(path string) bool { return path == "." || slices.Contains(s.RootPaths, path) } func (s *Scanner) onNewFolder(ctx context.Context, file ScannedFile) (*models.Folder, error) { renamed, err := s.handleFolderRename(ctx, file) if err != nil { return nil, err } if renamed != nil { return renamed, nil } now := time.Now() toCreate := &models.Folder{ DirEntry: file.DirEntry, Path: file.Path, CreatedAt: now, UpdatedAt: now, } if !s.isRootPath(file.Path) { dir := filepath.Dir(file.Path) // create full folder hierarchy if parent folder doesn't exist, and set parent folder ID parentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, dir, s.RootPaths) if err != nil { return nil, fmt.Errorf("getting parent folder %q: %w", dir, err) } toCreate.ParentFolderID = &parentFolder.ID } txn.AddPostCommitHook(ctx, func(ctx context.Context) { // log at the end so that if anything fails above due to a locked database // error and the transaction must be retried, then we shouldn't get multiple // logs of the same thing. logger.Infof("%s doesn't exist. Creating new folder entry...", file.Path) }) if err := s.Repository.Folder.Create(ctx, toCreate); err != nil { return nil, fmt.Errorf("creating folder %q: %w", file.Path, err) } return toCreate, nil } func (s *Scanner) handleFolderRename(ctx context.Context, file ScannedFile) (*models.Folder, error) { // ignore folders in zip files if file.ZipFileID != nil { return nil, nil } // check if the folder was moved from elsewhere renamedFrom, err := s.detectFolderMove(ctx, file) if err != nil { return nil, fmt.Errorf("detecting folder move: %w", err) } if renamedFrom == nil { return nil, nil } // if the folder was moved, update the existing folder logger.Infof("%s moved to %s. Updating path...", renamedFrom.Path, file.Path) renamedFrom.Path = file.Path // update the parent folder ID // find the parent folder parentFolderID, err := s.getFolderID(ctx, filepath.Dir(file.Path)) if err != nil { return nil, fmt.Errorf("getting parent folder for %q: %w", file.Path, err) } renamedFrom.ParentFolderID = parentFolderID if err := s.Repository.Folder.Update(ctx, renamedFrom); err != nil { return nil, fmt.Errorf("updating folder for rename %q: %w", renamedFrom.Path, err) } // #4146 - correct sub-folders to have the correct path if err := correctSubFolderHierarchy(ctx, s.Repository.Folder, renamedFrom); err != nil { return nil, fmt.Errorf("correcting sub folder hierarchy for %q: %w", renamedFrom.Path, err) } return renamedFrom, nil } func (s *Scanner) onExistingFolder(ctx context.Context, f ScannedFile, existing *models.Folder) (*models.Folder, error) { update := false // update if mod time is changed entryModTime := f.ModTime if !entryModTime.Equal(existing.ModTime) { existing.Path = f.Path existing.ModTime = entryModTime update = true } // #6326 - update if path has changed - should only happen if case is // changed and filesystem is case insensitive if existing.Path != f.Path { existing.Path = f.Path update = true } // update if zip file ID has changed fZfID := f.ZipFileID existingZfID := existing.ZipFileID if fZfID != existingZfID { if fZfID == nil { existing.ZipFileID = nil update = true } else if existingZfID == nil || *fZfID != *existingZfID { existing.ZipFileID = fZfID update = true } } // handle case where parent folder was not previously set if existing.ParentFolderID == nil && !s.isRootPath(existing.Path) { logger.Infof("Existing folder entry %q has no parent folder. Creating folder hierarchy and setting parent ID...", existing.Path) // create full folder hierarchy if parent folder doesn't exist, and set parent folder ID parentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, filepath.Dir(f.Path), s.RootPaths) if err != nil { return nil, fmt.Errorf("getting parent folder for %q: %w", f.Path, err) } existing.ParentFolderID = &parentFolder.ID update = true } if update { var err error if err = s.Repository.Folder.Update(ctx, existing); err != nil { return nil, fmt.Errorf("updating folder %q: %w", f.Path, err) } } return existing, nil } type ScanFileResult struct { File models.File New bool Renamed bool Updated bool FingerprintChanged bool } func (r ScanFileResult) IsUnchanged() bool { return !r.New && !r.Renamed && !r.Updated } // ScanFile scans the provided file into the database, returning the scan result. func (s *Scanner) ScanFile(ctx context.Context, f ScannedFile) (*ScanFileResult, error) { var r *ScanFileResult // don't use a transaction to check if new or existing if err := s.Repository.WithDB(ctx, func(ctx context.Context) error { // determine if file already exists in data store // assume case sensitive when searching for the file to begin with ff, err := s.Repository.File.FindByPath(ctx, f.Path, true) if err != nil { return fmt.Errorf("checking for existing file %q: %w", f.Path, err) } // #1426 / #6326 - if file is in a case-insensitive filesystem, then try // case insensitive search // assume case sensitive if in zip if ff == nil && f.ZipFileID != nil { caseSensitive, _ := f.FS.IsPathCaseSensitive(f.Path) if !caseSensitive { ff, err = s.Repository.File.FindByPath(ctx, f.Path, false) if err != nil { return fmt.Errorf("checking for existing file %q: %w", f.Path, err) } } } if ff == nil { // returns a file only if it is actually new r, err = s.onNewFile(ctx, f) return err } r, err = s.onExistingFile(ctx, f, ff) return err }); err != nil { return nil, err } return r, nil } // IsZipFile determines if the provided path is a zip file based on its extension. func (s *Scanner) IsZipFile(path string) bool { fExt := filepath.Ext(path) for _, ext := range s.ZipFileExtensions { if strings.EqualFold(fExt, "."+ext) { return true } } return false } func (s *Scanner) onNewFile(ctx context.Context, f ScannedFile) (*ScanFileResult, error) { now := time.Now() baseFile := f.BaseFile path := baseFile.Path baseFile.CreatedAt = now baseFile.UpdatedAt = now // find the parent folder folderPath := filepath.Dir(path) parentFolderID, err := s.getFolderID(ctx, folderPath) if err != nil { return nil, fmt.Errorf("getting parent folder for %q: %w", path, err) } if parentFolderID == nil { // parent folders should have been created before scanning this file in a recursive scan // assume that we are scanning specifically and only this file, // so we should create the parent folder hierarchy if it doesn't exist if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { parentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, folderPath, s.RootPaths) if err != nil { return fmt.Errorf("getting parent folder for %q: %w", f.Path, err) } parentFolderID = &parentFolder.ID return nil }); err != nil { return nil, err } } if parentFolderID == nil { // shouldn't happen return nil, fmt.Errorf("parent folder ID is nil for %q", path) } baseFile.ParentFolderID = *parentFolderID const useExisting = false fp, err := s.calculateFingerprints(f.FS, baseFile, path, useExisting) if err != nil { return nil, err } baseFile.SetFingerprints(fp) file, err := s.fireDecorators(ctx, f.FS, baseFile) if err != nil { return nil, err } // determine if the file is renamed from an existing file in the store // do this after decoration so that missing fields can be populated zipFilePath := "" if f.ZipFile != nil { zipFilePath = f.ZipFile.Base().Path } renamed, err := s.handleRename(ctx, file, fp, zipFilePath) if err != nil { return nil, err } if renamed != nil { return &ScanFileResult{ File: renamed, Renamed: true, }, nil // handle rename should have already handled the contents of the zip file // so shouldn't need to scan it again // return nil so it doesn't } // if not renamed, queue file for creation if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { if err := s.Repository.File.Create(ctx, file); err != nil { return fmt.Errorf("creating file %q: %w", path, err) } if err := s.fireHandlers(ctx, file, nil); err != nil { return err } return nil }); err != nil { return nil, err } return &ScanFileResult{ File: file, New: true, }, nil } func (s *Scanner) fireDecorators(ctx context.Context, fs models.FS, f models.File) (models.File, error) { for _, h := range s.FileDecorators { var err error f, err = h.Decorate(ctx, fs, f) if err != nil { return f, err } } return f, nil } func (s *Scanner) fireHandlers(ctx context.Context, f models.File, oldFile models.File) error { for _, h := range s.FileHandlers { if err := h.Handle(ctx, f, oldFile); err != nil { return err } } return nil } func (s *Scanner) calculateFingerprints(fs models.FS, f *models.BaseFile, path string, useExisting bool) (models.Fingerprints, error) { // only log if we're (re)calculating fingerprints if !useExisting { logger.Infof("Calculating fingerprints for %s ...", path) } // calculate primary fingerprint for the file fp, err := s.FingerprintCalculator.CalculateFingerprints(f, &fsOpener{ fs: fs, name: path, }, useExisting) if err != nil { return nil, fmt.Errorf("calculating fingerprint for file %q: %w", path, err) } return fp, nil } func appendFileUnique(v []models.File, toAdd []models.File) []models.File { for _, f := range toAdd { found := false id := f.Base().ID for _, vv := range v { if vv.Base().ID == id { found = true break } } if !found { v = append(v, f) } } return v } func (s *Scanner) getFileFS(f *models.BaseFile) (models.FS, error) { if f.ZipFile == nil { return s.FS, nil } fs, err := s.getFileFS(f.ZipFile.Base()) if err != nil { return nil, err } zipPath := f.ZipFile.Base().Path zipSize := f.ZipFile.Base().Size return fs.OpenZip(zipPath, zipSize) } func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.Fingerprint, zipFilePath string) (models.File, error) { var others []models.File for _, tfp := range fp { thisOthers, err := s.Repository.File.FindByFingerprint(ctx, tfp) if err != nil { return nil, fmt.Errorf("getting files by fingerprint %v: %w", tfp, err) } others = appendFileUnique(others, thisOthers) } var missing []models.File fZipID := f.Base().ZipFileID for _, other := range others { // if file is from a zip file, then only rename if both files are from the same zip file otherZipID := other.Base().ZipFileID if otherZipID != nil && (fZipID == nil || *otherZipID != *fZipID) { continue } // if file does not exist, then update it to the new path fs, err := s.getFileFS(other.Base()) if err != nil { missing = append(missing, other) continue } info, err := fs.Lstat(other.Base().Path) switch { case err != nil: missing = append(missing, other) case strings.EqualFold(f.Base().Path, other.Base().Path): // #1426 - if file exists but is a case-insensitive match for the // original filename, and the filesystem is case-insensitive // then treat it as a move // #6326 - this should now be handled earlier, and this shouldn't be necessary if caseSensitive, _ := fs.IsPathCaseSensitive(other.Base().Path); !caseSensitive { // treat as a move missing = append(missing, other) } case !s.AcceptEntry(ctx, other.Base().Path, info, zipFilePath): // #4393 - if the file is no longer in the configured library paths, treat it as a move logger.Debugf("File %q no longer in library paths. Treating as a move.", other.Base().Path) missing = append(missing, other) } } n := len(missing) if n == 0 { // no missing files, not a rename return nil, nil } // assume does not exist, update existing file // it's possible that there may be multiple missing files. // just use the first one to rename. // #4775 - using the new file instance means that any changes made to the existing // file will be lost. Update the existing file instead. other := missing[0] updated := other.Clone() updatedBase := updated.Base() fBaseCopy := *(f.Base()) oldPath := updatedBase.Path newPath := fBaseCopy.Path logger.Infof("%s moved to %s. Updating path...", oldPath, newPath) fBaseCopy.ID = updatedBase.ID fBaseCopy.CreatedAt = updatedBase.CreatedAt fBaseCopy.Fingerprints = updatedBase.Fingerprints *updatedBase = fBaseCopy zipMover := zipHierarchyMover{ folderStore: s.Repository.Folder, files: s.Repository.File, rootPaths: s.RootPaths, } if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { if err := s.Repository.File.Update(ctx, updated); err != nil { return fmt.Errorf("updating file for rename %q: %w", newPath, err) } if s.IsZipFile(updatedBase.Basename) { if err := zipMover.transferZipHierarchy(ctx, updatedBase.ID, oldPath, newPath); err != nil { return fmt.Errorf("moving zip hierarchy for renamed zip file %q: %w", newPath, err) } } if err := s.fireHandlers(ctx, updated, other); err != nil { return err } return nil }); err != nil { return nil, err } return updated, nil } func (s *Scanner) isHandlerRequired(ctx context.Context, f models.File) bool { accept := len(s.HandlerRequiredFilters) == 0 for _, filter := range s.HandlerRequiredFilters { // accept if any filter accepts the file if filter.Accept(ctx, f) { accept = true break } } return accept } // isMissingMetadata returns true if the provided file is missing metadata. // Missing metadata should only occur after the 32 schema migration. // Looks for special values. For numbers, this will be -1. For strings, this // will be 'unset'. // Missing metadata includes the following: // - file size // - image format, width or height // - video codec, audio codec, format, width, height, framerate or bitrate func (s *Scanner) isMissingMetadata(ctx context.Context, f ScannedFile, existing models.File) bool { for _, h := range s.FileDecorators { if h.IsMissingMetadata(ctx, f.FS, existing) { return true } } return false } func (s *Scanner) setMissingMetadata(ctx context.Context, f ScannedFile, existing models.File) (models.File, error) { path := existing.Base().Path logger.Infof("Updating metadata for %s", path) existing.Base().Size = f.Size var err error existing, err = s.fireDecorators(ctx, f.FS, existing) if err != nil { return nil, err } // queue file for update if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { if err := s.Repository.File.Update(ctx, existing); err != nil { return fmt.Errorf("updating file %q: %w", path, err) } return nil }); err != nil { return nil, err } return existing, nil } func (s *Scanner) setMissingFingerprints(ctx context.Context, f ScannedFile, existing models.File) (models.File, error) { const useExisting = true fp, err := s.calculateFingerprints(f.FS, existing.Base(), f.Path, useExisting) if err != nil { return nil, err } if fp.ContentsChanged(existing.Base().Fingerprints) { existing.SetFingerprints(fp) if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { if err := s.Repository.File.Update(ctx, existing); err != nil { return fmt.Errorf("updating file %q: %w", f.Path, err) } return nil }); err != nil { return nil, err } } return existing, nil } // returns a file only if it was updated func (s *Scanner) onExistingFile(ctx context.Context, f ScannedFile, existing models.File) (*ScanFileResult, error) { base := existing.Base() path := base.Path fileModTime := f.ModTime // #6326 - also force a rescan if the basename changed updated := !fileModTime.Equal(base.ModTime) || base.Basename != f.Basename forceRescan := s.Rescan if !updated && !forceRescan { return s.onUnchangedFile(ctx, f, existing) } oldBase := *base if !updated && forceRescan { logger.Infof("rescanning %s", path) } else { logger.Infof("%s has been updated: rescanning", path) } // #6326 - update basename in case it changed base.Basename = f.Basename base.ModTime = fileModTime base.Size = f.Size base.UpdatedAt = time.Now() // calculate and update fingerprints for the file const useExisting = false fp, err := s.calculateFingerprints(f.FS, base, path, useExisting) if err != nil { return nil, err } oldFingerprints := existing.Base().Fingerprints fingerprintChanged := fp.ContentsChanged(oldFingerprints) s.removeOutdatedFingerprints(existing, fp) existing.SetFingerprints(fp) existing, err = s.fireDecorators(ctx, f.FS, existing) if err != nil { return nil, err } // queue file for update if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { if err := s.Repository.File.Update(ctx, existing); err != nil { return fmt.Errorf("updating file %q: %w", path, err) } if err := s.fireHandlers(ctx, existing, &oldBase); err != nil { return err } return nil }); err != nil { return nil, err } return &ScanFileResult{ File: existing, Updated: true, FingerprintChanged: fingerprintChanged, }, nil } func (s *Scanner) removeOutdatedFingerprints(existing models.File, fp models.Fingerprints) { // HACK - if no MD5 fingerprint was returned, and the oshash is changed // then remove the MD5 fingerprint oshash := fp.For(models.FingerprintTypeOshash) if oshash == nil { return } existingOshash := existing.Base().Fingerprints.For(models.FingerprintTypeOshash) if existingOshash == nil || *existingOshash == *oshash { // missing oshash or same oshash - nothing to do return } md5 := fp.For(models.FingerprintTypeMD5) if md5 != nil { // nothing to do return } // oshash has changed, MD5 is missing - remove MD5 from the existing fingerprints logger.Infof("Removing outdated checksum from %s", existing.Base().Path) b := existing.Base() b.Fingerprints = b.Fingerprints.Remove(models.FingerprintTypeMD5) } // returns a file only if it was updated func (s *Scanner) onUnchangedFile(ctx context.Context, f ScannedFile, existing models.File) (*ScanFileResult, error) { var err error isMissingMetdata := s.isMissingMetadata(ctx, f, existing) // set missing information if isMissingMetdata { existing, err = s.setMissingMetadata(ctx, f, existing) if err != nil { return nil, err } } // calculate missing fingerprints existing, err = s.setMissingFingerprints(ctx, f, existing) if err != nil { return nil, err } handlerRequired := false if err := s.Repository.WithDB(ctx, func(ctx context.Context) error { // check if the handler needs to be run handlerRequired = s.isHandlerRequired(ctx, existing) return nil }); err != nil { return nil, err } if !handlerRequired { // if this file is a zip file, then we need to rescan the contents // as well. We do this by indicating that the file is updated. if isMissingMetdata { return &ScanFileResult{ File: existing, Updated: true, }, nil } return &ScanFileResult{ File: existing, }, nil } if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { if err := s.fireHandlers(ctx, existing, nil); err != nil { return err } return nil }); err != nil { return nil, err } // if this file is a zip file, then we need to rescan the contents // as well. We do this by indicating that the file is updated. return &ScanFileResult{ File: existing, Updated: true, }, nil } ================================================ FILE: pkg/file/stashignore.go ================================================ package file import ( "context" "io/fs" "os" "path/filepath" "strings" "sync" lru "github.com/hashicorp/golang-lru/v2" ignore "github.com/sabhiram/go-gitignore" "github.com/stashapp/stash/pkg/logger" ) const stashIgnoreFilename = ".stashignore" // entriesCacheSize is the size of the LRU cache for collected ignore entries. // This cache stores the computed list of ignore entries per directory, avoiding // repeated directory tree walks for files in the same directory. const entriesCacheSize = 500 // StashIgnoreFilter implements PathFilter to exclude files/directories // based on .stashignore files with gitignore-style patterns. type StashIgnoreFilter struct { // cache stores compiled ignore patterns per directory. cache sync.Map // map[string]*ignoreEntry // entriesCache stores collected ignore entries per (dir, libraryRoot) pair. // This avoids recomputing the entry list for every file in the same directory. entriesCache *lru.Cache[string, []*ignoreEntry] } // ignoreEntry holds the compiled ignore patterns for a directory. type ignoreEntry struct { // patterns is the compiled gitignore matcher for this directory. patterns *ignore.GitIgnore // dir is the directory this entry applies to. dir string } // NewStashIgnoreFilter creates a new StashIgnoreFilter. func NewStashIgnoreFilter() *StashIgnoreFilter { // Create the LRU cache for collected entries. // Ignore error as it only fails if size <= 0. entriesCache, _ := lru.New[string, []*ignoreEntry](entriesCacheSize) return &StashIgnoreFilter{ entriesCache: entriesCache, } } // Accept returns true if the path should be included in the scan. // It checks for .stashignore files in the directory hierarchy and // applies gitignore-style pattern matching. // The libraryRoot parameter bounds the search for .stashignore files - // only directories within the library root are checked. // zipFilepath is the path of the zip file if the file is inside a zip. // .stashignore files will not be read within zip files. func (f *StashIgnoreFilter) Accept(ctx context.Context, path string, info fs.FileInfo, libraryRoot string, zipFilePath string) bool { // If no library root provided, accept the file (safety fallback). if libraryRoot == "" { return true } // Get the directory containing this path. dir := filepath.Dir(path) // If the file is inside a zip, use the zip file's directory as the base for .stashignore lookup. if zipFilePath != "" { dir = filepath.Dir(zipFilePath) } // Collect all applicable ignore entries from library root to this directory. entries := f.collectIgnoreEntries(dir, libraryRoot) // If no .stashignore files found, accept the file. if len(entries) == 0 { return true } // Check each ignore entry in order (from root to most specific). // Later entries can override earlier ones with negation patterns. ignored := false for _, entry := range entries { // Get path relative to the ignore file's directory. entryRelPath, err := filepath.Rel(entry.dir, path) if err != nil { continue } entryRelPath = filepath.ToSlash(entryRelPath) if info.IsDir() { entryRelPath += "/" } if entry.patterns.MatchesPath(entryRelPath) { ignored = true } } return !ignored } // collectIgnoreEntries gathers all ignore entries from library root to the given directory. // It walks up the directory tree from dir to libraryRoot and returns entries in order // from root to most specific. Results are cached to avoid repeated computation for // files in the same directory. func (f *StashIgnoreFilter) collectIgnoreEntries(dir string, libraryRoot string) []*ignoreEntry { // Clean paths for consistent comparison and cache key generation. dir = filepath.Clean(dir) libraryRoot = filepath.Clean(libraryRoot) // Build cache key from dir and libraryRoot. cacheKey := dir + "\x00" + libraryRoot // Check the entries cache first. if cached, ok := f.entriesCache.Get(cacheKey); ok { return cached } // Try subdirectory shortcut: if parent's entries are cached, extend them. if dir != libraryRoot { parent := filepath.Dir(dir) if isPathInOrEqual(libraryRoot, parent) { parentKey := parent + "\x00" + libraryRoot if parentEntries, ok := f.entriesCache.Get(parentKey); ok { // Parent is cached - just check if current dir has a .stashignore. entries := parentEntries if entry := f.getOrLoadIgnoreEntry(dir); entry != nil { // Copy parent slice and append to avoid mutating cached slice. entries = make([]*ignoreEntry, len(parentEntries), len(parentEntries)+1) copy(entries, parentEntries) entries = append(entries, entry) } f.entriesCache.Add(cacheKey, entries) return entries } } } // No cache hit - compute from scratch. // Walk up from dir to library root, collecting directories. var dirs []string current := dir for { // Check if we're still within the library root. if !isPathInOrEqual(libraryRoot, current) { break } dirs = append(dirs, current) // Stop if we've reached the library root. if current == libraryRoot { break } parent := filepath.Dir(current) if parent == current { // Reached filesystem root without finding library root. break } current = parent } // Reverse to get root-to-leaf order. for i, j := 0, len(dirs)-1; i < j; i, j = i+1, j-1 { dirs[i], dirs[j] = dirs[j], dirs[i] } // Check each directory for .stashignore files. var entries []*ignoreEntry for _, d := range dirs { if entry := f.getOrLoadIgnoreEntry(d); entry != nil { entries = append(entries, entry) } } // Cache the result. f.entriesCache.Add(cacheKey, entries) return entries } // isPathInOrEqual checks if path is equal to or inside root. func isPathInOrEqual(root, path string) bool { if path == root { return true } // Check if path starts with root + separator. return strings.HasPrefix(path, root+string(filepath.Separator)) } // getOrLoadIgnoreEntry returns the cached ignore entry for a directory, or loads it. func (f *StashIgnoreFilter) getOrLoadIgnoreEntry(dir string) *ignoreEntry { // Check cache first. if cached, ok := f.cache.Load(dir); ok { entry := cached.(*ignoreEntry) if entry.patterns == nil { return nil // Cached negative result. } return entry } // Try to load .stashignore from this directory. stashIgnorePath := filepath.Join(dir, stashIgnoreFilename) patterns, err := f.loadIgnoreFile(stashIgnorePath) if err != nil { if !os.IsNotExist(err) { logger.Warnf("Failed to load .stashignore from %s: %v", dir, err) } f.cache.Store(dir, &ignoreEntry{patterns: nil, dir: dir}) return nil } if patterns == nil { // File exists but has no patterns (empty or only comments). f.cache.Store(dir, &ignoreEntry{patterns: nil, dir: dir}) return nil } logger.Debugf("Loaded .stashignore from %s", dir) entry := &ignoreEntry{ patterns: patterns, dir: dir, } f.cache.Store(dir, entry) return entry } // loadIgnoreFile loads and compiles a .stashignore file. func (f *StashIgnoreFilter) loadIgnoreFile(path string) (*ignore.GitIgnore, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } lines := strings.Split(string(data), "\n") var patterns []string for _, line := range lines { // Trim trailing whitespace (but preserve leading for patterns). line = strings.TrimRight(line, " \t\r") // Skip empty lines. if line == "" { continue } // Skip comments (but not escaped #). if strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "\\#") { continue } patterns = append(patterns, line) } if len(patterns) == 0 { // File exists but has no patterns (e.g., only comments). return nil, nil } return ignore.CompileIgnoreLines(patterns...), nil } ================================================ FILE: pkg/file/stashignore_test.go ================================================ package file import ( "context" "io/fs" "os" "path/filepath" "sort" "testing" ) // Helper to create an empty file. func createTestFile(t *testing.T, dir, name string) { t.Helper() path := filepath.Join(dir, name) if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatalf("failed to create directory for %s: %v", path, err) } if err := os.WriteFile(path, []byte{}, 0644); err != nil { t.Fatalf("failed to create file %s: %v", path, err) } } // Helper to create a file with content. func createTestFileWithContent(t *testing.T, dir, name, content string) { t.Helper() path := filepath.Join(dir, name) if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatalf("failed to create directory for %s: %v", path, err) } if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatalf("failed to create file %s: %v", path, err) } } // Helper to create a directory. func createTestDir(t *testing.T, dir, name string) { t.Helper() path := filepath.Join(dir, name) if err := os.MkdirAll(path, 0755); err != nil { t.Fatalf("failed to create directory %s: %v", path, err) } } // walkAndFilter walks the directory tree and returns paths accepted by the filter. // Returns paths relative to root for easier assertion. func walkAndFilter(t *testing.T, root string, filter *StashIgnoreFilter) []string { t.Helper() var accepted []string ctx := context.Background() err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } // Skip the root directory itself. if path == root { return nil } info, err := d.Info() if err != nil { return err } if filter.Accept(ctx, path, info, root, "") { relPath, _ := filepath.Rel(root, path) accepted = append(accepted, relPath) } else if info.IsDir() { // If directory is rejected, skip it. return filepath.SkipDir } return nil }) if err != nil { t.Fatalf("walk failed: %v", err) } sort.Strings(accepted) return accepted } // assertPathsEqual checks that the accepted paths match expected. func assertPathsEqual(t *testing.T, expected, actual []string) { t.Helper() sort.Strings(expected) if len(expected) != len(actual) { t.Errorf("path count mismatch:\nexpected %d: %v\nactual %d: %v", len(expected), expected, len(actual), actual) return } for i := range expected { if expected[i] != actual[i] { t.Errorf("path mismatch at index %d:\nexpected: %s\nactual: %s", i, expected[i], actual[i]) } } } func TestStashIgnore_ExactFilename(t *testing.T) { tmpDir := t.TempDir() // Create test files. createTestFile(t, tmpDir, "video1.mp4") createTestFile(t, tmpDir, "video2.mp4") createTestFile(t, tmpDir, "ignore_me.mp4") // Create .stashignore that excludes exact filename. createTestFileWithContent(t, tmpDir, ".stashignore", "ignore_me.mp4\n") filter := NewStashIgnoreFilter() accepted := walkAndFilter(t, tmpDir, filter) expected := []string{ ".stashignore", "video1.mp4", "video2.mp4", } assertPathsEqual(t, expected, accepted) } func TestStashIgnore_WildcardPattern(t *testing.T) { tmpDir := t.TempDir() // Create test files. createTestFile(t, tmpDir, "video1.mp4") createTestFile(t, tmpDir, "video2.mp4") createTestFile(t, tmpDir, "temp1.tmp") createTestFile(t, tmpDir, "temp2.tmp") createTestFile(t, tmpDir, "notes.log") // Create .stashignore that excludes by extension. createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n*.log\n") filter := NewStashIgnoreFilter() accepted := walkAndFilter(t, tmpDir, filter) expected := []string{ ".stashignore", "video1.mp4", "video2.mp4", } assertPathsEqual(t, expected, accepted) } func TestStashIgnore_DirectoryExclusion(t *testing.T) { tmpDir := t.TempDir() // Create test files. createTestFile(t, tmpDir, "video1.mp4") createTestDir(t, tmpDir, "excluded_dir") createTestFile(t, tmpDir, "excluded_dir/video2.mp4") createTestFile(t, tmpDir, "excluded_dir/video3.mp4") createTestDir(t, tmpDir, "included_dir") createTestFile(t, tmpDir, "included_dir/video4.mp4") // Create .stashignore that excludes a directory. createTestFileWithContent(t, tmpDir, ".stashignore", "excluded_dir/\n") filter := NewStashIgnoreFilter() accepted := walkAndFilter(t, tmpDir, filter) expected := []string{ ".stashignore", "included_dir", "included_dir/video4.mp4", "video1.mp4", } assertPathsEqual(t, expected, accepted) } func TestStashIgnore_NegationPattern(t *testing.T) { tmpDir := t.TempDir() // Create test files. createTestFile(t, tmpDir, "file1.tmp") createTestFile(t, tmpDir, "file2.tmp") createTestFile(t, tmpDir, "keep_this.tmp") // Create .stashignore that excludes *.tmp but keeps one. createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n!keep_this.tmp\n") filter := NewStashIgnoreFilter() accepted := walkAndFilter(t, tmpDir, filter) expected := []string{ ".stashignore", "keep_this.tmp", } assertPathsEqual(t, expected, accepted) } func TestStashIgnore_CommentsAndEmptyLines(t *testing.T) { tmpDir := t.TempDir() // Create test files. createTestFile(t, tmpDir, "video1.mp4") createTestFile(t, tmpDir, "ignore_me.mp4") // Create .stashignore with comments and empty lines. stashignore := `# This is a comment ignore_me.mp4 # Another comment ` createTestFileWithContent(t, tmpDir, ".stashignore", stashignore) filter := NewStashIgnoreFilter() accepted := walkAndFilter(t, tmpDir, filter) expected := []string{ ".stashignore", "video1.mp4", } assertPathsEqual(t, expected, accepted) } func TestStashIgnore_NestedStashIgnoreFiles(t *testing.T) { tmpDir := t.TempDir() // Create test files. createTestFile(t, tmpDir, "root_video.mp4") createTestFile(t, tmpDir, "root_ignore.tmp") createTestDir(t, tmpDir, "subdir") createTestFile(t, tmpDir, "subdir/sub_video.mp4") createTestFile(t, tmpDir, "subdir/sub_ignore.log") createTestFile(t, tmpDir, "subdir/also_tmp.tmp") // Root .stashignore excludes *.tmp. createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n") // Subdir .stashignore excludes *.log. createTestFileWithContent(t, tmpDir, "subdir/.stashignore", "*.log\n") filter := NewStashIgnoreFilter() accepted := walkAndFilter(t, tmpDir, filter) // *.tmp from root should apply everywhere. // *.log from subdir should only apply in subdir. expected := []string{ ".stashignore", "root_video.mp4", "subdir", "subdir/.stashignore", "subdir/sub_video.mp4", } assertPathsEqual(t, expected, accepted) } func TestStashIgnore_PathPattern(t *testing.T) { tmpDir := t.TempDir() // Create test files. createTestFile(t, tmpDir, "video1.mp4") createTestDir(t, tmpDir, "subdir") createTestFile(t, tmpDir, "subdir/video2.mp4") createTestFile(t, tmpDir, "subdir/skip_this.mp4") // Create .stashignore that excludes a specific path. createTestFileWithContent(t, tmpDir, ".stashignore", "subdir/skip_this.mp4\n") filter := NewStashIgnoreFilter() accepted := walkAndFilter(t, tmpDir, filter) expected := []string{ ".stashignore", "subdir", "subdir/video2.mp4", "video1.mp4", } assertPathsEqual(t, expected, accepted) } func TestStashIgnore_DoubleStarPattern(t *testing.T) { tmpDir := t.TempDir() // Create test files. createTestFile(t, tmpDir, "video1.mp4") createTestDir(t, tmpDir, "a") createTestFile(t, tmpDir, "a/video2.mp4") createTestDir(t, tmpDir, "a/temp") createTestFile(t, tmpDir, "a/temp/video3.mp4") createTestDir(t, tmpDir, "a/b") createTestDir(t, tmpDir, "a/b/temp") createTestFile(t, tmpDir, "a/b/temp/video4.mp4") // Create .stashignore that excludes temp directories at any level. createTestFileWithContent(t, tmpDir, ".stashignore", "**/temp/\n") filter := NewStashIgnoreFilter() accepted := walkAndFilter(t, tmpDir, filter) expected := []string{ ".stashignore", "a", "a/b", "a/video2.mp4", "video1.mp4", } assertPathsEqual(t, expected, accepted) } func TestStashIgnore_LeadingSlashPattern(t *testing.T) { tmpDir := t.TempDir() // Create test files. createTestFile(t, tmpDir, "ignore.mp4") createTestDir(t, tmpDir, "subdir") createTestFile(t, tmpDir, "subdir/ignore.mp4") // Create .stashignore that excludes only at root level. createTestFileWithContent(t, tmpDir, ".stashignore", "/ignore.mp4\n") filter := NewStashIgnoreFilter() accepted := walkAndFilter(t, tmpDir, filter) // Only root ignore.mp4 should be excluded. expected := []string{ ".stashignore", "subdir", "subdir/ignore.mp4", } assertPathsEqual(t, expected, accepted) } func TestStashIgnore_NoStashIgnoreFile(t *testing.T) { tmpDir := t.TempDir() // Create test files without any .stashignore. createTestFile(t, tmpDir, "video1.mp4") createTestFile(t, tmpDir, "video2.mp4") createTestDir(t, tmpDir, "subdir") createTestFile(t, tmpDir, "subdir/video3.mp4") filter := NewStashIgnoreFilter() accepted := walkAndFilter(t, tmpDir, filter) // All files should be accepted. expected := []string{ "subdir", "subdir/video3.mp4", "video1.mp4", "video2.mp4", } assertPathsEqual(t, expected, accepted) } func TestStashIgnore_HiddenDirectories(t *testing.T) { tmpDir := t.TempDir() // Create test files including hidden directory. createTestFile(t, tmpDir, "video1.mp4") createTestDir(t, tmpDir, ".hidden") createTestFile(t, tmpDir, ".hidden/video2.mp4") // Create .stashignore that excludes hidden directories. createTestFileWithContent(t, tmpDir, ".stashignore", ".*\n!.stashignore\n") filter := NewStashIgnoreFilter() accepted := walkAndFilter(t, tmpDir, filter) expected := []string{ ".stashignore", "video1.mp4", } assertPathsEqual(t, expected, accepted) } func TestStashIgnore_MultiplePatternsSameLine(t *testing.T) { tmpDir := t.TempDir() // Create test files. createTestFile(t, tmpDir, "video1.mp4") createTestFile(t, tmpDir, "file.tmp") createTestFile(t, tmpDir, "file.log") createTestFile(t, tmpDir, "file.bak") // Each pattern should be on its own line. createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n*.log\n*.bak\n") filter := NewStashIgnoreFilter() accepted := walkAndFilter(t, tmpDir, filter) expected := []string{ ".stashignore", "video1.mp4", } assertPathsEqual(t, expected, accepted) } func TestStashIgnore_TrailingSpaces(t *testing.T) { tmpDir := t.TempDir() // Create test files. createTestFile(t, tmpDir, "video1.mp4") createTestFile(t, tmpDir, "ignore_me.mp4") // Pattern with trailing spaces (should be trimmed). createTestFileWithContent(t, tmpDir, ".stashignore", "ignore_me.mp4 \n") filter := NewStashIgnoreFilter() accepted := walkAndFilter(t, tmpDir, filter) expected := []string{ ".stashignore", "video1.mp4", } assertPathsEqual(t, expected, accepted) } func TestStashIgnore_EscapedHash(t *testing.T) { tmpDir := t.TempDir() // Create test files. createTestFile(t, tmpDir, "video1.mp4") createTestFile(t, tmpDir, "#filename.mp4") // Escaped hash should match literal # character. createTestFileWithContent(t, tmpDir, ".stashignore", "\\#filename.mp4\n") filter := NewStashIgnoreFilter() accepted := walkAndFilter(t, tmpDir, filter) expected := []string{ ".stashignore", "video1.mp4", } assertPathsEqual(t, expected, accepted) } func TestStashIgnore_CaseSensitiveMatching(t *testing.T) { tmpDir := t.TempDir() // Create test files - use distinct names that work on all filesystems. createTestFile(t, tmpDir, "video_lower.mp4") createTestFile(t, tmpDir, "VIDEO_UPPER.mp4") createTestFile(t, tmpDir, "other.avi") // Pattern should match exactly (case-sensitive). createTestFileWithContent(t, tmpDir, ".stashignore", "video_lower.mp4\n") filter := NewStashIgnoreFilter() accepted := walkAndFilter(t, tmpDir, filter) // Only exact match is excluded. expected := []string{ ".stashignore", "VIDEO_UPPER.mp4", "other.avi", } assertPathsEqual(t, expected, accepted) } func TestStashIgnore_ComplexScenario(t *testing.T) { tmpDir := t.TempDir() // Create a complex directory structure. createTestFile(t, tmpDir, "video1.mp4") createTestFile(t, tmpDir, "video2.avi") createTestFile(t, tmpDir, "thumbnail.jpg") createTestFile(t, tmpDir, "metadata.nfo") createTestDir(t, tmpDir, "movies") createTestFile(t, tmpDir, "movies/movie1.mp4") createTestFile(t, tmpDir, "movies/movie1.nfo") createTestDir(t, tmpDir, "movies/.thumbnails") createTestFile(t, tmpDir, "movies/.thumbnails/thumb1.jpg") createTestDir(t, tmpDir, "temp") createTestFile(t, tmpDir, "temp/processing.mp4") createTestDir(t, tmpDir, "backup") createTestFile(t, tmpDir, "backup/video1.mp4.bak") // Complex .stashignore. stashignore := `# Ignore metadata files *.nfo # Ignore hidden directories .* !.stashignore # Ignore temp and backup directories temp/ backup/ # But keep thumbnails in specific location !movies/.thumbnails/ ` createTestFileWithContent(t, tmpDir, ".stashignore", stashignore) filter := NewStashIgnoreFilter() accepted := walkAndFilter(t, tmpDir, filter) expected := []string{ ".stashignore", "movies", "movies/.thumbnails", "movies/.thumbnails/thumb1.jpg", "movies/movie1.mp4", "thumbnail.jpg", "video1.mp4", "video2.avi", } assertPathsEqual(t, expected, accepted) } ================================================ FILE: pkg/file/video/caption.go ================================================ package video import ( "context" "errors" "fmt" "os" "path/filepath" "strings" "github.com/asticode/go-astisub" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/txn" "golang.org/x/text/language" ) var CaptionExts = []string{"vtt", "srt"} // in a case where vtt and srt files are both provided prioritize vtt file due to native support // to be used for captions without a language code in the filename // ISO 639-1 uses 2 or 3 a-z chars for codes so 00 is a safe non valid choise // https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes const LangUnknown = "00" // GetCaptionPath generates the path of a caption // from a given file path, wanted language and caption sufffix func GetCaptionPath(path, lang, suffix string) string { ext := filepath.Ext(path) fn := strings.TrimSuffix(path, ext) captionExt := "" if len(lang) == 0 || lang == LangUnknown { captionExt = suffix } else { captionExt = lang + "." + suffix } return fn + "." + captionExt } // ReadSubs reads a captions file func ReadSubs(path string) (*astisub.Subtitles, error) { return astisub.OpenFile(path) } // IsValidLanguage checks whether the given string is a valid // ISO 639 language code func IsValidLanguage(lang string) bool { _, err := language.ParseBase(lang) return err == nil } // IsLangInCaptions returns true if lang is present // in the captions func IsLangInCaptions(lang string, ext string, captions []*models.VideoCaption) bool { for _, caption := range captions { if lang == caption.LanguageCode && ext == caption.CaptionType { return true } } return false } // getCaptionPrefix returns the prefix used to search for video files for the provided caption path func getCaptionPrefix(captionPath string) string { basename := strings.TrimSuffix(captionPath, filepath.Ext(captionPath)) // caption filename without the extension // a caption file can be something like scene_filename.srt or scene_filename.en.srt // if a language code is present and valid remove it from the basename languageExt := filepath.Ext(basename) if len(languageExt) > 2 && IsValidLanguage(languageExt[1:]) { basename = strings.TrimSuffix(basename, languageExt) } return basename + "." } // GetCaptionsLangFromPath returns the language code from a given captions path // If no valid language is present LangUknown is returned func getCaptionsLangFromPath(captionPath string) string { langCode := LangUnknown basename := strings.TrimSuffix(captionPath, filepath.Ext(captionPath)) // caption filename without the extension languageExt := filepath.Ext(basename) if len(languageExt) > 2 && IsValidLanguage(languageExt[1:]) { langCode = languageExt[1:] } return langCode } type CaptionUpdater interface { GetCaptions(ctx context.Context, fileID models.FileID) ([]*models.VideoCaption, error) UpdateCaptions(ctx context.Context, fileID models.FileID, captions []*models.VideoCaption) error } // MatchesCaption returns true if the caption file matches the video file based on the filename func MatchesCaption(videoPath, captionPath string) bool { captionPrefix := getCaptionPrefix(captionPath) videoPrefix := strings.TrimSuffix(videoPath, filepath.Ext(videoPath)) + "." return captionPrefix == videoPrefix } // associates captions to scene/s with the same basename // returns true if the caption file was matched to a video file and processed, false otherwise func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manager, fqb models.FileFinder, w CaptionUpdater) bool { captionLang := getCaptionsLangFromPath(captionPath) captionPrefix := getCaptionPrefix(captionPath) matched := false if err := txn.WithTxn(ctx, txnMgr, func(ctx context.Context) error { var err error files, er := fqb.FindAllByPath(ctx, captionPrefix+"*", true) if er != nil { return fmt.Errorf("searching for scene %s: %w", captionPrefix, er) } for _, f := range files { // found some files // filter out non video files switch f.(type) { case *models.VideoFile: break default: continue } fileID := f.Base().ID path := f.Base().Path logger.Debugf("Matched captions to file %s", path) matched = true captions, er := w.GetCaptions(ctx, fileID) if er != nil { return fmt.Errorf("getting captions for file %s: %w", path, er) } fileExt := filepath.Ext(captionPath) ext := fileExt[1:] if !IsLangInCaptions(captionLang, ext, captions) { // only update captions if language code is not present newCaption := &models.VideoCaption{ LanguageCode: captionLang, Filename: filepath.Base(captionPath), CaptionType: ext, } captions = append(captions, newCaption) er = w.UpdateCaptions(ctx, fileID, captions) if er != nil { return fmt.Errorf("updating captions for file %s: %w", path, er) } logger.Debugf("Updated captions for file %s. Added %s", path, captionLang) } } return err }); err != nil { logger.Error(err.Error()) } return matched } // CleanCaptions removes non existent/accessible language codes from captions func CleanCaptions(ctx context.Context, f *models.VideoFile, txnMgr txn.Manager, w CaptionUpdater) error { captions, err := w.GetCaptions(ctx, f.ID) if err != nil { return fmt.Errorf("getting captions for file %s: %w", f.Path, err) } if len(captions) == 0 { return nil } filePath := f.Path changed := false var newCaptions []*models.VideoCaption for _, caption := range captions { captionPath := caption.Path(filePath) _, err := os.Stat(captionPath) if errors.Is(err, os.ErrNotExist) { logger.Infof("Removing non existent caption %s for %s", caption.Filename, f.Path) changed = true } else { // other errors are ignored for the purposes of cleaning newCaptions = append(newCaptions, caption) } } if changed { fn := func(ctx context.Context) error { return w.UpdateCaptions(ctx, f.ID, newCaptions) } // possible that we are already in a transaction and txnMgr is nil // in that case just call the function directly if txnMgr == nil { err = fn(ctx) } else { err = txn.WithTxn(ctx, txnMgr, fn) } if err != nil { return fmt.Errorf("updating captions for file %s: %w", f.Path, err) } } return nil } ================================================ FILE: pkg/file/video/caption_test.go ================================================ package video import ( "testing" "github.com/stretchr/testify/assert" ) type testCase struct { captionPath string expectedLang string expectedResult string } var testCases = []testCase{ { captionPath: "/stash/video.vtt", expectedLang: LangUnknown, expectedResult: "/stash/video.", }, { captionPath: "/stash/video.en.vtt", expectedLang: "en", expectedResult: "/stash/video.", // lang code valid, remove en part }, { captionPath: "/stash/video.test.srt", expectedLang: LangUnknown, expectedResult: "/stash/video.test.", // no lang code/lang code invalid test should remain }, { captionPath: "C:\\videos\\video.fr.srt", expectedLang: "fr", expectedResult: "C:\\videos\\video.", }, { captionPath: "C:\\videos\\video.xx.srt", expectedLang: LangUnknown, expectedResult: "C:\\videos\\video.xx.", // no lang code/lang code invalid xx should remain }, } func TestGenerateCaptionCandidates(t *testing.T) { for _, c := range testCases { assert.Equal(t, c.expectedResult, getCaptionPrefix(c.captionPath)) } } func TestGetCaptionsLangFromPath(t *testing.T) { for _, l := range testCases { assert.Equal(t, l.expectedLang, getCaptionsLangFromPath(l.captionPath)) } } ================================================ FILE: pkg/file/video/funscript.go ================================================ package video import ( "path/filepath" "strings" ) // GetFunscriptPath returns the path of a file // with the extension changed to .funscript func GetFunscriptPath(path string) string { ext := filepath.Ext(path) fn := strings.TrimSuffix(path, ext) return fn + ".funscript" } ================================================ FILE: pkg/file/video/scan.go ================================================ package video import ( "context" "errors" "fmt" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/models" ) // Decorator adds video specific fields to a File. type Decorator struct { FFProbe *ffmpeg.FFProbe } func (d *Decorator) Decorate(ctx context.Context, fs models.FS, f models.File) (models.File, error) { if d.FFProbe == nil { return f, errors.New("ffprobe not configured") } base := f.Base() // TODO - copy to temp file if not an OsFS if _, isOs := fs.(*file.OsFS); !isOs { return f, fmt.Errorf("video.constructFile: only OsFS is supported") } probe := d.FFProbe videoFile, err := probe.NewVideoFile(base.Path) if err != nil { return f, fmt.Errorf("running ffprobe on %q: %w", base.Path, err) } container, err := ffmpeg.MatchContainer(videoFile.Container, base.Path) if err != nil { return f, fmt.Errorf("matching container for %q: %w", base.Path, err) } // check if there is a funscript file interactive := false if _, err := fs.Lstat(GetFunscriptPath(base.Path)); err == nil { interactive = true } return &models.VideoFile{ BaseFile: base, Format: string(container), VideoCodec: videoFile.VideoCodec, AudioCodec: videoFile.AudioCodec, Width: videoFile.Width, Height: videoFile.Height, Duration: videoFile.FileDuration, FrameRate: videoFile.FrameRate, BitRate: videoFile.Bitrate, Interactive: interactive, }, nil } func (d *Decorator) IsMissingMetadata(ctx context.Context, fs models.FS, f models.File) bool { const ( unsetString = "unset" unsetNumber = -1 ) vf, ok := f.(*models.VideoFile) if !ok { return true } interactive := false if _, err := fs.Lstat(GetFunscriptPath(vf.Base().Path)); err == nil { interactive = true } return vf.VideoCodec == unsetString || vf.AudioCodec == unsetString || vf.Format == unsetString || vf.Width == unsetNumber || vf.Height == unsetNumber || vf.FrameRate == unsetNumber || vf.Duration == unsetNumber || vf.BitRate == unsetNumber || interactive != vf.Interactive } ================================================ FILE: pkg/file/walk.go ================================================ package file import ( "errors" "io/fs" "os" "path/filepath" "sort" "github.com/stashapp/stash/pkg/models" ) // Modified from github.com/facebookgo/symwalk // BSD License // For symwalk software // Copyright (c) 2015, Facebook, Inc. All rights reserved. // Redistribution and use in source and binary forms, with or without modification, // are permitted provided that the following conditions are met: // * Redistributions of source code must retain the above copyright notice, this // list of conditions and the following disclaimer. // * Redistributions in binary form must reproduce the above copyright notice, // this list of conditions and the following disclaimer in the documentation // and/or other materials provided with the distribution. // * Neither the name Facebook nor the names of its contributors may be used to // endorse or promote products derived from this software without specific // prior written permission. // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND // ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE // DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR // ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES // (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; // LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON // ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS // SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. // symwalkFunc calls the provided WalkFn for regular files. // However, when it encounters a symbolic link, it resolves the link fully using the // filepath.EvalSymlinks function and recursively calls symwalk.Walk on the resolved path. // This ensures that unlink filepath.Walk, traversal does not stop at symbolic links. // // Note that symwalk.Walk does not terminate if there are any non-terminating loops in // the file structure. func walkSym(f models.FS, filename string, linkDirname string, walkFn fs.WalkDirFunc) error { symWalkFunc := func(path string, info fs.DirEntry, err error) error { if fname, err := filepath.Rel(filename, path); err == nil { path = filepath.Join(linkDirname, fname) } else { return err } if err == nil && info.Type()&os.ModeSymlink == os.ModeSymlink { finalPath, err := filepath.EvalSymlinks(path) if err != nil { // don't bail out if symlink is invalid return walkFn(path, info, err) } info, err := f.Lstat(finalPath) if err != nil { return walkFn(path, &statDirEntry{ info: info, }, err) } if info.IsDir() { return walkSym(f, finalPath, path, walkFn) } } return walkFn(path, info, err) } return fsWalk(f, filename, symWalkFunc) } // SymWalk extends filepath.Walk to also follow symlinks func SymWalk(fs models.FS, path string, walkFn fs.WalkDirFunc) error { return walkSym(fs, path, path, walkFn) } type statDirEntry struct { info fs.FileInfo } func (d *statDirEntry) Name() string { return d.info.Name() } func (d *statDirEntry) IsDir() bool { return d.info.IsDir() } func (d *statDirEntry) Type() fs.FileMode { return d.info.Mode().Type() } func (d *statDirEntry) Info() (fs.FileInfo, error) { return d.info, nil } func fsWalk(f models.FS, root string, fn fs.WalkDirFunc) error { info, err := f.Lstat(root) if err != nil { err = fn(root, nil, err) } else { err = walkDir(f, root, &statDirEntry{info}, fn) } if errors.Is(err, fs.SkipDir) { return nil } return err } func walkDir(f models.FS, path string, d fs.DirEntry, walkDirFn fs.WalkDirFunc) error { if err := walkDirFn(path, d, nil); err != nil || !d.IsDir() { if errors.Is(err, fs.SkipDir) && d.IsDir() { // Successfully skipped directory. err = nil } return err } dirs, err := readDir(f, path) if err != nil { // Second call, to report ReadDir error. err = walkDirFn(path, d, err) if err != nil { return err } } for _, d1 := range dirs { name := d1.Name() // Prevent infinite loops; this can happen with certain FS implementations (e.g. ZipFS). if name == "" || name == "." { continue } path1 := filepath.Join(path, name) if err := walkDir(f, path1, d1, walkDirFn); err != nil { if errors.Is(err, fs.SkipDir) { break } return err } } return nil } // readDir reads the directory named by dirname and returns // a sorted list of directory entries. func readDir(fs models.FS, dirname string) ([]fs.DirEntry, error) { f, err := fs.Open(dirname) if err != nil { return nil, err } dirs, err := f.ReadDir(-1) f.Close() if err != nil { return nil, err } sort.Slice(dirs, func(i, j int) bool { return dirs[i].Name() < dirs[j].Name() }) return dirs, nil } ================================================ FILE: pkg/file/zip.go ================================================ package file import ( "archive/zip" "bytes" "errors" "fmt" "io" "io/fs" "path/filepath" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/xWTF/chardet" "golang.org/x/net/html/charset" "golang.org/x/text/transform" ) var ( ErrNotReaderAt = errors.New("invalid reader: does not implement io.ReaderAt") errZipFSOpenZip = errors.New("cannot open zip file inside zip file") ) // ZipFS is a file system backed by a zip file. type zipFS struct { *zip.Reader zipFileCloser io.Closer zipPath string } func newZipFS(fs models.FS, path string, size int64) (*zipFS, error) { reader, err := fs.Open(path) if err != nil { return nil, err } asReaderAt, _ := reader.(io.ReaderAt) if asReaderAt == nil { reader.Close() return nil, ErrNotReaderAt } zipReader, err := zip.NewReader(asReaderAt, size) if err != nil { reader.Close() return nil, err } // Concat all Name and Comment for better detection result var buffer bytes.Buffer for _, f := range zipReader.File { buffer.WriteString(f.Name) buffer.WriteString(f.Comment) } buffer.WriteString(zipReader.Comment) // Detect encoding d, err := chardet.NewTextDetector().DetectBest(buffer.Bytes()) if err != nil { // If we can't detect the encoding, just assume it's UTF8 logger.Warnf("Unable to detect decoding for %s: %w", path, err) } // If the charset is not UTF8, decode'em if d != nil && d.Charset != "UTF-8" { logger.Debugf("Detected non-utf8 zip charset %s (%s): %s", d.Charset, d.Language, path) e, _ := charset.Lookup(d.Charset) if e == nil { // if we can't find the encoding, just assume it's UTF8 logger.Warnf("Failed to lookup charset %s, language %s", d.Charset, d.Language) } else { decoder := e.NewDecoder() for _, f := range zipReader.File { newName, _, err := transform.String(decoder, f.Name) if err != nil { reader.Close() logger.Warnf("Failed to decode %v: %v", []byte(f.Name), err) } else { f.Name = newName } // Comments are not decoded cuz stash doesn't use that } } } return &zipFS{ Reader: zipReader, zipFileCloser: reader, zipPath: path, }, nil } func (f *zipFS) rel(name string) (string, error) { if f.zipPath == name { return ".", nil } relName, err := filepath.Rel(f.zipPath, name) if err != nil { // if the path is not relative to the zip path, then it's not found in the zip file, // so treat this as a file not found return "", fs.ErrNotExist } // convert relName to use slash, since zip files do so regardless // of os relName = filepath.ToSlash(relName) return relName, nil } func (f *zipFS) Stat(name string) (fs.FileInfo, error) { reader, err := f.Open(name) if err != nil { return nil, err } defer reader.Close() return reader.Stat() } func (f *zipFS) Lstat(name string) (fs.FileInfo, error) { return f.Stat(name) } func (f *zipFS) OpenZip(name string, size int64) (models.ZipFS, error) { return nil, errZipFSOpenZip } func (f *zipFS) IsPathCaseSensitive(path string) (bool, error) { return true, nil } type zipReadDirFile struct { fs.File } func (f *zipReadDirFile) ReadDir(n int) ([]fs.DirEntry, error) { asReadDirFile, _ := f.File.(fs.ReadDirFile) if asReadDirFile == nil { return nil, fmt.Errorf("internal error: not a ReadDirFile") } return asReadDirFile.ReadDir(n) } func (f *zipFS) Open(name string) (fs.ReadDirFile, error) { relName, err := f.rel(name) if err != nil { return nil, err } r, err := f.Reader.Open(relName) if err != nil { return nil, err } return &zipReadDirFile{ File: r, }, nil } func (f *zipFS) Close() error { return f.zipFileCloser.Close() } // openOnly returns a ReadCloser where calling Close will close the zip fs as well. func (f *zipFS) OpenOnly(name string) (io.ReadCloser, error) { r, err := f.Open(name) if err != nil { return nil, err } return &wrappedReadCloser{ ReadCloser: r, outer: f, }, nil } type wrappedReadCloser struct { io.ReadCloser outer io.Closer } func (f *wrappedReadCloser) Close() error { _ = f.ReadCloser.Close() return f.outer.Close() } ================================================ FILE: pkg/fsutil/dir.go ================================================ package fsutil import ( "fmt" "io/fs" "os" "os/user" "path/filepath" "strings" ) // DirExists returns true if the given path exists and is a directory func DirExists(path string) (bool, error) { fileInfo, err := os.Stat(path) if err != nil { return false, fs.ErrNotExist } if !fileInfo.IsDir() { return false, fmt.Errorf("path is not a directory <%s>", path) } return true, nil } // IsPathInDir returns true if pathToCheck is within dir. func IsPathInDir(dir, pathToCheck string) bool { rel, err := filepath.Rel(dir, pathToCheck) if err == nil { if !strings.HasPrefix(rel, "..") { return true } } return false } // IsPathInDirs returns true if pathToCheck is within anys of the paths in dirs. func IsPathInDirs(dirs []string, pathToCheck string) bool { for _, dir := range dirs { if IsPathInDir(dir, pathToCheck) { return true } } return false } // GetWorkingDirectory returns the current working directory. func GetWorkingDirectory() string { ret, err := os.Getwd() if err != nil { // if we can't get cwd for whatever reason, just return "." ret = "." } return ret } // GetHomeDirectory returns the path of the user's home directory. ~ on Unix and C:\Users\UserName on Windows func GetHomeDirectory() string { currentUser, err := user.Current() if err != nil { panic(err) } return currentUser.HomeDir } // EnsureDir will create a directory at the given path if it doesn't already exist func EnsureDir(path string) error { exists, err := DirExists(path) if !exists { err = os.Mkdir(path, 0755) return err } return err } // EnsureDirAll will create a directory at the given path along with any necessary parents if they don't already exist func EnsureDirAll(path string) error { return os.MkdirAll(path, 0755) } // RemoveDir removes the given dir (if it exists) along with all of its contents func RemoveDir(path string) error { return os.RemoveAll(path) } // EmptyDir will recursively remove the contents of a directory at the given path func EmptyDir(path string) error { d, err := os.Open(path) if err != nil { return err } defer d.Close() names, err := d.Readdirnames(-1) if err != nil { return err } for _, name := range names { err = os.RemoveAll(filepath.Join(path, name)) if err != nil { return err } } return nil } // GetIntraDir returns a string that can be added to filepath.Join to implement directory depth, "" on error // eg given a pattern of 0af63ce3c99162e9df23a997f62621c5 and a depth of 2 length of 3 // returns 0af/63c or 0af\63c ( dependin on os) that can be later used like this filepath.Join(directory, intradir, basename) func GetIntraDir(pattern string, depth, length int) string { if depth < 1 || length < 1 || (depth*length > len(pattern)) { return "" } intraDir := pattern[0:length] // depth 1 , get length number of characters from pattern for i := 1; i < depth; i++ { // for every extra depth: move to the right of the pattern length positions, get length number of chars intraDir = filepath.Join(intraDir, pattern[length*i:length*(i+1)]) // adding each time to intradir the extra characters with a filepath join } return intraDir } ================================================ FILE: pkg/fsutil/dir_test.go ================================================ package fsutil import ( "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" ) func TestIsPathInDir(t *testing.T) { type test struct { dir string pathToCheck string expected bool } const parentDirName = "parentDir" const subDirName = "subDir" const filename = "filename" subDir := filepath.Join(parentDirName, subDirName) fileInSubDir := filepath.Join(subDir, filename) fileInParentDir := filepath.Join(parentDirName, filename) subSubSubDir := filepath.Join(parentDirName, subDirName, subDirName, subDirName) tests := []test{ {dir: parentDirName, pathToCheck: subDir, expected: true}, {dir: subDir, pathToCheck: subDir, expected: true}, {dir: subDir, pathToCheck: parentDirName, expected: false}, {dir: subDir, pathToCheck: fileInSubDir, expected: true}, {dir: parentDirName, pathToCheck: fileInSubDir, expected: true}, {dir: subDir, pathToCheck: fileInParentDir, expected: false}, {dir: parentDirName, pathToCheck: fileInParentDir, expected: true}, {dir: parentDirName, pathToCheck: filename, expected: false}, {dir: parentDirName, pathToCheck: subSubSubDir, expected: true}, {dir: subSubSubDir, pathToCheck: parentDirName, expected: false}, } assert := assert.New(t) for i, tc := range tests { result := IsPathInDir(tc.dir, tc.pathToCheck) assert.Equal(tc.expected, result, "[%d] expected: %t for dir: %s; pathToCheck: %s", i, tc.expected, tc.dir, tc.pathToCheck) } } func TestDirExists(t *testing.T) { type test struct { dir string expected bool } const st = "stash_tmp" tmp := os.TempDir() tmpDir, err := os.MkdirTemp(tmp, st) // create a tmp dir in the system's tmp folder if err == nil { defer os.RemoveAll(tmpDir) tmpFile, err := os.CreateTemp(tmpDir, st) if err != nil { return } tmpFile.Close() tests := []test{ {dir: tmpDir, expected: true}, // exists {dir: tmpFile.Name(), expected: false}, // not a directory {dir: filepath.Join(tmpDir, st), expected: false}, // doesn't exist {dir: "\000x", expected: false}, // stat error \000 (ASCII: NUL) is an invalid character in unix,ntfs file names. } assert := assert.New(t) for i, tc := range tests { result, _ := DirExists(tc.dir) assert.Equal(tc.expected, result, "[%d] expected: %t for dir: %s;", i, tc.expected, tc.dir) } } } ================================================ FILE: pkg/fsutil/file.go ================================================ package fsutil import ( "crypto/sha1" "encoding/hex" "fmt" "io" "os" "path/filepath" "regexp" "runtime" "strings" ) // CopyFile copies the contents of the file at srcpath to a regular file at dstpath. // It will copy the last modified timestamp // If dstpath already exists the function will fail. func CopyFile(srcpath, dstpath string) (err error) { r, err := os.Open(srcpath) if err != nil { return err } w, err := os.OpenFile(dstpath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0666) if err != nil { r.Close() // We need to close the input file as the defer below would not be called. return err } defer func() { r.Close() // ok to ignore error: file was opened read-only. e := w.Close() // Report the error from w.Close, if any. // But do so only if there isn't already an outgoing error. if e != nil && err == nil { err = e } // Copy modified time if err == nil { // io.Copy succeeded, we should fix the dstpath timestamp srcFileInfo, e := os.Stat(srcpath) if e != nil { err = e return } e = os.Chtimes(dstpath, srcFileInfo.ModTime(), srcFileInfo.ModTime()) if e != nil { err = e } } }() _, err = io.Copy(w, r) return err } // SafeMove attempts to move the file with path src to dest using os.Rename. If this fails, then it copies src to dest, then deletes src. // If the copy fails, or the delete fails, the function will return an error. func SafeMove(src, dst string) error { err := os.Rename(src, dst) if err != nil { copyErr := CopyFile(src, dst) if copyErr != nil { return fmt.Errorf("copying file during SaveMove failed with: '%w'; renaming file failed previously with: '%v'", copyErr, err) } removeErr := os.Remove(src) if removeErr != nil { // if we can't remove the old file, remove the new one and fail _ = os.Remove(dst) return fmt.Errorf("removing old file during SafeMove failed with: '%w'; renaming file failed previously with: '%v'", removeErr, err) } } return nil } // MatchExtension returns true if the extension of the provided path // matches any of the provided extensions. func MatchExtension(path string, extensions []string) bool { ext := filepath.Ext(path) for _, e := range extensions { if strings.EqualFold(ext, "."+e) { return true } } return false } // FindInPaths returns the path to baseName in the first path where it exists from paths. func FindInPaths(paths []string, baseName string) string { for _, p := range paths { filePath := filepath.Join(p, baseName) if exists, _ := FileExists(filePath); exists { return filePath } } return "" } // FileExists returns true if the given path exists and is a file. // This function returns false and the error encountered if the call to os.Stat fails. func FileExists(path string) (bool, error) { info, err := os.Stat(path) if err == nil { return !info.IsDir(), nil } return false, err } // WriteFile writes file to path creating parent directories if needed func WriteFile(path string, file []byte) error { pathErr := EnsureDirAll(filepath.Dir(path)) if pathErr != nil { return fmt.Errorf("cannot ensure path exists: %w", pathErr) } return os.WriteFile(path, file, 0755) } // GetNameFromPath returns the name of a file from its path // if stripExtension is true the extension is omitted from the name func GetNameFromPath(path string, stripExtension bool) string { fn := filepath.Base(path) if stripExtension { ext := filepath.Ext(fn) fn = strings.TrimSuffix(fn, ext) } return fn } // Touch creates an empty file at the given path if it doesn't already exist func Touch(path string) error { var _, err = os.Stat(path) if os.IsNotExist(err) { var file, err = os.Create(path) if err != nil { return err } defer file.Close() } return nil } var ( replaceCharsRE = regexp.MustCompile(`[&=\\/:*"?_ ]`) removeCharsRE = regexp.MustCompile(`[^[:alnum:]-.]`) multiHyphenRE = regexp.MustCompile(`\-+`) ) // SanitiseBasename returns a file basename removing any characters that are illegal or problematic to use in the filesystem. // It appends a short hash of the original string to ensure uniqueness. func SanitiseBasename(v string) string { // Generate a short hash for uniqueness hash := sha1.Sum([]byte(v)) shortHash := hex.EncodeToString(hash[:4]) // Use the first 4 bytes of the hash v = strings.TrimSpace(v) // replace illegal filename characters with - v = replaceCharsRE.ReplaceAllString(v, "-") // remove other characters v = removeCharsRE.ReplaceAllString(v, "") // remove multiple hyphens v = multiHyphenRE.ReplaceAllString(v, "-") return strings.TrimSpace(v) + "-" + shortHash } // GetExeName returns the name of the given executable for the current platform. // One windows it returns the name with the .exe extension. func GetExeName(base string) string { if runtime.GOOS == "windows" { return base + ".exe" } return base } ================================================ FILE: pkg/fsutil/file_test.go ================================================ package fsutil import "testing" func TestSanitiseBasename(t *testing.T) { tests := []struct { name string v string want string }{ {"basic", "basic", "basic-61a7508e"}, {"spaces", `spaced name`, "spaced-name-b297cf60"}, {"leading/trailing spaces", ` spaced name `, "spaced-name-175433e9"}, {"hyphen name", `hyphened-name`, "hyphened-name-789c55f2"}, {"multi-hyphen", `hyphened--name`, "hyphened-name-2da2a58f"}, {"replaced characters", `a&b=c\d/:e*"f?_ g`, "a-b-c-d-e-f-g-ffca6fb0"}, {"removed characters", `foo!!bar@@and, more`, "foobarand-more-7cee02ab"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := SanitiseBasename(tt.v); got != tt.want { t.Errorf("SanitiseBasename() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: pkg/fsutil/fs.go ================================================ // Package fsutil provides filesystem utility functions for the application. package fsutil import ( "fmt" "os" "path/filepath" "unicode" ) // IsFsPathCaseSensitive checks the fs of the given path to see if it is case sensitive // if the case sensitivity can not be determined false and an error != nil are returned func IsFsPathCaseSensitive(path string) (bool, error) { // The case sensitivity of the fs of "path" is determined by case flipping // the first letter rune from the base string of the path // If the resulting flipped path exists then the fs should not be case sensitive // ( we check the file mod time to avoid matching an existing path ) fi, err := os.Stat(path) if err != nil { // path cannot be stat'd return false, err } base := filepath.Base(path) fBase, err := flipCaseSingle(base) if err != nil { // cannot be case flipped return false, err } flippedPath := filepath.Join(filepath.Dir(path), fBase) fiCase, err := os.Stat(flippedPath) if err != nil { // cannot stat the case flipped path return true, nil // fs of path should be case sensitive } if fiCase.ModTime().Equal(fi.ModTime()) { // file path exists and is the same return false, nil // fs of path is not case sensitive } return false, fmt.Errorf("can not determine case sensitivity of path %s", path) } // flipCaseSingle flips the case ( lower<->upper ) of a single char from the string s // If the string cannot be flipped, the original string value and an error are returned func flipCaseSingle(s string) (string, error) { rr := []rune(s) for i, r := range rr { if unicode.IsLetter(r) { // look for a letter to flip if unicode.IsUpper(r) { rr[i] = unicode.ToLower(r) return string(rr), nil } rr[i] = unicode.ToUpper(r) return string(rr), nil } } return s, fmt.Errorf("could not case flip string %s", s) } ================================================ FILE: pkg/fsutil/fs_test.go ================================================ package fsutil import ( "os" "path/filepath" "testing" ) func TestIsFsPathCaseSensitive_UnicodeByteLength(t *testing.T) { // Ⱥ (U+023A) is 2 bytes in UTF-8 // Its lowercase ⱥ (U+2C65) is 3 bytes in UTF-8 dir := t.TempDir() makeDir := func(path string) { // Create the directory so os.Stat succeeds if err := os.Mkdir(path, 0755); err != nil { t.Fatal(err) } } path := filepath.Join(dir, "Ⱥtest") makeDir(path) // ensure the test does not panic due to byte length differences in the case flipped path _, err := IsFsPathCaseSensitive(path) if err != nil { t.Fatal(err) } // no guarantee about case sensitivity of the fs running the tests, // so we just want to ensure the function works and does not panic // assert.True(t, r, "expected fs to be case sensitive") // test regular ASCII paths still work path2 := filepath.Join(dir, "Test") makeDir(path2) _, err = IsFsPathCaseSensitive(path2) if err != nil { t.Fatal(err) } // assert.True(t, r, "expected fs to be case sensitive") // Ensure that subfolders of a folder with multi-byte chars is not causing a panic path3 := filepath.Join(dir, "NoPanic ❤️") makeDir(path3) path4 := filepath.Join(path3, "Test") makeDir(path4) _, err = IsFsPathCaseSensitive(path4) if err != nil { t.Fatal(err) } } ================================================ FILE: pkg/fsutil/lock_manager.go ================================================ package fsutil import ( "context" "os/exec" "sync" "time" ) type Cancellable interface { Cancel() } type LockContext struct { context.Context cancel context.CancelFunc cmd *exec.Cmd } func (c *LockContext) AttachCommand(cmd *exec.Cmd) { c.cmd = cmd } func (c *LockContext) Cancel() { c.cancel() if c.cmd != nil { // wait for the process to die before returning // don't wait more than a few seconds done := make(chan error) go func() { err := c.cmd.Wait() done <- err }() select { case <-done: return case <-time.After(5 * time.Second): return } } } // ReadLockManager manages read locks on file paths. type ReadLockManager struct { readLocks map[string][]*LockContext mutex sync.RWMutex } // NewReadLockManager creates a new ReadLockManager. func NewReadLockManager() *ReadLockManager { return &ReadLockManager{ readLocks: make(map[string][]*LockContext), } } // ReadLock adds a pending file read lock for fn to its storage, returning a context and cancel function. // Per standard WithCancel usage, cancel must be called when the lock is freed. func (m *ReadLockManager) ReadLock(ctx context.Context, fn string) *LockContext { retCtx, cancel := context.WithCancel(ctx) // if Cancellable, call Cancel() when cancelled cancellable, ok := ctx.(Cancellable) if ok { origCancel := cancel cancel = func() { origCancel() cancellable.Cancel() } } m.mutex.Lock() defer m.mutex.Unlock() locks := m.readLocks[fn] cc := &LockContext{ Context: retCtx, cancel: cancel, } m.readLocks[fn] = append(locks, cc) go m.waitAndUnlock(fn, cc) return cc } func (m *ReadLockManager) waitAndUnlock(fn string, cc *LockContext) { <-cc.Done() m.mutex.Lock() defer m.mutex.Unlock() locks := m.readLocks[fn] for i, v := range locks { if v == cc { m.readLocks[fn] = append(locks[:i], locks[i+1:]...) return } } } // Cancel cancels all read lock contexts associated with fn. func (m *ReadLockManager) Cancel(fn string) { m.mutex.RLock() locks := m.readLocks[fn] m.mutex.RUnlock() for _, l := range locks { l.Cancel() <-l.Done() } } ================================================ FILE: pkg/fsutil/symwalk.go ================================================ // Modified from github.com/facebookgo/symwalk // BSD License // For symwalk software // Copyright (c) 2015, Facebook, Inc. All rights reserved. // Redistribution and use in source and binary forms, with or without modification, // are permitted provided that the following conditions are met: // * Redistributions of source code must retain the above copyright notice, this // list of conditions and the following disclaimer. // * Redistributions in binary form must reproduce the above copyright notice, // this list of conditions and the following disclaimer in the documentation // and/or other materials provided with the distribution. // * Neither the name Facebook nor the names of its contributors may be used to // endorse or promote products derived from this software without specific // prior written permission. // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND // ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE // DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR // ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES // (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; // LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON // ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS // SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. package fsutil import ( "os" "path/filepath" ) // symwalkFunc calls the provided WalkFn for regular files. // However, when it encounters a symbolic link, it resolves the link fully using the // filepath.EvalSymlinks function and recursively calls symwalk.Walk on the resolved path. // This ensures that unlink filepath.Walk, traversal does not stop at symbolic links. // // Note that symwalk.Walk does not terminate if there are any non-terminating loops in // the file structure. func walk(filename string, linkDirname string, walkFn filepath.WalkFunc) error { symWalkFunc := func(path string, info os.FileInfo, err error) error { if fname, err := filepath.Rel(filename, path); err == nil { path = filepath.Join(linkDirname, fname) } else { return err } if err == nil && info.Mode()&os.ModeSymlink == os.ModeSymlink { finalPath, err := filepath.EvalSymlinks(path) if err != nil { // don't bail out if symlink is invalid return walkFn(path, info, err) } info, err := os.Lstat(finalPath) if err != nil { return walkFn(path, info, err) } if info.IsDir() { return walk(finalPath, path, walkFn) } } return walkFn(path, info, err) } return filepath.Walk(filename, symWalkFunc) } // SymWalk extends filepath.Walk to also follow symlinks func SymWalk(path string, walkFn filepath.WalkFunc) error { return walk(path, path, walkFn) } ================================================ FILE: pkg/fsutil/trash.go ================================================ package fsutil import ( "fmt" "os" "path/filepath" "time" ) // MoveToTrash moves a file or directory to a custom trash directory. // If a file with the same name already exists in the trash, a timestamp is appended. // Returns the destination path where the file was moved to. func MoveToTrash(sourcePath string, trashPath string) (string, error) { // Get absolute path for the source absSourcePath, err := filepath.Abs(sourcePath) if err != nil { return "", fmt.Errorf("failed to get absolute path: %w", err) } // Ensure trash directory exists if err := os.MkdirAll(trashPath, 0755); err != nil { return "", fmt.Errorf("failed to create trash directory: %w", err) } // Get the base name of the file/directory baseName := filepath.Base(absSourcePath) destPath := filepath.Join(trashPath, baseName) // If a file with the same name already exists in trash, append timestamp if _, err := os.Stat(destPath); err == nil { ext := filepath.Ext(baseName) nameWithoutExt := baseName[:len(baseName)-len(ext)] timestamp := time.Now().Format("20060102-150405") destPath = filepath.Join(trashPath, fmt.Sprintf("%s_%s%s", nameWithoutExt, timestamp, ext)) } // Move the file to trash using SafeMove to support cross-filesystem moves if err := SafeMove(absSourcePath, destPath); err != nil { return "", fmt.Errorf("failed to move to trash: %w", err) } return destPath, nil } ================================================ FILE: pkg/gallery/chapter_import.go ================================================ package gallery import ( "context" "fmt" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" ) type ChapterImporterReaderWriter interface { models.GalleryChapterCreatorUpdater FindByGalleryID(ctx context.Context, galleryID int) ([]*models.GalleryChapter, error) } type ChapterImporter struct { GalleryID int ReaderWriter ChapterImporterReaderWriter Input jsonschema.GalleryChapter MissingRefBehaviour models.ImportMissingRefEnum chapter models.GalleryChapter } func (i *ChapterImporter) PreImport(ctx context.Context) error { i.chapter = models.GalleryChapter{ Title: i.Input.Title, ImageIndex: i.Input.ImageIndex, GalleryID: i.GalleryID, CreatedAt: i.Input.CreatedAt.GetTime(), UpdatedAt: i.Input.UpdatedAt.GetTime(), } return nil } func (i *ChapterImporter) Name() string { return fmt.Sprintf("%s (%d)", i.Input.Title, i.Input.ImageIndex) } func (i *ChapterImporter) PostImport(ctx context.Context, id int) error { return nil } func (i *ChapterImporter) FindExistingID(ctx context.Context) (*int, error) { existingChapters, err := i.ReaderWriter.FindByGalleryID(ctx, i.GalleryID) if err != nil { return nil, err } for _, m := range existingChapters { if m.ImageIndex == i.chapter.ImageIndex { id := m.ID return &id, nil } } return nil, nil } func (i *ChapterImporter) Create(ctx context.Context) (*int, error) { err := i.ReaderWriter.Create(ctx, &i.chapter) if err != nil { return nil, fmt.Errorf("error creating chapter: %v", err) } id := i.chapter.ID return &id, nil } func (i *ChapterImporter) Update(ctx context.Context, id int) error { chapter := i.chapter chapter.ID = id err := i.ReaderWriter.Update(ctx, &chapter) if err != nil { return fmt.Errorf("error updating existing chapter: %v", err) } return nil } ================================================ FILE: pkg/gallery/delete.go ================================================ package gallery import ( "context" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" ) func (s *Service) Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) ([]*models.Image, error) { var imgsDestroyed []*models.Image // chapter deletion is done via delete cascade, so we don't need to do anything here // if this is a zip-based gallery, delete the images as well first zipImgsDestroyed, err := s.destroyZipFileImages(ctx, i, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry) if err != nil { return nil, err } imgsDestroyed = zipImgsDestroyed // only delete folder based gallery images if we're deleting the folder if deleteFile && i.FolderID != nil { folderImgsDestroyed, err := s.ImageService.DestroyFolderImages(ctx, *i.FolderID, fileDeleter, deleteGenerated, deleteFile) if err != nil { return nil, err } imgsDestroyed = append(imgsDestroyed, folderImgsDestroyed...) } // we only want to delete a folder-based gallery if it is empty. // this has to be done post-transaction if err := s.Repository.Destroy(ctx, i.ID); err != nil { return nil, err } return imgsDestroyed, nil } func DestroyChapter(ctx context.Context, galleryChapter *models.GalleryChapter, qb models.GalleryChapterDestroyer) error { return qb.Destroy(ctx, galleryChapter.ID) } func (s *Service) destroyZipFileImages(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) ([]*models.Image, error) { if err := i.LoadFiles(ctx, s.Repository); err != nil { return nil, err } var imgsDestroyed []*models.Image destroyer := &file.ZipDestroyer{ FileDestroyer: s.File, FolderDestroyer: s.Folder, } // for zip-based galleries, delete the images as well first for _, f := range i.Files.List() { // only do this where there are no other galleries related to the file otherGalleries, err := s.Repository.FindByFileID(ctx, f.Base().ID) if err != nil { return nil, err } if len(otherGalleries) > 1 { // other gallery associated, don't remove continue } thisDestroyed, err := s.ImageService.DestroyZipImages(ctx, f, fileDeleter, deleteGenerated) if err != nil { return nil, err } imgsDestroyed = append(imgsDestroyed, thisDestroyed...) if deleteFile { if err := destroyer.DestroyZip(ctx, f, fileDeleter.Deleter, deleteFile); err != nil { return nil, err } } else if destroyFileEntry { // destroy file DB entry without deleting filesystem file const deleteFileFromFS = false if err := destroyer.DestroyZip(ctx, f, nil, deleteFileFromFS); err != nil { return nil, err } } } return imgsDestroyed, nil } ================================================ FILE: pkg/gallery/export.go ================================================ package gallery import ( "context" "fmt" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" ) // ToBasicJSON converts a gallery object into its JSON object equivalent. It // does not convert the relationships to other objects. func ToBasicJSON(gallery *models.Gallery) (*jsonschema.Gallery, error) { newGalleryJSON := jsonschema.Gallery{ Title: gallery.Title, Code: gallery.Code, URLs: gallery.URLs.List(), Details: gallery.Details, Photographer: gallery.Photographer, CreatedAt: json.JSONTime{Time: gallery.CreatedAt}, UpdatedAt: json.JSONTime{Time: gallery.UpdatedAt}, } if gallery.FolderID != nil { newGalleryJSON.FolderPath = gallery.Path } for _, f := range gallery.Files.List() { newGalleryJSON.ZipFiles = append(newGalleryJSON.ZipFiles, f.Base().Path) } if gallery.Date != nil { newGalleryJSON.Date = gallery.Date.String() } if gallery.Rating != nil { newGalleryJSON.Rating = *gallery.Rating } newGalleryJSON.Organized = gallery.Organized return &newGalleryJSON, nil } // GetStudioName returns the name of the provided gallery's studio. It returns an // empty string if there is no studio assigned to the gallery. func GetStudioName(ctx context.Context, reader models.StudioGetter, gallery *models.Gallery) (string, error) { if gallery.StudioID != nil { studio, err := reader.Find(ctx, *gallery.StudioID) if err != nil { return "", err } if studio != nil { return studio.Name, nil } } return "", nil } // GetGalleryChaptersJSON returns a slice of GalleryChapter JSON representation // objects corresponding to the provided gallery's chapters. func GetGalleryChaptersJSON(ctx context.Context, chapterReader models.GalleryChapterFinder, gallery *models.Gallery) ([]jsonschema.GalleryChapter, error) { galleryChapters, err := chapterReader.FindByGalleryID(ctx, gallery.ID) if err != nil { return nil, fmt.Errorf("error getting gallery chapters: %v", err) } var results []jsonschema.GalleryChapter for _, galleryChapter := range galleryChapters { galleryChapterJSON := jsonschema.GalleryChapter{ Title: galleryChapter.Title, ImageIndex: galleryChapter.ImageIndex, CreatedAt: json.JSONTime{Time: galleryChapter.CreatedAt}, UpdatedAt: json.JSONTime{Time: galleryChapter.UpdatedAt}, } results = append(results, galleryChapterJSON) } return results, nil } func GetIDs(galleries []*models.Gallery) []int { var results []int for _, gallery := range galleries { results = append(results, gallery.ID) } return results } func GetRefs(galleries []*models.Gallery) []jsonschema.GalleryRef { var results []jsonschema.GalleryRef for _, gallery := range galleries { toAdd := jsonschema.GalleryRef{} switch { case gallery.FolderID != nil: toAdd.FolderPath = gallery.Path case len(gallery.Files.List()) > 0: for _, f := range gallery.Files.List() { toAdd.ZipFiles = append(toAdd.ZipFiles, f.Base().Path) } default: toAdd.Title = gallery.Title } results = append(results, toAdd) } return results } ================================================ FILE: pkg/gallery/export_test.go ================================================ package gallery import ( "errors" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "testing" "time" ) const ( galleryID = 1 studioID = 4 missingStudioID = 5 errStudioID = 6 // noTagsID = 11 noChaptersID = 7 errChaptersID = 8 errFindByChapterID = 9 ) var ( url = "url" title = "title" date = "2001-01-01" dateObj, _ = models.ParseDate(date) rating = 5 organized = true details = "details" ) const ( studioName = "studioName" path = "path" ) var ( createTime = time.Date(2001, 01, 01, 0, 0, 0, 0, time.UTC) updateTime = time.Date(2002, 01, 01, 0, 0, 0, 0, time.UTC) ) func createFullGallery(id int) models.Gallery { return models.Gallery{ ID: id, Files: models.NewRelatedFiles([]models.File{ &models.BaseFile{ Path: path, }, }), Title: title, Date: &dateObj, Details: details, Rating: &rating, Organized: organized, URLs: models.NewRelatedStrings([]string{url}), CreatedAt: createTime, UpdatedAt: updateTime, } } func createEmptyGallery(id int) models.Gallery { return models.Gallery{ ID: id, Files: models.NewRelatedFiles([]models.File{ &models.BaseFile{ Path: path, }, }), CreatedAt: createTime, UpdatedAt: updateTime, } } func createFullJSONGallery() *jsonschema.Gallery { return &jsonschema.Gallery{ Title: title, Date: date, Details: details, Rating: rating, Organized: organized, URLs: []string{url}, ZipFiles: []string{path}, CreatedAt: json.JSONTime{ Time: createTime, }, UpdatedAt: json.JSONTime{ Time: updateTime, }, } } type basicTestScenario struct { input models.Gallery expected *jsonschema.Gallery err bool } var scenarios = []basicTestScenario{ { createFullGallery(galleryID), createFullJSONGallery(), false, }, } func TestToJSON(t *testing.T) { for i, s := range scenarios { gallery := s.input json, err := ToBasicJSON(&gallery) switch { case !s.err && err != nil: t.Errorf("[%d] unexpected error: %s", i, err.Error()) case s.err && err == nil: t.Errorf("[%d] expected error not returned", i) default: assert.Equal(t, s.expected, json, "[%d]", i) } } } func createStudioGallery(studioID int) models.Gallery { return models.Gallery{ StudioID: &studioID, } } type stringTestScenario struct { input models.Gallery expected string err bool } var getStudioScenarios = []stringTestScenario{ { createStudioGallery(studioID), studioName, false, }, { createStudioGallery(missingStudioID), "", false, }, { createStudioGallery(errStudioID), "", true, }, } func TestGetStudioName(t *testing.T) { db := mocks.NewDatabase() studioErr := errors.New("error getting image") db.Studio.On("Find", testCtx, studioID).Return(&models.Studio{ Name: studioName, }, nil).Once() db.Studio.On("Find", testCtx, missingStudioID).Return(nil, nil).Once() db.Studio.On("Find", testCtx, errStudioID).Return(nil, studioErr).Once() for i, s := range getStudioScenarios { gallery := s.input json, err := GetStudioName(testCtx, db.Studio, &gallery) switch { case !s.err && err != nil: t.Errorf("[%d] unexpected error: %s", i, err.Error()) case s.err && err == nil: t.Errorf("[%d] expected error not returned", i) default: assert.Equal(t, s.expected, json, "[%d]", i) } } db.AssertExpectations(t) } const ( validChapterID1 = 1 validChapterID2 = 2 chapterTitle1 = "chapterTitle1" chapterTitle2 = "chapterTitle2" chapterImageIndex1 = 10 chapterImageIndex2 = 50 ) type galleryChaptersTestScenario struct { input models.Gallery expected []jsonschema.GalleryChapter err bool } var getGalleryChaptersJSONScenarios = []galleryChaptersTestScenario{ { createEmptyGallery(galleryID), []jsonschema.GalleryChapter{ { Title: chapterTitle1, ImageIndex: chapterImageIndex1, CreatedAt: json.JSONTime{ Time: createTime, }, UpdatedAt: json.JSONTime{ Time: updateTime, }, }, { Title: chapterTitle2, ImageIndex: chapterImageIndex2, CreatedAt: json.JSONTime{ Time: createTime, }, UpdatedAt: json.JSONTime{ Time: updateTime, }, }, }, false, }, { createEmptyGallery(noChaptersID), nil, false, }, { createEmptyGallery(errChaptersID), nil, true, }, } var validChapters = []*models.GalleryChapter{ { ID: validChapterID1, Title: chapterTitle1, ImageIndex: chapterImageIndex1, CreatedAt: createTime, UpdatedAt: updateTime, }, { ID: validChapterID2, Title: chapterTitle2, ImageIndex: chapterImageIndex2, CreatedAt: createTime, UpdatedAt: updateTime, }, } func TestGetGalleryChaptersJSON(t *testing.T) { db := mocks.NewDatabase() chaptersErr := errors.New("error getting gallery chapters") db.GalleryChapter.On("FindByGalleryID", testCtx, galleryID).Return(validChapters, nil).Once() db.GalleryChapter.On("FindByGalleryID", testCtx, noChaptersID).Return(nil, nil).Once() db.GalleryChapter.On("FindByGalleryID", testCtx, errChaptersID).Return(nil, chaptersErr).Once() for i, s := range getGalleryChaptersJSONScenarios { gallery := s.input json, err := GetGalleryChaptersJSON(testCtx, db.GalleryChapter, &gallery) switch { case !s.err && err != nil: t.Errorf("[%d] unexpected error: %s", i, err.Error()) case s.err && err == nil: t.Errorf("[%d] expected error not returned", i) default: assert.Equal(t, s.expected, json, "[%d]", i) } } db.AssertExpectations(t) } ================================================ FILE: pkg/gallery/filter.go ================================================ package gallery import ( "path/filepath" "strings" "github.com/stashapp/stash/pkg/models" ) func PathsFilter(paths []string) *models.GalleryFilterType { if paths == nil { return nil } sep := string(filepath.Separator) var ret *models.GalleryFilterType var or *models.GalleryFilterType for _, p := range paths { newOr := &models.GalleryFilterType{} if or != nil { or.Or = newOr } else { ret = newOr } or = newOr if !strings.HasSuffix(p, sep) { p += sep } or.Path = &models.StringCriterionInput{ Modifier: models.CriterionModifierEquals, Value: p + "%", } } return ret } ================================================ FILE: pkg/gallery/import.go ================================================ package gallery import ( "context" "fmt" "slices" "strings" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/sliceutil" ) type ImporterReaderWriter interface { models.GalleryCreatorUpdater FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Gallery, error) FindByFolderID(ctx context.Context, folderID models.FolderID) ([]*models.Gallery, error) FindUserGalleryByTitle(ctx context.Context, title string) ([]*models.Gallery, error) } type Importer struct { ReaderWriter ImporterReaderWriter StudioWriter models.StudioFinderCreator PerformerWriter models.PerformerFinderCreator TagWriter models.TagFinderCreator FileFinder models.FileFinder FolderFinder models.FolderFinder Input jsonschema.Gallery MissingRefBehaviour models.ImportMissingRefEnum ID int gallery models.Gallery customFields map[string]interface{} } func (i *Importer) PreImport(ctx context.Context) error { i.gallery = i.galleryJSONToGallery(i.Input) if err := i.populateFilesFolder(ctx); err != nil { return err } if err := i.populateStudio(ctx); err != nil { return err } if err := i.populatePerformers(ctx); err != nil { return err } if err := i.populateTags(ctx); err != nil { return err } i.customFields = i.Input.CustomFields return nil } func (i *Importer) galleryJSONToGallery(galleryJSON jsonschema.Gallery) models.Gallery { newGallery := models.Gallery{ PerformerIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), } if galleryJSON.Title != "" { newGallery.Title = galleryJSON.Title } if galleryJSON.Code != "" { newGallery.Code = galleryJSON.Code } if galleryJSON.Details != "" { newGallery.Details = galleryJSON.Details } if galleryJSON.Photographer != "" { newGallery.Photographer = galleryJSON.Photographer } if len(galleryJSON.URLs) > 0 { newGallery.URLs = models.NewRelatedStrings(galleryJSON.URLs) } else if galleryJSON.URL != "" { newGallery.URLs = models.NewRelatedStrings([]string{galleryJSON.URL}) } if galleryJSON.Date != "" { d, err := models.ParseDate(galleryJSON.Date) if err == nil { newGallery.Date = &d } } if galleryJSON.Rating != 0 { newGallery.Rating = &galleryJSON.Rating } newGallery.Organized = galleryJSON.Organized newGallery.CreatedAt = galleryJSON.CreatedAt.GetTime() newGallery.UpdatedAt = galleryJSON.UpdatedAt.GetTime() return newGallery } func (i *Importer) populateStudio(ctx context.Context) error { if i.Input.Studio != "" { studio, err := i.StudioWriter.FindByName(ctx, i.Input.Studio, false) if err != nil { return fmt.Errorf("error finding studio by name: %v", err) } if studio == nil { if i.MissingRefBehaviour == models.ImportMissingRefEnumFail { return fmt.Errorf("gallery studio '%s' not found", i.Input.Studio) } if i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore { return nil } if i.MissingRefBehaviour == models.ImportMissingRefEnumCreate { studioID, err := i.createStudio(ctx, i.Input.Studio) if err != nil { return err } i.gallery.StudioID = &studioID } } else { i.gallery.StudioID = &studio.ID } } return nil } func (i *Importer) createStudio(ctx context.Context, name string) (int, error) { newStudio := models.NewCreateStudioInput() newStudio.Name = name err := i.StudioWriter.Create(ctx, &newStudio) if err != nil { return 0, err } return newStudio.ID, nil } func (i *Importer) populatePerformers(ctx context.Context) error { if len(i.Input.Performers) > 0 { names := i.Input.Performers performers, err := i.PerformerWriter.FindByNames(ctx, names, false) if err != nil { return err } var pluckedNames []string for _, performer := range performers { if performer.Name == "" { continue } pluckedNames = append(pluckedNames, performer.Name) } missingPerformers := sliceutil.Filter(names, func(name string) bool { return !slices.Contains(pluckedNames, name) }) if len(missingPerformers) > 0 { if i.MissingRefBehaviour == models.ImportMissingRefEnumFail { return fmt.Errorf("gallery performers [%s] not found", strings.Join(missingPerformers, ", ")) } if i.MissingRefBehaviour == models.ImportMissingRefEnumCreate { createdPerformers, err := i.createPerformers(ctx, missingPerformers) if err != nil { return fmt.Errorf("error creating gallery performers: %v", err) } performers = append(performers, createdPerformers...) } // ignore if MissingRefBehaviour set to Ignore } for _, p := range performers { i.gallery.PerformerIDs.Add(p.ID) } } return nil } func (i *Importer) createPerformers(ctx context.Context, names []string) ([]*models.Performer, error) { var ret []*models.Performer for _, name := range names { newPerformer := models.NewPerformer() newPerformer.Name = name err := i.PerformerWriter.Create(ctx, &models.CreatePerformerInput{ Performer: &newPerformer, }) if err != nil { return nil, err } ret = append(ret, &newPerformer) } return ret, nil } func (i *Importer) populateTags(ctx context.Context) error { if len(i.Input.Tags) > 0 { names := i.Input.Tags tags, err := i.TagWriter.FindByNames(ctx, names, false) if err != nil { return err } var pluckedNames []string for _, tag := range tags { pluckedNames = append(pluckedNames, tag.Name) } missingTags := sliceutil.Filter(names, func(name string) bool { return !slices.Contains(pluckedNames, name) }) if len(missingTags) > 0 { if i.MissingRefBehaviour == models.ImportMissingRefEnumFail { return fmt.Errorf("gallery tags [%s] not found", strings.Join(missingTags, ", ")) } if i.MissingRefBehaviour == models.ImportMissingRefEnumCreate { createdTags, err := i.createTags(ctx, missingTags) if err != nil { return fmt.Errorf("error creating gallery tags: %v", err) } tags = append(tags, createdTags...) } // ignore if MissingRefBehaviour set to Ignore } for _, t := range tags { i.gallery.TagIDs.Add(t.ID) } } return nil } func (i *Importer) createTags(ctx context.Context, names []string) ([]*models.Tag, error) { var ret []*models.Tag for _, name := range names { newTag := models.NewTag() newTag.Name = name err := i.TagWriter.Create(ctx, &models.CreateTagInput{ Tag: &newTag, }) if err != nil { return nil, err } ret = append(ret, &newTag) } return ret, nil } func (i *Importer) populateFilesFolder(ctx context.Context) error { files := make([]models.File, 0) for _, ref := range i.Input.ZipFiles { path := ref f, err := i.FileFinder.FindByPath(ctx, path, true) if err != nil { return fmt.Errorf("error finding file: %w", err) } if f == nil { return fmt.Errorf("gallery zip file '%s' not found", path) } else { files = append(files, f) } } i.gallery.Files = models.NewRelatedFiles(files) if i.Input.FolderPath != "" { path := i.Input.FolderPath f, err := i.FolderFinder.FindByPath(ctx, path, true) if err != nil { return fmt.Errorf("error finding folder: %w", err) } if f == nil { return fmt.Errorf("gallery folder '%s' not found", path) } else { i.gallery.FolderID = &f.ID } } return nil } func (i *Importer) PostImport(ctx context.Context, id int) error { return nil } func (i *Importer) Name() string { if i.Input.Title != "" { return i.Input.Title } if i.Input.FolderPath != "" { return i.Input.FolderPath } if len(i.Input.ZipFiles) > 0 { return i.Input.ZipFiles[0] } return "" } func (i *Importer) FindExistingID(ctx context.Context) (*int, error) { var existing []*models.Gallery var err error switch { case len(i.gallery.Files.List()) > 0: for _, f := range i.gallery.Files.List() { existing, err := i.ReaderWriter.FindByFileID(ctx, f.Base().ID) if err != nil { return nil, err } if existing != nil { break } } case i.gallery.FolderID != nil: existing, err = i.ReaderWriter.FindByFolderID(ctx, *i.gallery.FolderID) default: existing, err = i.ReaderWriter.FindUserGalleryByTitle(ctx, i.gallery.Title) } if err != nil { return nil, err } if len(existing) > 0 { id := existing[0].ID return &id, nil } return nil, nil } func (i *Importer) Create(ctx context.Context) (*int, error) { var fileIDs []models.FileID for _, f := range i.gallery.Files.List() { fileIDs = append(fileIDs, f.Base().ID) } err := i.ReaderWriter.Create(ctx, &models.CreateGalleryInput{ Gallery: &i.gallery, FileIDs: fileIDs, CustomFields: i.customFields, }) if err != nil { return nil, fmt.Errorf("error creating gallery: %v", err) } id := i.gallery.ID return &id, nil } func (i *Importer) Update(ctx context.Context, id int) error { gallery := i.gallery gallery.ID = id err := i.ReaderWriter.Update(ctx, &models.UpdateGalleryInput{ Gallery: &gallery, CustomFields: models.CustomFieldsInput{ Full: i.customFields, }, }) if err != nil { return fmt.Errorf("error updating existing gallery: %v", err) } return nil } ================================================ FILE: pkg/gallery/import_test.go ================================================ package gallery import ( "context" "errors" "testing" "time" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) var ( existingStudioID = 101 existingPerformerID = 103 existingTagID = 105 existingStudioName = "existingStudioName" existingStudioErr = "existingStudioErr" missingStudioName = "missingStudioName" existingPerformerName = "existingPerformerName" existingPerformerErr = "existingPerformerErr" missingPerformerName = "missingPerformerName" existingTagName = "existingTagName" existingTagErr = "existingTagErr" missingTagName = "missingTagName" ) var testCtx = context.Background() var ( createdAt = time.Date(2001, time.January, 2, 1, 2, 3, 4, time.Local) updatedAt = time.Date(2002, time.January, 2, 1, 2, 3, 4, time.Local) ) func TestImporterPreImport(t *testing.T) { i := Importer{ Input: jsonschema.Gallery{ Title: title, Date: date, Details: details, Rating: rating, Organized: organized, URL: url, CreatedAt: json.JSONTime{ Time: createdAt, }, UpdatedAt: json.JSONTime{ Time: updatedAt, }, }, } err := i.PreImport(testCtx) assert.Nil(t, err) expectedGallery := models.Gallery{ Title: title, Date: &dateObj, Details: details, Rating: &rating, Organized: organized, URLs: models.NewRelatedStrings([]string{url}), Files: models.NewRelatedFiles([]models.File{}), TagIDs: models.NewRelatedIDs([]int{}), PerformerIDs: models.NewRelatedIDs([]int{}), CreatedAt: createdAt, UpdatedAt: updatedAt, } assert.Equal(t, expectedGallery, i.gallery) } func TestImporterPreImportWithStudio(t *testing.T) { db := mocks.NewDatabase() i := Importer{ StudioWriter: db.Studio, Input: jsonschema.Gallery{ Studio: existingStudioName, }, } db.Studio.On("FindByName", testCtx, existingStudioName, false).Return(&models.Studio{ ID: existingStudioID, }, nil).Once() db.Studio.On("FindByName", testCtx, existingStudioErr, false).Return(nil, errors.New("FindByName error")).Once() err := i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, existingStudioID, *i.gallery.StudioID) i.Input.Studio = existingStudioErr err = i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithMissingStudio(t *testing.T) { db := mocks.NewDatabase() i := Importer{ StudioWriter: db.Studio, Input: jsonschema.Gallery{ Studio: missingStudioName, }, MissingRefBehaviour: models.ImportMissingRefEnumFail, } db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Times(3) db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Run(func(args mock.Arguments) { s := args.Get(1).(*models.CreateStudioInput) s.Studio.ID = existingStudioID }).Return(nil) err := i.PreImport(testCtx) assert.NotNil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore err = i.PreImport(testCtx) assert.Nil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumCreate err = i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, existingStudioID, *i.gallery.StudioID) db.AssertExpectations(t) } func TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) { db := mocks.NewDatabase() i := Importer{ StudioWriter: db.Studio, Input: jsonschema.Gallery{ Studio: missingStudioName, }, MissingRefBehaviour: models.ImportMissingRefEnumCreate, } db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Once() db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithPerformer(t *testing.T) { db := mocks.NewDatabase() i := Importer{ PerformerWriter: db.Performer, MissingRefBehaviour: models.ImportMissingRefEnumFail, Input: jsonschema.Gallery{ Performers: []string{ existingPerformerName, }, }, } db.Performer.On("FindByNames", testCtx, []string{existingPerformerName}, false).Return([]*models.Performer{ { ID: existingPerformerID, Name: existingPerformerName, }, }, nil).Once() db.Performer.On("FindByNames", testCtx, []string{existingPerformerErr}, false).Return(nil, errors.New("FindByNames error")).Once() err := i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, []int{existingPerformerID}, i.gallery.PerformerIDs.List()) i.Input.Performers = []string{existingPerformerErr} err = i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithMissingPerformer(t *testing.T) { db := mocks.NewDatabase() i := Importer{ PerformerWriter: db.Performer, Input: jsonschema.Gallery{ Performers: []string{ missingPerformerName, }, }, MissingRefBehaviour: models.ImportMissingRefEnumFail, } db.Performer.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Times(3) db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.CreatePerformerInput")).Run(func(args mock.Arguments) { performer := args.Get(1).(*models.CreatePerformerInput) performer.ID = existingPerformerID }).Return(nil) err := i.PreImport(testCtx) assert.NotNil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore err = i.PreImport(testCtx) assert.Nil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumCreate err = i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, []int{existingPerformerID}, i.gallery.PerformerIDs.List()) db.AssertExpectations(t) } func TestImporterPreImportWithMissingPerformerCreateErr(t *testing.T) { db := mocks.NewDatabase() i := Importer{ PerformerWriter: db.Performer, Input: jsonschema.Gallery{ Performers: []string{ missingPerformerName, }, }, MissingRefBehaviour: models.ImportMissingRefEnumCreate, } db.Performer.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Once() db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.CreatePerformerInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithTag(t *testing.T) { db := mocks.NewDatabase() i := Importer{ TagWriter: db.Tag, MissingRefBehaviour: models.ImportMissingRefEnumFail, Input: jsonschema.Gallery{ Tags: []string{ existingTagName, }, }, } db.Tag.On("FindByNames", testCtx, []string{existingTagName}, false).Return([]*models.Tag{ { ID: existingTagID, Name: existingTagName, }, }, nil).Once() db.Tag.On("FindByNames", testCtx, []string{existingTagErr}, false).Return(nil, errors.New("FindByNames error")).Once() err := i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, []int{existingTagID}, i.gallery.TagIDs.List()) i.Input.Tags = []string{existingTagErr} err = i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithMissingTag(t *testing.T) { db := mocks.NewDatabase() i := Importer{ TagWriter: db.Tag, Input: jsonschema.Gallery{ Tags: []string{ missingTagName, }, }, MissingRefBehaviour: models.ImportMissingRefEnumFail, } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3) db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Run(func(args mock.Arguments) { t := args.Get(1).(*models.CreateTagInput) t.Tag.ID = existingTagID }).Return(nil) err := i.PreImport(testCtx) assert.NotNil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore err = i.PreImport(testCtx) assert.Nil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumCreate err = i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, []int{existingTagID}, i.gallery.TagIDs.List()) db.AssertExpectations(t) } func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) { db := mocks.NewDatabase() i := Importer{ TagWriter: db.Tag, Input: jsonschema.Gallery{ Tags: []string{ missingTagName, }, }, MissingRefBehaviour: models.ImportMissingRefEnumCreate, } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once() db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } ================================================ FILE: pkg/gallery/query.go ================================================ package gallery import ( "context" "strconv" "github.com/stashapp/stash/pkg/models" ) func CountByPerformerID(ctx context.Context, r models.GalleryQueryer, id int) (int, error) { filter := &models.GalleryFilterType{ Performers: &models.MultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, }, } return r.QueryCount(ctx, filter, nil) } func CountByStudioID(ctx context.Context, r models.GalleryQueryer, id int, depth *int) (int, error) { filter := &models.GalleryFilterType{ Studios: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, Depth: depth, }, } return r.QueryCount(ctx, filter, nil) } func CountByTagID(ctx context.Context, r models.GalleryQueryer, id int, depth *int) (int, error) { filter := &models.GalleryFilterType{ Tags: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, Depth: depth, }, } return r.QueryCount(ctx, filter, nil) } ================================================ FILE: pkg/gallery/scan.go ================================================ package gallery import ( "context" "fmt" "path/filepath" "strings" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/plugin/hook" ) type ScanCreatorUpdater interface { FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Gallery, error) FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Gallery, error) GetFiles(ctx context.Context, relatedID int) ([]models.File, error) models.GalleryCreator UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error) AddFileID(ctx context.Context, id int, fileID models.FileID) error } type ScanSceneFinderUpdater interface { FindByPath(ctx context.Context, p string) ([]*models.Scene, error) AddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error } type ScanImageFinderUpdater interface { FindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]*models.Image, error) UpdatePartial(ctx context.Context, id int, partial models.ImagePartial) (*models.Image, error) } type ScanHandler struct { CreatorUpdater ScanCreatorUpdater SceneFinderUpdater ScanSceneFinderUpdater ImageFinderUpdater ScanImageFinderUpdater PluginCache *plugin.Cache } func (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models.File) error { baseFile := f.Base() // try to match the file to a gallery existing, err := h.CreatorUpdater.FindByFileID(ctx, f.Base().ID) if err != nil { return fmt.Errorf("finding existing gallery: %w", err) } if len(existing) == 0 { // try also to match file by fingerprints existing, err = h.CreatorUpdater.FindByFingerprints(ctx, baseFile.Fingerprints) if err != nil { return fmt.Errorf("finding existing gallery by fingerprints: %w", err) } } if len(existing) > 0 { updateExisting := oldFile != nil if err := h.associateExisting(ctx, existing, f, updateExisting); err != nil { return err } } else { // only create galleries if there is something to put in them // otherwise, they will be created on the fly when an image is created images, err := h.ImageFinderUpdater.FindByZipFileID(ctx, f.Base().ID) if err != nil { return err } if len(images) == 0 { // don't create an empty gallery return nil } // create a new gallery newGallery := models.NewGallery() logger.Infof("%s doesn't exist. Creating new gallery...", f.Base().Path) if err := h.CreatorUpdater.Create(ctx, &models.CreateGalleryInput{ Gallery: &newGallery, FileIDs: []models.FileID{baseFile.ID}, }); err != nil { return fmt.Errorf("creating new gallery: %w", err) } h.PluginCache.RegisterPostHooks(ctx, newGallery.ID, hook.GalleryCreatePost, nil, nil) // associate all the images in the zip file with the gallery for _, i := range images { imagePartial := models.ImagePartial{ GalleryIDs: &models.UpdateIDs{ IDs: []int{newGallery.ID}, Mode: models.RelationshipUpdateModeAdd, }, // set UpdatedAt directly instead of using NewImagePartial, to ensure // that the images have the same UpdatedAt time as the gallery UpdatedAt: models.NewOptionalTime(newGallery.UpdatedAt), } if _, err := h.ImageFinderUpdater.UpdatePartial(ctx, i.ID, imagePartial); err != nil { return fmt.Errorf("adding image %s to gallery: %w", i.Path, err) } } existing = []*models.Gallery{&newGallery} } if err := h.associateScene(ctx, existing, f); err != nil { return err } return nil } func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.Gallery, f models.File, updateExisting bool) error { for _, i := range existing { if err := i.LoadFiles(ctx, h.CreatorUpdater); err != nil { return err } found := false for _, sf := range i.Files.List() { if sf.Base().ID == f.Base().ID { found = true break } } if !found { logger.Infof("Adding %s to gallery %s", f.Base().Path, i.DisplayName()) if err := h.CreatorUpdater.AddFileID(ctx, i.ID, f.Base().ID); err != nil { return fmt.Errorf("adding file to gallery: %w", err) } } if !found || updateExisting { // update updated_at time when file association or content changes if _, err := h.CreatorUpdater.UpdatePartial(ctx, i.ID, models.NewGalleryPartial()); err != nil { return fmt.Errorf("updating gallery: %w", err) } h.PluginCache.RegisterPostHooks(ctx, i.ID, hook.GalleryUpdatePost, nil, nil) } } return nil } func (h *ScanHandler) associateScene(ctx context.Context, existing []*models.Gallery, f models.File) error { galleryIDs := make([]int, len(existing)) for i, g := range existing { galleryIDs[i] = g.ID } path := f.Base().Path withoutExt := strings.TrimSuffix(path, filepath.Ext(path)) + ".*" // find scenes with a file that matches scenes, err := h.SceneFinderUpdater.FindByPath(ctx, withoutExt) if err != nil { return err } for _, scene := range scenes { // found related Scene logger.Infof("associate: Gallery %s is related to scene: %d", path, scene.ID) if err := h.SceneFinderUpdater.AddGalleryIDs(ctx, scene.ID, galleryIDs); err != nil { return err } } return nil } ================================================ FILE: pkg/gallery/scan_test.go ================================================ package gallery import ( "context" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stashapp/stash/pkg/plugin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) { const ( testGalleryID = 1 testFileID = 100 ) existingFile := &models.BaseFile{ID: models.FileID(testFileID), Path: "test.zip"} makeGallery := func() *models.Gallery { return &models.Gallery{ ID: testGalleryID, Files: models.NewRelatedFiles([]models.File{existingFile}), } } tests := []struct { name string updateExisting bool expectUpdate bool }{ { name: "calls UpdatePartial when file content changed", updateExisting: true, expectUpdate: true, }, { name: "skips UpdatePartial when file unchanged and already associated", updateExisting: false, expectUpdate: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { db := mocks.NewDatabase() db.Gallery.On("GetFiles", mock.Anything, testGalleryID).Return([]models.File{existingFile}, nil) if tt.expectUpdate { db.Gallery.On("UpdatePartial", mock.Anything, testGalleryID, mock.Anything). Return(&models.Gallery{ID: testGalleryID}, nil) } h := &ScanHandler{ CreatorUpdater: db.Gallery, PluginCache: &plugin.Cache{}, } db.WithTxnCtx(func(ctx context.Context) { err := h.associateExisting(ctx, []*models.Gallery{makeGallery()}, existingFile, tt.updateExisting) assert.NoError(t, err) }) if tt.expectUpdate { db.Gallery.AssertCalled(t, "UpdatePartial", mock.Anything, testGalleryID, mock.Anything) } else { db.Gallery.AssertNotCalled(t, "UpdatePartial", mock.Anything, mock.Anything, mock.Anything) } }) } } func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) { const ( testGalleryID = 1 existFileID = 100 newFileID = 200 ) existingFile := &models.BaseFile{ID: models.FileID(existFileID), Path: "existing.zip"} newFile := &models.BaseFile{ID: models.FileID(newFileID), Path: "new.zip"} gallery := &models.Gallery{ ID: testGalleryID, Files: models.NewRelatedFiles([]models.File{existingFile}), } db := mocks.NewDatabase() db.Gallery.On("GetFiles", mock.Anything, testGalleryID).Return([]models.File{existingFile}, nil) db.Gallery.On("AddFileID", mock.Anything, testGalleryID, models.FileID(newFileID)).Return(nil) db.Gallery.On("UpdatePartial", mock.Anything, testGalleryID, mock.Anything). Return(&models.Gallery{ID: testGalleryID}, nil) h := &ScanHandler{ CreatorUpdater: db.Gallery, PluginCache: &plugin.Cache{}, } db.WithTxnCtx(func(ctx context.Context) { err := h.associateExisting(ctx, []*models.Gallery{gallery}, newFile, false) assert.NoError(t, err) }) db.Gallery.AssertCalled(t, "AddFileID", mock.Anything, testGalleryID, models.FileID(newFileID)) db.Gallery.AssertCalled(t, "UpdatePartial", mock.Anything, testGalleryID, mock.Anything) } ================================================ FILE: pkg/gallery/service.go ================================================ // Package gallery provides application logic for managing galleries. // This functionality is exposed via the [Service] type. package gallery import ( "context" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" ) type ImageFinder interface { FindByFolderID(ctx context.Context, folder models.FolderID) ([]*models.Image, error) FindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]*models.Image, error) models.GalleryIDLoader } type ImageService interface { Destroy(ctx context.Context, i *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error DestroyZipImages(ctx context.Context, zipFile models.File, fileDeleter *image.FileDeleter, deleteGenerated bool) ([]*models.Image, error) DestroyFolderImages(ctx context.Context, folderID models.FolderID, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) } type Service struct { Repository models.GalleryReaderWriter ImageFinder ImageFinder ImageService ImageService File models.FileReaderWriter Folder models.FolderReaderWriter } ================================================ FILE: pkg/gallery/update.go ================================================ package gallery import ( "context" "fmt" "github.com/stashapp/stash/pkg/models" ) type ImageUpdater interface { GetImageIDs(ctx context.Context, galleryID int) ([]int, error) AddImages(ctx context.Context, galleryID int, imageIDs ...int) error RemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error } func (s *Service) Updated(ctx context.Context, galleryID int) error { galleryPartial := models.NewGalleryPartial() _, err := s.Repository.UpdatePartial(ctx, galleryID, galleryPartial) return err } // AddImages adds images to the provided gallery. // It returns an error if the gallery does not support adding images, or if // the operation fails. func (s *Service) AddImages(ctx context.Context, g *models.Gallery, toAdd ...int) error { if err := validateContentChange(g); err != nil { return err } if err := s.Repository.AddImages(ctx, g.ID, toAdd...); err != nil { return fmt.Errorf("failed to add images to gallery: %w", err) } // #3759 - update the gallery's UpdatedAt timestamp return s.Updated(ctx, g.ID) } // RemoveImages removes images from the provided gallery. // It does not validate if the images are part of the gallery. // It returns an error if the gallery does not support removing images, or if // the operation fails. func (s *Service) RemoveImages(ctx context.Context, g *models.Gallery, toRemove ...int) error { if err := validateContentChange(g); err != nil { return err } if err := s.Repository.RemoveImages(ctx, g.ID, toRemove...); err != nil { return fmt.Errorf("failed to remove images from gallery: %w", err) } // #3759 - update the gallery's UpdatedAt timestamp return s.Updated(ctx, g.ID) } func (s *Service) SetCover(ctx context.Context, g *models.Gallery, coverImageID int) error { if err := s.Repository.SetCover(ctx, g.ID, coverImageID); err != nil { return fmt.Errorf("failed to set cover: %w", err) } return s.Updated(ctx, g.ID) } func (s *Service) ResetCover(ctx context.Context, g *models.Gallery) error { if err := s.Repository.ResetCover(ctx, g.ID); err != nil { return fmt.Errorf("failed to reset cover: %w", err) } return s.Updated(ctx, g.ID) } func AddPerformer(ctx context.Context, qb models.GalleryUpdater, o *models.Gallery, performerID int) error { galleryPartial := models.NewGalleryPartial() galleryPartial.PerformerIDs = &models.UpdateIDs{ IDs: []int{performerID}, Mode: models.RelationshipUpdateModeAdd, } _, err := qb.UpdatePartial(ctx, o.ID, galleryPartial) return err } func AddTag(ctx context.Context, qb models.GalleryUpdater, o *models.Gallery, tagID int) error { galleryPartial := models.NewGalleryPartial() galleryPartial.TagIDs = &models.UpdateIDs{ IDs: []int{tagID}, Mode: models.RelationshipUpdateModeAdd, } _, err := qb.UpdatePartial(ctx, o.ID, galleryPartial) return err } ================================================ FILE: pkg/gallery/validation.go ================================================ package gallery import ( "context" "fmt" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil" ) type ContentsChangedError struct { Gallery *models.Gallery } func (e *ContentsChangedError) Error() string { typ := "zip-based" if e.Gallery.FolderID != nil { typ = "folder-based" } return fmt.Sprintf("cannot change contents of %s gallery %q", typ, e.Gallery.GetTitle()) } // validateContentChange returns an error if a gallery cannot have its contents changed. // Only manually created galleries can have images changed. func validateContentChange(g *models.Gallery) error { if g.FolderID != nil || g.PrimaryFileID != nil { return &ContentsChangedError{ Gallery: g, } } return nil } func (s *Service) ValidateImageGalleryChange(ctx context.Context, i *models.Image, updateIDs models.UpdateIDs) error { // determine what is changing var changedIDs []int switch updateIDs.Mode { case models.RelationshipUpdateModeAdd, models.RelationshipUpdateModeRemove: changedIDs = updateIDs.IDs case models.RelationshipUpdateModeSet: // get the difference between the two lists changedIDs = sliceutil.NotIntersect(i.GalleryIDs.List(), updateIDs.IDs) } galleries, err := s.Repository.FindMany(ctx, changedIDs) if err != nil { return err } for _, g := range galleries { if err := validateContentChange(g); err != nil { return fmt.Errorf("changing galleries of image %q: %w", i.GetTitle(), err) } } return nil } ================================================ FILE: pkg/group/create.go ================================================ package group import ( "context" "errors" "github.com/stashapp/stash/pkg/models" ) var ( ErrEmptyName = errors.New("name cannot be empty") ErrHierarchyLoop = errors.New("a group cannot be contained by one of its subgroups") ) func (s *Service) Create(ctx context.Context, input *models.CreateGroupInput) error { r := s.Repository group := input.Group if err := s.validateCreate(ctx, group); err != nil { return err } err := r.Create(ctx, input.Group) if err != nil { return err } // set custom fields if len(input.CustomFields) > 0 { if err := r.SetCustomFields(ctx, group.ID, models.CustomFieldsInput{ Full: input.CustomFields, }); err != nil { return err } } // update image table if len(input.FrontImageData) > 0 { if err := r.UpdateFrontImage(ctx, group.ID, input.FrontImageData); err != nil { return err } } if len(input.BackImageData) > 0 { if err := r.UpdateBackImage(ctx, group.ID, input.BackImageData); err != nil { return err } } return nil } ================================================ FILE: pkg/group/doc.go ================================================ // Package group provides the application logic for groups. package group ================================================ FILE: pkg/group/export.go ================================================ package group import ( "context" "fmt" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/utils" ) type GroupExportReader interface { GetFrontImage(ctx context.Context, groupID int) ([]byte, error) GetBackImage(ctx context.Context, groupID int) ([]byte, error) GetCustomFields(ctx context.Context, groupID int) (map[string]interface{}, error) } // ToJSON converts a Group into its JSON equivalent. func ToJSON(ctx context.Context, reader GroupExportReader, studioReader models.StudioGetter, group *models.Group) (*jsonschema.Group, error) { newGroupJSON := jsonschema.Group{ Name: group.Name, Aliases: group.Aliases, Director: group.Director, Synopsis: group.Synopsis, URLs: group.URLs.List(), CreatedAt: json.JSONTime{Time: group.CreatedAt}, UpdatedAt: json.JSONTime{Time: group.UpdatedAt}, } if group.Date != nil { newGroupJSON.Date = group.Date.String() } if group.Rating != nil { newGroupJSON.Rating = *group.Rating } if group.Duration != nil { newGroupJSON.Duration = *group.Duration } if group.StudioID != nil { studio, err := studioReader.Find(ctx, *group.StudioID) if err != nil { return nil, fmt.Errorf("error getting movie studio: %v", err) } if studio != nil { newGroupJSON.Studio = studio.Name } } frontImage, err := reader.GetFrontImage(ctx, group.ID) if err != nil { logger.Errorf("Error getting movie front image: %v", err) } if len(frontImage) > 0 { newGroupJSON.FrontImage = utils.GetBase64StringFromData(frontImage) } backImage, err := reader.GetBackImage(ctx, group.ID) if err != nil { logger.Errorf("Error getting movie back image: %v", err) } if len(backImage) > 0 { newGroupJSON.BackImage = utils.GetBase64StringFromData(backImage) } newGroupJSON.CustomFields, err = reader.GetCustomFields(ctx, group.ID) if err != nil { return nil, fmt.Errorf("getting group custom fields: %v", err) } return &newGroupJSON, nil } ================================================ FILE: pkg/group/export_test.go ================================================ package group import ( "errors" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "testing" "time" ) const ( movieID = iota + 1 emptyID errFrontImageID errBackImageID errStudioMovieID missingStudioMovieID errCustomFieldsID ) const ( studioID = iota + 1 missingStudioID errStudioID ) const movieName = "testMovie" const movieAliases = "aliases" var ( date = "2001-01-01" dateObj, _ = models.ParseDate(date) rating = 5 duration = 100 director = "director" synopsis = "synopsis" url = "url" ) const studioName = "studio" const ( frontImage = "ZnJvbnRJbWFnZUJ5dGVz" backImage = "YmFja0ltYWdlQnl0ZXM=" ) var ( frontImageBytes = []byte("frontImageBytes") backImageBytes = []byte("backImageBytes") emptyCustomFields = make(map[string]interface{}) customFields = map[string]interface{}{ "customField1": "customValue1", } ) var movieStudio models.Studio = models.Studio{ Name: studioName, } var ( createTime = time.Date(2001, 01, 01, 0, 0, 0, 0, time.UTC) updateTime = time.Date(2002, 01, 01, 0, 0, 0, 0, time.UTC) ) func createFullMovie(id int, studioID int) models.Group { return models.Group{ ID: id, Name: movieName, Aliases: movieAliases, Date: &dateObj, Rating: &rating, Duration: &duration, Director: director, Synopsis: synopsis, URLs: models.NewRelatedStrings([]string{url}), StudioID: &studioID, CreatedAt: createTime, UpdatedAt: updateTime, } } func createEmptyMovie(id int) models.Group { return models.Group{ ID: id, URLs: models.NewRelatedStrings([]string{}), CreatedAt: createTime, UpdatedAt: updateTime, } } func createFullJSONMovie(studio, frontImage, backImage string, customFields map[string]interface{}) *jsonschema.Group { return &jsonschema.Group{ Name: movieName, Aliases: movieAliases, Date: date, Rating: rating, Duration: duration, Director: director, Synopsis: synopsis, URLs: []string{url}, Studio: studio, FrontImage: frontImage, BackImage: backImage, CreatedAt: json.JSONTime{ Time: createTime, }, UpdatedAt: json.JSONTime{ Time: updateTime, }, CustomFields: customFields, } } func createEmptyJSONMovie() *jsonschema.Group { return &jsonschema.Group{ URLs: []string{}, CreatedAt: json.JSONTime{ Time: createTime, }, UpdatedAt: json.JSONTime{ Time: updateTime, }, CustomFields: emptyCustomFields, } } type testScenario struct { movie models.Group customFields map[string]interface{} expected *jsonschema.Group err bool } var scenarios []testScenario func initTestTable() { scenarios = []testScenario{ { createFullMovie(movieID, studioID), customFields, createFullJSONMovie(studioName, frontImage, backImage, customFields), false, }, { createEmptyMovie(emptyID), emptyCustomFields, createEmptyJSONMovie(), false, }, { createFullMovie(errFrontImageID, studioID), emptyCustomFields, createFullJSONMovie(studioName, "", backImage, emptyCustomFields), // failure to get front image should not cause error false, }, { createFullMovie(errBackImageID, studioID), emptyCustomFields, createFullJSONMovie(studioName, frontImage, "", emptyCustomFields), // failure to get back image should not cause error false, }, { createFullMovie(errStudioMovieID, errStudioID), emptyCustomFields, nil, true, }, { createFullMovie(missingStudioMovieID, missingStudioID), emptyCustomFields, createFullJSONMovie("", frontImage, backImage, emptyCustomFields), false, }, { createFullMovie(errCustomFieldsID, studioID), customFields, nil, true, }, } } func TestToJSON(t *testing.T) { initTestTable() db := mocks.NewDatabase() imageErr := errors.New("error getting image") db.Group.On("GetFrontImage", testCtx, movieID).Return(frontImageBytes, nil).Once() db.Group.On("GetFrontImage", testCtx, missingStudioMovieID).Return(frontImageBytes, nil).Once() db.Group.On("GetFrontImage", testCtx, emptyID).Return(nil, nil).Once().Maybe() db.Group.On("GetFrontImage", testCtx, errFrontImageID).Return(nil, imageErr).Once() db.Group.On("GetFrontImage", testCtx, errBackImageID).Return(frontImageBytes, nil).Once() db.Group.On("GetFrontImage", testCtx, errCustomFieldsID).Return(nil, nil).Once() db.Group.On("GetBackImage", testCtx, movieID).Return(backImageBytes, nil).Once() db.Group.On("GetBackImage", testCtx, missingStudioMovieID).Return(backImageBytes, nil).Once() db.Group.On("GetBackImage", testCtx, emptyID).Return(nil, nil).Once() db.Group.On("GetBackImage", testCtx, errBackImageID).Return(nil, imageErr).Once() db.Group.On("GetBackImage", testCtx, errFrontImageID).Return(backImageBytes, nil).Maybe() db.Group.On("GetBackImage", testCtx, errStudioMovieID).Return(backImageBytes, nil).Maybe() db.Group.On("GetBackImage", testCtx, errCustomFieldsID).Return(nil, nil).Once() db.Group.On("GetCustomFields", testCtx, movieID).Return(customFields, nil).Once() db.Group.On("GetCustomFields", testCtx, errCustomFieldsID).Return(nil, errors.New("error getting custom fields")).Once() db.Group.On("GetCustomFields", testCtx, mock.Anything).Return(emptyCustomFields, nil).Times(4) studioErr := errors.New("error getting studio") db.Studio.On("Find", testCtx, studioID).Return(&movieStudio, nil) db.Studio.On("Find", testCtx, missingStudioID).Return(nil, nil) db.Studio.On("Find", testCtx, errStudioID).Return(nil, studioErr) for i, s := range scenarios { movie := s.movie json, err := ToJSON(testCtx, db.Group, db.Studio, &movie) switch { case !s.err && err != nil: t.Errorf("[%d] unexpected error: %s", i, err.Error()) case s.err && err == nil: t.Errorf("[%d] expected error not returned", i) default: assert.Equal(t, s.expected, json, "[%d]", i) } } db.AssertExpectations(t) } ================================================ FILE: pkg/group/import.go ================================================ package group import ( "context" "fmt" "slices" "strings" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/utils" ) type ImporterReaderWriter interface { models.GroupCreatorUpdater models.CustomFieldsWriter FindByName(ctx context.Context, name string, nocase bool) (*models.Group, error) } type SubGroupNotExistError struct { missingSubGroup string } func (e SubGroupNotExistError) Error() string { return fmt.Sprintf("sub group <%s> does not exist", e.missingSubGroup) } func (e SubGroupNotExistError) MissingSubGroup() string { return e.missingSubGroup } type Importer struct { ReaderWriter ImporterReaderWriter StudioWriter models.StudioFinderCreator TagWriter models.TagFinderCreator Input jsonschema.Group MissingRefBehaviour models.ImportMissingRefEnum group models.Group frontImageData []byte backImageData []byte } func (i *Importer) PreImport(ctx context.Context) error { i.group = i.groupJSONToGroup(i.Input) if err := i.populateStudio(ctx); err != nil { return err } if err := i.populateTags(ctx); err != nil { return err } var err error if len(i.Input.FrontImage) > 0 { i.frontImageData, err = utils.ProcessBase64Image(i.Input.FrontImage) if err != nil { return fmt.Errorf("invalid front_image: %v", err) } } if len(i.Input.BackImage) > 0 { i.backImageData, err = utils.ProcessBase64Image(i.Input.BackImage) if err != nil { return fmt.Errorf("invalid back_image: %v", err) } } return nil } func (i *Importer) populateTags(ctx context.Context) error { if len(i.Input.Tags) > 0 { tags, err := importTags(ctx, i.TagWriter, i.Input.Tags, i.MissingRefBehaviour) if err != nil { return err } for _, p := range tags { i.group.TagIDs.Add(p.ID) } } return nil } func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string, missingRefBehaviour models.ImportMissingRefEnum) ([]*models.Tag, error) { tags, err := tagWriter.FindByNames(ctx, names, false) if err != nil { return nil, err } var pluckedNames []string for _, tag := range tags { pluckedNames = append(pluckedNames, tag.Name) } missingTags := sliceutil.Filter(names, func(name string) bool { return !slices.Contains(pluckedNames, name) }) if len(missingTags) > 0 { if missingRefBehaviour == models.ImportMissingRefEnumFail { return nil, fmt.Errorf("tags [%s] not found", strings.Join(missingTags, ", ")) } if missingRefBehaviour == models.ImportMissingRefEnumCreate { createdTags, err := createTags(ctx, tagWriter, missingTags) if err != nil { return nil, fmt.Errorf("error creating tags: %v", err) } tags = append(tags, createdTags...) } // ignore if MissingRefBehaviour set to Ignore } return tags, nil } func createTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string) ([]*models.Tag, error) { var ret []*models.Tag for _, name := range names { newTag := models.NewTag() newTag.Name = name err := tagWriter.Create(ctx, &models.CreateTagInput{ Tag: &newTag, }) if err != nil { return nil, err } ret = append(ret, &newTag) } return ret, nil } func (i *Importer) groupJSONToGroup(groupJSON jsonschema.Group) models.Group { newGroup := models.Group{ Name: groupJSON.Name, Aliases: groupJSON.Aliases, Director: groupJSON.Director, Synopsis: groupJSON.Synopsis, CreatedAt: groupJSON.CreatedAt.GetTime(), UpdatedAt: groupJSON.UpdatedAt.GetTime(), TagIDs: models.NewRelatedIDs([]int{}), } if len(groupJSON.URLs) > 0 { newGroup.URLs = models.NewRelatedStrings(groupJSON.URLs) } else if groupJSON.URL != "" { newGroup.URLs = models.NewRelatedStrings([]string{groupJSON.URL}) } if groupJSON.Date != "" { d, err := models.ParseDate(groupJSON.Date) if err == nil { newGroup.Date = &d } } if groupJSON.Rating != 0 { newGroup.Rating = &groupJSON.Rating } if groupJSON.Duration != 0 { newGroup.Duration = &groupJSON.Duration } return newGroup } func (i *Importer) populateStudio(ctx context.Context) error { if i.Input.Studio != "" { studio, err := i.StudioWriter.FindByName(ctx, i.Input.Studio, false) if err != nil { return fmt.Errorf("error finding studio by name: %v", err) } if studio == nil { if i.MissingRefBehaviour == models.ImportMissingRefEnumFail { return fmt.Errorf("group studio '%s' not found", i.Input.Studio) } if i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore { return nil } if i.MissingRefBehaviour == models.ImportMissingRefEnumCreate { studioID, err := i.createStudio(ctx, i.Input.Studio) if err != nil { return err } i.group.StudioID = &studioID } } else { i.group.StudioID = &studio.ID } } return nil } func (i *Importer) createStudio(ctx context.Context, name string) (int, error) { newStudio := models.NewCreateStudioInput() newStudio.Name = name err := i.StudioWriter.Create(ctx, &newStudio) if err != nil { return 0, err } return newStudio.ID, nil } func (i *Importer) PostImport(ctx context.Context, id int) error { subGroups, err := i.getSubGroups(ctx) if err != nil { return err } if len(subGroups) > 0 { if _, err := i.ReaderWriter.UpdatePartial(ctx, id, models.GroupPartial{ SubGroups: &models.UpdateGroupDescriptions{ Groups: subGroups, Mode: models.RelationshipUpdateModeSet, }, }); err != nil { return fmt.Errorf("error setting parents: %v", err) } } if len(i.Input.CustomFields) > 0 { if err := i.ReaderWriter.SetCustomFields(ctx, id, models.CustomFieldsInput{ Full: i.Input.CustomFields, }); err != nil { return fmt.Errorf("error setting custom fields: %v", err) } } if len(i.frontImageData) > 0 { if err := i.ReaderWriter.UpdateFrontImage(ctx, id, i.frontImageData); err != nil { return fmt.Errorf("error setting group front image: %v", err) } } if len(i.backImageData) > 0 { if err := i.ReaderWriter.UpdateBackImage(ctx, id, i.backImageData); err != nil { return fmt.Errorf("error setting group back image: %v", err) } } return nil } func (i *Importer) Name() string { return i.Input.Name } func (i *Importer) FindExistingID(ctx context.Context) (*int, error) { const nocase = false existing, err := i.ReaderWriter.FindByName(ctx, i.Name(), nocase) if err != nil { return nil, err } if existing != nil { id := existing.ID return &id, nil } return nil, nil } func (i *Importer) Create(ctx context.Context) (*int, error) { err := i.ReaderWriter.Create(ctx, &i.group) if err != nil { return nil, fmt.Errorf("error creating group: %v", err) } id := i.group.ID return &id, nil } func (i *Importer) Update(ctx context.Context, id int) error { group := i.group group.ID = id err := i.ReaderWriter.Update(ctx, &group) if err != nil { return fmt.Errorf("error updating existing group: %v", err) } return nil } func (i *Importer) getSubGroups(ctx context.Context) ([]models.GroupIDDescription, error) { var subGroups []models.GroupIDDescription for _, subGroup := range i.Input.SubGroups { group, err := i.ReaderWriter.FindByName(ctx, subGroup.Group, false) if err != nil { return nil, fmt.Errorf("error finding parent by name: %v", err) } if group == nil { if i.MissingRefBehaviour == models.ImportMissingRefEnumFail { return nil, SubGroupNotExistError{missingSubGroup: subGroup.Group} } if i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore { continue } if i.MissingRefBehaviour == models.ImportMissingRefEnumCreate { parentID, err := i.createSubGroup(ctx, subGroup.Group) if err != nil { return nil, err } subGroups = append(subGroups, models.GroupIDDescription{ GroupID: parentID, Description: subGroup.Description, }) } } else { subGroups = append(subGroups, models.GroupIDDescription{ GroupID: group.ID, Description: subGroup.Description, }) } } return subGroups, nil } func (i *Importer) createSubGroup(ctx context.Context, name string) (int, error) { newGroup := models.NewGroup() newGroup.Name = name err := i.ReaderWriter.Create(ctx, &newGroup) if err != nil { return 0, err } return newGroup.ID, nil } ================================================ FILE: pkg/group/import_test.go ================================================ package group import ( "context" "errors" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) const invalidImage = "aW1hZ2VCeXRlcw&&" const ( movieNameErr = "movieNameErr" existingMovieName = "existingMovieName" existingMovieID = 100 existingStudioID = 101 existingStudioName = "existingStudioName" existingStudioErr = "existingStudioErr" missingStudioName = "existingStudioName" errImageID = 3 existingTagID = 105 errTagsID = 106 existingTagName = "existingTagName" existingTagErr = "existingTagErr" missingTagName = "missingTagName" ) var testCtx = context.Background() func TestImporterName(t *testing.T) { i := Importer{ Input: jsonschema.Group{ Name: movieName, }, } assert.Equal(t, movieName, i.Name()) } func TestImporterPreImport(t *testing.T) { i := Importer{ Input: jsonschema.Group{ Name: movieName, FrontImage: invalidImage, }, } err := i.PreImport(testCtx) assert.NotNil(t, err) i.Input.FrontImage = frontImage i.Input.BackImage = invalidImage err = i.PreImport(testCtx) assert.NotNil(t, err) i.Input.BackImage = "" err = i.PreImport(testCtx) assert.Nil(t, err) i.Input.BackImage = backImage err = i.PreImport(testCtx) assert.Nil(t, err) } func TestImporterPreImportWithStudio(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Group, StudioWriter: db.Studio, Input: jsonschema.Group{ Name: movieName, FrontImage: frontImage, Studio: existingStudioName, Rating: 5, Duration: 10, }, } db.Studio.On("FindByName", testCtx, existingStudioName, false).Return(&models.Studio{ ID: existingStudioID, }, nil).Once() db.Studio.On("FindByName", testCtx, existingStudioErr, false).Return(nil, errors.New("FindByName error")).Once() err := i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, existingStudioID, *i.group.StudioID) i.Input.Studio = existingStudioErr err = i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithMissingStudio(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Group, StudioWriter: db.Studio, Input: jsonschema.Group{ Name: movieName, FrontImage: frontImage, Studio: missingStudioName, }, MissingRefBehaviour: models.ImportMissingRefEnumFail, } db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Times(3) db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Run(func(args mock.Arguments) { s := args.Get(1).(*models.CreateStudioInput) s.Studio.ID = existingStudioID }).Return(nil) err := i.PreImport(testCtx) assert.NotNil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore err = i.PreImport(testCtx) assert.Nil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumCreate err = i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, existingStudioID, *i.group.StudioID) db.AssertExpectations(t) } func TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Group, StudioWriter: db.Studio, Input: jsonschema.Group{ Name: movieName, FrontImage: frontImage, Studio: missingStudioName, }, MissingRefBehaviour: models.ImportMissingRefEnumCreate, } db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Once() db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithTag(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Group, TagWriter: db.Tag, MissingRefBehaviour: models.ImportMissingRefEnumFail, Input: jsonschema.Group{ Tags: []string{ existingTagName, }, }, } db.Tag.On("FindByNames", testCtx, []string{existingTagName}, false).Return([]*models.Tag{ { ID: existingTagID, Name: existingTagName, }, }, nil).Once() db.Tag.On("FindByNames", testCtx, []string{existingTagErr}, false).Return(nil, errors.New("FindByNames error")).Once() err := i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, existingTagID, i.group.TagIDs.List()[0]) i.Input.Tags = []string{existingTagErr} err = i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithMissingTag(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Group, TagWriter: db.Tag, Input: jsonschema.Group{ Tags: []string{ missingTagName, }, }, MissingRefBehaviour: models.ImportMissingRefEnumFail, } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3) db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Run(func(args mock.Arguments) { t := args.Get(1).(*models.CreateTagInput) t.Tag.ID = existingTagID }).Return(nil) err := i.PreImport(testCtx) assert.NotNil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore err = i.PreImport(testCtx) assert.Nil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumCreate err = i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, existingTagID, i.group.TagIDs.List()[0]) db.AssertExpectations(t) } func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Group, TagWriter: db.Tag, Input: jsonschema.Group{ Tags: []string{ missingTagName, }, }, MissingRefBehaviour: models.ImportMissingRefEnumCreate, } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once() db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPostImport(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Group, StudioWriter: db.Studio, Input: jsonschema.Group{ CustomFields: customFields, }, frontImageData: frontImageBytes, backImageData: backImageBytes, } updateMovieImageErr := errors.New("UpdateImages error") customFieldsErr := errors.New("SetCustomFields error") customFieldsInput := models.CustomFieldsInput{ Full: customFields, } db.Group.On("UpdateFrontImage", testCtx, movieID, frontImageBytes).Return(nil).Once() db.Group.On("UpdateFrontImage", testCtx, errImageID, frontImageBytes).Return(updateMovieImageErr).Once() db.Group.On("UpdateBackImage", testCtx, movieID, backImageBytes).Return(nil).Once() db.Group.On("SetCustomFields", testCtx, movieID, customFieldsInput).Return(nil).Once() db.Group.On("SetCustomFields", testCtx, errImageID, customFieldsInput).Return(nil).Once() db.Group.On("SetCustomFields", testCtx, errCustomFieldsID, customFieldsInput).Return(customFieldsErr).Once() err := i.PostImport(testCtx, movieID) assert.Nil(t, err) err = i.PostImport(testCtx, errImageID) assert.NotNil(t, err) err = i.PostImport(testCtx, errCustomFieldsID) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterFindExistingID(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Group, StudioWriter: db.Studio, Input: jsonschema.Group{ Name: movieName, }, } errFindByName := errors.New("FindByName error") db.Group.On("FindByName", testCtx, movieName, false).Return(nil, nil).Once() db.Group.On("FindByName", testCtx, existingMovieName, false).Return(&models.Group{ ID: existingMovieID, }, nil).Once() db.Group.On("FindByName", testCtx, movieNameErr, false).Return(nil, errFindByName).Once() id, err := i.FindExistingID(testCtx) assert.Nil(t, id) assert.Nil(t, err) i.Input.Name = existingMovieName id, err = i.FindExistingID(testCtx) assert.Equal(t, existingMovieID, *id) assert.Nil(t, err) i.Input.Name = movieNameErr id, err = i.FindExistingID(testCtx) assert.Nil(t, id) assert.NotNil(t, err) db.AssertExpectations(t) } func TestCreate(t *testing.T) { db := mocks.NewDatabase() movie := models.Group{ Name: movieName, } movieErr := models.Group{ Name: movieNameErr, } i := Importer{ ReaderWriter: db.Group, StudioWriter: db.Studio, group: movie, } errCreate := errors.New("Create error") db.Group.On("Create", testCtx, &movie).Run(func(args mock.Arguments) { m := args.Get(1).(*models.Group) m.ID = movieID }).Return(nil).Once() db.Group.On("Create", testCtx, &movieErr).Return(errCreate).Once() id, err := i.Create(testCtx) assert.Equal(t, movieID, *id) assert.Nil(t, err) i.group = movieErr id, err = i.Create(testCtx) assert.Nil(t, id) assert.NotNil(t, err) db.AssertExpectations(t) } func TestUpdate(t *testing.T) { db := mocks.NewDatabase() movie := models.Group{ Name: movieName, } movieErr := models.Group{ Name: movieNameErr, } i := Importer{ ReaderWriter: db.Group, StudioWriter: db.Studio, group: movie, } errUpdate := errors.New("Update error") // id needs to be set for the mock input movie.ID = movieID db.Group.On("Update", testCtx, &movie).Return(nil).Once() err := i.Update(testCtx, movieID) assert.Nil(t, err) i.group = movieErr // need to set id separately movieErr.ID = errImageID db.Group.On("Update", testCtx, &movieErr).Return(errUpdate).Once() err = i.Update(testCtx, errImageID) assert.NotNil(t, err) db.AssertExpectations(t) } ================================================ FILE: pkg/group/query.go ================================================ package group import ( "context" "strconv" "github.com/stashapp/stash/pkg/models" ) func CountByStudioID(ctx context.Context, r models.GroupQueryer, id int, depth *int) (int, error) { filter := &models.GroupFilterType{ Studios: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, Depth: depth, }, } return r.QueryCount(ctx, filter, nil) } func CountByTagID(ctx context.Context, r models.GroupQueryer, id int, depth *int) (int, error) { filter := &models.GroupFilterType{ Tags: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, Depth: depth, }, } return r.QueryCount(ctx, filter, nil) } func CountByContainingGroupID(ctx context.Context, r models.GroupQueryer, id int, depth *int) (int, error) { filter := &models.GroupFilterType{ ContainingGroups: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, Depth: depth, }, } return r.QueryCount(ctx, filter, nil) } ================================================ FILE: pkg/group/reorder.go ================================================ package group import ( "context" "errors" "github.com/stashapp/stash/pkg/models" ) var ErrInvalidInsertIndex = errors.New("invalid insert index") func (s *Service) ReorderSubGroups(ctx context.Context, groupID int, subGroupIDs []int, insertPointID int, insertAfter bool) error { // get the group existing, err := s.Repository.Find(ctx, groupID) if err != nil { return err } // ensure it exists if existing == nil { return models.ErrNotFound } // TODO - ensure the subgroups exist in the group // ensure the insert index is valid if insertPointID < 0 { return ErrInvalidInsertIndex } // reorder the subgroups return s.Repository.ReorderSubGroups(ctx, groupID, subGroupIDs, insertPointID, insertAfter) } ================================================ FILE: pkg/group/service.go ================================================ package group import ( "context" "github.com/stashapp/stash/pkg/models" ) type CreatorUpdater interface { models.GroupGetter models.GroupCreator models.GroupUpdater models.CustomFieldsWriter models.ContainingGroupLoader models.SubGroupLoader AnscestorFinder SubGroupIDFinder SubGroupAdder SubGroupRemover SubGroupReorderer } type AnscestorFinder interface { FindInAncestors(ctx context.Context, ascestorIDs []int, ids []int) ([]int, error) } type SubGroupIDFinder interface { FindSubGroupIDs(ctx context.Context, containingID int, ids []int) ([]int, error) } type SubGroupAdder interface { AddSubGroups(ctx context.Context, groupID int, subGroups []models.GroupIDDescription, insertIndex *int) error } type SubGroupRemover interface { RemoveSubGroups(ctx context.Context, groupID int, subGroupIDs []int) error } type SubGroupReorderer interface { ReorderSubGroups(ctx context.Context, groupID int, subGroupIDs []int, insertID int, insertAfter bool) error } type Service struct { Repository CreatorUpdater } ================================================ FILE: pkg/group/update.go ================================================ package group import ( "context" "fmt" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil" ) type SubGroupAlreadyInGroupError struct { GroupIDs []int } func (e *SubGroupAlreadyInGroupError) Error() string { return fmt.Sprintf("subgroups with IDs %v already in group", e.GroupIDs) } type ImageInput struct { Image []byte Set bool } func (s *Service) UpdatePartial(ctx context.Context, id int, updatedGroup models.GroupPartial, frontImage ImageInput, backImage ImageInput) (*models.Group, error) { if err := s.validateUpdate(ctx, id, updatedGroup); err != nil { return nil, err } r := s.Repository group, err := r.UpdatePartial(ctx, id, updatedGroup) if err != nil { return nil, err } // update image table if frontImage.Set { if err := r.UpdateFrontImage(ctx, id, frontImage.Image); err != nil { return nil, err } } if backImage.Set { if err := r.UpdateBackImage(ctx, id, backImage.Image); err != nil { return nil, err } } return group, nil } func (s *Service) AddSubGroups(ctx context.Context, groupID int, subGroups []models.GroupIDDescription, insertIndex *int) error { // get the group existing, err := s.Repository.Find(ctx, groupID) if err != nil { return err } // ensure it exists if existing == nil { return models.ErrNotFound } // ensure the subgroups aren't already sub-groups of the group subGroupIDs := sliceutil.Map(subGroups, func(sg models.GroupIDDescription) int { return sg.GroupID }) existingSubGroupIDs, err := s.Repository.FindSubGroupIDs(ctx, groupID, subGroupIDs) if err != nil { return err } if len(existingSubGroupIDs) > 0 { return &SubGroupAlreadyInGroupError{ GroupIDs: existingSubGroupIDs, } } // validate the hierarchy d := &models.UpdateGroupDescriptions{ Groups: subGroups, Mode: models.RelationshipUpdateModeAdd, } if err := s.validateUpdateGroupHierarchy(ctx, existing, nil, d); err != nil { return err } // validate insert index if insertIndex != nil && *insertIndex < 0 { return ErrInvalidInsertIndex } // add the subgroups return s.Repository.AddSubGroups(ctx, groupID, subGroups, insertIndex) } func (s *Service) RemoveSubGroups(ctx context.Context, groupID int, subGroupIDs []int) error { // get the group existing, err := s.Repository.Find(ctx, groupID) if err != nil { return err } // ensure it exists if existing == nil { return models.ErrNotFound } // add the subgroups return s.Repository.RemoveSubGroups(ctx, groupID, subGroupIDs) } ================================================ FILE: pkg/group/validate.go ================================================ package group import ( "context" "slices" "strings" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil" ) func (s *Service) validateCreate(ctx context.Context, group *models.Group) error { if err := validateName(group.Name); err != nil { return err } containingIDs := group.ContainingGroups.IDs() subIDs := group.SubGroups.IDs() if err := s.validateGroupHierarchy(ctx, containingIDs, subIDs); err != nil { return err } return nil } func (s *Service) validateUpdate(ctx context.Context, id int, partial models.GroupPartial) error { // get the existing group - ensure it exists existing, err := s.Repository.Find(ctx, id) if err != nil { return err } if existing == nil { return models.ErrNotFound } if partial.Name.Set { if err := validateName(partial.Name.Value); err != nil { return err } } if err := s.validateUpdateGroupHierarchy(ctx, existing, partial.ContainingGroups, partial.SubGroups); err != nil { return err } return nil } func validateName(n string) error { // ensure name is not empty if strings.TrimSpace(n) == "" { return ErrEmptyName } return nil } func (s *Service) validateGroupHierarchy(ctx context.Context, containingIDs []int, subIDs []int) error { // only need to validate if both are non-empty if len(containingIDs) == 0 || len(subIDs) == 0 { return nil } // ensure none of the containing groups are in the sub groups found, err := s.Repository.FindInAncestors(ctx, containingIDs, subIDs) if err != nil { return err } if len(found) > 0 { return ErrHierarchyLoop } return nil } func (s *Service) validateUpdateGroupHierarchy(ctx context.Context, existing *models.Group, containingGroups *models.UpdateGroupDescriptions, subGroups *models.UpdateGroupDescriptions) error { // no need to validate if there are no changes if containingGroups == nil && subGroups == nil { return nil } if err := existing.LoadContainingGroupIDs(ctx, s.Repository); err != nil { return err } existingContainingGroups := existing.ContainingGroups.List() if err := existing.LoadSubGroupIDs(ctx, s.Repository); err != nil { return err } existingSubGroups := existing.SubGroups.List() effectiveContainingGroups := existingContainingGroups if containingGroups != nil { effectiveContainingGroups = containingGroups.Apply(existingContainingGroups) } effectiveSubGroups := existingSubGroups if subGroups != nil { effectiveSubGroups = subGroups.Apply(existingSubGroups) } containingIDs := idsFromGroupDescriptions(effectiveContainingGroups) subIDs := idsFromGroupDescriptions(effectiveSubGroups) // ensure we haven't set the group as a subgroup of itself if slices.Contains(containingIDs, existing.ID) || slices.Contains(subIDs, existing.ID) { return ErrHierarchyLoop } return s.validateGroupHierarchy(ctx, containingIDs, subIDs) } func idsFromGroupDescriptions(v []models.GroupIDDescription) []int { return sliceutil.Map(v, func(g models.GroupIDDescription) int { return g.GroupID }) } ================================================ FILE: pkg/hash/imagephash/phash.go ================================================ package imagephash import ( "bytes" "context" "errors" "fmt" "image" "github.com/corona10/goimagehash" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/ffmpeg/transcoder" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/models" ) // Generate computes a perceptual hash for an image file. func Generate(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (*uint64, error) { img, err := loadImage(encoder, imageFile) if err != nil { return nil, fmt.Errorf("loading image: %w", err) } hash, err := goimagehash.PerceptionHash(img) if err != nil { return nil, fmt.Errorf("computing phash from image: %w", err) } hashValue := hash.GetHash() return &hashValue, nil } // loadImage loads an image from disk and decodes it. // Where Go has no built-in decoder for a specific format, ffmpeg is used to convert to BMP first. func loadImage(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (image.Image, error) { // try to load with Go's built-in decoders first for better performance reader, err := imageFile.Open(&file.OsFS{}) if err != nil { return nil, err } defer reader.Close() buf := new(bytes.Buffer) if _, err := buf.ReadFrom(reader); err != nil { return nil, err } img, _, err := image.Decode(buf) if errors.Is(err, image.ErrFormat) { // try ffmpeg as a fallback for unsupported formats // ffmpeg cannot read files inside zips if imageFile.Base().ZipFileID != nil { return nil, fmt.Errorf("ffmpeg fallback unsupported for images in zip files") } return loadImageFFmpeg(encoder, imageFile.Path) } if err != nil { return nil, fmt.Errorf("decoding image: %w", err) } return img, nil } // loadImageFFmpeg uses ffmpeg to convert an image to BMP and then decodes it. func loadImageFFmpeg(encoder *ffmpeg.FFMpeg, path string) (image.Image, error) { options := transcoder.ScreenshotOptions{ OutputPath: "-", OutputType: transcoder.ScreenshotOutputTypeBMP, } args := transcoder.ScreenshotTime(path, 0, options) data, err := encoder.GenerateOutput(context.Background(), args, nil) if err != nil { return nil, fmt.Errorf("converting image with ffmpeg: %w", err) } img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { return nil, fmt.Errorf("decoding ffmpeg output: %w", err) } return img, nil } ================================================ FILE: pkg/hash/key.go ================================================ // Package hash provides utility functions for generating hashes from strings and random keys. package hash import ( "crypto/rand" "fmt" "hash/fnv" ) // GenerateRandomKey generates a random string of length l. // It returns an empty string and an error if an error occurs while generating a random number. func GenerateRandomKey(l int) (string, error) { b := make([]byte, l) if _, err := rand.Read(b); err != nil { return "", err } return fmt.Sprintf("%x", b), nil } // IntFromString generates a uint64 from a string. // Values returned by this function are guaranteed to be the same for equal strings. // They are not guaranteed to be unique for different strings. func IntFromString(str string) uint64 { h := fnv.New64a() h.Write([]byte(str)) return h.Sum64() } ================================================ FILE: pkg/hash/md5/md5.go ================================================ // Package md5 provides utility functions for generating MD5 hashes. package md5 import ( "crypto/md5" "fmt" "io" "os" ) // FromBytes returns an MD5 checksum string from data. func FromBytes(data []byte) string { result := md5.Sum(data) return fmt.Sprintf("%x", result) } // FromString returns an MD5 checksum string from str. func FromString(str string) string { data := []byte(str) return FromBytes(data) } // FromFilePath returns an MD5 checksum string for the file at filePath. // It returns an empty string and an error if an error occurs opening the file. func FromFilePath(filePath string) (string, error) { f, err := os.Open(filePath) if err != nil { return "", err } defer f.Close() return FromReader(f) } // FromReader returns an MD5 checksum string from data read from src. // It returns an empty string and an error if an error occurs reading from src. func FromReader(src io.Reader) (string, error) { h := md5.New() if _, err := io.Copy(h, src); err != nil { return "", err } checksum := h.Sum(nil) return fmt.Sprintf("%x", checksum), nil } ================================================ FILE: pkg/hash/oshash/oshash.go ================================================ // Package oshash implements the algorithm that OpenSubtitles.org uses to generate unique hashes. // // Calculation is as follows: // size + 64 bit checksum of the first and last 64k bytes of the file. package oshash import ( "encoding/binary" "errors" "fmt" "io" "os" ) const chunkSize int64 = 64 * 1024 var ErrOsHashLen = errors.New("buffer is not a multiple of 8") func sumBytes(buf []byte) (uint64, error) { if len(buf)%8 != 0 { return 0, ErrOsHashLen } sz := len(buf) / 8 var sum uint64 for j := 0; j < sz; j++ { sum += binary.LittleEndian.Uint64(buf[8*j : 8*(j+1)]) } return sum, nil } func oshash(size int64, head []byte, tail []byte) (string, error) { headSum, err := sumBytes(head) if err != nil { return "", fmt.Errorf("oshash head: %w", err) } tailSum, err := sumBytes(tail) if err != nil { return "", fmt.Errorf("oshash tail: %w", err) } // Compute the sum of the head, tail and file size result := headSum + tailSum + uint64(size) // output as hex return fmt.Sprintf("%016x", result), nil } // FromReader calculates the hash reading from src. func FromReader(src io.ReadSeeker, fileSize int64) (string, error) { if fileSize <= 8 { return "", fmt.Errorf("cannot calculate oshash where size < 8 (%d)", fileSize) } fileChunkSize := chunkSize if fileSize < fileChunkSize { // Must be a multiple of 8. fileChunkSize = (fileSize / 8) * 8 } head := make([]byte, fileChunkSize) tail := make([]byte, fileChunkSize) // read the head of the file into the start of the buffer _, err := src.Read(head) if err != nil { return "", err } // seek to the end of the file - the chunk size _, err = src.Seek(-fileChunkSize, io.SeekEnd) if err != nil { return "", err } // read the tail of the file _, err = src.Read(tail) if err != nil { return "", err } return oshash(fileSize, head, tail) } // Is the equivalent of opening filePath, and calling FromReader with the data and file size. func FromFilePath(filePath string) (string, error) { f, err := os.Open(filePath) if err != nil { return "", err } defer f.Close() fi, err := f.Stat() if err != nil { return "", err } fileSize := fi.Size() return FromReader(f, fileSize) } ================================================ FILE: pkg/hash/oshash/oshash_test.go ================================================ package oshash import ( "bytes" "math/rand" "testing" ) func BenchmarkOsHash(b *testing.B) { src := rand.NewSource(9999) r := rand.New(src) size := int64(1234567890) head := make([]byte, 1024*64) _, err := r.Read(head) if err != nil { b.Errorf("unable to generate head array: %v", err) } tail := make([]byte, 1024*64) _, err = r.Read(tail) if err != nil { b.Errorf("unable to generate tail array: %v", err) } b.ResetTimer() for n := 0; n < b.N; n++ { _, err := oshash(size, head, tail) if err != nil { b.Errorf("unexpected error: %v", err) } } } func TestFromReader(t *testing.T) { makeByteArray := func(base []byte, mag int) []byte { ret := base for i := 0; i < mag; i++ { ret = append(ret, ret...) } return ret } makeTailArray := func(base []byte, tail []byte) []byte { ret := base t := make([]byte, chunkSize) copy(t[len(t)-len(tail):], tail) ret = append(ret, t...) return ret } tests := []struct { name string data []byte want string wantErr bool }{ { "empty", []byte{}, "", true, }, { "regular", makeByteArray([]byte("this is a test"), 15), "6a0eba04654d0b9b", false, }, { "< chunk size", []byte("hello world"), "d3e392dee38cd4df", false, }, { "< 8", []byte("hello"), "", true, }, { "identical #1", makeTailArray(make([]byte, chunkSize), []byte("this is dumb")), "d5d6ddd820756920", false, }, { "identical #2", makeTailArray(make([]byte, chunkSize), []byte("dumb is this")), "d5d6ddd820756920", false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := bytes.NewReader(tt.data) got, err := FromReader(r, int64(len(tt.data))) if (err != nil) != tt.wantErr { t.Errorf("FromReader() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { t.Errorf("FromReader() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: pkg/hash/videophash/phash.go ================================================ package videophash import ( "bytes" "context" "fmt" "image" "image/color" "math" "github.com/corona10/goimagehash" "github.com/disintegration/imaging" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/ffmpeg/transcoder" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) const ( screenshotSize = 160 columns = 5 rows = 5 ) func Generate(encoder *ffmpeg.FFMpeg, videoFile *models.VideoFile) (*uint64, error) { sprite, err := generateSprite(encoder, videoFile) if err != nil { return nil, err } hash, err := goimagehash.PerceptionHash(sprite) if err != nil { return nil, fmt.Errorf("computing phash from sprite: %w", err) } hashValue := hash.GetHash() return &hashValue, nil } func generateSpriteScreenshot(encoder *ffmpeg.FFMpeg, input string, t float64) (image.Image, error) { options := transcoder.ScreenshotOptions{ Width: screenshotSize, OutputPath: "-", OutputType: transcoder.ScreenshotOutputTypeBMP, } args := transcoder.ScreenshotTime(input, t, options) data, err := encoder.GenerateOutput(context.Background(), args, nil) if err != nil { return nil, err } reader := bytes.NewReader(data) img, _, err := image.Decode(reader) if err != nil { return nil, fmt.Errorf("decoding image: %w", err) } return img, nil } func combineImages(images []image.Image) image.Image { width := images[0].Bounds().Size().X height := images[0].Bounds().Size().Y canvasWidth := width * columns canvasHeight := height * rows montage := imaging.New(canvasWidth, canvasHeight, color.NRGBA{}) for index := 0; index < len(images); index++ { x := width * (index % columns) y := height * int(math.Floor(float64(index)/float64(rows))) img := images[index] montage = imaging.Paste(montage, img, image.Pt(x, y)) } return montage } func generateSprite(encoder *ffmpeg.FFMpeg, videoFile *models.VideoFile) (image.Image, error) { logger.Infof("[generator] generating phash sprite for %s", videoFile.Path) // Generate sprite image offset by 5% on each end to avoid intro/outros chunkCount := columns * rows offset := 0.05 * videoFile.Duration stepSize := (0.9 * videoFile.Duration) / float64(chunkCount) var images []image.Image for i := 0; i < chunkCount; i++ { time := offset + (float64(i) * stepSize) img, err := generateSpriteScreenshot(encoder, videoFile.Path, time) if err != nil { return nil, fmt.Errorf("generating sprite screenshot: %w", err) } images = append(images, img) } // Combine all of the thumbnails into a sprite image if len(images) == 0 { return nil, fmt.Errorf("images slice is empty, failed to generate phash sprite for %s", videoFile.Path) } return combineImages(images), nil } ================================================ FILE: pkg/image/delete.go ================================================ package image import ( "context" "fmt" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/paths" ) // FileDeleter is an extension of file.Deleter that handles deletion of image files. type FileDeleter struct { *file.Deleter Paths *paths.Paths } // MarkGeneratedFiles marks for deletion the generated files for the provided image. // Generated files bypass trash and are permanently deleted since they can be regenerated. func (d *FileDeleter) MarkGeneratedFiles(image *models.Image) error { var files []string thumbPath := d.Paths.Generated.GetThumbnailPath(image.Checksum, models.DefaultGthumbWidth) exists, _ := fsutil.FileExists(thumbPath) if exists { files = append(files, thumbPath) } prevPath := d.Paths.Generated.GetClipPreviewPath(image.Checksum, models.DefaultGthumbWidth) exists, _ = fsutil.FileExists(prevPath) if exists { files = append(files, prevPath) } return d.FilesWithoutTrash(files) } // Destroy destroys an image, optionally marking the file and generated files for deletion. func (s *Service) Destroy(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error { return s.destroyImage(ctx, i, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry) } // DestroyZipImages destroys all images in zip, optionally marking the files and generated files for deletion. // Returns a slice of images that were destroyed. func (s *Service) DestroyZipImages(ctx context.Context, zipFile models.File, fileDeleter *FileDeleter, deleteGenerated bool) ([]*models.Image, error) { var imgsDestroyed []*models.Image zipFileID := zipFile.Base().ID imgs, err := s.Repository.FindByZipFileID(ctx, zipFileID) if err != nil { return nil, err } for _, img := range imgs { if err := img.LoadFiles(ctx, s.Repository); err != nil { return nil, err } // #5048 - if the image has multiple files, we just want to remove the file in the zip file, // not delete the image entirely if len(img.Files.List()) > 1 { for _, f := range img.Files.List() { if f.Base().ZipFileID == nil || *f.Base().ZipFileID != zipFileID { continue } if err := s.Repository.RemoveFileID(ctx, img.ID, f.Base().ID); err != nil { return nil, fmt.Errorf("failed to remove file from image: %w", err) } } // don't delete the image continue } const deleteFileInZip = false const destroyFileEntry = false if err := s.destroyImage(ctx, img, fileDeleter, deleteGenerated, deleteFileInZip, destroyFileEntry); err != nil { return nil, err } imgsDestroyed = append(imgsDestroyed, img) } return imgsDestroyed, nil } // DestroyFolderImages destroys all images in a folder, optionally marking the files and generated files for deletion. // It will not delete images that are attached to more than one gallery. // Returns a slice of images that were destroyed. func (s *Service) DestroyFolderImages(ctx context.Context, folderID models.FolderID, fileDeleter *FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) { var imgsDestroyed []*models.Image // find images in this folder imgs, err := s.Repository.FindByFolderID(ctx, folderID) if err != nil { return nil, err } for _, img := range imgs { if err := img.LoadFiles(ctx, s.Repository); err != nil { return nil, err } // #5048 - if the image has multiple files, we just want to remove the file // in the folder if len(img.Files.List()) > 1 { for _, f := range img.Files.List() { if f.Base().ParentFolderID != folderID { continue } if err := s.Repository.RemoveFileID(ctx, img.ID, f.Base().ID); err != nil { return nil, fmt.Errorf("failed to remove file from image: %w", err) } // we still want to delete the file from the folder, if applicable if deleteFile { if err := file.Destroy(ctx, s.File, f, fileDeleter.Deleter, deleteFile); err != nil { return nil, fmt.Errorf("failed to delete image file: %w", err) } } } // don't delete the image continue } if err := img.LoadGalleryIDs(ctx, s.Repository); err != nil { return nil, err } // only destroy images that are not attached to other galleries if len(img.GalleryIDs.List()) > 1 { continue } const destroyFileEntry = false if err := s.Destroy(ctx, img, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil { return nil, err } imgsDestroyed = append(imgsDestroyed, img) } return imgsDestroyed, nil } // Destroy destroys an image, optionally marking the file and generated files for deletion. func (s *Service) destroyImage(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error { if deleteFile { if err := s.deleteFiles(ctx, i, fileDeleter); err != nil { return err } } else if destroyFileEntry { if err := s.destroyFileEntries(ctx, i); err != nil { return err } } if deleteGenerated { if err := fileDeleter.MarkGeneratedFiles(i); err != nil { return err } } return s.Repository.Destroy(ctx, i.ID) } // deleteFiles deletes files for the image from the database and file system, if they are not in use by other images func (s *Service) deleteFiles(ctx context.Context, i *models.Image, fileDeleter *FileDeleter) error { if err := i.LoadFiles(ctx, s.Repository); err != nil { return err } for _, f := range i.Files.List() { // only delete files where there is no other associated image otherImages, err := s.Repository.FindByFileID(ctx, f.Base().ID) if err != nil { return err } if len(otherImages) > 1 { // other image associated, don't remove continue } // don't delete files in zip archives const deleteFile = true if f.Base().ZipFileID == nil { logger.Info("Deleting image file: ", f.Base().Path) if err := file.Destroy(ctx, s.File, f, fileDeleter.Deleter, deleteFile); err != nil { return err } } } return nil } // destroyFileEntries destroys file entries from the database without deleting // the files from the filesystem func (s *Service) destroyFileEntries(ctx context.Context, i *models.Image) error { if err := i.LoadFiles(ctx, s.Repository); err != nil { return err } for _, f := range i.Files.List() { // only destroy file entries where there is no other associated image otherImages, err := s.Repository.FindByFileID(ctx, f.Base().ID) if err != nil { return err } if len(otherImages) > 1 { // other image associated, don't remove continue } // don't destroy files in zip archives if f.Base().ZipFileID == nil { const deleteFile = false logger.Info("Destroying image file entry: ", f.Base().Path) if err := file.Destroy(ctx, s.File, f, nil, deleteFile); err != nil { return err } } } return nil } ================================================ FILE: pkg/image/export.go ================================================ package image import ( "context" "fmt" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" ) type ExportReader interface { models.CustomFieldsReader } // ToBasicJSON converts a image object into its JSON object equivalent. It // does not convert the relationships to other objects, with the exception // of cover image. func ToBasicJSON(ctx context.Context, reader ExportReader, image *models.Image) (*jsonschema.Image, error) { newImageJSON := jsonschema.Image{ Title: image.Title, Code: image.Code, URLs: image.URLs.List(), Details: image.Details, Photographer: image.Photographer, CreatedAt: json.JSONTime{Time: image.CreatedAt}, UpdatedAt: json.JSONTime{Time: image.UpdatedAt}, } if image.Rating != nil { newImageJSON.Rating = *image.Rating } if image.Date != nil { newImageJSON.Date = image.Date.String() } newImageJSON.Organized = image.Organized newImageJSON.OCounter = image.OCounter var err error newImageJSON.CustomFields, err = reader.GetCustomFields(ctx, image.ID) if err != nil { return nil, fmt.Errorf("getting image custom fields: %v", err) } for _, f := range image.Files.List() { newImageJSON.Files = append(newImageJSON.Files, f.Base().Path) } return &newImageJSON, nil } // GetStudioName returns the name of the provided image's studio. It returns an // empty string if there is no studio assigned to the image. func GetStudioName(ctx context.Context, reader models.StudioGetter, image *models.Image) (string, error) { if image.StudioID != nil { studio, err := reader.Find(ctx, *image.StudioID) if err != nil { return "", err } if studio != nil { return studio.Name, nil } } return "", nil } // GetGalleryChecksum returns the checksum of the provided image. It returns an // empty string if there is no gallery assigned to the image. // func GetGalleryChecksum(reader models.GalleryReader, image *models.Image) (string, error) { // gallery, err := reader.FindByImageID(image.ID) // if err != nil { // return "", fmt.Errorf("error getting image gallery: %v", err) // } // if gallery != nil { // return gallery.Checksum, nil // } // return "", nil // } ================================================ FILE: pkg/image/export_test.go ================================================ package image import ( "errors" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "testing" "time" ) const ( imageID = 1 studioID = 4 missingStudioID = 5 errStudioID = 6 ) var ( title = "title" rating = 5 url = "http://a.com" date = "2001-01-01" dateObj, _ = models.ParseDate(date) organized = true ocounter = 2 customFields = map[string]interface{}{ "customField1": "customValue1", } ) const ( studioName = "studioName" path = "path" ) var ( createTime = time.Date(2001, 01, 01, 0, 0, 0, 0, time.UTC) updateTime = time.Date(2002, 01, 01, 0, 0, 0, 0, time.UTC) ) func createFullImage(id int) models.Image { return models.Image{ ID: id, Files: models.NewRelatedFiles([]models.File{ &models.BaseFile{ Path: path, }, }), Title: title, OCounter: ocounter, Rating: &rating, Date: &dateObj, URLs: models.NewRelatedStrings([]string{url}), Organized: organized, CreatedAt: createTime, UpdatedAt: updateTime, } } func createFullJSONImage(customFields map[string]interface{}) *jsonschema.Image { return &jsonschema.Image{ Title: title, OCounter: ocounter, Rating: rating, Date: date, URLs: []string{url}, Organized: organized, Files: []string{path}, CreatedAt: json.JSONTime{ Time: createTime, }, UpdatedAt: json.JSONTime{ Time: updateTime, }, CustomFields: customFields, } } type basicTestScenario struct { input models.Image customFields map[string]interface{} expected *jsonschema.Image } var scenarios = []basicTestScenario{ { createFullImage(imageID), customFields, createFullJSONImage(customFields), }, } func TestToJSON(t *testing.T) { db := mocks.NewDatabase() db.Image.On("GetCustomFields", testCtx, imageID).Return(customFields, nil).Once() for i, s := range scenarios { image := s.input json, err := ToBasicJSON(testCtx, db.Image, &image) if err != nil { t.Errorf("[%d] unexpected error: %s", i, err.Error()) continue } assert.Equal(t, s.expected, json, "[%d]", i) } db.AssertExpectations(t) } func createStudioImage(studioID int) models.Image { return models.Image{ StudioID: &studioID, } } type stringTestScenario struct { input models.Image expected string err bool } var getStudioScenarios = []stringTestScenario{ { createStudioImage(studioID), studioName, false, }, { createStudioImage(missingStudioID), "", false, }, { createStudioImage(errStudioID), "", true, }, } func TestGetStudioName(t *testing.T) { db := mocks.NewDatabase() studioErr := errors.New("error getting image") db.Studio.On("Find", testCtx, studioID).Return(&models.Studio{ Name: studioName, }, nil).Once() db.Studio.On("Find", testCtx, missingStudioID).Return(nil, nil).Once() db.Studio.On("Find", testCtx, errStudioID).Return(nil, studioErr).Once() for i, s := range getStudioScenarios { image := s.input json, err := GetStudioName(testCtx, db.Studio, &image) switch { case !s.err && err != nil: t.Errorf("[%d] unexpected error: %s", i, err.Error()) case s.err && err == nil: t.Errorf("[%d] expected error not returned", i) default: assert.Equal(t, s.expected, json, "[%d]", i) } } db.AssertExpectations(t) } ================================================ FILE: pkg/image/filter.go ================================================ package image import ( "path/filepath" "strings" "github.com/stashapp/stash/pkg/models" ) func PathsFilter(paths []string) *models.ImageFilterType { if paths == nil { return nil } sep := string(filepath.Separator) var ret *models.ImageFilterType var or *models.ImageFilterType for _, p := range paths { newOr := &models.ImageFilterType{} if or != nil { or.Or = newOr } else { ret = newOr } or = newOr if !strings.HasSuffix(p, sep) { p += sep } or.Path = &models.StringCriterionInput{ Modifier: models.CriterionModifierEquals, Value: p + "%", } } return ret } ================================================ FILE: pkg/image/import.go ================================================ package image import ( "context" "fmt" "slices" "strings" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/sliceutil" ) type GalleryFinder interface { FindByPath(ctx context.Context, p string) ([]*models.Gallery, error) FindUserGalleryByTitle(ctx context.Context, title string) ([]*models.Gallery, error) } type ImporterReaderWriter interface { models.ImageCreatorUpdater FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Image, error) } type Importer struct { ReaderWriter ImporterReaderWriter FileFinder models.FileFinder StudioWriter models.StudioFinderCreator GalleryFinder GalleryFinder PerformerWriter models.PerformerFinderCreator TagWriter models.TagFinderCreator Input jsonschema.Image MissingRefBehaviour models.ImportMissingRefEnum ID int image models.Image customFields map[string]interface{} } func (i *Importer) PreImport(ctx context.Context) error { i.image = i.imageJSONToImage(i.Input) if err := i.populateFiles(ctx); err != nil { return err } if err := i.populateStudio(ctx); err != nil { return err } if err := i.populateGalleries(ctx); err != nil { return err } if err := i.populatePerformers(ctx); err != nil { return err } if err := i.populateTags(ctx); err != nil { return err } i.customFields = i.Input.CustomFields return nil } func (i *Importer) imageJSONToImage(imageJSON jsonschema.Image) models.Image { newImage := models.Image{ PerformerIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), GalleryIDs: models.NewRelatedIDs([]int{}), Title: imageJSON.Title, Organized: imageJSON.Organized, OCounter: imageJSON.OCounter, CreatedAt: imageJSON.CreatedAt.GetTime(), UpdatedAt: imageJSON.UpdatedAt.GetTime(), } if imageJSON.Title != "" { newImage.Title = imageJSON.Title } if imageJSON.Code != "" { newImage.Code = imageJSON.Code } if imageJSON.Details != "" { newImage.Details = imageJSON.Details } if imageJSON.Photographer != "" { newImage.Photographer = imageJSON.Photographer } if imageJSON.Rating != 0 { newImage.Rating = &imageJSON.Rating } if len(imageJSON.URLs) > 0 { newImage.URLs = models.NewRelatedStrings(imageJSON.URLs) } else if imageJSON.URL != "" { newImage.URLs = models.NewRelatedStrings([]string{imageJSON.URL}) } if imageJSON.Date != "" { d, err := models.ParseDate(imageJSON.Date) if err == nil { newImage.Date = &d } } return newImage } func (i *Importer) populateFiles(ctx context.Context) error { files := make([]models.File, 0) for _, ref := range i.Input.Files { path := ref f, err := i.FileFinder.FindByPath(ctx, path, true) if err != nil { return fmt.Errorf("error finding file: %w", err) } if f == nil { return fmt.Errorf("image file '%s' not found", path) } else { files = append(files, f) } } i.image.Files = models.NewRelatedFiles(files) return nil } func (i *Importer) populateStudio(ctx context.Context) error { if i.Input.Studio != "" { studio, err := i.StudioWriter.FindByName(ctx, i.Input.Studio, false) if err != nil { return fmt.Errorf("error finding studio by name: %v", err) } if studio == nil { if i.MissingRefBehaviour == models.ImportMissingRefEnumFail { return fmt.Errorf("image studio '%s' not found", i.Input.Studio) } if i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore { return nil } if i.MissingRefBehaviour == models.ImportMissingRefEnumCreate { studioID, err := i.createStudio(ctx, i.Input.Studio) if err != nil { return err } i.image.StudioID = &studioID } } else { i.image.StudioID = &studio.ID } } return nil } func (i *Importer) createStudio(ctx context.Context, name string) (int, error) { newStudio := models.NewCreateStudioInput() newStudio.Name = name err := i.StudioWriter.Create(ctx, &newStudio) if err != nil { return 0, err } return newStudio.ID, nil } func (i *Importer) locateGallery(ctx context.Context, ref jsonschema.GalleryRef) (*models.Gallery, error) { var galleries []*models.Gallery var err error switch { case ref.FolderPath != "": galleries, err = i.GalleryFinder.FindByPath(ctx, ref.FolderPath) case len(ref.ZipFiles) > 0: for _, p := range ref.ZipFiles { galleries, err = i.GalleryFinder.FindByPath(ctx, p) if err != nil { break } if len(galleries) > 0 { break } } case ref.Title != "": galleries, err = i.GalleryFinder.FindUserGalleryByTitle(ctx, ref.Title) } var ret *models.Gallery if len(galleries) > 0 { ret = galleries[0] } return ret, err } func (i *Importer) populateGalleries(ctx context.Context) error { for _, ref := range i.Input.Galleries { gallery, err := i.locateGallery(ctx, ref) if err != nil { return fmt.Errorf("error finding gallery: %v", err) } if gallery == nil { if i.MissingRefBehaviour == models.ImportMissingRefEnumFail { return fmt.Errorf("image gallery '%s' not found", ref.String()) } // we don't create galleries - just ignore if i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore || i.MissingRefBehaviour == models.ImportMissingRefEnumCreate { continue } } else { i.image.GalleryIDs.Add(gallery.ID) } } return nil } func (i *Importer) populatePerformers(ctx context.Context) error { if len(i.Input.Performers) > 0 { names := i.Input.Performers performers, err := i.PerformerWriter.FindByNames(ctx, names, false) if err != nil { return err } var pluckedNames []string for _, performer := range performers { if performer.Name == "" { continue } pluckedNames = append(pluckedNames, performer.Name) } missingPerformers := sliceutil.Filter(names, func(name string) bool { return !slices.Contains(pluckedNames, name) }) if len(missingPerformers) > 0 { if i.MissingRefBehaviour == models.ImportMissingRefEnumFail { return fmt.Errorf("image performers [%s] not found", strings.Join(missingPerformers, ", ")) } if i.MissingRefBehaviour == models.ImportMissingRefEnumCreate { createdPerformers, err := i.createPerformers(ctx, missingPerformers) if err != nil { return fmt.Errorf("error creating image performers: %v", err) } performers = append(performers, createdPerformers...) } // ignore if MissingRefBehaviour set to Ignore } for _, p := range performers { i.image.PerformerIDs.Add(p.ID) } } return nil } func (i *Importer) createPerformers(ctx context.Context, names []string) ([]*models.Performer, error) { var ret []*models.Performer for _, name := range names { newPerformer := models.NewPerformer() newPerformer.Name = name err := i.PerformerWriter.Create(ctx, &models.CreatePerformerInput{ Performer: &newPerformer, }) if err != nil { return nil, err } ret = append(ret, &newPerformer) } return ret, nil } func (i *Importer) populateTags(ctx context.Context) error { if len(i.Input.Tags) > 0 { tags, err := importTags(ctx, i.TagWriter, i.Input.Tags, i.MissingRefBehaviour) if err != nil { return err } for _, t := range tags { i.image.TagIDs.Add(t.ID) } } return nil } func (i *Importer) PostImport(ctx context.Context, id int) error { return nil } func (i *Importer) Name() string { if i.Input.Title != "" { return i.Input.Title } if len(i.Input.Files) > 0 { return i.Input.Files[0] } return "" } func (i *Importer) FindExistingID(ctx context.Context) (*int, error) { var existing []*models.Image var err error for _, f := range i.image.Files.List() { existing, err = i.ReaderWriter.FindByFileID(ctx, f.Base().ID) if err != nil { return nil, err } if len(existing) > 0 { id := existing[0].ID return &id, nil } } return nil, nil } func (i *Importer) Create(ctx context.Context) (*int, error) { var fileIDs []models.FileID for _, f := range i.image.Files.List() { fileIDs = append(fileIDs, f.Base().ID) } err := i.ReaderWriter.Create(ctx, &models.CreateImageInput{ Image: &i.image, FileIDs: fileIDs, CustomFields: i.customFields, }) if err != nil { return nil, fmt.Errorf("error creating image: %v", err) } id := i.image.ID i.ID = id return &id, nil } func (i *Importer) Update(ctx context.Context, id int) error { image := i.image image.ID = id i.ID = id err := i.ReaderWriter.Update(ctx, &image) if err != nil { return fmt.Errorf("error updating existing image: %v", err) } return nil } func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string, missingRefBehaviour models.ImportMissingRefEnum) ([]*models.Tag, error) { tags, err := tagWriter.FindByNames(ctx, names, false) if err != nil { return nil, err } var pluckedNames []string for _, tag := range tags { pluckedNames = append(pluckedNames, tag.Name) } missingTags := sliceutil.Filter(names, func(name string) bool { return !slices.Contains(pluckedNames, name) }) if len(missingTags) > 0 { if missingRefBehaviour == models.ImportMissingRefEnumFail { return nil, fmt.Errorf("tags [%s] not found", strings.Join(missingTags, ", ")) } if missingRefBehaviour == models.ImportMissingRefEnumCreate { createdTags, err := createTags(ctx, tagWriter, missingTags) if err != nil { return nil, fmt.Errorf("error creating tags: %v", err) } tags = append(tags, createdTags...) } // ignore if MissingRefBehaviour set to Ignore } return tags, nil } func createTags(ctx context.Context, tagWriter models.TagCreator, names []string) ([]*models.Tag, error) { var ret []*models.Tag for _, name := range names { newTag := models.NewTag() newTag.Name = name err := tagWriter.Create(ctx, &models.CreateTagInput{ Tag: &newTag, }) if err != nil { return nil, err } ret = append(ret, &newTag) } return ret, nil } ================================================ FILE: pkg/image/import_test.go ================================================ package image import ( "context" "errors" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) var ( existingStudioID = 101 existingPerformerID = 103 existingTagID = 105 existingStudioName = "existingStudioName" existingStudioErr = "existingStudioErr" missingStudioName = "missingStudioName" existingPerformerName = "existingPerformerName" existingPerformerErr = "existingPerformerErr" missingPerformerName = "missingPerformerName" existingTagName = "existingTagName" existingTagErr = "existingTagErr" missingTagName = "missingTagName" ) var testCtx = context.Background() func TestImporterPreImport(t *testing.T) { i := Importer{} err := i.PreImport(testCtx) assert.Nil(t, err) } func TestImporterPreImportWithStudio(t *testing.T) { db := mocks.NewDatabase() i := Importer{ StudioWriter: db.Studio, Input: jsonschema.Image{ Studio: existingStudioName, CustomFields: customFields, }, } db.Studio.On("FindByName", testCtx, existingStudioName, false).Return(&models.Studio{ ID: existingStudioID, }, nil).Once() db.Studio.On("FindByName", testCtx, existingStudioErr, false).Return(nil, errors.New("FindByName error")).Once() err := i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, existingStudioID, *i.image.StudioID) assert.Equal(t, customFields, i.customFields) i.Input.Studio = existingStudioErr err = i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithMissingStudio(t *testing.T) { db := mocks.NewDatabase() i := Importer{ StudioWriter: db.Studio, Input: jsonschema.Image{ Studio: missingStudioName, }, MissingRefBehaviour: models.ImportMissingRefEnumFail, } db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Times(3) db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Run(func(args mock.Arguments) { s := args.Get(1).(*models.CreateStudioInput) s.Studio.ID = existingStudioID }).Return(nil) err := i.PreImport(testCtx) assert.NotNil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore err = i.PreImport(testCtx) assert.Nil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumCreate err = i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, existingStudioID, *i.image.StudioID) db.AssertExpectations(t) } func TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) { db := mocks.NewDatabase() i := Importer{ StudioWriter: db.Studio, Input: jsonschema.Image{ Studio: missingStudioName, }, MissingRefBehaviour: models.ImportMissingRefEnumCreate, } db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Once() db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithPerformer(t *testing.T) { db := mocks.NewDatabase() i := Importer{ PerformerWriter: db.Performer, MissingRefBehaviour: models.ImportMissingRefEnumFail, Input: jsonschema.Image{ Performers: []string{ existingPerformerName, }, }, } db.Performer.On("FindByNames", testCtx, []string{existingPerformerName}, false).Return([]*models.Performer{ { ID: existingPerformerID, Name: existingPerformerName, }, }, nil).Once() db.Performer.On("FindByNames", testCtx, []string{existingPerformerErr}, false).Return(nil, errors.New("FindByNames error")).Once() err := i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, []int{existingPerformerID}, i.image.PerformerIDs.List()) i.Input.Performers = []string{existingPerformerErr} err = i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithMissingPerformer(t *testing.T) { db := mocks.NewDatabase() i := Importer{ PerformerWriter: db.Performer, Input: jsonschema.Image{ Performers: []string{ missingPerformerName, }, }, MissingRefBehaviour: models.ImportMissingRefEnumFail, } db.Performer.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Times(3) db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.CreatePerformerInput")).Run(func(args mock.Arguments) { performer := args.Get(1).(*models.CreatePerformerInput) performer.ID = existingPerformerID }).Return(nil) err := i.PreImport(testCtx) assert.NotNil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore err = i.PreImport(testCtx) assert.Nil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumCreate err = i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, []int{existingPerformerID}, i.image.PerformerIDs.List()) db.AssertExpectations(t) } func TestImporterPreImportWithMissingPerformerCreateErr(t *testing.T) { db := mocks.NewDatabase() i := Importer{ PerformerWriter: db.Performer, Input: jsonschema.Image{ Performers: []string{ missingPerformerName, }, }, MissingRefBehaviour: models.ImportMissingRefEnumCreate, } db.Performer.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Once() db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.CreatePerformerInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithTag(t *testing.T) { db := mocks.NewDatabase() i := Importer{ TagWriter: db.Tag, MissingRefBehaviour: models.ImportMissingRefEnumFail, Input: jsonschema.Image{ Tags: []string{ existingTagName, }, }, } db.Tag.On("FindByNames", testCtx, []string{existingTagName}, false).Return([]*models.Tag{ { ID: existingTagID, Name: existingTagName, }, }, nil).Once() db.Tag.On("FindByNames", testCtx, []string{existingTagErr}, false).Return(nil, errors.New("FindByNames error")).Once() err := i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, []int{existingTagID}, i.image.TagIDs.List()) i.Input.Tags = []string{existingTagErr} err = i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithMissingTag(t *testing.T) { db := mocks.NewDatabase() i := Importer{ TagWriter: db.Tag, Input: jsonschema.Image{ Tags: []string{ missingTagName, }, }, MissingRefBehaviour: models.ImportMissingRefEnumFail, } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3) db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Run(func(args mock.Arguments) { t := args.Get(1).(*models.CreateTagInput) t.Tag.ID = existingTagID }).Return(nil) err := i.PreImport(testCtx) assert.NotNil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore err = i.PreImport(testCtx) assert.Nil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumCreate err = i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, []int{existingTagID}, i.image.TagIDs.List()) db.AssertExpectations(t) } func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) { db := mocks.NewDatabase() i := Importer{ TagWriter: db.Tag, Input: jsonschema.Image{ Tags: []string{ missingTagName, }, }, MissingRefBehaviour: models.ImportMissingRefEnumCreate, } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once() db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } ================================================ FILE: pkg/image/query.go ================================================ package image import ( "context" "path/filepath" "strconv" "strings" "github.com/stashapp/stash/pkg/models" ) type Queryer interface { Query(ctx context.Context, options models.ImageQueryOptions) (*models.ImageQueryResult, error) } type CoverQueryer interface { Queryer CoverByGalleryID(ctx context.Context, galleryId int) (*models.Image, error) } type QueryCounter interface { QueryCount(ctx context.Context, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (int, error) } // QueryOptions returns a ImageQueryResult populated with the provided filters. func QueryOptions(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType, count bool) models.ImageQueryOptions { return models.ImageQueryOptions{ QueryOptions: models.QueryOptions{ FindFilter: findFilter, Count: count, }, ImageFilter: imageFilter, } } // Query queries for images using the provided filters. func Query(ctx context.Context, qb Queryer, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) ([]*models.Image, error) { result, err := qb.Query(ctx, QueryOptions(imageFilter, findFilter, false)) if err != nil { return nil, err } images, err := result.Resolve(ctx) if err != nil { return nil, err } return images, nil } // FilterFromPaths creates a ImageFilterType that filters using the provided // paths. func FilterFromPaths(paths []string) *models.ImageFilterType { ret := &models.ImageFilterType{} or := ret sep := string(filepath.Separator) for _, p := range paths { if !strings.HasSuffix(p, sep) { p += sep } if ret.Path == nil { or = ret } else { newOr := &models.ImageFilterType{} or.Or = newOr or = newOr } or.Path = &models.StringCriterionInput{ Modifier: models.CriterionModifierEquals, Value: p + "%", } } return ret } func CountByPerformerID(ctx context.Context, r QueryCounter, id int) (int, error) { filter := &models.ImageFilterType{ Performers: &models.MultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, }, } return r.QueryCount(ctx, filter, nil) } func CountByStudioID(ctx context.Context, r QueryCounter, id int, depth *int) (int, error) { filter := &models.ImageFilterType{ Studios: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, Depth: depth, }, } return r.QueryCount(ctx, filter, nil) } func CountByTagID(ctx context.Context, r QueryCounter, id int, depth *int) (int, error) { filter := &models.ImageFilterType{ Tags: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, Depth: depth, }, } return r.QueryCount(ctx, filter, nil) } func FindByGalleryID(ctx context.Context, r Queryer, galleryID int, sortBy string, sortDir models.SortDirectionEnum) ([]*models.Image, error) { perPage := -1 findFilter := models.FindFilterType{ PerPage: &perPage, } if sortBy != "" { findFilter.Sort = &sortBy } if sortDir.IsValid() { findFilter.Direction = &sortDir } return Query(ctx, r, &models.ImageFilterType{ Galleries: &models.MultiCriterionInput{ Value: []string{strconv.Itoa(galleryID)}, Modifier: models.CriterionModifierIncludes, }, }, &findFilter) } func FindGalleryCover(ctx context.Context, r CoverQueryer, galleryID int, galleryCoverRegex string) (*models.Image, error) { const useCoverJpg = true img, err := findGalleryCover(ctx, r, galleryID, useCoverJpg, galleryCoverRegex) if err != nil { return nil, err } if img != nil { return img, nil } // return the first image in the gallery return findGalleryCover(ctx, r, galleryID, !useCoverJpg, galleryCoverRegex) } func findGalleryCover(ctx context.Context, r CoverQueryer, galleryID int, useCoverJpg bool, galleryCoverRegex string) (*models.Image, error) { img, err := r.CoverByGalleryID(ctx, galleryID) if err != nil { return nil, err } else if img != nil { return img, nil } // try to find cover.jpg in the gallery perPage := 1 sortBy := "path" sortDir := models.SortDirectionEnumAsc findFilter := models.FindFilterType{ PerPage: &perPage, Sort: &sortBy, Direction: &sortDir, } imageFilter := &models.ImageFilterType{ Galleries: &models.MultiCriterionInput{ Value: []string{strconv.Itoa(galleryID)}, Modifier: models.CriterionModifierIncludes, }, } if useCoverJpg { imageFilter.Path = &models.StringCriterionInput{ Value: "(?i)" + galleryCoverRegex, Modifier: models.CriterionModifierMatchesRegex, } } imgs, err := Query(ctx, r, imageFilter, &findFilter) if err != nil { return nil, err } if len(imgs) > 0 { return imgs[0], nil } return nil, nil } ================================================ FILE: pkg/image/scan.go ================================================ package image import ( "context" "errors" "fmt" "os" "path/filepath" "slices" "strings" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/paths" "github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/txn" ) var ( ErrNotImageFile = errors.New("not an image file") ) type ScanCreatorUpdater interface { FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Image, error) FindByFolderID(ctx context.Context, folderID models.FolderID) ([]*models.Image, error) FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Image, error) GetFiles(ctx context.Context, relatedID int) ([]models.File, error) GetGalleryIDs(ctx context.Context, relatedID int) ([]int, error) Create(ctx context.Context, newImage *models.CreateImageInput) error UpdatePartial(ctx context.Context, id int, updatedImage models.ImagePartial) (*models.Image, error) AddFileID(ctx context.Context, id int, fileID models.FileID) error } type GalleryFinderCreator interface { FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Gallery, error) FindByFolderID(ctx context.Context, folderID models.FolderID) ([]*models.Gallery, error) models.GalleryCreator UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error) } type ScanSceneFinderUpdater interface { FindByPath(ctx context.Context, p string) ([]*models.Scene, error) AddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error } type ScanConfig interface { GetCreateGalleriesFromFolders() bool } type ScanGenerator interface { Generate(ctx context.Context, i *models.Image, f models.File) error } type ScanHandler struct { CreatorUpdater ScanCreatorUpdater GalleryFinder GalleryFinderCreator SceneFinderUpdater ScanSceneFinderUpdater ScanGenerator ScanGenerator ScanConfig ScanConfig PluginCache *plugin.Cache Paths *paths.Paths } func (h *ScanHandler) validate() error { if h.CreatorUpdater == nil { return errors.New("CreatorUpdater is required") } if h.ScanGenerator == nil { return errors.New("ScanGenerator is required") } if h.GalleryFinder == nil { return errors.New("GalleryFinder is required") } if h.ScanConfig == nil { return errors.New("ScanConfig is required") } if h.Paths == nil { return errors.New("Paths is required") } return nil } func (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models.File) error { if err := h.validate(); err != nil { return err } imageFile := f.Base() // try to match the file to an image existing, err := h.CreatorUpdater.FindByFileID(ctx, imageFile.ID) if err != nil { return fmt.Errorf("finding existing image: %w", err) } if len(existing) == 0 { // try also to match file by fingerprints existing, err = h.CreatorUpdater.FindByFingerprints(ctx, imageFile.Fingerprints) if err != nil { return fmt.Errorf("finding existing image by fingerprints: %w", err) } } if len(existing) > 0 { updateExisting := oldFile != nil if err := h.associateExisting(ctx, existing, imageFile, updateExisting); err != nil { return err } } else { // create a new image newImage := models.NewImage() newImage.GalleryIDs = models.NewRelatedIDs([]int{}) logger.Infof("%s doesn't exist. Creating new image...", f.Base().Path) g, err := h.getGalleryToAssociate(ctx, &newImage, f) if err != nil { return err } if g != nil { newImage.GalleryIDs.Add(g.ID) logger.Infof("Adding %s to gallery %s", f.Base().Path, g.Path) } if err := h.CreatorUpdater.Create(ctx, &models.CreateImageInput{ Image: &newImage, FileIDs: []models.FileID{imageFile.ID}, }); err != nil { return fmt.Errorf("creating new image: %w", err) } // update the gallery updated at timestamp if applicable if g != nil { galleryPartial := models.GalleryPartial{ UpdatedAt: models.NewOptionalTime(newImage.UpdatedAt), } if _, err := h.GalleryFinder.UpdatePartial(ctx, g.ID, galleryPartial); err != nil { return fmt.Errorf("updating gallery updated at timestamp: %w", err) } } h.PluginCache.RegisterPostHooks(ctx, newImage.ID, hook.ImageCreatePost, nil, nil) existing = []*models.Image{&newImage} } // remove the old thumbnail if the checksum changed - we'll regenerate it if oldFile != nil { oldHash := oldFile.Base().Fingerprints.GetString(models.FingerprintTypeMD5) newHash := f.Base().Fingerprints.GetString(models.FingerprintTypeMD5) if oldHash != "" && newHash != "" && oldHash != newHash { // remove cache dir of gallery _ = os.Remove(h.Paths.Generated.GetThumbnailPath(oldHash, models.DefaultGthumbWidth)) } } // do this after the commit so that generation doesn't hold up the transaction txn.AddPostCommitHook(ctx, func(ctx context.Context) { for _, s := range existing { if err := h.ScanGenerator.Generate(ctx, s, f); err != nil { // just log if cover generation fails. We can try again on rescan logger.Errorf("Error generating content for %s: %v", imageFile.Path, err) } } }) return nil } func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.Image, f *models.BaseFile, updateExisting bool) error { for _, i := range existing { if err := i.LoadFiles(ctx, h.CreatorUpdater); err != nil { return err } found := false for _, sf := range i.Files.List() { if sf.Base().ID == f.Base().ID { found = true break } } // associate with gallery if applicable g, err := h.getGalleryToAssociate(ctx, i, f) if err != nil { return err } var galleryIDs *models.UpdateIDs changed := false if g != nil { changed = true galleryIDs = &models.UpdateIDs{ IDs: []int{g.ID}, Mode: models.RelationshipUpdateModeAdd, } } if !found { logger.Infof("Adding %s to image %s", f.Path, i.DisplayName()) if err := h.CreatorUpdater.AddFileID(ctx, i.ID, f.ID); err != nil { return fmt.Errorf("adding file to image: %w", err) } changed = true } if changed || updateExisting { // update updated_at time when file association or content changes imagePartial := models.NewImagePartial() imagePartial.GalleryIDs = galleryIDs if _, err := h.CreatorUpdater.UpdatePartial(ctx, i.ID, imagePartial); err != nil { return fmt.Errorf("updating image: %w", err) } if g != nil { galleryPartial := models.GalleryPartial{ // set UpdatedAt directly instead of using NewGalleryPartial, to ensure // that the linked gallery has the same UpdatedAt time as this image UpdatedAt: imagePartial.UpdatedAt, } if _, err := h.GalleryFinder.UpdatePartial(ctx, g.ID, galleryPartial); err != nil { return fmt.Errorf("updating gallery updated at timestamp: %w", err) } } h.PluginCache.RegisterPostHooks(ctx, i.ID, hook.ImageUpdatePost, nil, nil) } } return nil } func (h *ScanHandler) getOrCreateFolderBasedGallery(ctx context.Context, f models.File) (*models.Gallery, error) { folderID := f.Base().ParentFolderID g, err := h.GalleryFinder.FindByFolderID(ctx, folderID) if err != nil { return nil, fmt.Errorf("finding folder based gallery: %w", err) } if len(g) > 0 { gg := g[0] return gg, nil } // create a new folder-based gallery newGallery := models.NewGallery() newGallery.FolderID = &folderID input := models.CreateGalleryInput{ Gallery: &newGallery, } logger.Infof("Creating folder-based gallery for %s", filepath.Dir(f.Base().Path)) if err := h.GalleryFinder.Create(ctx, &input); err != nil { return nil, fmt.Errorf("creating folder based gallery: %w", err) } h.PluginCache.RegisterPostHooks(ctx, newGallery.ID, hook.GalleryCreatePost, nil, nil) // it's possible that there are other images in the folder that // need to be added to the new gallery. Find and add them now. if err := h.associateFolderImages(ctx, &newGallery); err != nil { return nil, fmt.Errorf("associating existing folder images: %w", err) } return &newGallery, nil } func (h *ScanHandler) associateFolderImages(ctx context.Context, g *models.Gallery) error { i, err := h.CreatorUpdater.FindByFolderID(ctx, *g.FolderID) if err != nil { return fmt.Errorf("finding images in folder: %w", err) } for _, ii := range i { logger.Infof("Adding %s to gallery %s", ii.Path, g.Path) imagePartial := models.NewImagePartial() imagePartial.GalleryIDs = &models.UpdateIDs{ IDs: []int{g.ID}, Mode: models.RelationshipUpdateModeAdd, } if _, err := h.CreatorUpdater.UpdatePartial(ctx, ii.ID, imagePartial); err != nil { return fmt.Errorf("updating image: %w", err) } } return nil } func (h *ScanHandler) getOrCreateZipBasedGallery(ctx context.Context, zipFile models.File) (*models.Gallery, error) { g, err := h.GalleryFinder.FindByFileID(ctx, zipFile.Base().ID) if err != nil { return nil, fmt.Errorf("finding zip based gallery: %w", err) } if len(g) > 0 { gg := g[0] return gg, nil } // create a new zip-based gallery newGallery := models.NewGallery() logger.Infof("%s doesn't exist. Creating new gallery...", zipFile.Base().Path) input := models.CreateGalleryInput{ Gallery: &newGallery, FileIDs: []models.FileID{zipFile.Base().ID}, } if err := h.GalleryFinder.Create(ctx, &input); err != nil { return nil, fmt.Errorf("creating zip-based gallery: %w", err) } // try to associate with scene if err := h.associateScene(ctx, &newGallery, zipFile); err != nil { return nil, fmt.Errorf("associating scene: %w", err) } h.PluginCache.RegisterPostHooks(ctx, newGallery.ID, hook.GalleryCreatePost, nil, nil) return &newGallery, nil } func (h *ScanHandler) associateScene(ctx context.Context, existing *models.Gallery, zipFile models.File) error { galleryIDs := []int{existing.ID} path := zipFile.Base().Path withoutExt := strings.TrimSuffix(path, filepath.Ext(path)) + ".*" // find scenes with a file that matches scenes, err := h.SceneFinderUpdater.FindByPath(ctx, withoutExt) if err != nil { return err } for _, scene := range scenes { // found related Scene logger.Infof("associate: Gallery %s is related to scene: %d", path, scene.ID) if err := h.SceneFinderUpdater.AddGalleryIDs(ctx, scene.ID, galleryIDs); err != nil { return err } } return nil } func (h *ScanHandler) getOrCreateGallery(ctx context.Context, f models.File) (*models.Gallery, error) { // don't create folder-based galleries for files in zip file if f.Base().ZipFile != nil { return h.getOrCreateZipBasedGallery(ctx, f.Base().ZipFile) } // Look for specific filename in Folder to find out if the Folder is marked to be handled differently as the setting folderPath := filepath.Dir(f.Base().Path) forceGallery := false if _, err := os.Stat(filepath.Join(folderPath, ".forcegallery")); err == nil { forceGallery = true } else if !errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("Could not test Path %s: %w", folderPath, err) } exemptGallery := false if _, err := os.Stat(filepath.Join(folderPath, ".nogallery")); err == nil { exemptGallery = true } else if !errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("Could not test Path %s: %w", folderPath, err) } if forceGallery || (h.ScanConfig.GetCreateGalleriesFromFolders() && !exemptGallery) { return h.getOrCreateFolderBasedGallery(ctx, f) } return nil, nil } func (h *ScanHandler) getGalleryToAssociate(ctx context.Context, newImage *models.Image, f models.File) (*models.Gallery, error) { g, err := h.getOrCreateGallery(ctx, f) if err != nil { return nil, err } if err := newImage.LoadGalleryIDs(ctx, h.CreatorUpdater); err != nil { return nil, err } if g != nil && !slices.Contains(newImage.GalleryIDs.List(), g.ID) { return g, nil } return nil, nil } ================================================ FILE: pkg/image/scan_test.go ================================================ package image import ( "context" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stashapp/stash/pkg/plugin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) type mockScanConfig struct{} func (m *mockScanConfig) GetCreateGalleriesFromFolders() bool { return false } func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) { const ( testImageID = 1 testFileID = 100 ) existingFile := &models.BaseFile{ID: models.FileID(testFileID), Path: "/images/test.jpg"} makeImage := func() *models.Image { return &models.Image{ ID: testImageID, Files: models.NewRelatedFiles([]models.File{existingFile}), GalleryIDs: models.NewRelatedIDs([]int{}), } } tests := []struct { name string updateExisting bool expectUpdate bool }{ { name: "calls UpdatePartial when file content changed", updateExisting: true, expectUpdate: true, }, { name: "skips UpdatePartial when file unchanged and already associated", updateExisting: false, expectUpdate: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { db := mocks.NewDatabase() db.Image.On("GetFiles", mock.Anything, testImageID).Return([]models.File{existingFile}, nil) db.Image.On("GetGalleryIDs", mock.Anything, testImageID).Return([]int{}, nil) if tt.expectUpdate { db.Image.On("UpdatePartial", mock.Anything, testImageID, mock.Anything). Return(&models.Image{ID: testImageID}, nil) } h := &ScanHandler{ CreatorUpdater: db.Image, GalleryFinder: db.Gallery, ScanConfig: &mockScanConfig{}, PluginCache: &plugin.Cache{}, } db.WithTxnCtx(func(ctx context.Context) { err := h.associateExisting(ctx, []*models.Image{makeImage()}, existingFile, tt.updateExisting) assert.NoError(t, err) }) if tt.expectUpdate { db.Image.AssertCalled(t, "UpdatePartial", mock.Anything, testImageID, mock.Anything) } else { db.Image.AssertNotCalled(t, "UpdatePartial", mock.Anything, mock.Anything, mock.Anything) } }) } } func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) { const ( testImageID = 1 existFileID = 100 newFileID = 200 ) existingFile := &models.BaseFile{ID: models.FileID(existFileID), Path: "/images/existing.jpg"} newFile := &models.BaseFile{ID: models.FileID(newFileID), Path: "/images/new.jpg"} image := &models.Image{ ID: testImageID, Files: models.NewRelatedFiles([]models.File{existingFile}), GalleryIDs: models.NewRelatedIDs([]int{}), } db := mocks.NewDatabase() db.Image.On("GetFiles", mock.Anything, testImageID).Return([]models.File{existingFile}, nil) db.Image.On("GetGalleryIDs", mock.Anything, testImageID).Return([]int{}, nil) db.Image.On("AddFileID", mock.Anything, testImageID, models.FileID(newFileID)).Return(nil) db.Image.On("UpdatePartial", mock.Anything, testImageID, mock.Anything). Return(&models.Image{ID: testImageID}, nil) h := &ScanHandler{ CreatorUpdater: db.Image, GalleryFinder: db.Gallery, ScanConfig: &mockScanConfig{}, PluginCache: &plugin.Cache{}, } db.WithTxnCtx(func(ctx context.Context) { err := h.associateExisting(ctx, []*models.Image{image}, newFile, false) assert.NoError(t, err) }) db.Image.AssertCalled(t, "AddFileID", mock.Anything, testImageID, models.FileID(newFileID)) db.Image.AssertCalled(t, "UpdatePartial", mock.Anything, testImageID, mock.Anything) } ================================================ FILE: pkg/image/service.go ================================================ // Package image provides the application logic for images. // The functionality is exposed via the [Service] type. package image import ( "github.com/stashapp/stash/pkg/models" ) type Service struct { File models.FileReaderWriter Repository models.ImageReaderWriter } ================================================ FILE: pkg/image/thumbnail.go ================================================ package image import ( "bytes" "context" "errors" "fmt" "os/exec" "path/filepath" "runtime" "sync" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/ffmpeg/transcoder" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/models" ) const ffmpegImageQuality = 5 var vipsPath string var once sync.Once // ErrNotSupportedForThumbnail is returned if the image format is not supported for thumbnail generation var ErrNotSupportedForThumbnail = errors.New("unsupported image format for thumbnail") type ThumbnailEncoder struct { FFMpeg *ffmpeg.FFMpeg FFProbe *ffmpeg.FFProbe ClipPreviewOptions ClipPreviewOptions vips *vipsEncoder } type ClipPreviewOptions struct { InputArgs []string OutputArgs []string Preset string } func GetVipsPath() string { once.Do(func() { vipsPath, _ = exec.LookPath("vips") }) return vipsPath } func NewThumbnailEncoder(ffmpegEncoder *ffmpeg.FFMpeg, ffProbe *ffmpeg.FFProbe, clipPreviewOptions ClipPreviewOptions) ThumbnailEncoder { ret := ThumbnailEncoder{ FFMpeg: ffmpegEncoder, FFProbe: ffProbe, ClipPreviewOptions: clipPreviewOptions, } vipsPath := GetVipsPath() if vipsPath != "" { vipsEncoder := vipsEncoder(vipsPath) ret.vips = &vipsEncoder } return ret } // GetThumbnail returns the thumbnail image of the provided image resized to // the provided max size. It resizes based on the largest X/Y direction. // It returns nil and an error if an error occurs reading, decoding or encoding // the image, or if the image is not suitable for thumbnails. func (e *ThumbnailEncoder) GetThumbnail(f models.File, maxSize int) ([]byte, error) { reader, err := f.Open(&file.OsFS{}) if err != nil { return nil, err } defer reader.Close() buf := new(bytes.Buffer) if _, err := buf.ReadFrom(reader); err != nil { return nil, err } data := buf.Bytes() format := "" if imageFile, ok := f.(*models.ImageFile); ok { format = imageFile.Format animated := imageFile.Format == formatGif // #2266 - if image is webp, then determine if it is animated if format == formatWebP { animated = isWebPAnimated(data) } // #2266 - don't generate a thumbnail for animated images if animated { return nil, fmt.Errorf("%w: %s", ErrNotSupportedForThumbnail, format) } // AVIF cannot be read from stdin, must use file path // AVIF in zip files is not supported // Note: No Windows check needed here since we use file path, not stdin if format == "avif" { if f.Base().ZipFileID != nil { return nil, fmt.Errorf("%w: AVIF in zip file", ErrNotSupportedForThumbnail) } if e.vips != nil { return e.vips.ImageThumbnailPath(f.Base().Path, maxSize) } return e.ffmpegImageThumbnailPath(f.Base().Path, maxSize) } } // Videofiles can only be thumbnailed with ffmpeg if _, ok := f.(*models.VideoFile); ok { return e.ffmpegImageThumbnail(buf, maxSize) } // vips has issues loading files from stdin on Windows if e.vips != nil { if runtime.GOOS == "windows" && f.Base().ZipFileID == nil { return e.vips.ImageThumbnailPath(f.Base().Path, maxSize) } if runtime.GOOS != "windows" { return e.vips.ImageThumbnail(buf, maxSize) } } return e.ffmpegImageThumbnail(buf, maxSize) } // GetPreview returns the preview clip of the provided image clip resized to // the provided max size. It resizes based on the largest X/Y direction. // It is hardcoded to 30 seconds maximum right now func (e *ThumbnailEncoder) GetPreview(inPath string, outPath string, maxSize int) error { fileData, err := e.FFProbe.NewVideoFile(inPath) if err != nil { return err } if fileData.Width <= maxSize { maxSize = fileData.Width } clipDuration := fileData.VideoStreamDuration if clipDuration > 30.0 { clipDuration = 30.0 } return e.getClipPreview(inPath, outPath, maxSize, clipDuration, fileData.FrameRate) } func (e *ThumbnailEncoder) ffmpegImageThumbnail(image *bytes.Buffer, maxSize int) ([]byte, error) { options := transcoder.ImageThumbnailOptions{ OutputFormat: ffmpeg.ImageFormatJpeg, OutputPath: "-", MaxDimensions: maxSize, Quality: ffmpegImageQuality, } args := transcoder.ImageThumbnail("-", options) return e.FFMpeg.GenerateOutput(context.TODO(), args, image) } // ffmpegImageThumbnailPath generates a thumbnail from a file path (used for AVIF which can't be piped) func (e *ThumbnailEncoder) ffmpegImageThumbnailPath(inputPath string, maxSize int) ([]byte, error) { options := transcoder.ImageThumbnailOptions{ OutputFormat: ffmpeg.ImageFormatJpeg, OutputPath: "-", MaxDimensions: maxSize, Quality: ffmpegImageQuality, } args := transcoder.ImageThumbnail(inputPath, options) return e.FFMpeg.GenerateOutput(context.TODO(), args, nil) } func (e *ThumbnailEncoder) getClipPreview(inPath string, outPath string, maxSize int, clipDuration float64, frameRate float64) error { var thumbFilter ffmpeg.VideoFilter thumbFilter = thumbFilter.ScaleMaxSize(maxSize) var thumbArgs ffmpeg.Args thumbArgs = thumbArgs.VideoFilter(thumbFilter) o := e.ClipPreviewOptions thumbArgs = append(thumbArgs, "-pix_fmt", "yuv420p", "-preset", o.Preset, "-crf", "25", "-threads", "4", "-strict", "-2", "-f", "webm", ) if frameRate <= 0.01 { thumbArgs = append(thumbArgs, "-vsync", "2") } thumbOptions := transcoder.TranscodeOptions{ OutputPath: outPath, StartTime: 0, Duration: clipDuration, XError: true, SlowSeek: false, VideoCodec: ffmpeg.VideoCodecVP9, VideoArgs: thumbArgs, ExtraInputArgs: o.InputArgs, ExtraOutputArgs: o.OutputArgs, } if err := fsutil.EnsureDirAll(filepath.Dir(outPath)); err != nil { return err } args := transcoder.Transcode(inPath, thumbOptions) return e.FFMpeg.Generate(context.TODO(), args) } ================================================ FILE: pkg/image/update.go ================================================ package image import ( "context" "github.com/stashapp/stash/pkg/models" ) func AddPerformer(ctx context.Context, qb models.ImageUpdater, i *models.Image, performerID int) error { imagePartial := models.NewImagePartial() imagePartial.PerformerIDs = &models.UpdateIDs{ IDs: []int{performerID}, Mode: models.RelationshipUpdateModeAdd, } _, err := qb.UpdatePartial(ctx, i.ID, imagePartial) return err } func AddTag(ctx context.Context, qb models.ImageUpdater, i *models.Image, tagID int) error { imagePartial := models.NewImagePartial() imagePartial.TagIDs = &models.UpdateIDs{ IDs: []int{tagID}, Mode: models.RelationshipUpdateModeAdd, } _, err := qb.UpdatePartial(ctx, i.ID, imagePartial) return err } ================================================ FILE: pkg/image/vips.go ================================================ package image import ( "bytes" "fmt" "strings" "github.com/stashapp/stash/pkg/exec" "github.com/stashapp/stash/pkg/logger" ) type vipsEncoder string func (e *vipsEncoder) ImageThumbnail(image *bytes.Buffer, maxSize int) ([]byte, error) { args := []string{ "thumbnail_source", "[descriptor=0]", ".jpg[Q=70,strip]", fmt.Sprint(maxSize), "--size", "down", } data, err := e.run(args, image) return []byte(data), err } // ImageThumbnailPath generates a thumbnail from a file path instead of stdin. // This is required for formats like AVIF that need random file access (seeking) // which stdin cannot provide. func (e *vipsEncoder) ImageThumbnailPath(path string, maxSize int) ([]byte, error) { // vips thumbnail syntax: thumbnail input output width [options] // Using .jpg[Q=70,strip] as output writes to stdout args := []string{ "thumbnail", path, ".jpg[Q=70,strip]", fmt.Sprint(maxSize), "--size", "down", } cmd := exec.Command(string(*e), args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Start(); err != nil { return nil, err } if err := cmd.Wait(); err != nil { logger.Errorf("image encoder error when running command <%s>: %s", strings.Join(cmd.Args, " "), stderr.String()) return nil, err } return stdout.Bytes(), nil } func (e *vipsEncoder) run(args []string, stdin *bytes.Buffer) (string, error) { cmd := exec.Command(string(*e), args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr cmd.Stdin = stdin if err := cmd.Start(); err != nil { return "", err } err := cmd.Wait() if err != nil { // error message should be in the stderr stream logger.Errorf("image encoder error when running command <%s>: %s", strings.Join(cmd.Args, " "), stderr.String()) return stdout.String(), err } return stdout.String(), nil } ================================================ FILE: pkg/image/webp.go ================================================ package image import ( "bytes" ) const ( formatWebP = "webp" formatGif = "gif" ) // https://developers.google.com/speed/webp/docs/riff_container func isWebPAnimated(buf []byte) bool { const ( webPHeaderStart = 8 webPHeaderEnd = 12 webPHeader = "WEBP" animationHeaderLoc = 16 minAnimSignatureIndex = 20 maxSize = 48 ) // truncate the buffer to the max size if len(buf) > maxSize { buf = buf[:maxSize] } isWebp := len(buf) >= webPHeaderEnd && string(buf[webPHeaderStart:webPHeaderEnd]) == "WEBP" // is WEBP if isWebp { const animBit byte = 1 << 1 if len(buf) > minAnimSignatureIndex { // Animation Bit is set and ANIM header is present return (buf[animationHeaderLoc]&animBit == animBit) && containsAnimSignature(buf[minAnimSignatureIndex:]) } } return false } // https://developers.google.com/speed/webp/docs/riff_container#animation func containsAnimSignature(buf []byte) bool { index := bytes.Index(buf, []byte("ANIM")) return index != -1 } ================================================ FILE: pkg/image/webp_internal_test.go ================================================ package image import "testing" func Test_isWebPAnimated(t *testing.T) { tests := []struct { name string buf []byte want bool }{ { "basic animated", []byte{ 0x52, 0x49, 0x46, 0x46, 0xb2, 0x3a, 0x17, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x58, 0x0a, 0x00, 0x00, 0x00, 0x12, 0x00, 0x00, 0x00, 0x7f, 0x02, 0x00, 0x55, 0x01, 0x00, 0x41, 0x4e, 0x49, 0x4d, 0x06, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x41, 0x4e, 0x4d, 0x46, }, true, }, { "static webp", []byte{ 0x52, 0x49, 0x46, 0x46, 0x68, 0x76, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x20, 0x5c, 0x76, 0x00, 0x00, 0xd2, 0xbe, 0x01, 0x9d, 0x01, 0x2a, 0x26, 0x02, 0x70, 0x01, 0x3e, 0xd5, 0x4e, 0x97, 0x43, 0xa2, 0x06, 0x16, 0xd1, 0xb4, 0x88, 0x03, 0x51, 0x39, 0xb7, 0x13, 0x33, 0x75, }, false, }, { "false animated bit", []byte{ 0x52, 0x49, 0x46, 0x46, 0xb2, 0x3a, 0x17, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x58, 0x09, 0x00, 0x00, 0x00, 0x12, 0x00, 0x00, 0x00, 0x7f, 0x02, 0x00, 0x55, 0x01, 0x00, 0x41, 0x4e, 0x49, 0x4d, 0x06, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x41, 0x4e, 0x4d, 0x46, }, false, }, { "ANIM out of range", []byte{ 0x52, 0x49, 0x46, 0x46, 0xb2, 0x3a, 0x17, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x58, 0x0a, 0x00, 0x00, 0x00, 0x12, 0x00, 0x00, 0x00, 0x7f, 0x02, 0x00, 0x55, 0x01, 0x00, 0x3e, 0xd5, 0x4e, 0x97, 0x43, 0xa2, 0x06, 0x16, 0xd1, 0xb4, 0x88, 0x03, 0x51, 0x39, 0xb7, 0x13, 0x33, 0x75, 0x41, 0x4e, 0x49, 0x4d, 0x41, 0x4e, 0x49, 0x4d, 0x41, 0x4e, 0x49, 0x4d, 0x41, 0x4e, 0x49, 0x4d, }, false, }, { "not webp", []byte{ 0x52, 0x49, 0x46, 0x46, 0xb2, 0x3a, 0x17, 0x00, 0x58, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x58, 0x0a, 0x00, 0x00, 0x00, 0x12, 0x00, 0x00, 0x00, 0x7f, 0x02, 0x00, 0x55, 0x01, 0x00, 0x3e, 0xd5, 0x4e, 0x97, 0x43, 0xa2, 0x06, 0x16, 0xd1, 0xb4, 0x88, 0x03, 0x51, 0x39, 0xb7, 0x13, 0x33, 0x75, 0x41, 0x4e, 0x49, 0x4d, 0x41, 0x4e, 0x49, 0x4d, 0x41, 0x4e, 0x49, 0x4d, 0x41, 0x4e, 0x49, 0x4d, }, false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := isWebPAnimated(tt.buf); got != tt.want { t.Errorf("isWebPAnimated() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: pkg/javascript/console.go ================================================ package javascript import "fmt" type console struct { Log } func (c *console) AddToVM(globalName string, vm *VM) error { console := vm.NewObject() if err := SetAll(console, ObjectValueDef{"log", c.logInfo}, ObjectValueDef{"error", c.logError}, ObjectValueDef{"warn", c.logWarn}, ObjectValueDef{"info", c.logInfo}, ObjectValueDef{"debug", c.logDebug}, ); err != nil { return err } if err := vm.Set(globalName, console); err != nil { return fmt.Errorf("unable to set console: %w", err) } return nil } ================================================ FILE: pkg/javascript/gql.go ================================================ package javascript import ( "bytes" "context" "encoding/json" "fmt" "net/http" "strings" "github.com/dop251/goja" ) type responseWriter struct { r strings.Builder header http.Header statusCode int } func (w *responseWriter) Header() http.Header { return w.header } func (w *responseWriter) WriteHeader(statusCode int) { w.statusCode = statusCode } func (w *responseWriter) Write(b []byte) (int, error) { return w.r.Write(b) } type GQL struct { Context context.Context Cookie *http.Cookie GQLHandler http.Handler } func (g *GQL) gqlRequestFunc(vm *VM) func(query string, variables map[string]interface{}) (goja.Value, error) { return func(query string, variables map[string]interface{}) (goja.Value, error) { in := struct { Query string `json:"query"` Variables map[string]interface{} `json:"variables,omitempty"` }{ Query: query, Variables: variables, } var body bytes.Buffer err := json.NewEncoder(&body).Encode(in) if err != nil { return nil, err } r, err := http.NewRequestWithContext(g.Context, "POST", "/graphql", &body) if err != nil { return nil, fmt.Errorf("could not make request") } r.Header.Set("Content-Type", "application/json") if g.Cookie != nil { r.AddCookie(g.Cookie) } w := &responseWriter{ header: make(http.Header), } g.GQLHandler.ServeHTTP(w, r) if w.statusCode != http.StatusOK && w.statusCode != 0 { vm.Throw(fmt.Errorf("graphQL query failed: %d - %s. Query: %s. Variables: %v", w.statusCode, w.r.String(), in.Query, in.Variables)) } output := w.r.String() // convert to JSON var obj map[string]interface{} if err = json.Unmarshal([]byte(output), &obj); err != nil { vm.Throw(fmt.Errorf("could not unmarshal object %s: %s", output, err.Error())) } retErr, hasErr := obj["errors"] if hasErr { errOut, _ := json.Marshal(retErr) vm.Throw(fmt.Errorf("graphql error: %s", string(errOut))) } v := vm.ToValue(obj["data"]) return v, nil } } func (g *GQL) AddToVM(globalName string, vm *VM) error { gql := vm.NewObject() if err := gql.Set("Do", g.gqlRequestFunc(vm)); err != nil { return fmt.Errorf("unable to set GraphQL Do function: %w", err) } if err := vm.Set(globalName, gql); err != nil { return fmt.Errorf("unable to set gql: %w", err) } return nil } ================================================ FILE: pkg/javascript/log.go ================================================ package javascript import ( "encoding/json" "fmt" "math" "reflect" "github.com/dop251/goja" "github.com/stashapp/stash/pkg/logger" ) // Log provides log wrappers for usable from the JS VM. type Log struct { // Logger is the LoggerImpl to forward log messages to. Logger logger.LoggerImpl // Prefix is the prefix to prepend to log messages. Prefix string // ProgressChan is a channel that receives float64s indicating the current progress of an operation. ProgressChan chan float64 } func (l *Log) argToString(call goja.FunctionCall) string { arg := call.Argument(0) var o map[string]interface{} if arg.ExportType() == reflect.TypeOf(o) { ii := arg.Export() o = ii.(map[string]interface{}) data, err := json.Marshal(o) if err != nil { logger.Warnf("Couldn't json encode object") } return string(data) } return arg.String() } func (l *Log) logTrace(call goja.FunctionCall) goja.Value { l.Logger.Trace(l.Prefix, l.argToString(call)) return nil } func (l *Log) logDebug(call goja.FunctionCall) goja.Value { l.Logger.Debug(l.Prefix, l.argToString(call)) return nil } func (l *Log) logInfo(call goja.FunctionCall) goja.Value { l.Logger.Info(l.Prefix, l.argToString(call)) return nil } func (l *Log) logWarn(call goja.FunctionCall) goja.Value { l.Logger.Warn(l.Prefix, l.argToString(call)) return nil } func (l *Log) logError(call goja.FunctionCall) goja.Value { l.Logger.Error(l.Prefix, l.argToString(call)) return nil } // Progress logs the current progress value. The progress value should be // between 0 and 1.0 inclusively, with 1 representing that the task is // complete. Values outside of this range will be clamp to be within it. func (l *Log) logProgress(value float64) { value = math.Min(math.Max(0, value), 1) l.ProgressChan <- value } func (l *Log) AddToVM(globalName string, vm *VM) error { log := vm.NewObject() if err := SetAll(log, ObjectValueDef{"Trace", l.logTrace}, ObjectValueDef{"Debug", l.logDebug}, ObjectValueDef{"Info", l.logInfo}, ObjectValueDef{"Warn", l.logWarn}, ObjectValueDef{"Error", l.logError}, ObjectValueDef{"Progress", l.logProgress}, ); err != nil { return err } if err := vm.Set(globalName, log); err != nil { return fmt.Errorf("unable to set log: %w", err) } return nil } ================================================ FILE: pkg/javascript/util.go ================================================ package javascript import ( "fmt" "time" ) type Util struct{} func (u *Util) sleepFunc(ms int64) { time.Sleep(time.Millisecond * time.Duration(ms)) } func (u *Util) AddToVM(globalName string, vm *VM) error { util := vm.NewObject() if err := util.Set("Sleep", u.sleepFunc); err != nil { return fmt.Errorf("unable to set sleep func: %w", err) } if err := vm.Set(globalName, util); err != nil { return fmt.Errorf("unable to set util: %w", err) } return nil } ================================================ FILE: pkg/javascript/vm.go ================================================ // Package javascript provides the javascript runtime for the application. package javascript import ( "fmt" "os" "reflect" "github.com/dop251/goja" "github.com/stashapp/stash/pkg/logger" ) // VM is a wrapper around goja.Runtime. type VM struct { *goja.Runtime } // optionalFieldNameMapper wraps a goja.FieldNameMapper and returns the field name if the wrapped mapper returns an empty string. type optionalFieldNameMapper struct { mapper goja.FieldNameMapper } func (tfm optionalFieldNameMapper) FieldName(t reflect.Type, f reflect.StructField) string { if ret := tfm.mapper.FieldName(t, f); ret != "" { return ret } return f.Name } func (tfm optionalFieldNameMapper) MethodName(t reflect.Type, m reflect.Method) string { return tfm.mapper.MethodName(t, m) } func NewVM() *VM { r := goja.New() // enable console for backwards compatibility c := console{ Log{ Logger: logger.Logger, }, } // there should not be any reason for this to fail _ = c.AddToVM("console", &VM{Runtime: r}) r.SetFieldNameMapper(optionalFieldNameMapper{goja.TagFieldNameMapper("json", true)}) return &VM{Runtime: r} } type APIAdder interface { AddToVM(globalName string, vm *VM) error } type ObjectValueDef struct { Name string Value interface{} } type setter interface { Set(name string, value interface{}) error } func Compile(path string) (*goja.Program, error) { js, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read file: %w", err) } return goja.Compile(path, string(js), true) } func CompileScript(name, script string) (*goja.Program, error) { return goja.Compile(name, string(script), true) } func SetAll(s setter, defs ...ObjectValueDef) error { for _, def := range defs { if err := s.Set(def.Name, def.Value); err != nil { return fmt.Errorf("failed to set %s: %w", def.Name, err) } } return nil } func (v *VM) Throw(err error) { e, newErr := v.New(v.Get("Error"), v.ToValue(err)) if newErr != nil { panic(newErr) } panic(e) } ================================================ FILE: pkg/job/job.go ================================================ // Package job provides the job execution and management functionality for the application. package job import ( "context" "time" ) type JobExecFn func(ctx context.Context, progress *Progress) error // JobExec represents the implementation of a Job to be executed. type JobExec interface { Execute(ctx context.Context, progress *Progress) error } type jobExecImpl struct { fn JobExecFn } func (j *jobExecImpl) Execute(ctx context.Context, progress *Progress) error { return j.fn(ctx, progress) } // MakeJobExec returns a simple JobExec implementation using the provided // function. func MakeJobExec(fn JobExecFn) JobExec { return &jobExecImpl{ fn: fn, } } // Status is the status of a Job type Status string const ( // StatusReady means that the Job is not yet started. StatusReady Status = "READY" // StatusRunning means that the job is currently running. StatusRunning Status = "RUNNING" // StatusStopping means that the job is cancelled but is still running. StatusStopping Status = "STOPPING" // StatusFinished means that the job was completed. StatusFinished Status = "FINISHED" // StatusCancelled means that the job was cancelled and is now stopped. StatusCancelled Status = "CANCELLED" // StatusFailed means that the job failed. StatusFailed Status = "FAILED" ) // Job represents the status of a queued or running job. type Job struct { ID int Status Status // details of the current operations of the job Details []string Description string // Progress in terms of 0 - 1. Progress float64 StartTime *time.Time EndTime *time.Time AddTime time.Time Error *string outerCtx context.Context exec JobExec cancelFunc context.CancelFunc } // TimeElapsed returns the total time elapsed for the job. // If the EndTime is set, then it uses this to calculate the elapsed time, otherwise it uses time.Now. func (j *Job) TimeElapsed() time.Duration { var end time.Time if j.EndTime != nil { end = time.Now() } else { end = *j.EndTime } return end.Sub(*j.StartTime) } func (j *Job) cancel() { if j.Status == StatusReady { j.Status = StatusCancelled } else if j.Status == StatusRunning { j.Status = StatusStopping } if j.cancelFunc != nil { j.cancelFunc() } } func (j *Job) error(err error) { errStr := err.Error() j.Error = &errStr j.Status = StatusFailed } // IsCancelled returns true if cancel has been called on the context. func IsCancelled(ctx context.Context) bool { select { case <-ctx.Done(): return true default: return false } } ================================================ FILE: pkg/job/manager.go ================================================ package job import ( "context" "runtime/debug" "sync" "time" "github.com/stashapp/stash/pkg/logger" ) const maxGraveyardSize = 10 const defaultThrottleLimit = 100 * time.Millisecond // Manager maintains a queue of jobs. Jobs are executed one at a time. type Manager struct { queue []*Job graveyard []*Job mutex sync.Mutex notEmpty *sync.Cond stop chan struct{} lastID int subscriptions []*ManagerSubscription updateThrottleLimit time.Duration } // NewManager initialises and returns a new Manager. func NewManager() *Manager { ret := &Manager{ stop: make(chan struct{}), updateThrottleLimit: defaultThrottleLimit, } ret.notEmpty = sync.NewCond(&ret.mutex) go ret.dispatcher() return ret } // Stop is used to stop the dispatcher thread. Once Stop is called, no // more Jobs will be processed. func (m *Manager) Stop() { m.CancelAll() close(m.stop) } // Add queues a job. func (m *Manager) Add(ctx context.Context, description string, e JobExec) int { m.mutex.Lock() defer m.mutex.Unlock() t := time.Now() j := Job{ ID: m.nextID(), Status: StatusReady, Description: description, AddTime: t, exec: e, outerCtx: ctx, } m.queue = append(m.queue, &j) if len(m.queue) == 1 { // notify that there is now a job in the queue m.notEmpty.Broadcast() } m.notifyNewJob(&j) return j.ID } // Start adds a job and starts it immediately, concurrently with any other // jobs. func (m *Manager) Start(ctx context.Context, description string, e JobExec) int { m.mutex.Lock() defer m.mutex.Unlock() t := time.Now() j := Job{ ID: m.nextID(), Status: StatusReady, Description: description, AddTime: t, exec: e, outerCtx: ctx, } m.queue = append(m.queue, &j) m.dispatch(ctx, &j) return j.ID } func (m *Manager) notifyNewJob(j *Job) { // assumes lock held for _, s := range m.subscriptions { // don't block if channel is full select { case s.newJob <- *j: default: } } } func (m *Manager) nextID() int { m.lastID++ return m.lastID } func (m *Manager) getReadyJob() *Job { // assumes lock held for _, j := range m.queue { if j.Status == StatusReady { return j } } return nil } func (m *Manager) dispatcher() { m.mutex.Lock() for { // wait until we have something to process j := m.getReadyJob() for j == nil { m.notEmpty.Wait() // it's possible that we have been stopped - check here select { case <-m.stop: m.mutex.Unlock() return default: // keep going j = m.getReadyJob() } } done := m.dispatch(j.outerCtx, j) // unlock the mutex and wait for the job to finish m.mutex.Unlock() <-done m.mutex.Lock() // remove the job from the queue m.removeJob(j) // process next job } } func (m *Manager) newProgress(j *Job) *Progress { return &Progress{ updater: &updater{ m: m, job: j, }, percent: ProgressIndefinite, } } func (m *Manager) dispatch(ctx context.Context, j *Job) (done chan struct{}) { // assumes lock held t := time.Now() j.StartTime = &t j.Status = StatusRunning // create a cancellable context for the job that is not canceled by the outer context ctx, cancelFunc := context.WithCancel(context.WithoutCancel(ctx)) j.cancelFunc = cancelFunc done = make(chan struct{}) go m.executeJob(ctx, j, done) m.notifyJobUpdate(j) return } func (m *Manager) executeJob(ctx context.Context, j *Job, done chan struct{}) { defer close(done) defer m.onJobFinish(j) defer func() { if p := recover(); p != nil { // a panic occurred, log and mark the job as failed logger.Errorf("panic in job %d - %s: %v", j.ID, j.Description, p) logger.Error(string(debug.Stack())) m.mutex.Lock() defer m.mutex.Unlock() j.Status = StatusFailed } }() progress := m.newProgress(j) if err := j.exec.Execute(ctx, progress); err != nil { logger.Errorf("task failed due to error: %v", err) j.error(err) } } func (m *Manager) onJobFinish(job *Job) { m.mutex.Lock() defer m.mutex.Unlock() if job.Status == StatusStopping { job.Status = StatusCancelled } else if job.Status != StatusFailed { job.Status = StatusFinished } t := time.Now() job.EndTime = &t } func (m *Manager) removeJob(job *Job) { // assumes lock held index, _ := m.getJob(m.queue, job.ID) if index == -1 { return } // clear any subtasks job.Details = nil m.queue = append(m.queue[:index], m.queue[index+1:]...) m.graveyard = append(m.graveyard, job) if len(m.graveyard) > maxGraveyardSize { m.graveyard = m.graveyard[1:] } // notify job removed for _, s := range m.subscriptions { // don't block if channel is full select { case s.removedJob <- *job: default: } } } func (m *Manager) getJob(list []*Job, id int) (index int, job *Job) { // assumes lock held for i, j := range list { if j.ID == id { index = i job = j return } } return -1, nil } // CancelJob cancels the job with the provided id. Jobs that have been started // are notified that they are stopping. Jobs that have not yet started are // removed from the queue. If no job exists with the provided id, then there is // no effect. Likewise, if the job is already cancelled, there is no effect. func (m *Manager) CancelJob(id int) { m.mutex.Lock() defer m.mutex.Unlock() _, j := m.getJob(m.queue, id) if j != nil { j.cancel() if j.Status == StatusCancelled { // remove from the queue m.removeJob(j) } } } // CancelAll cancels all of the jobs in the queue. This is the same as // calling CancelJob on all jobs in the queue. func (m *Manager) CancelAll() { m.mutex.Lock() defer m.mutex.Unlock() // call cancel on all for _, j := range m.queue { j.cancel() if j.Status == StatusCancelled { // add to graveyard m.removeJob(j) } } } // GetJob returns a copy of the Job for the provided id. Returns nil if the job // does not exist. func (m *Manager) GetJob(id int) *Job { m.mutex.Lock() defer m.mutex.Unlock() // get from the queue or graveyard _, j := m.getJob(append(m.queue, m.graveyard...), id) if j != nil { // make a copy of the job and return the pointer jCopy := *j return &jCopy } return nil } // GetQueue returns a copy of the current job queue. func (m *Manager) GetQueue() []Job { m.mutex.Lock() defer m.mutex.Unlock() var ret []Job for _, j := range m.queue { jCopy := *j ret = append(ret, jCopy) } return ret } // Subscribe subscribes to changes to jobs in the manager queue. func (m *Manager) Subscribe(ctx context.Context) *ManagerSubscription { m.mutex.Lock() defer m.mutex.Unlock() ret := newSubscription() m.subscriptions = append(m.subscriptions, ret) go func() { <-ctx.Done() m.mutex.Lock() defer m.mutex.Unlock() ret.close() // remove from the list for i, s := range m.subscriptions { if s == ret { m.subscriptions = append(m.subscriptions[:i], m.subscriptions[i+1:]...) break } } }() return ret } func (m *Manager) notifyJobUpdate(j *Job) { // don't update if job is finished or cancelled - these are handled // by removeJob if j.Status == StatusCancelled || j.Status == StatusFinished { return } // assumes lock held for _, s := range m.subscriptions { // don't block if channel is full select { case s.updatedJob <- *j: default: } } } type updater struct { m *Manager job *Job lastUpdate time.Time updateTimer *time.Timer } func (u *updater) notifyUpdate() { // assumes lock held u.m.notifyJobUpdate(u.job) u.lastUpdate = time.Now() u.updateTimer = nil } func (u *updater) updateProgress(progress float64, details []string) { u.m.mutex.Lock() defer u.m.mutex.Unlock() u.job.Progress = progress u.job.Details = details if time.Since(u.lastUpdate) < u.m.updateThrottleLimit { if u.updateTimer == nil { u.updateTimer = time.AfterFunc(u.m.updateThrottleLimit-time.Since(u.lastUpdate), func() { u.m.mutex.Lock() defer u.m.mutex.Unlock() u.notifyUpdate() }) } } else { u.notifyUpdate() } } ================================================ FILE: pkg/job/manager_test.go ================================================ package job import ( "context" "testing" "time" "github.com/stretchr/testify/assert" ) const sleepTime time.Duration = 10 * time.Millisecond type testExec struct { started chan struct{} finish chan struct{} cancelled bool progress *Progress } func newTestExec(finish chan struct{}) *testExec { return &testExec{ started: make(chan struct{}), finish: finish, } } func (e *testExec) Execute(ctx context.Context, p *Progress) error { e.progress = p close(e.started) if e.finish != nil { <-e.finish select { case <-ctx.Done(): e.cancelled = true default: // fall through } } return nil } func TestAdd(t *testing.T) { m := NewManager() const jobName = "test job" exec1 := newTestExec(make(chan struct{})) jobID := m.Add(context.Background(), jobName, exec1) // expect jobID to be the first ID assert := assert.New(t) assert.Equal(1, jobID) // wait a tiny bit time.Sleep(sleepTime) // expect job to have started select { case <-exec1.started: // ok default: t.Error("exec was not started") } // expect status to be running j := m.GetJob(jobID) assert.Equal(StatusRunning, j.Status) // expect description to be set assert.Equal(jobName, j.Description) // expect startTime and addTime to be set assert.NotNil(j.StartTime) assert.NotNil(j.AddTime) // expect endTime to not be set assert.Nil(j.EndTime) // add another job to the queue const otherJobName = "other job name" exec2 := newTestExec(make(chan struct{})) job2ID := m.Add(context.Background(), otherJobName, exec2) // expect status to be ready j2 := m.GetJob(job2ID) assert.Equal(StatusReady, j2.Status) // expect addTime to be set assert.NotNil(j2.AddTime) // expect startTime and endTime to not be set assert.Nil(j2.StartTime) assert.Nil(j2.EndTime) // allow first job to finish close(exec1.finish) // wait a tiny bit time.Sleep(sleepTime) // expect first job to be finished j = m.GetJob(jobID) assert.Equal(StatusFinished, j.Status) // expect end time to be set assert.NotNil(j.EndTime) // expect second job to have started select { case <-exec2.started: // ok default: t.Error("exec was not started") } // expect status to be running j2 = m.GetJob(job2ID) assert.Equal(StatusRunning, j2.Status) // expect startTime to be set assert.NotNil(j2.StartTime) } func TestCancel(t *testing.T) { m := NewManager() // add two jobs const jobName = "test job" exec1 := newTestExec(make(chan struct{})) jobID := m.Add(context.Background(), jobName, exec1) const otherJobName = "other job" exec2 := newTestExec(make(chan struct{})) job2ID := m.Add(context.Background(), otherJobName, exec2) // wait a tiny bit time.Sleep(sleepTime) m.CancelJob(job2ID) // expect job to be cancelled assert := assert.New(t) j := m.GetJob(job2ID) assert.Equal(StatusCancelled, j.Status) // expect end time not to be set assert.Nil(j.EndTime) // expect job to be removed from the queue assert.Len(m.GetQueue(), 1) // expect job to have not have been started select { case <-exec2.started: t.Error("cancelled exec was started") default: } // cancel running job m.CancelJob(jobID) // wait a tiny bit time.Sleep(sleepTime) // expect status to be stopping j = m.GetJob(jobID) assert.Equal(StatusStopping, j.Status) // expect job to still be in the queue assert.Len(m.GetQueue(), 1) // allow first job to finish close(exec1.finish) // wait a tiny bit time.Sleep(sleepTime) // expect job to be removed from the queue assert.Len(m.GetQueue(), 0) // expect job to be cancelled j = m.GetJob(jobID) assert.Equal(StatusCancelled, j.Status) // expect endtime to be set assert.NotNil(j.EndTime) // expect job to have been cancelled via context assert.True(exec1.cancelled) } func TestCancelAll(t *testing.T) { m := NewManager() // add two jobs const jobName = "test job" exec1 := newTestExec(make(chan struct{})) jobID := m.Add(context.Background(), jobName, exec1) const otherJobName = "other job" exec2 := newTestExec(make(chan struct{})) job2ID := m.Add(context.Background(), otherJobName, exec2) // wait a tiny bit time.Sleep(sleepTime) m.CancelAll() // allow first job to finish close(exec1.finish) // wait a tiny bit time.Sleep(sleepTime) // expect all jobs to be cancelled assert := assert.New(t) j := m.GetJob(job2ID) assert.Equal(StatusCancelled, j.Status) j = m.GetJob(jobID) assert.Equal(StatusCancelled, j.Status) // expect all jobs to be removed from the queue assert.Len(m.GetQueue(), 0) // expect job to have not have been started select { case <-exec2.started: t.Error("cancelled exec was started") default: } } func TestSubscribe(t *testing.T) { m := NewManager() m.updateThrottleLimit = time.Millisecond * 100 ctx, cancel := context.WithCancel(context.Background()) s := m.Subscribe(ctx) // add a job const jobName = "test job" exec1 := newTestExec(make(chan struct{})) jobID := m.Add(context.Background(), jobName, exec1) assert := assert.New(t) select { case newJob := <-s.NewJob: assert.Equal(jobID, newJob.ID) assert.Equal(jobName, newJob.Description) assert.Equal(StatusReady, newJob.Status) case <-time.After(time.Second): t.Error("new job was not received") } // should receive an update when the job begins to run select { case updatedJob := <-s.UpdatedJob: assert.Equal(jobID, updatedJob.ID) assert.Equal(jobName, updatedJob.Description) assert.Equal(StatusRunning, updatedJob.Status) case <-time.After(time.Second): t.Error("updated job was not received") } // wait for it to start select { case <-exec1.started: // ok case <-time.After(time.Second): t.Error("exec was not started") } // test update throttling exec1.progress.SetPercent(0.1) // first update should be immediate select { case updatedJob := <-s.UpdatedJob: assert.Equal(0.1, updatedJob.Progress) case <-time.After(m.updateThrottleLimit): t.Error("updated job was not received") } exec1.progress.SetPercent(0.2) exec1.progress.SetPercent(0.3) // should only receive a single update with the second status select { case updatedJob := <-s.UpdatedJob: assert.Equal(0.3, updatedJob.Progress) case <-time.After(time.Second): t.Error("updated job was not received") } select { case <-s.UpdatedJob: t.Error("received an additional updatedJob") default: } // allow job to finish close(exec1.finish) select { case removedJob := <-s.RemovedJob: assert.Equal(jobID, removedJob.ID) assert.Equal(jobName, removedJob.Description) assert.Equal(StatusFinished, removedJob.Status) case <-time.After(time.Second): t.Error("removed job was not received") } // should not receive another update select { case <-s.UpdatedJob: t.Error("updated job was received after update") case <-time.After(m.updateThrottleLimit): } // add another job and cancel it exec2 := newTestExec(make(chan struct{})) jobID = m.Add(context.Background(), jobName, exec2) m.CancelJob(jobID) select { case removedJob := <-s.RemovedJob: assert.Equal(jobID, removedJob.ID) assert.Equal(jobName, removedJob.Description) assert.Equal(StatusCancelled, removedJob.Status) case <-time.After(time.Second): t.Error("cancelled job was not received") } cancel() } ================================================ FILE: pkg/job/progress.go ================================================ package job import "sync" // ProgressIndefinite is the special percent value to indicate that the // percent progress is not known. const ProgressIndefinite float64 = -1 // Progress is used by JobExec to communicate updates to the job's progress to // the JobManager. type Progress struct { defined bool processed int total int percent float64 currentTasks []*task mutex sync.Mutex updater *updater } type task struct { description string } func (p *Progress) updated() { var details []string for _, t := range p.currentTasks { details = append(details, t.description) } p.updater.updateProgress(p.percent, details) } // Indefinite sets the progress to an indefinite amount. func (p *Progress) Indefinite() { p.mutex.Lock() defer p.mutex.Unlock() p.defined = false p.total = 0 p.calculatePercent() } // Definite notifies that the total is known. func (p *Progress) Definite() { p.mutex.Lock() defer p.mutex.Unlock() p.defined = true p.calculatePercent() } // SetTotal sets the total number of work units and sets definite to true. // This is used to calculate the progress percentage. func (p *Progress) SetTotal(total int) { p.mutex.Lock() defer p.mutex.Unlock() p.total = total p.defined = true p.calculatePercent() } // AddTotal adds to the total number of work units. This is used to calculate the // progress percentage. func (p *Progress) AddTotal(total int) { p.mutex.Lock() defer p.mutex.Unlock() p.total += total p.calculatePercent() } // SetProcessed sets the number of work units completed. This is used to // calculate the progress percentage. func (p *Progress) SetProcessed(processed int) { p.mutex.Lock() defer p.mutex.Unlock() p.processed = processed p.calculatePercent() } func (p *Progress) calculatePercent() { switch { case !p.defined || p.total <= 0: p.percent = ProgressIndefinite case p.processed < 0: p.percent = 0 default: p.percent = float64(p.processed) / float64(p.total) if p.percent > 1 { p.percent = 1 } } p.updated() } // SetPercent sets the progress percent directly. This value will be // overwritten if Indefinite, SetTotal, Increment or SetProcessed is called. // Constrains the percent value between 0 and 1, inclusive. func (p *Progress) SetPercent(percent float64) { p.mutex.Lock() defer p.mutex.Unlock() if percent < 0 { percent = 0 } else if percent > 1 { percent = 1 } p.percent = percent p.updated() } // Increment increments the number of processed work units. This is used to calculate the percentage. // If total is set already, then the number of processed work units will not exceed the total. func (p *Progress) Increment() { p.mutex.Lock() defer p.mutex.Unlock() if !p.defined || p.total <= 0 || p.processed < p.total { p.processed++ p.calculatePercent() } } // AddProcessed increments the number of processed work units by the provided // amount. This is used to calculate the percentage. func (p *Progress) AddProcessed(v int) { p.mutex.Lock() defer p.mutex.Unlock() newVal := v if p.defined && p.total > 0 && newVal > p.total { newVal = p.total } p.processed = newVal p.calculatePercent() } func (p *Progress) addTask(t *task) { p.mutex.Lock() defer p.mutex.Unlock() p.currentTasks = append([]*task{t}, p.currentTasks...) p.updated() } func (p *Progress) removeTask(t *task) { p.mutex.Lock() defer p.mutex.Unlock() for i, tt := range p.currentTasks { if tt == t { p.currentTasks = append(p.currentTasks[:i], p.currentTasks[i+1:]...) p.updated() return } } } // ExecuteTask executes a task as part of a job. The description is used to // populate the Details slice in the parent Job. func (p *Progress) ExecuteTask(description string, fn func()) { t := &task{ description: description, } p.addTask(t) defer p.removeTask(t) fn() } ================================================ FILE: pkg/job/progress_test.go ================================================ package job import ( "testing" "time" "github.com/stretchr/testify/assert" ) func createProgress(m *Manager, j *Job) Progress { return Progress{ updater: &updater{ m: m, job: j, }, total: 100, defined: true, processed: 10, percent: 10, } } func TestProgressIndefinite(t *testing.T) { m := NewManager() j := &Job{} p := createProgress(m, j) p.Indefinite() assert := assert.New(t) // ensure job progress was updated assert.Equal(ProgressIndefinite, j.Progress) } func TestProgressSetTotal(t *testing.T) { m := NewManager() j := &Job{} p := createProgress(m, j) p.SetTotal(50) assert := assert.New(t) // ensure job progress was updated assert.Equal(0.2, j.Progress) p.SetTotal(0) assert.Equal(ProgressIndefinite, j.Progress) p.SetTotal(-10) assert.Equal(ProgressIndefinite, j.Progress) p.SetTotal(9) assert.Equal(float64(1), j.Progress) } func TestProgressSetProcessed(t *testing.T) { m := NewManager() j := &Job{} p := createProgress(m, j) p.SetProcessed(30) assert := assert.New(t) // ensure job progress was updated assert.Equal(0.3, j.Progress) p.SetProcessed(-10) assert.Equal(float64(0), j.Progress) p.SetProcessed(200) assert.Equal(float64(1), j.Progress) } func TestProgressSetPercent(t *testing.T) { m := NewManager() j := &Job{} p := createProgress(m, j) p.SetPercent(0.3) assert := assert.New(t) // ensure job progress was updated assert.Equal(0.3, j.Progress) p.SetPercent(-10) assert.Equal(float64(0), j.Progress) p.SetPercent(200) assert.Equal(float64(1), j.Progress) } func TestProgressIncrement(t *testing.T) { m := NewManager() j := &Job{} p := createProgress(m, j) p.SetProcessed(49) p.Increment() assert := assert.New(t) // ensure job progress was updated assert.Equal(0.5, j.Progress) p.SetProcessed(100) p.Increment() assert.Equal(float64(1), j.Progress) } func TestExecuteTask(t *testing.T) { m := NewManager() j := &Job{} p := createProgress(m, j) c := make(chan struct{}, 1) const taskDesciption = "taskDescription" go p.ExecuteTask(taskDesciption, func() { <-c }) time.Sleep(sleepTime) assert := assert.New(t) m.mutex.Lock() // ensure task is added to the job details assert.Equal(taskDesciption, j.Details[0]) m.mutex.Unlock() // allow task to finish close(c) time.Sleep(sleepTime) m.mutex.Lock() // ensure task is removed from the job details assert.Len(j.Details, 0) m.mutex.Unlock() } ================================================ FILE: pkg/job/subscribe.go ================================================ package job // ManagerSubscription is a collection of channels that will receive updates // from the job manager. type ManagerSubscription struct { // new jobs are sent to this channel NewJob <-chan Job // removed jobs are sent to this channel RemovedJob <-chan Job // updated jobs are sent to this channel UpdatedJob <-chan Job newJob chan Job removedJob chan Job updatedJob chan Job } func newSubscription() *ManagerSubscription { ret := &ManagerSubscription{ newJob: make(chan Job, 100), removedJob: make(chan Job, 100), updatedJob: make(chan Job, 100), } ret.NewJob = ret.newJob ret.RemovedJob = ret.removedJob ret.UpdatedJob = ret.updatedJob return ret } func (s *ManagerSubscription) close() { close(s.newJob) close(s.removedJob) close(s.updatedJob) } ================================================ FILE: pkg/job/task.go ================================================ package job import ( "context" "github.com/remeh/sizedwaitgroup" ) type taskExec struct { task fn func(ctx context.Context) } type TaskQueue struct { p *Progress wg sizedwaitgroup.SizedWaitGroup tasks chan taskExec done chan struct{} } func NewTaskQueue(ctx context.Context, p *Progress, queueSize int, processes int) *TaskQueue { ret := &TaskQueue{ p: p, wg: sizedwaitgroup.New(processes), tasks: make(chan taskExec, queueSize), done: make(chan struct{}), } go ret.executer(ctx) return ret } func (tq *TaskQueue) Add(description string, fn func(ctx context.Context)) { tq.tasks <- taskExec{ task: task{ description: description, }, fn: fn, } } func (tq *TaskQueue) Close() { close(tq.tasks) // wait for all tasks to finish <-tq.done } func (tq *TaskQueue) executer(ctx context.Context) { defer close(tq.done) defer tq.wg.Wait() for task := range tq.tasks { if IsCancelled(ctx) { return } tt := task tq.wg.Add() go func() { defer tq.wg.Done() tq.p.ExecuteTask(tt.description, func() { tt.fn(ctx) }) }() } } ================================================ FILE: pkg/logger/basic.go ================================================ package logger import ( "fmt" "os" ) // BasicLogger logs all messages to stdout type BasicLogger struct{} var _ LoggerImpl = &BasicLogger{} func (log *BasicLogger) print(level string, args ...interface{}) { fmt.Print(level + ": ") fmt.Println(args...) } func (log *BasicLogger) printf(level string, format string, args ...interface{}) { fmt.Printf(level+": "+format+"\n", args...) } func (log *BasicLogger) Progressf(format string, args ...interface{}) { log.printf("Progress", format, args...) } func (log *BasicLogger) Trace(args ...interface{}) { log.print("Trace", args...) } func (log *BasicLogger) Tracef(format string, args ...interface{}) { log.printf("Trace", format, args...) } func (log *BasicLogger) TraceFunc(fn func() (string, []interface{})) { format, args := fn() log.printf("Trace", format, args...) } func (log *BasicLogger) Debug(args ...interface{}) { log.print("Debug", args...) } func (log *BasicLogger) Debugf(format string, args ...interface{}) { log.printf("Debug", format, args...) } func (log *BasicLogger) DebugFunc(fn func() (string, []interface{})) { format, args := fn() log.printf("Debug", format, args...) } func (log *BasicLogger) Info(args ...interface{}) { log.print("Info", args...) } func (log *BasicLogger) Infof(format string, args ...interface{}) { log.printf("Info", format, args...) } func (log *BasicLogger) InfoFunc(fn func() (string, []interface{})) { format, args := fn() log.printf("Info", format, args...) } func (log *BasicLogger) Warn(args ...interface{}) { log.print("Warn", args...) } func (log *BasicLogger) Warnf(format string, args ...interface{}) { log.printf("Warn", format, args...) } func (log *BasicLogger) WarnFunc(fn func() (string, []interface{})) { format, args := fn() log.printf("Warn", format, args...) } func (log *BasicLogger) Error(args ...interface{}) { log.print("Error", args...) } func (log *BasicLogger) Errorf(format string, args ...interface{}) { log.printf("Error", format, args...) } func (log *BasicLogger) ErrorFunc(fn func() (string, []interface{})) { format, args := fn() log.printf("Error", format, args...) } func (log *BasicLogger) Fatal(args ...interface{}) { log.print("Fatal", args...) os.Exit(1) } func (log *BasicLogger) Fatalf(format string, args ...interface{}) { log.printf("Fatal", format, args...) os.Exit(1) } ================================================ FILE: pkg/logger/logger.go ================================================ // Package logger provides methods and interfaces used by other stash packages for logging purposes. package logger import ( "os" ) // LoggerImpl is the interface that groups logging methods. // // Progressf logs using a specific progress format. // Trace, Debug, Info, Warn and Error log to the applicable log level. Arguments are handled in the manner of fmt.Print. // Tracef, Debugf, Infof, Warnf, Errorf log to the applicable log level. Arguments are handled in the manner of fmt.Printf. // Fatal and Fatalf log to the applicable log level, then call os.Exit(1). type LoggerImpl interface { Progressf(format string, args ...interface{}) Trace(args ...interface{}) Tracef(format string, args ...interface{}) TraceFunc(fn func() (string, []interface{})) Debug(args ...interface{}) Debugf(format string, args ...interface{}) DebugFunc(fn func() (string, []interface{})) Info(args ...interface{}) Infof(format string, args ...interface{}) InfoFunc(fn func() (string, []interface{})) Warn(args ...interface{}) Warnf(format string, args ...interface{}) WarnFunc(fn func() (string, []interface{})) Error(args ...interface{}) Errorf(format string, args ...interface{}) ErrorFunc(fn func() (string, []interface{})) Fatal(args ...interface{}) Fatalf(format string, args ...interface{}) } // Logger is the LoggerImpl used when calling the global Logger functions. // It is suggested to use the LoggerImpl interface directly, rather than calling global log functions. var Logger LoggerImpl // Progressf calls Progressf with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func Progressf(format string, args ...interface{}) { if Logger != nil { Logger.Progressf(format, args...) } } // Trace calls Trace with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func Trace(args ...interface{}) { if Logger != nil { Logger.Trace(args...) } } // Tracef calls Tracef with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func Tracef(format string, args ...interface{}) { if Logger != nil { Logger.Tracef(format, args...) } } // TraceFunc calls TraceFunc with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func TraceFunc(fn func() (string, []interface{})) { if Logger != nil { Logger.TraceFunc(fn) } } // Debug calls Debug with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func Debug(args ...interface{}) { if Logger != nil { Logger.Debug(args...) } } // Debugf calls Debugf with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func Debugf(format string, args ...interface{}) { if Logger != nil { Logger.Debugf(format, args...) } } // DebugFunc calls DebugFunc with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func DebugFunc(fn func() (string, []interface{})) { if Logger != nil { Logger.DebugFunc(fn) } } // Info calls Info with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func Info(args ...interface{}) { if Logger != nil { Logger.Info(args...) } } // Infof calls Infof with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func Infof(format string, args ...interface{}) { if Logger != nil { Logger.Infof(format, args...) } } // InfoFunc calls InfoFunc with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func InfoFunc(fn func() (string, []interface{})) { if Logger != nil { Logger.InfoFunc(fn) } } // Warn calls Warn with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func Warn(args ...interface{}) { if Logger != nil { Logger.Warn(args...) } } // Warnf calls Warnf with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func Warnf(format string, args ...interface{}) { if Logger != nil { Logger.Warnf(format, args...) } } // WarnFunc calls WarnFunc with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func WarnFunc(fn func() (string, []interface{})) { if Logger != nil { Logger.WarnFunc(fn) } } // Error calls Error with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func Error(args ...interface{}) { if Logger != nil { Logger.Error(args...) } } // Errorf calls Errorf with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func Errorf(format string, args ...interface{}) { if Logger != nil { Logger.Errorf(format, args...) } } // ErrorFunc calls ErrorFunc with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func ErrorFunc(fn func() (string, []interface{})) { if Logger != nil { Logger.ErrorFunc(fn) } } // Fatal calls Fatal with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func Fatal(args ...interface{}) { if Logger != nil { Logger.Fatal(args...) } else { os.Exit(1) } } // Fatalf calls Fatalf with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func Fatalf(format string, args ...interface{}) { if Logger != nil { Logger.Fatalf(format, args...) } else { os.Exit(1) } } ================================================ FILE: pkg/logger/plugin.go ================================================ package logger import ( "bufio" "fmt" "io" "os" "strconv" "strings" ) // PluginLogLevel represents a logging level for plugins to send log messages to stash. type PluginLogLevel struct { char byte name string } // Valid Level values. var ( TraceLevel = PluginLogLevel{ char: 't', name: "trace", } DebugLevel = PluginLogLevel{ char: 'd', name: "debug", } InfoLevel = PluginLogLevel{ char: 'i', name: "info", } WarningLevel = PluginLogLevel{ char: 'w', name: "warning", } ErrorLevel = PluginLogLevel{ char: 'e', name: "error", } ProgressLevel = PluginLogLevel{ char: 'p', name: "progress", } NoneLevel = PluginLogLevel{ name: "none", } ) var validLevels = []PluginLogLevel{ TraceLevel, DebugLevel, InfoLevel, WarningLevel, ErrorLevel, ProgressLevel, NoneLevel, } const startLevelChar byte = 1 const endLevelChar byte = 2 func (l PluginLogLevel) prefix() string { return string([]byte{ startLevelChar, byte(l.char), endLevelChar, }) } // Log prints the provided message to os.Stderr in a format that provides the correct LogLevel for stash. // The message is formatted in the same way as fmt.Println. func (l PluginLogLevel) Log(args ...interface{}) { if l.char == 0 { return } argsToUse := []interface{}{ l.prefix(), } argsToUse = append(argsToUse, args...) fmt.Fprintln(os.Stderr, argsToUse...) } // Logf prints the provided message to os.Stderr in a format that provides the correct LogLevel for stash. // The message is formatted in the same way as fmt.Printf. func (l PluginLogLevel) Logf(format string, args ...interface{}) { if l.char == 0 { return } formatToUse := string(l.prefix()) + format + "\n" fmt.Fprintf(os.Stderr, formatToUse, args...) } // PluginLogLevelFromName returns the PluginLogLevel that matches the provided name or nil if // the name does not match a valid value. func PluginLogLevelFromName(name string) *PluginLogLevel { for _, l := range validLevels { if l.name == name { return &l } } return nil } // detectLogLevel returns the Level and the logging string for a provided line // of plugin output. It parses the string for logging control characters and // determines the log level, if present. If not present, the plugin output // is returned unchanged with a nil Level. func detectLogLevel(line string) (*PluginLogLevel, string) { if len(line) < 4 || line[0] != startLevelChar || line[2] != endLevelChar { return nil, line } char := line[1] var level *PluginLogLevel for _, l := range validLevels { if l.char == char { l := l // Make a copy of the loop variable level = &l break } } if level == nil { return nil, line } line = strings.TrimSpace(line[3:]) return level, line } // PluginLogger interprets incoming log messages from plugins and logs to the appropriate log level. type PluginLogger struct { // Logger is the LoggerImpl to forward log messages to. Logger LoggerImpl // Prefix is the prefix to prepend to log messages. Prefix string // DefaultLogLevel is the log level used if a log level prefix is not present in the received log message. DefaultLogLevel *PluginLogLevel // ProgressChan is a channel that receives float64s indicating the current progress of an operation. ProgressChan chan float64 } func (log *PluginLogger) handleStderrLine(line string) { if log.Logger == nil { return } logger := log.Logger level, ll := detectLogLevel(line) // if no log level, just output to info if level == nil { if log.DefaultLogLevel != nil { level = log.DefaultLogLevel } else { level = &InfoLevel } } switch *level { case TraceLevel: logger.Trace(log.Prefix, ll) case DebugLevel: logger.Debug(log.Prefix, ll) case InfoLevel: logger.Info(log.Prefix, ll) case WarningLevel: logger.Warn(log.Prefix, ll) case ErrorLevel: logger.Error(log.Prefix, ll) case ProgressLevel: p, err := strconv.ParseFloat(ll, 64) if err != nil { logger.Errorf("Error parsing progress value '%s': %s", ll, err.Error()) } else if log.ProgressChan != nil { // only pass progress through if channel present // don't block on this select { case log.ProgressChan <- p: default: } } } } // ReadLogMessages reads plugin log messages from src, forwarding them to the PluginLoggers Logger. // ProgressLevel messages are parsed as float64 and forwarded to ProgressChan. If ProgressChan is full, // then the progress message is not forwarded. // This method only returns when it reaches the end of src or encounters an error while reading src. // This method closes src before returning. func (log *PluginLogger) ReadLogMessages(src io.ReadCloser) { // pipe plugin stderr to our logging scanner := bufio.NewScanner(src) for scanner.Scan() { str := scanner.Text() if str != "" { log.handleStderrLine(str) } } str := scanner.Text() if str != "" { log.handleStderrLine(str) } src.Close() } ================================================ FILE: pkg/logger/progress_formatter.go ================================================ package logger import ( "github.com/sirupsen/logrus" ) type ProgressFormatter struct{} func (f *ProgressFormatter) Format(entry *logrus.Entry) ([]byte, error) { msg := []byte("Processing --> " + entry.Message + "\r") return msg, nil } ================================================ FILE: pkg/match/cache.go ================================================ package match import ( "context" "github.com/stashapp/stash/pkg/models" ) const singleFirstCharacterRegex = `^[\p{L}][.\-_ ]` // Cache is used to cache queries that should not change across an autotag process. type Cache struct { singleCharPerformers []*models.Performer singleCharStudios []*models.Studio singleCharTags []*models.Tag } // getSingleLetterPerformers returns all performers with names that start with single character words. // The autotag query splits the words into two-character words to query // against. This means that performers with single-letter words in their names could potentially // be missed. // This query is expensive, so it's queried once and cached, if the cache if provided. func getSingleLetterPerformers(ctx context.Context, c *Cache, reader models.PerformerAutoTagQueryer) ([]*models.Performer, error) { if c == nil { c = &Cache{} } if c.singleCharPerformers == nil { pp := -1 performers, _, err := reader.Query(ctx, &models.PerformerFilterType{ Name: &models.StringCriterionInput{ Value: singleFirstCharacterRegex, Modifier: models.CriterionModifierMatchesRegex, }, }, &models.FindFilterType{ PerPage: &pp, }) if err != nil { return nil, err } if len(performers) == 0 { // make singleWordPerformers not nil c.singleCharPerformers = make([]*models.Performer, 0) } else { c.singleCharPerformers = performers } } return c.singleCharPerformers, nil } // getSingleLetterStudios returns all studios with names that start with single character words. // See getSingleLetterPerformers for details. func getSingleLetterStudios(ctx context.Context, c *Cache, reader models.StudioAutoTagQueryer) ([]*models.Studio, error) { if c == nil { c = &Cache{} } if c.singleCharStudios == nil { pp := -1 studios, _, err := reader.Query(ctx, &models.StudioFilterType{ Name: &models.StringCriterionInput{ Value: singleFirstCharacterRegex, Modifier: models.CriterionModifierMatchesRegex, }, }, &models.FindFilterType{ PerPage: &pp, }) if err != nil { return nil, err } if len(studios) == 0 { // make singleWordStudios not nil c.singleCharStudios = make([]*models.Studio, 0) } else { c.singleCharStudios = studios } } return c.singleCharStudios, nil } // getSingleLetterTags returns all tags with names that start with single character words. // See getSingleLetterPerformers for details. func getSingleLetterTags(ctx context.Context, c *Cache, reader models.TagAutoTagQueryer) ([]*models.Tag, error) { if c == nil { c = &Cache{} } if c.singleCharTags == nil { pp := -1 tags, _, err := reader.Query(ctx, &models.TagFilterType{ Name: &models.StringCriterionInput{ Value: singleFirstCharacterRegex, Modifier: models.CriterionModifierMatchesRegex, }, OperatorFilter: models.OperatorFilter[models.TagFilterType]{ Or: &models.TagFilterType{ Aliases: &models.StringCriterionInput{ Value: singleFirstCharacterRegex, Modifier: models.CriterionModifierMatchesRegex, }, }, }, }, &models.FindFilterType{ PerPage: &pp, }) if err != nil { return nil, err } if len(tags) == 0 { // make singleWordTags not nil c.singleCharTags = make([]*models.Tag, 0) } else { c.singleCharTags = tags } } return c.singleCharTags, nil } ================================================ FILE: pkg/match/path.go ================================================ // Package match provides functions for matching paths to models. package match import ( "context" "fmt" "path/filepath" "regexp" "strings" "unicode" "unicode/utf8" "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/sliceutil" ) const ( separatorChars = `.\-_ ` separatorPattern = `(?:_|[^\p{L}\w\d])+` reNotLetterWordUnicode = `[^\p{L}\w\d]` reNotLetterWord = `[^\w\d]` ) var separatorRE = regexp.MustCompile(separatorPattern) func getPathQueryRegex(name string) string { // escape specific regex characters name = regexp.QuoteMeta(name) // handle path separators const separator = `[` + separatorChars + `]` ret := strings.ReplaceAll(name, " ", separator+"*") ret = `(?:^|_|[^\p{L}\d])` + ret + `(?:$|_|[^\p{L}\d])` return ret } func getPathWords(path string, trimExt bool) []string { retStr := path if trimExt { // remove the extension ext := filepath.Ext(retStr) if ext != "" { retStr = strings.TrimSuffix(retStr, ext) } } // handle path separators retStr = separatorRE.ReplaceAllString(retStr, " ") words := strings.Split(retStr, " ") // remove any single letter words var ret []string for _, w := range words { if utf8.RuneCountInString(w) > 1 { // #1450 - we need to open up the criteria for matching so that we // can match where path has no space between subject names - // ie name = "foo bar" - path = "foobar" // we post-match afterwards, so we can afford to be a little loose // with the query // just use the first two characters // #2293 - need to convert to unicode runes for the substring, otherwise // the resulting string is corrupted. ret = sliceutil.AppendUnique(ret, string([]rune(w)[0:2])) } } return ret } // https://stackoverflow.com/a/53069799 func allASCII(s string) bool { for i := 0; i < len(s); i++ { if s[i] > unicode.MaxASCII { return false } } return true } // nameMatchesPath returns the index in the path for the right-most match. // Returns -1 if not found. func nameMatchesPath(name, path string) int { // #2363 - optimisation: only use unicode character regexp if path contains // unicode characters re := nameToRegexp(name, !allASCII(path)) return regexpMatchesPath(re, path) } // nameToRegexp compiles a regexp pattern to match paths from the given name. // Set useUnicode to true if this regexp is to be used on any strings with unicode characters. func nameToRegexp(name string, useUnicode bool) *regexp.Regexp { // escape specific regex characters name = regexp.QuoteMeta(name) name = strings.ToLower(name) // handle path separators const separator = `[` + separatorChars + `]` // performance optimisation: only use \p{L} is useUnicode is true notWord := reNotLetterWord if useUnicode { notWord = reNotLetterWordUnicode } reStr := strings.ReplaceAll(name, " ", separator+"*") reStr = `(?:^|_|` + notWord + `)` + reStr + `(?:$|_|` + notWord + `)` re := regexp.MustCompile(reStr) return re } func regexpMatchesPath(r *regexp.Regexp, path string) int { path = strings.ToLower(path) found := r.FindAllStringIndex(path, -1) if found == nil { return -1 } return found[len(found)-1][0] } func getPerformers(ctx context.Context, words []string, performerReader models.PerformerAutoTagQueryer, cache *Cache) ([]*models.Performer, error) { performers, err := performerReader.QueryForAutoTag(ctx, words) if err != nil { return nil, err } swPerformers, err := getSingleLetterPerformers(ctx, cache, performerReader) if err != nil { return nil, err } return append(performers, swPerformers...), nil } func PathToPerformers(ctx context.Context, path string, reader models.PerformerAutoTagQueryer, cache *Cache, trimExt bool) ([]*models.Performer, error) { words := getPathWords(path, trimExt) performers, err := getPerformers(ctx, words, reader, cache) if err != nil { return nil, err } var ret []*models.Performer for _, p := range performers { matches := false if nameMatchesPath(p.Name, path) != -1 { matches = true } // TODO - disabled alias matching until we can get finer // control over the matching // if !matches { // if err := p.LoadAliases(ctx, reader); err != nil { // return nil, err // } // for _, alias := range p.Aliases.List() { // if nameMatchesPath(alias, path) != -1 { // matches = true // break // } // } // } if matches { ret = append(ret, p) } } return ret, nil } func getStudios(ctx context.Context, words []string, reader models.StudioAutoTagQueryer, cache *Cache) ([]*models.Studio, error) { studios, err := reader.QueryForAutoTag(ctx, words) if err != nil { return nil, err } swStudios, err := getSingleLetterStudios(ctx, cache, reader) if err != nil { return nil, err } return append(studios, swStudios...), nil } // PathToStudio returns the Studio that matches the given path. // Where multiple matching studios are found, the one that matches the latest // position in the path is returned. func PathToStudio(ctx context.Context, path string, reader models.StudioAutoTagQueryer, cache *Cache, trimExt bool) (*models.Studio, error) { words := getPathWords(path, trimExt) candidates, err := getStudios(ctx, words, reader, cache) if err != nil { return nil, err } var ret *models.Studio index := -1 for _, c := range candidates { matchIndex := nameMatchesPath(c.Name, path) if matchIndex != -1 && matchIndex > index { ret = c index = matchIndex } aliases, err := reader.GetAliases(ctx, c.ID) if err != nil { return nil, err } for _, alias := range aliases { matchIndex = nameMatchesPath(alias, path) if matchIndex != -1 && matchIndex > index { ret = c index = matchIndex } } } return ret, nil } func getTags(ctx context.Context, words []string, reader models.TagAutoTagQueryer, cache *Cache) ([]*models.Tag, error) { tags, err := reader.QueryForAutoTag(ctx, words) if err != nil { return nil, err } swTags, err := getSingleLetterTags(ctx, cache, reader) if err != nil { return nil, err } return append(tags, swTags...), nil } func PathToTags(ctx context.Context, path string, reader models.TagAutoTagQueryer, cache *Cache, trimExt bool) ([]*models.Tag, error) { words := getPathWords(path, trimExt) tags, err := getTags(ctx, words, reader, cache) if err != nil { return nil, err } var ret []*models.Tag for _, t := range tags { matches := false if nameMatchesPath(t.Name, path) != -1 { matches = true } if !matches { aliases, err := reader.GetAliases(ctx, t.ID) if err != nil { return nil, err } for _, alias := range aliases { if nameMatchesPath(alias, path) != -1 { matches = true break } } } if matches { ret = append(ret, t) } } return ret, nil } func PathToScenesFn(ctx context.Context, name string, paths []string, sceneReader models.SceneQueryer, fn func(ctx context.Context, scene *models.Scene) error) error { regex := getPathQueryRegex(name) organized := false filter := models.SceneFilterType{ Path: &models.StringCriterionInput{ Value: "(?i)" + regex, Modifier: models.CriterionModifierMatchesRegex, }, Organized: &organized, } filter.And = scene.PathsFilter(paths) // do in batches pp := 1000 sort := "id" sortDir := models.SortDirectionEnumAsc lastID := 0 for { if lastID != 0 { filter.ID = &models.IntCriterionInput{ Value: lastID, Modifier: models.CriterionModifierGreaterThan, } } scenes, err := scene.Query(ctx, sceneReader, &filter, &models.FindFilterType{ PerPage: &pp, Sort: &sort, Direction: &sortDir, }) if err != nil { return fmt.Errorf("error querying scenes with regex '%s': %s", regex, err.Error()) } // paths may have unicode characters const useUnicode = true r := nameToRegexp(name, useUnicode) for _, p := range scenes { if regexpMatchesPath(r, p.Path) != -1 { if err := fn(ctx, p); err != nil { return fmt.Errorf("processing scene %s: %w", p.GetTitle(), err) } } } if len(scenes) < pp { break } lastID = scenes[len(scenes)-1].ID } return nil } func PathToImagesFn(ctx context.Context, name string, paths []string, imageReader models.ImageQueryer, fn func(ctx context.Context, scene *models.Image) error) error { regex := getPathQueryRegex(name) organized := false filter := models.ImageFilterType{ Path: &models.StringCriterionInput{ Value: "(?i)" + regex, Modifier: models.CriterionModifierMatchesRegex, }, Organized: &organized, } filter.And = image.PathsFilter(paths) // do in batches pp := 1000 sort := "id" sortDir := models.SortDirectionEnumAsc lastID := 0 for { if lastID != 0 { filter.ID = &models.IntCriterionInput{ Value: lastID, Modifier: models.CriterionModifierGreaterThan, } } images, err := image.Query(ctx, imageReader, &filter, &models.FindFilterType{ PerPage: &pp, Sort: &sort, Direction: &sortDir, }) if err != nil { return fmt.Errorf("error querying images with regex '%s': %s", regex, err.Error()) } // paths may have unicode characters const useUnicode = true r := nameToRegexp(name, useUnicode) for _, p := range images { if regexpMatchesPath(r, p.Path) != -1 { if err := fn(ctx, p); err != nil { return fmt.Errorf("processing image %s: %w", p.GetTitle(), err) } } } if len(images) < pp { break } lastID = images[len(images)-1].ID } return nil } func PathToGalleriesFn(ctx context.Context, name string, paths []string, galleryReader models.GalleryQueryer, fn func(ctx context.Context, scene *models.Gallery) error) error { regex := getPathQueryRegex(name) organized := false filter := models.GalleryFilterType{ Path: &models.StringCriterionInput{ Value: "(?i)" + regex, Modifier: models.CriterionModifierMatchesRegex, }, Organized: &organized, } filter.And = gallery.PathsFilter(paths) // do in batches pp := 1000 sort := "id" sortDir := models.SortDirectionEnumAsc lastID := 0 for { if lastID != 0 { filter.ID = &models.IntCriterionInput{ Value: lastID, Modifier: models.CriterionModifierGreaterThan, } } galleries, _, err := galleryReader.Query(ctx, &filter, &models.FindFilterType{ PerPage: &pp, Sort: &sort, Direction: &sortDir, }) if err != nil { return fmt.Errorf("error querying galleries with regex '%s': %s", regex, err.Error()) } // paths may have unicode characters const useUnicode = true r := nameToRegexp(name, useUnicode) for _, p := range galleries { path := p.Path if path != "" && regexpMatchesPath(r, path) != -1 { if err := fn(ctx, p); err != nil { return fmt.Errorf("processing gallery %s: %w", p.GetTitle(), err) } } } if len(galleries) < pp { break } lastID = galleries[len(galleries)-1].ID } return nil } ================================================ FILE: pkg/match/path_test.go ================================================ package match import "testing" func Test_nameMatchesPath(t *testing.T) { const name = "first last" const unicodeName = "伏字" tests := []struct { testName string name string path string want int }{ { "exact", name, name, 0, }, { "partial", name, "first", -1, }, { "separator", name, "first.last", 0, }, { "separator", name, "first-last", 0, }, { "separator", name, "first_last", 0, }, { "separators", name, "first.-_ last", 0, }, { "within string", name, "before_first last/after", 6, }, { "within string case insensitive", name, "before FIRST last/after", 6, }, { "not within string", name, "beforefirst last/after", -1, }, { "not within string", name, "before/first lastafter", -1, }, { "not within string", name, "first last1", -1, }, { "not within string", name, "1first last", -1, }, { "unicode", unicodeName, unicodeName, 0, }, } for _, tt := range tests { t.Run(tt.testName, func(t *testing.T) { if got := nameMatchesPath(tt.name, tt.path); got != tt.want { t.Errorf("nameMatchesPath() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: pkg/match/scraped.go ================================================ package match import ( "context" "strconv" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/studio" "github.com/stashapp/stash/pkg/tag" ) type PerformerFinder interface { models.PerformerQueryer FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Performer, error) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Performer, error) } type GroupNamesFinder interface { FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Group, error) } type SceneRelationships struct { PerformerFinder PerformerFinder TagFinder models.TagQueryer StudioFinder StudioFinder } // MatchRelationships accepts a scraped scene and attempts to match its relationships to existing stash models. func (r SceneRelationships) MatchRelationships(ctx context.Context, s *models.ScrapedScene, endpoint string) error { thisStudio := s.Studio for thisStudio != nil { if err := ScrapedStudio(ctx, r.StudioFinder, thisStudio, endpoint); err != nil { return err } thisStudio = thisStudio.Parent } for _, p := range s.Performers { err := ScrapedPerformer(ctx, r.PerformerFinder, p, endpoint) if err != nil { return err } } for _, t := range s.Tags { err := ScrapedTag(ctx, r.TagFinder, t, endpoint) if err != nil { return err } } return nil } // ScrapedPerformer matches the provided performer with the // performers in the database and sets the ID field if one is found. func ScrapedPerformer(ctx context.Context, qb PerformerFinder, p *models.ScrapedPerformer, stashBoxEndpoint string) error { if p.StoredID != nil || p.Name == nil { return nil } // Check if a performer with the StashID already exists if stashBoxEndpoint != "" && p.RemoteSiteID != nil { performers, err := qb.FindByStashID(ctx, models.StashID{ StashID: *p.RemoteSiteID, Endpoint: stashBoxEndpoint, }) if err != nil { return err } if len(performers) > 0 { id := strconv.Itoa(performers[0].ID) p.StoredID = &id return nil } } performers, err := qb.FindByNames(ctx, []string{*p.Name}, true) if err != nil { return err } if len(performers) == 0 { // if no names matched, try match an exact alias performers, err = performer.ByAlias(ctx, qb, *p.Name) if err != nil { return err } } if len(performers) != 1 { // ignore - cannot match return nil } id := strconv.Itoa(performers[0].ID) p.StoredID = &id return nil } type StudioFinder interface { models.StudioQueryer FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Studio, error) } // ScrapedStudio matches the provided studio with the studios // in the database and sets the ID field if one is found. func ScrapedStudio(ctx context.Context, qb StudioFinder, s *models.ScrapedStudio, stashBoxEndpoint string) error { if s.StoredID != nil { return nil } // Check if a studio with the StashID already exists if stashBoxEndpoint != "" && s.RemoteSiteID != nil { studios, err := qb.FindByStashID(ctx, models.StashID{ StashID: *s.RemoteSiteID, Endpoint: stashBoxEndpoint, }) if err != nil { return err } if len(studios) > 0 { id := strconv.Itoa(studios[0].ID) s.StoredID = &id return nil } } st, err := studio.ByName(ctx, qb, s.Name) if err != nil { return err } if st == nil { // try matching by alias st, err = studio.ByAlias(ctx, qb, s.Name) if err != nil { return err } } if st == nil { // ignore - cannot match return nil } id := strconv.Itoa(st.ID) s.StoredID = &id return nil } // ScrapedStudioHierarchy executes ScrapedStudio for the provided studio and its parents recursively. func ScrapedStudioHierarchy(ctx context.Context, qb StudioFinder, s *models.ScrapedStudio, stashBoxEndpoint string) error { if err := ScrapedStudio(ctx, qb, s, stashBoxEndpoint); err != nil { return err } if s.Parent == nil { return nil } return ScrapedStudioHierarchy(ctx, qb, s.Parent, stashBoxEndpoint) } // ScrapedGroup matches the provided movie with the movies // in the database and returns the ID field if one is found. func ScrapedGroup(ctx context.Context, qb GroupNamesFinder, storedID *string, name *string) (matchedID *string, err error) { if storedID != nil || name == nil { return } movies, err := qb.FindByNames(ctx, []string{*name}, true) if err != nil { return } if len(movies) != 1 { // ignore - cannot match return } id := strconv.Itoa(movies[0].ID) matchedID = &id return } // ScrapedTagHierarchy executes ScrapedTag for the provided tag and its parent. func ScrapedTagHierarchy(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error { if err := ScrapedTag(ctx, qb, s, stashBoxEndpoint); err != nil { return err } if s.Parent == nil { return nil } // Match parent by name only (categories don't have StashDB tag IDs) return ScrapedTag(ctx, qb, s.Parent, "") } // ScrapedTag matches the provided tag with the tags // in the database and sets the ID field if one is found. func ScrapedTag(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error { if s.StoredID != nil { return nil } // Check if a tag with the StashID already exists if stashBoxEndpoint != "" && s.RemoteSiteID != nil { if finder, ok := qb.(models.TagFinder); ok { tags, err := finder.FindByStashID(ctx, models.StashID{ StashID: *s.RemoteSiteID, Endpoint: stashBoxEndpoint, }) if err != nil { return err } if len(tags) > 0 { id := strconv.Itoa(tags[0].ID) s.StoredID = &id return nil } } } t, err := tag.ByName(ctx, qb, s.Name) if err != nil { return err } if t == nil { // try matching by alias t, err = tag.ByAlias(ctx, qb, s.Name) if err != nil { return err } } if t == nil { // ignore - cannot match return nil } id := strconv.Itoa(t.ID) s.StoredID = &id return nil } ================================================ FILE: pkg/models/custom_fields.go ================================================ package models import "context" type CustomFieldMap map[string]interface{} type CustomFieldsInput struct { // If populated, the entire custom fields map will be replaced with this value Full map[string]interface{} `json:"full"` // If populated, only the keys in this map will be updated Partial map[string]interface{} `json:"partial"` // Remove any keys in this list Remove []string `json:"remove"` } type CustomFieldsReader interface { GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]CustomFieldMap, error) } type CustomFieldsWriter interface { SetCustomFields(ctx context.Context, id int, fields CustomFieldsInput) error } ================================================ FILE: pkg/models/date.go ================================================ package models import ( "fmt" "strings" "time" "github.com/stashapp/stash/pkg/utils" ) type DatePrecision int const ( // default precision is day DatePrecisionDay DatePrecision = iota DatePrecisionMonth DatePrecisionYear ) // Date wraps a time.Time with a format of "YYYY-MM-DD" type Date struct { time.Time Precision DatePrecision } var dateFormatPrecision = []string{ "2006-01-02", "2006-01", "2006", } func (d Date) String() string { return d.Format(dateFormatPrecision[d.Precision]) } func (d Date) After(o Date) bool { return d.Time.After(o.Time) } // ParseDate tries to parse the input string into a date using utils.ParseDateStringAsTime. // If that fails, it attempts to parse the string with decreasing precision (month, then year). // It returns a Date struct with the appropriate precision set, or an error if all parsing attempts fail. func ParseDate(s string) (Date, error) { var errs []error // default parse to day precision ret, err := utils.ParseDateStringAsTime(s) if err == nil { return Date{Time: ret, Precision: DatePrecisionDay}, nil } errs = append(errs, err) // try month and year precision for i, format := range dateFormatPrecision[1:] { ret, err := time.Parse(format, s) if err == nil { return Date{Time: ret, Precision: DatePrecision(i + 1)}, nil } errs = append(errs, err) } return Date{}, fmt.Errorf("failed to parse date %q: %v", s, errs) } func DateFromYear(year int) Date { return Date{ Time: time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionYear, } } func FormatYearRange(start *Date, end *Date) string { var ( startStr, endStr string ) if start != nil { startStr = start.Format(dateFormatPrecision[DatePrecisionYear]) } if end != nil { endStr = end.Format(dateFormatPrecision[DatePrecisionYear]) } switch { case startStr == "" && endStr == "": return "" case endStr == "": return fmt.Sprintf("%s -", startStr) case startStr == "": return fmt.Sprintf("- %s", endStr) default: return fmt.Sprintf("%s - %s", startStr, endStr) } } func FormatYearRangeString(start *string, end *string) string { switch { case start == nil && end == nil: return "" case end == nil: return fmt.Sprintf("%s -", *start) case start == nil: return fmt.Sprintf("- %s", *end) default: return fmt.Sprintf("%s - %s", *start, *end) } } // ParseYearRangeString parses a year range string into start and end year integers. // Supported formats: "YYYY", "YYYY - YYYY", "YYYY-YYYY", "YYYY -", "- YYYY", "YYYY-present". // Returns nil for start/end if not present in the string. func ParseYearRangeString(s string) (start *Date, end *Date, err error) { s = strings.TrimSpace(s) if s == "" { return nil, nil, fmt.Errorf("empty year range string") } // normalize "present" to empty end lower := strings.ToLower(s) lower = strings.ReplaceAll(lower, "present", "") // split on "-" if it contains one var parts []string if strings.Contains(lower, "-") { parts = strings.SplitN(lower, "-", 2) } else { // single value, treat as start year year, err := parseYear(lower) if err != nil { return nil, nil, fmt.Errorf("invalid year range %q: %w", s, err) } return year, nil, nil } startStr := strings.TrimSpace(parts[0]) endStr := strings.TrimSpace(parts[1]) if startStr != "" { y, err := parseYear(startStr) if err != nil { return nil, nil, fmt.Errorf("invalid start year in %q: %w", s, err) } start = y } if endStr != "" { y, err := parseYear(endStr) if err != nil { return nil, nil, fmt.Errorf("invalid end year in %q: %w", s, err) } end = y } if start == nil && end == nil { return nil, nil, fmt.Errorf("could not parse year range %q", s) } return start, end, nil } func parseYear(s string) (*Date, error) { ret, err := ParseDate(s) if err != nil { return nil, fmt.Errorf("parsing year %q: %w", s, err) } year := ret.Time.Year() if year < 1900 || year > 2200 { return nil, fmt.Errorf("year %d out of reasonable range", year) } return &ret, nil } ================================================ FILE: pkg/models/date_test.go ================================================ package models import ( "testing" "time" "github.com/stretchr/testify/assert" ) func TestParseDateStringAsTime(t *testing.T) { tests := []struct { name string input string output Date expectError bool }{ // Full date formats (existing support) {"RFC3339", "2014-01-02T15:04:05Z", Date{Time: time.Date(2014, 1, 2, 15, 4, 5, 0, time.UTC), Precision: DatePrecisionDay}, false}, {"Date only", "2014-01-02", Date{Time: time.Date(2014, 1, 2, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionDay}, false}, {"Date with time", "2014-01-02 15:04:05", Date{Time: time.Date(2014, 1, 2, 15, 4, 5, 0, time.UTC), Precision: DatePrecisionDay}, false}, // Partial date formats (new support) {"Year-Month", "2006-08", Date{Time: time.Date(2006, 8, 1, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionMonth}, false}, {"Year only", "2014", Date{Time: time.Date(2014, 1, 1, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionYear}, false}, // Invalid formats {"Invalid format", "not-a-date", Date{}, true}, {"Empty string", "", Date{}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := ParseDate(tt.input) if tt.expectError { if err == nil { t.Errorf("Expected error for input %q, but got none", tt.input) } return } if err != nil { t.Errorf("Unexpected error for input %q: %v", tt.input, err) return } if !result.Time.Equal(tt.output.Time) || result.Precision != tt.output.Precision { t.Errorf("For input %q, expected output %+v, got %+v", tt.input, tt.output, result) } }) } } func TestFormatYearRange(t *testing.T) { datePtr := func(v int) *Date { date := DateFromYear(v) return &date } tests := []struct { name string start *Date end *Date want string }{ {"both nil", nil, nil, ""}, {"only start", datePtr(2005), nil, "2005 -"}, {"only end", nil, datePtr(2010), "- 2010"}, {"start and end", datePtr(2005), datePtr(2010), "2005 - 2010"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := FormatYearRange(tt.start, tt.end) assert.Equal(t, tt.want, got) }) } } func TestFormatYearRangeString(t *testing.T) { stringPtr := func(v string) *string { return &v } tests := []struct { name string start *string end *string want string }{ {"both nil", nil, nil, ""}, {"only start", stringPtr("2005"), nil, "2005 -"}, {"only end", nil, stringPtr("2010"), "- 2010"}, {"start and end", stringPtr("2005"), stringPtr("2010"), "2005 - 2010"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := FormatYearRangeString(tt.start, tt.end) assert.Equal(t, tt.want, got) }) } } func TestParseYearRangeString(t *testing.T) { intPtr := func(v int) *int { return &v } tests := []struct { name string input string wantStart *int wantEnd *int wantErr bool }{ {"single year", "2005", intPtr(2005), nil, false}, {"year range with spaces", "2005 - 2010", intPtr(2005), intPtr(2010), false}, {"year range no spaces", "2005-2010", intPtr(2005), intPtr(2010), false}, {"year dash open", "2005 -", intPtr(2005), nil, false}, {"year dash open no space", "2005-", intPtr(2005), nil, false}, {"dash year", "- 2010", nil, intPtr(2010), false}, {"year present", "2005-present", intPtr(2005), nil, false}, {"year Present caps", "2005 - Present", intPtr(2005), nil, false}, {"whitespace padding", " 2005 - 2010 ", intPtr(2005), intPtr(2010), false}, {"empty string", "", nil, nil, true}, {"garbage", "not a year", nil, nil, true}, {"partial garbage start", "abc - 2010", nil, nil, true}, {"partial garbage end", "2005 - abc", nil, nil, true}, {"year out of range", "1800", nil, nil, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { start, end, err := ParseYearRangeString(tt.input) if tt.wantErr { assert.Error(t, err) return } assert.NoError(t, err) if tt.wantStart != nil { assert.NotNil(t, start) assert.Equal(t, *tt.wantStart, start.Time.Year()) } else { assert.Nil(t, start) } if tt.wantEnd != nil { assert.NotNil(t, end) assert.Equal(t, *tt.wantEnd, end.Time.Year()) } else { assert.Nil(t, end) } }) } } ================================================ FILE: pkg/models/doc.go ================================================ // Package models provides application models that are used throughout the application. package models ================================================ FILE: pkg/models/errors.go ================================================ package models import "errors" var ( // ErrNotFound signifies entities which are not found ErrNotFound = errors.New("not found") // ErrConversion signifies conversion errors ErrConversion = errors.New("conversion error") ErrScraperSource = errors.New("invalid ScraperSource") ) ================================================ FILE: pkg/models/file.go ================================================ package models import ( "context" "path/filepath" "strings" ) type FileQueryOptions struct { QueryOptions FileFilter *FileFilterType TotalDuration bool Megapixels bool TotalSize bool } type FileFilterType struct { OperatorFilter[FileFilterType] // Filter by path Path *StringCriterionInput `json:"path"` Basename *StringCriterionInput `json:"basename"` Dir *StringCriterionInput `json:"dir"` ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder"` ZipFile *MultiCriterionInput `json:"zip_file"` ModTime *TimestampCriterionInput `json:"mod_time"` Duplicated *FileDuplicationCriterionInput `json:"duplicated"` Hashes []*FingerprintFilterInput `json:"hashes"` VideoFileFilter *VideoFileFilterInput `json:"video_file_filter"` ImageFileFilter *ImageFileFilterInput `json:"image_file_filter"` SceneCount *IntCriterionInput `json:"scene_count"` ImageCount *IntCriterionInput `json:"image_count"` GalleryCount *IntCriterionInput `json:"gallery_count"` ScenesFilter *SceneFilterType `json:"scenes_filter"` ImagesFilter *ImageFilterType `json:"images_filter"` GalleriesFilter *GalleryFilterType `json:"galleries_filter"` CreatedAt *TimestampCriterionInput `json:"created_at"` UpdatedAt *TimestampCriterionInput `json:"updated_at"` } func PathsFileFilter(paths []string) *FileFilterType { if paths == nil { return nil } sep := string(filepath.Separator) var ret *FileFilterType var or *FileFilterType for _, p := range paths { newOr := &FileFilterType{} if or != nil { or.Or = newOr } else { ret = newOr } or = newOr if !strings.HasSuffix(p, sep) { p += sep } or.Path = &StringCriterionInput{ Modifier: CriterionModifierEquals, Value: p + "%", } } return ret } type FileQueryResult struct { QueryResult[FileID] TotalDuration float64 Megapixels float64 TotalSize int64 getter FileGetter files []File resolveErr error } func NewFileQueryResult(fileGetter FileGetter) *FileQueryResult { return &FileQueryResult{ getter: fileGetter, } } func (r *FileQueryResult) Resolve(ctx context.Context) ([]File, error) { // cache results if r.files == nil && r.resolveErr == nil { r.files, r.resolveErr = r.getter.Find(ctx, r.IDs...) } return r.files, r.resolveErr } ================================================ FILE: pkg/models/filename_parser.go ================================================ package models type SceneParserInput struct { IgnoreWords []string `json:"ignoreWords"` WhitespaceCharacters *string `json:"whitespaceCharacters"` CapitalizeTitle *bool `json:"capitalizeTitle"` IgnoreOrganized *bool `json:"ignoreOrganized"` } type SceneParserResult struct { Scene *Scene `json:"scene"` Title *string `json:"title"` Code *string `json:"code"` Details *string `json:"details"` Director *string `json:"director"` URL *string `json:"url"` Date *string `json:"date"` Rating *int `json:"rating"` Rating100 *int `json:"rating100"` StudioID *string `json:"studio_id"` GalleryIds []string `json:"gallery_ids"` PerformerIds []string `json:"performer_ids"` Movies []*SceneMovieID `json:"movies"` TagIds []string `json:"tag_ids"` } type SceneMovieID struct { MovieID string `json:"movie_id"` SceneIndex *string `json:"scene_index"` } ================================================ FILE: pkg/models/filter.go ================================================ package models import ( "fmt" "io" "strconv" ) type OperatorFilter[T any] struct { And *T `json:"AND"` Or *T `json:"OR"` Not *T `json:"NOT"` } // SubFilter returns the subfilter of the operator filter. // Only one of And, Or, or Not should be set, so it returns the first of these that are not nil. func (f *OperatorFilter[T]) SubFilter() *T { if f.And != nil { return f.And } if f.Or != nil { return f.Or } if f.Not != nil { return f.Not } return nil } type CriterionModifier string const ( // = CriterionModifierEquals CriterionModifier = "EQUALS" // != CriterionModifierNotEquals CriterionModifier = "NOT_EQUALS" // > CriterionModifierGreaterThan CriterionModifier = "GREATER_THAN" // < CriterionModifierLessThan CriterionModifier = "LESS_THAN" // IS NULL CriterionModifierIsNull CriterionModifier = "IS_NULL" // IS NOT NULL CriterionModifierNotNull CriterionModifier = "NOT_NULL" // INCLUDES ALL CriterionModifierIncludesAll CriterionModifier = "INCLUDES_ALL" CriterionModifierIncludes CriterionModifier = "INCLUDES" CriterionModifierExcludes CriterionModifier = "EXCLUDES" // MATCHES REGEX CriterionModifierMatchesRegex CriterionModifier = "MATCHES_REGEX" // NOT MATCHES REGEX CriterionModifierNotMatchesRegex CriterionModifier = "NOT_MATCHES_REGEX" // >= AND <= CriterionModifierBetween CriterionModifier = "BETWEEN" // < OR > CriterionModifierNotBetween CriterionModifier = "NOT_BETWEEN" ) var AllCriterionModifier = []CriterionModifier{ CriterionModifierEquals, CriterionModifierNotEquals, CriterionModifierGreaterThan, CriterionModifierLessThan, CriterionModifierIsNull, CriterionModifierNotNull, CriterionModifierIncludesAll, CriterionModifierIncludes, CriterionModifierExcludes, CriterionModifierMatchesRegex, CriterionModifierNotMatchesRegex, CriterionModifierBetween, CriterionModifierNotBetween, } func (e CriterionModifier) IsValid() bool { switch e { case CriterionModifierEquals, CriterionModifierNotEquals, CriterionModifierGreaterThan, CriterionModifierLessThan, CriterionModifierIsNull, CriterionModifierNotNull, CriterionModifierIncludesAll, CriterionModifierIncludes, CriterionModifierExcludes, CriterionModifierMatchesRegex, CriterionModifierNotMatchesRegex, CriterionModifierBetween, CriterionModifierNotBetween: return true } return false } func (e CriterionModifier) String() string { return string(e) } func (e *CriterionModifier) UnmarshalGQL(v interface{}) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = CriterionModifier(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid CriterionModifier", str) } return nil } func (e CriterionModifier) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } type StringCriterionInput struct { Value string `json:"value"` Modifier CriterionModifier `json:"modifier"` } func (i StringCriterionInput) ValidModifier() bool { switch i.Modifier { case CriterionModifierEquals, CriterionModifierNotEquals, CriterionModifierIncludes, CriterionModifierExcludes, CriterionModifierMatchesRegex, CriterionModifierNotMatchesRegex, CriterionModifierIsNull, CriterionModifierNotNull: return true } return false } type IntCriterionInput struct { Value int `json:"value"` Value2 *int `json:"value2"` Modifier CriterionModifier `json:"modifier"` } func (i IntCriterionInput) ValidModifier() bool { switch i.Modifier { case CriterionModifierEquals, CriterionModifierNotEquals, CriterionModifierGreaterThan, CriterionModifierLessThan, CriterionModifierIsNull, CriterionModifierNotNull, CriterionModifierBetween, CriterionModifierNotBetween: return true } return false } type FloatCriterionInput struct { Value float64 `json:"value"` Value2 *float64 `json:"value2"` Modifier CriterionModifier `json:"modifier"` } func (i FloatCriterionInput) ValidModifier() bool { switch i.Modifier { case CriterionModifierEquals, CriterionModifierNotEquals, CriterionModifierGreaterThan, CriterionModifierLessThan, CriterionModifierIsNull, CriterionModifierNotNull, CriterionModifierBetween, CriterionModifierNotBetween: return true } return false } type ResolutionCriterionInput struct { Value ResolutionEnum `json:"value"` Modifier CriterionModifier `json:"modifier"` } type HierarchicalMultiCriterionInput struct { Value []string `json:"value"` Modifier CriterionModifier `json:"modifier"` Depth *int `json:"depth"` Excludes []string `json:"excludes"` } func (i HierarchicalMultiCriterionInput) CombineExcludes() HierarchicalMultiCriterionInput { ii := i if ii.Modifier == CriterionModifierExcludes { ii.Modifier = CriterionModifierIncludesAll ii.Excludes = append(ii.Excludes, ii.Value...) ii.Value = nil } return ii } type MultiCriterionInput struct { Value []string `json:"value"` Modifier CriterionModifier `json:"modifier"` Excludes []string `json:"excludes"` } type DateCriterionInput struct { Value string `json:"value"` Value2 *string `json:"value2"` Modifier CriterionModifier `json:"modifier"` } type TimestampCriterionInput struct { Value string `json:"value"` Value2 *string `json:"value2"` Modifier CriterionModifier `json:"modifier"` } type PhashDistanceCriterionInput struct { Value string `json:"value"` Modifier CriterionModifier `json:"modifier"` Distance *int `json:"distance"` } type OrientationCriterionInput struct { Value []OrientationEnum `json:"value"` } type CustomFieldCriterionInput struct { Field string `json:"field"` Value []any `json:"value"` Modifier CriterionModifier `json:"modifier"` } type FingerprintFilterInput struct { Type string `json:"type"` Value string `json:"value"` // Hamming distance - defaults to 0 Distance *int `json:"distance,omitempty"` } type VideoFileFilterInput struct { Format *StringCriterionInput `json:"format,omitempty"` Resolution *ResolutionCriterionInput `json:"resolution,omitempty"` Orientation *OrientationCriterionInput `json:"orientation,omitempty"` Framerate *IntCriterionInput `json:"framerate,omitempty"` Bitrate *IntCriterionInput `json:"bitrate,omitempty"` VideoCodec *StringCriterionInput `json:"video_codec,omitempty"` AudioCodec *StringCriterionInput `json:"audio_codec,omitempty"` // in seconds Duration *IntCriterionInput `json:"duration,omitempty"` Captions *StringCriterionInput `json:"captions,omitempty"` Interactive *bool `json:"interactive,omitempty"` InteractiveSpeed *IntCriterionInput `json:"interactive_speed,omitempty"` } type ImageFileFilterInput struct { Format *StringCriterionInput `json:"format,omitempty"` Resolution *ResolutionCriterionInput `json:"resolution,omitempty"` Orientation *OrientationCriterionInput `json:"orientation,omitempty"` } ================================================ FILE: pkg/models/find_filter.go ================================================ package models import ( "fmt" "io" "strconv" ) // PerPageAll is the value used for perPage to indicate all results should be // returned. const PerPageAll = -1 type SortDirectionEnum string const ( SortDirectionEnumAsc SortDirectionEnum = "ASC" SortDirectionEnumDesc SortDirectionEnum = "DESC" ) var AllSortDirectionEnum = []SortDirectionEnum{ SortDirectionEnumAsc, SortDirectionEnumDesc, } func (e SortDirectionEnum) IsValid() bool { switch e { case SortDirectionEnumAsc, SortDirectionEnumDesc: return true } return false } func (e SortDirectionEnum) String() string { return string(e) } func (e *SortDirectionEnum) UnmarshalGQL(v interface{}) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = SortDirectionEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid SortDirectionEnum", str) } return nil } func (e SortDirectionEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } type FindFilterType struct { Q *string `json:"q"` Page *int `json:"page"` // use per_page = -1 to indicate all results. Defaults to 25. PerPage *int `json:"per_page"` Sort *string `json:"sort"` Direction *SortDirectionEnum `json:"direction"` } func (ff FindFilterType) GetSort(defaultSort string) string { var sort string if ff.Sort == nil { sort = defaultSort } else { sort = *ff.Sort } return sort } func (ff FindFilterType) GetDirection() string { var direction string if directionFilter := ff.Direction; directionFilter != nil { if dir := directionFilter.String(); directionFilter.IsValid() { direction = dir } else { direction = "ASC" } } else { direction = "ASC" } return direction } func (ff FindFilterType) GetPage() int { const defaultPage = 1 if ff.Page == nil || *ff.Page < 1 { return defaultPage } return *ff.Page } func (ff FindFilterType) GetPageSize() int { const defaultPerPage = 25 const minPerPage = 0 if ff.PerPage == nil { return defaultPerPage } // removed the maxPerPage check. We already all -1 to indicate all results // so there is no conceivable reason we should limit the page size if *ff.PerPage < minPerPage { // negative page sizes should return all results // this is a sanity check in case GetPageSize is // called with a negative page size. return minPerPage } return *ff.PerPage } func (ff FindFilterType) IsGetAll() bool { return ff.PerPage != nil && *ff.PerPage < 0 } // BatchFindFilter returns a FindFilterType suitable for batch finding // using the provided batch size. func BatchFindFilter(batchSize int) *FindFilterType { page := 1 return &FindFilterType{ PerPage: &batchSize, Page: &page, } } ================================================ FILE: pkg/models/fingerprint.go ================================================ package models import ( "fmt" "strconv" ) var ( FingerprintTypeOshash = "oshash" FingerprintTypeMD5 = "md5" FingerprintTypePhash = "phash" ) // Fingerprint represents a fingerprint of a file. type Fingerprint struct { Type string Fingerprint interface{} } func (f *Fingerprint) Value() string { switch v := f.Fingerprint.(type) { case int64: return strconv.FormatUint(uint64(v), 16) default: return fmt.Sprintf("%v", f.Fingerprint) } } // String returns the string representation of the Fingerprint. // It will return an empty string if the Fingerprint is not a string. func (f Fingerprint) String() string { s, _ := f.Fingerprint.(string) return s } // Int64 returns the int64 representation of the Fingerprint. // It will return 0 if the Fingerprint is not an int64. func (f Fingerprint) Int64() int64 { v, _ := f.Fingerprint.(int64) return v } type Fingerprints []Fingerprint func (f Fingerprints) Remove(type_ string) Fingerprints { var ret Fingerprints for _, ff := range f { if ff.Type != type_ { ret = append(ret, ff) } } return ret } func (f Fingerprints) Filter(types ...string) Fingerprints { var ret Fingerprints for _, ff := range f { for _, t := range types { if ff.Type == t { ret = append(ret, ff) break } } } return ret } // Equals returns true if the contents of this slice are equal to those in the other slice. func (f Fingerprints) Equals(other Fingerprints) bool { if len(f) != len(other) { return false } for _, ff := range f { found := false for _, oo := range other { if ff == oo { found = true break } } if !found { return false } } return true } // ContentsChanged returns true if this Fingerprints slice contains any Fingerprints that different Fingerprint values for the matching type in other, or if this slice contains any Fingerprint types that are not in other. func (f Fingerprints) ContentsChanged(other Fingerprints) bool { for _, ff := range f { oo := other.For(ff.Type) if oo == nil || oo.Fingerprint != ff.Fingerprint { return true } } return false } // For returns a pointer to the first Fingerprint element matching the provided type. func (f Fingerprints) For(type_ string) *Fingerprint { for _, fp := range f { if fp.Type == type_ { return &fp } } return nil } func (f Fingerprints) Get(type_ string) interface{} { fp := f.For(type_) if fp == nil { return nil } return fp.Fingerprint } func (f Fingerprints) GetString(type_ string) string { fp := f.For(type_) if fp == nil { return "" } return fp.String() } func (f Fingerprints) GetInt64(type_ string) int64 { fp := f.For(type_) if fp != nil { return 0 } return fp.Int64() } // AppendUnique appends a fingerprint to the list if a Fingerprint of the same type does not already exist in the list. If one does, then it is updated with o's Fingerprint value. func (f Fingerprints) AppendUnique(o Fingerprint) Fingerprints { ret := f for i, fp := range ret { if fp.Type == o.Type { ret[i] = o return ret } } return append(f, o) } ================================================ FILE: pkg/models/fingerprint_test.go ================================================ package models import "testing" func TestFingerprints_Equals(t *testing.T) { var ( value1 = 1 value2 = "2" value3 = 1.23 fingerprint1 = Fingerprint{ Type: FingerprintTypeMD5, Fingerprint: value1, } fingerprint2 = Fingerprint{ Type: FingerprintTypeOshash, Fingerprint: value2, } fingerprint3 = Fingerprint{ Type: FingerprintTypePhash, Fingerprint: value3, } ) tests := []struct { name string f Fingerprints other Fingerprints want bool }{ { "identical", Fingerprints{ fingerprint1, fingerprint2, }, Fingerprints{ fingerprint1, fingerprint2, }, true, }, { "different order", Fingerprints{ fingerprint1, fingerprint2, }, Fingerprints{ fingerprint2, fingerprint1, }, true, }, { "different length", Fingerprints{ fingerprint1, fingerprint2, }, Fingerprints{ fingerprint1, }, false, }, { "different", Fingerprints{ fingerprint1, fingerprint2, }, Fingerprints{ fingerprint1, fingerprint3, }, false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.f.Equals(tt.other); got != tt.want { t.Errorf("Fingerprints.Equals() = %v, want %v", got, tt.want) } }) } } func TestFingerprints_ContentsChanged(t *testing.T) { var ( value1 = 1 value2 = "2" value3 = 1.23 fingerprint1 = Fingerprint{ Type: FingerprintTypeMD5, Fingerprint: value1, } fingerprint2 = Fingerprint{ Type: FingerprintTypeOshash, Fingerprint: value2, } fingerprint3 = Fingerprint{ Type: FingerprintTypeMD5, Fingerprint: value3, } ) tests := []struct { name string f Fingerprints other Fingerprints want bool }{ { "identical", Fingerprints{ fingerprint1, fingerprint2, }, Fingerprints{ fingerprint1, fingerprint2, }, false, }, { "has new", Fingerprints{ fingerprint1, fingerprint2, }, Fingerprints{ fingerprint1, }, true, }, { "has different value", Fingerprints{ fingerprint3, fingerprint2, }, Fingerprints{ fingerprint1, fingerprint2, }, true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.f.ContentsChanged(tt.other); got != tt.want { t.Errorf("Fingerprints.ContentsChanged() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: pkg/models/folder.go ================================================ package models import ( "context" "path/filepath" "strings" ) type FolderQueryOptions struct { QueryOptions FolderFilter *FolderFilterType TotalDuration bool Megapixels bool TotalSize bool } type FolderFilterType struct { OperatorFilter[FolderFilterType] Path *StringCriterionInput `json:"path,omitempty"` Basename *StringCriterionInput `json:"basename,omitempty"` ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder,omitempty"` ZipFile *MultiCriterionInput `json:"zip_file,omitempty"` // Filter by modification time ModTime *TimestampCriterionInput `json:"mod_time,omitempty"` GalleryCount *IntCriterionInput `json:"gallery_count,omitempty"` // Filter by files that meet this criteria FilesFilter *FileFilterType `json:"files_filter,omitempty"` // Filter by related galleries that meet this criteria GalleriesFilter *GalleryFilterType `json:"galleries_filter,omitempty"` // Filter by creation time CreatedAt *TimestampCriterionInput `json:"created_at,omitempty"` // Filter by last update time UpdatedAt *TimestampCriterionInput `json:"updated_at,omitempty"` } func PathsFolderFilter(paths []string) *FileFilterType { if paths == nil { return nil } sep := string(filepath.Separator) var ret *FileFilterType var or *FileFilterType for _, p := range paths { newOr := &FileFilterType{} if or != nil { or.Or = newOr } else { ret = newOr } or = newOr if !strings.HasSuffix(p, sep) { p += sep } or.Path = &StringCriterionInput{ Modifier: CriterionModifierEquals, Value: p + "%", } } return ret } type FolderQueryResult struct { QueryResult[FolderID] getter FolderGetter folders []*Folder resolveErr error } func NewFolderQueryResult(folderGetter FolderGetter) *FolderQueryResult { return &FolderQueryResult{ getter: folderGetter, } } func (r *FolderQueryResult) Resolve(ctx context.Context) ([]*Folder, error) { // cache results if r.folders == nil && r.resolveErr == nil { r.folders, r.resolveErr = r.getter.FindMany(ctx, r.IDs) } return r.folders, r.resolveErr } ================================================ FILE: pkg/models/fs.go ================================================ package models import ( "io" "io/fs" ) // FileOpener provides an interface to open a file. type FileOpener interface { Open() (io.ReadCloser, error) } // FS represents a file system. type FS interface { Stat(name string) (fs.FileInfo, error) Lstat(name string) (fs.FileInfo, error) Open(name string) (fs.ReadDirFile, error) OpenZip(name string, size int64) (ZipFS, error) IsPathCaseSensitive(path string) (bool, error) } // ZipFS represents a zip file system. type ZipFS interface { FS io.Closer OpenOnly(name string) (io.ReadCloser, error) } ================================================ FILE: pkg/models/gallery.go ================================================ package models type GalleryFilterType struct { OperatorFilter[GalleryFilterType] ID *IntCriterionInput `json:"id"` Title *StringCriterionInput `json:"title"` Code *StringCriterionInput `json:"code"` Details *StringCriterionInput `json:"details"` Photographer *StringCriterionInput `json:"photographer"` // Filter by file checksum Checksum *StringCriterionInput `json:"checksum"` // Filter by path Path *StringCriterionInput `json:"path"` // Filter by parent folder ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder,omitempty"` // Filter by zip file count FileCount *IntCriterionInput `json:"file_count"` // Filter to only include galleries missing this property IsMissing *string `json:"is_missing"` // Filter to include/exclude galleries that were created from zip IsZip *bool `json:"is_zip"` // Filter by rating expressed as 1-100 Rating100 *IntCriterionInput `json:"rating100"` // Filter by organized Organized *bool `json:"organized"` // Filter by average image resolution AverageResolution *ResolutionCriterionInput `json:"average_resolution"` // Filter to only include scenes which have chapters. `true` or `false` HasChapters *string `json:"has_chapters"` // Filter to only include galleries with these scenes Scenes *MultiCriterionInput `json:"scenes"` // Filter to only include galleries with this studio Studios *HierarchicalMultiCriterionInput `json:"studios"` // Filter to only include galleries with these tags Tags *HierarchicalMultiCriterionInput `json:"tags"` // Filter by tag count TagCount *IntCriterionInput `json:"tag_count"` // Filter to only include galleries with performers with these tags PerformerTags *HierarchicalMultiCriterionInput `json:"performer_tags"` // Filter to only include galleries with these performers Performers *MultiCriterionInput `json:"performers"` // Filter by performer count PerformerCount *IntCriterionInput `json:"performer_count"` // Filter galleries that have performers that have been favorited PerformerFavorite *bool `json:"performer_favorite"` // Filter galleries by performer age at time of gallery PerformerAge *IntCriterionInput `json:"performer_age"` // Filter by number of images in this gallery ImageCount *IntCriterionInput `json:"image_count"` // Filter by url URL *StringCriterionInput `json:"url"` // Filter by date Date *DateCriterionInput `json:"date"` // Filter by related scenes that meet this criteria ScenesFilter *SceneFilterType `json:"scenes_filter"` // Filter by related images that meet this criteria ImagesFilter *ImageFilterType `json:"images_filter"` // Filter by related performers that meet this criteria PerformersFilter *PerformerFilterType `json:"performers_filter"` // Filter by related studios that meet this criteria StudiosFilter *StudioFilterType `json:"studios_filter"` // Filter by related tags that meet this criteria TagsFilter *TagFilterType `json:"tags_filter"` // Filter by related files that meet this criteria FilesFilter *FileFilterType `json:"files_filter"` // Filter by related folders that meet this criteria FoldersFilter *FolderFilterType `json:"folders_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at UpdatedAt *TimestampCriterionInput `json:"updated_at"` // Filter by custom fields CustomFields []CustomFieldCriterionInput `json:"custom_fields"` } type GalleryUpdateInput struct { ClientMutationID *string `json:"clientMutationId"` ID string `json:"id"` Title *string `json:"title"` Code *string `json:"code"` Urls []string `json:"urls"` Date *string `json:"date"` Details *string `json:"details"` Photographer *string `json:"photographer"` Rating100 *int `json:"rating100"` Organized *bool `json:"organized"` SceneIds []string `json:"scene_ids"` StudioID *string `json:"studio_id"` TagIds []string `json:"tag_ids"` PerformerIds []string `json:"performer_ids"` PrimaryFileID *string `json:"primary_file_id"` CustomFields *CustomFieldsInput `json:"custom_fields"` // deprecated URL *string `json:"url"` } type GalleryDestroyInput struct { Ids []string `json:"ids"` // If true, then the zip file will be deleted if the gallery is zip-file-based. // If gallery is folder-based, then any files not associated with other // galleries will be deleted, along with the folder, if it is not empty. DeleteFile *bool `json:"delete_file"` DeleteGenerated *bool `json:"delete_generated"` DestroyFileEntry *bool `json:"destroy_file_entry"` } ================================================ FILE: pkg/models/generate.go ================================================ package models import ( "fmt" "io" "strconv" ) type GenerateMetadataOptions struct { Covers bool `json:"covers"` Sprites bool `json:"sprites"` Previews bool `json:"previews"` ImagePreviews bool `json:"imagePreviews"` PreviewOptions *GeneratePreviewOptions `json:"previewOptions"` Markers bool `json:"markers"` MarkerImagePreviews bool `json:"markerImagePreviews"` MarkerScreenshots bool `json:"markerScreenshots"` Transcodes bool `json:"transcodes"` Phashes bool `json:"phashes"` InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"` ImageThumbnails bool `json:"imageThumbnails"` ClipPreviews bool `json:"clipPreviews"` } type GeneratePreviewOptions struct { // Number of segments in a preview file PreviewSegments *int `json:"previewSegments"` // Preview segment duration, in seconds PreviewSegmentDuration *float64 `json:"previewSegmentDuration"` // Duration of start of video to exclude when generating previews PreviewExcludeStart *string `json:"previewExcludeStart"` // Duration of end of video to exclude when generating previews PreviewExcludeEnd *string `json:"previewExcludeEnd"` // Preset when generating preview PreviewPreset *PreviewPreset `json:"previewPreset"` } type PreviewPreset string const ( // X264_ULTRAFAST PreviewPresetUltrafast PreviewPreset = "ultrafast" // X264_VERYFAST PreviewPresetVeryfast PreviewPreset = "veryfast" // X264_FAST PreviewPresetFast PreviewPreset = "fast" // X264_MEDIUM PreviewPresetMedium PreviewPreset = "medium" // X264_SLOW PreviewPresetSlow PreviewPreset = "slow" // X264_SLOWER PreviewPresetSlower PreviewPreset = "slower" // X264_VERYSLOW PreviewPresetVeryslow PreviewPreset = "veryslow" ) var AllPreviewPreset = []PreviewPreset{ PreviewPresetUltrafast, PreviewPresetVeryfast, PreviewPresetFast, PreviewPresetMedium, PreviewPresetSlow, PreviewPresetSlower, PreviewPresetVeryslow, } func (e PreviewPreset) IsValid() bool { switch e { case PreviewPresetUltrafast, PreviewPresetVeryfast, PreviewPresetFast, PreviewPresetMedium, PreviewPresetSlow, PreviewPresetSlower, PreviewPresetVeryslow: return true } return false } func (e PreviewPreset) String() string { return string(e) } func (e *PreviewPreset) UnmarshalGQL(v interface{}) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = PreviewPreset(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid PreviewPreset", str) } return nil } func (e PreviewPreset) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } ================================================ FILE: pkg/models/group.go ================================================ package models type GroupFilterType struct { OperatorFilter[GroupFilterType] Name *StringCriterionInput `json:"name"` Director *StringCriterionInput `json:"director"` Synopsis *StringCriterionInput `json:"synopsis"` // Filter by duration (in seconds) Duration *IntCriterionInput `json:"duration"` // Filter by rating expressed as 1-100 Rating100 *IntCriterionInput `json:"rating100"` // Filter to only include movies with this studio Studios *HierarchicalMultiCriterionInput `json:"studios"` // Filter to only include movies missing this property IsMissing *string `json:"is_missing"` // Filter by url URL *StringCriterionInput `json:"url"` // Filter to only include movies where performer appears in a scene Performers *MultiCriterionInput `json:"performers"` // Filter to only include performers with these tags Tags *HierarchicalMultiCriterionInput `json:"tags"` // Filter by tag count TagCount *IntCriterionInput `json:"tag_count"` // Filter by date Date *DateCriterionInput `json:"date"` // Filter by O counter OCounter *IntCriterionInput `json:"o_counter"` // Filter by containing groups ContainingGroups *HierarchicalMultiCriterionInput `json:"containing_groups"` // Filter by sub groups SubGroups *HierarchicalMultiCriterionInput `json:"sub_groups"` // Filter by number of containing groups the group has ContainingGroupCount *IntCriterionInput `json:"containing_group_count"` // Filter by number of sub-groups the group has SubGroupCount *IntCriterionInput `json:"sub_group_count"` // Filter by number of scenes the group has SceneCount *IntCriterionInput `json:"scene_count"` // Filter by related scenes that meet this criteria ScenesFilter *SceneFilterType `json:"scenes_filter"` // Filter by related studios that meet this criteria StudiosFilter *StudioFilterType `json:"studios_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at UpdatedAt *TimestampCriterionInput `json:"updated_at"` // Filter by custom fields CustomFields []CustomFieldCriterionInput `json:"custom_fields"` } ================================================ FILE: pkg/models/image.go ================================================ package models import ( "context" ) type ImageFilterType struct { OperatorFilter[ImageFilterType] ID *IntCriterionInput `json:"id"` Title *StringCriterionInput `json:"title"` Code *StringCriterionInput `json:"code"` Details *StringCriterionInput `json:"details"` Photographer *StringCriterionInput `json:"photographer"` // Filter by file checksum Checksum *StringCriterionInput `json:"checksum"` // Filter by phash distance PhashDistance *PhashDistanceCriterionInput `json:"phash_distance"` // Filter by path Path *StringCriterionInput `json:"path"` // Filter by file count FileCount *IntCriterionInput `json:"file_count"` // Filter by rating expressed as 1-100 Rating100 *IntCriterionInput `json:"rating100"` // Filter by date Date *DateCriterionInput `json:"date"` // Filter by url URL *StringCriterionInput `json:"url"` // Filter by organized Organized *bool `json:"organized"` // Filter by o-counter OCounter *IntCriterionInput `json:"o_counter"` // Filter by resolution Resolution *ResolutionCriterionInput `json:"resolution"` // Filter by landscape/portrait Orientation *OrientationCriterionInput `json:"orientation"` // Filter to only include images missing this property IsMissing *string `json:"is_missing"` // Filter to only include images with this studio Studios *HierarchicalMultiCriterionInput `json:"studios"` // Filter to only include images with these tags Tags *HierarchicalMultiCriterionInput `json:"tags"` // Filter by tag count TagCount *IntCriterionInput `json:"tag_count"` // Filter to only include images with performers with these tags PerformerTags *HierarchicalMultiCriterionInput `json:"performer_tags"` // Filter to only include images with these performers Performers *MultiCriterionInput `json:"performers"` // Filter by performer count PerformerCount *IntCriterionInput `json:"performer_count"` // Filter images that have performers that have been favorited PerformerFavorite *bool `json:"performer_favorite"` // Filter images by performer age at time of image PerformerAge *IntCriterionInput `json:"performer_age"` // Filter to only include images with these galleries Galleries *MultiCriterionInput `json:"galleries"` // Filter by related galleries that meet this criteria GalleriesFilter *GalleryFilterType `json:"galleries_filter"` // Filter by related performers that meet this criteria PerformersFilter *PerformerFilterType `json:"performers_filter"` // Filter by related studios that meet this criteria StudiosFilter *StudioFilterType `json:"studios_filter"` // Filter by related tags that meet this criteria TagsFilter *TagFilterType `json:"tags_filter"` // Filter by related files that meet this criteria FilesFilter *FileFilterType `json:"files_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at UpdatedAt *TimestampCriterionInput `json:"updated_at"` // Filter by custom fields CustomFields []CustomFieldCriterionInput `json:"custom_fields"` } type ImageUpdateInput struct { ClientMutationID *string `json:"clientMutationId"` ID string `json:"id"` Title *string `json:"title"` Code *string `json:"code"` Urls []string `json:"urls"` Date *string `json:"date"` Details *string `json:"details"` Photographer *string `json:"photographer"` Rating100 *int `json:"rating100"` Organized *bool `json:"organized"` SceneIds []string `json:"scene_ids"` StudioID *string `json:"studio_id"` TagIds []string `json:"tag_ids"` PerformerIds []string `json:"performer_ids"` GalleryIds []string `json:"gallery_ids"` PrimaryFileID *string `json:"primary_file_id"` CustomFields *CustomFieldsInput `json:"custom_fields"` // deprecated URL *string `json:"url"` } type ImageDestroyInput struct { ID string `json:"id"` DeleteFile *bool `json:"delete_file"` DeleteGenerated *bool `json:"delete_generated"` DestroyFileEntry *bool `json:"destroy_file_entry"` } type ImagesDestroyInput struct { Ids []string `json:"ids"` DeleteFile *bool `json:"delete_file"` DeleteGenerated *bool `json:"delete_generated"` DestroyFileEntry *bool `json:"destroy_file_entry"` } type ImageQueryOptions struct { QueryOptions ImageFilter *ImageFilterType Megapixels bool TotalSize bool } type ImageQueryResult struct { QueryResult[int] Megapixels float64 TotalSize float64 getter ImageGetter images []*Image resolveErr error } func NewImageQueryResult(getter ImageGetter) *ImageQueryResult { return &ImageQueryResult{ getter: getter, } } func (r *ImageQueryResult) Resolve(ctx context.Context) ([]*Image, error) { // cache results if r.images == nil && r.resolveErr == nil { r.images, r.resolveErr = r.getter.FindMany(ctx, r.IDs) } return r.images, r.resolveErr } ================================================ FILE: pkg/models/import.go ================================================ package models import ( "fmt" "io" "strconv" ) type ImportMissingRefEnum string const ( ImportMissingRefEnumIgnore ImportMissingRefEnum = "IGNORE" ImportMissingRefEnumFail ImportMissingRefEnum = "FAIL" ImportMissingRefEnumCreate ImportMissingRefEnum = "CREATE" ) var AllImportMissingRefEnum = []ImportMissingRefEnum{ ImportMissingRefEnumIgnore, ImportMissingRefEnumFail, ImportMissingRefEnumCreate, } func (e ImportMissingRefEnum) IsValid() bool { switch e { case ImportMissingRefEnumIgnore, ImportMissingRefEnumFail, ImportMissingRefEnumCreate: return true } return false } func (e ImportMissingRefEnum) String() string { return string(e) } func (e *ImportMissingRefEnum) UnmarshalGQL(v interface{}) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = ImportMissingRefEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid ImportMissingRefEnum", str) } return nil } func (e ImportMissingRefEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } ================================================ FILE: pkg/models/json/json_time.go ================================================ // Package json provides generic JSON types. package json import ( "fmt" "strings" "time" "github.com/stashapp/stash/pkg/utils" ) var currentLocation = time.Now().Location() type JSONTime struct { time.Time } func (jt *JSONTime) UnmarshalJSON(b []byte) error { s := strings.Trim(string(b), "\"") if s == "null" { jt.Time = time.Time{} return nil } // #731 - returning an error here causes the entire JSON parse to fail for ffprobe. jt.Time, _ = utils.ParseDateStringAsTime(s) return nil } func (jt *JSONTime) MarshalJSON() ([]byte, error) { if jt.Time.IsZero() { return []byte("null"), nil } return []byte(fmt.Sprintf("\"%s\"", jt.Time.Format(time.RFC3339))), nil } func (jt JSONTime) GetTime() time.Time { if currentLocation != nil { if jt.IsZero() { return time.Now().In(currentLocation) } else { return jt.Time.In(currentLocation) } } else { if jt.IsZero() { return time.Now() } else { return jt.Time } } } ================================================ FILE: pkg/models/jsonschema/doc.go ================================================ // Package jsonschema provides the JSON schema models used for importing and exporting data. package jsonschema ================================================ FILE: pkg/models/jsonschema/file_folder.go ================================================ package jsonschema import ( "bytes" "errors" "fmt" "io" "os" "path/filepath" "strings" jsoniter "github.com/json-iterator/go" "github.com/stashapp/stash/pkg/hash/md5" "github.com/stashapp/stash/pkg/models/json" ) const ( DirEntryTypeFolder = "folder" DirEntryTypeVideo = "video" DirEntryTypeImage = "image" DirEntryTypeFile = "file" ) type DirEntry interface { IsFile() bool Filename() string DirEntry() *BaseDirEntry } type BaseDirEntry struct { ZipFile string `json:"zip_file,omitempty"` ModTime json.JSONTime `json:"mod_time"` Type string `json:"type,omitempty"` Path string `json:"path,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"` } func (f *BaseDirEntry) DirEntry() *BaseDirEntry { return f } func (f *BaseDirEntry) IsFile() bool { return false } func (f *BaseDirEntry) Filename() string { // prefix with the path depth so that we can import lower-level files/folders first depth := strings.Count(f.Path, string(filepath.Separator)) // hash the full path for a unique filename hash := md5.FromString(f.Path) basename := filepath.Base(f.Path) return fmt.Sprintf("%02x.%s.%s.json", depth, basename, hash) } type BaseFile struct { BaseDirEntry Fingerprints []Fingerprint `json:"fingerprints,omitempty"` Size int64 `json:"size"` } func (f *BaseFile) IsFile() bool { return true } type Fingerprint struct { Type string `json:"type,omitempty"` Fingerprint interface{} `json:"fingerprint,omitempty"` } type VideoFile struct { *BaseFile Format string `json:"format,omitempty"` Width int `json:"width,omitempty"` Height int `json:"height,omitempty"` Duration float64 `json:"duration,omitempty"` VideoCodec string `json:"video_codec,omitempty"` AudioCodec string `json:"audio_codec,omitempty"` FrameRate float64 `json:"frame_rate,omitempty"` BitRate int64 `json:"bitrate,omitempty"` Interactive bool `json:"interactive,omitempty"` InteractiveSpeed *int `json:"interactive_speed,omitempty"` } type ImageFile struct { *BaseFile Format string `json:"format,omitempty"` Width int `json:"width,omitempty"` Height int `json:"height,omitempty"` } func LoadFileFile(filePath string) (DirEntry, error) { r, err := os.Open(filePath) if err != nil { return nil, err } defer r.Close() data, err := io.ReadAll(r) if err != nil { return nil, err } var json = jsoniter.ConfigCompatibleWithStandardLibrary jsonParser := json.NewDecoder(bytes.NewReader(data)) var bf BaseDirEntry if err := jsonParser.Decode(&bf); err != nil { return nil, err } jsonParser = json.NewDecoder(bytes.NewReader(data)) switch bf.Type { case DirEntryTypeFolder: return &bf, nil case DirEntryTypeVideo: var vf VideoFile if err := jsonParser.Decode(&vf); err != nil { return nil, err } return &vf, nil case DirEntryTypeImage: var imf ImageFile if err := jsonParser.Decode(&imf); err != nil { return nil, err } return &imf, nil case DirEntryTypeFile: var bff BaseFile if err := jsonParser.Decode(&bff); err != nil { return nil, err } return &bff, nil default: return nil, errors.New("unknown file type") } } func SaveFileFile(filePath string, file DirEntry) error { if file == nil { return fmt.Errorf("file must not be nil") } return marshalToFile(filePath, file) } ================================================ FILE: pkg/models/jsonschema/folder.go ================================================ package jsonschema import ( "fmt" "os" "path/filepath" "strings" jsoniter "github.com/json-iterator/go" "github.com/stashapp/stash/pkg/hash/md5" "github.com/stashapp/stash/pkg/models/json" ) type Folder struct { BaseDirEntry Path string `json:"path,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"` } func (f *Folder) Filename() string { // prefix with the path depth so that we can import lower-level folders first depth := strings.Count(f.Path, string(filepath.Separator)) // hash the full path for a unique filename hash := md5.FromString(f.Path) basename := filepath.Base(f.Path) return fmt.Sprintf("%2x.%s.%s.json", depth, basename, hash) } func LoadFolderFile(filePath string) (*Folder, error) { var folder Folder file, err := os.Open(filePath) if err != nil { return nil, err } defer file.Close() var json = jsoniter.ConfigCompatibleWithStandardLibrary jsonParser := json.NewDecoder(file) err = jsonParser.Decode(&folder) if err != nil { return nil, err } return &folder, nil } func SaveFolderFile(filePath string, folder *Folder) error { if folder == nil { return fmt.Errorf("folder must not be nil") } return marshalToFile(filePath, folder) } ================================================ FILE: pkg/models/jsonschema/gallery.go ================================================ package jsonschema import ( "fmt" "os" "strings" jsoniter "github.com/json-iterator/go" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/models/json" ) type GalleryChapter struct { Title string `json:"title,omitempty"` ImageIndex int `json:"image_index,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"` } type Gallery struct { ZipFiles []string `json:"zip_files,omitempty"` FolderPath string `json:"folder_path,omitempty"` Title string `json:"title,omitempty"` Code string `json:"code,omitempty"` URLs []string `json:"urls,omitempty"` Date string `json:"date,omitempty"` Details string `json:"details,omitempty"` Photographer string `json:"photographer,omitempty"` Rating int `json:"rating,omitempty"` Organized bool `json:"organized,omitempty"` Chapters []GalleryChapter `json:"chapters,omitempty"` Studio string `json:"studio,omitempty"` Performers []string `json:"performers,omitempty"` Tags []string `json:"tags,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"` CustomFields map[string]interface{} `json:"custom_fields,omitempty"` // deprecated - for import only URL string `json:"url,omitempty"` } func (s Gallery) Filename(basename string, hash string) string { ret := fsutil.SanitiseBasename(basename) if ret != "" { ret += "." } ret += hash return ret + ".json" } func LoadGalleryFile(filePath string) (*Gallery, error) { var gallery Gallery file, err := os.Open(filePath) if err != nil { return nil, err } defer file.Close() var json = jsoniter.ConfigCompatibleWithStandardLibrary jsonParser := json.NewDecoder(file) err = jsonParser.Decode(&gallery) if err != nil { return nil, err } return &gallery, nil } func SaveGalleryFile(filePath string, gallery *Gallery) error { if gallery == nil { return fmt.Errorf("gallery must not be nil") } return marshalToFile(filePath, gallery) } // GalleryRef is used to identify a Gallery. // Only one field should be populated. type GalleryRef struct { ZipFiles []string `json:"zip_files,omitempty"` FolderPath string `json:"folder_path,omitempty"` // Title is used only if FolderPath and ZipPaths is empty Title string `json:"title,omitempty"` } func (r GalleryRef) String() string { switch { case r.FolderPath != "": return "{ folder: " + r.FolderPath + " }" case len(r.ZipFiles) > 0: return "{ zipFiles: [" + strings.Join(r.ZipFiles, ", ") + "] }" default: return "{ title: " + r.Title + " }" } } ================================================ FILE: pkg/models/jsonschema/group.go ================================================ package jsonschema import ( "fmt" "os" jsoniter "github.com/json-iterator/go" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models/json" ) type SubGroupDescription struct { Group string `json:"name,omitempty"` Description string `json:"description,omitempty"` } type Group struct { Name string `json:"name,omitempty"` Aliases string `json:"aliases,omitempty"` Duration int `json:"duration,omitempty"` Date string `json:"date,omitempty"` Rating int `json:"rating,omitempty"` Director string `json:"director,omitempty"` Synopsis string `json:"synopsis,omitempty"` FrontImage string `json:"front_image,omitempty"` BackImage string `json:"back_image,omitempty"` URLs []string `json:"urls,omitempty"` Studio string `json:"studio,omitempty"` Tags []string `json:"tags,omitempty"` SubGroups []SubGroupDescription `json:"sub_groups,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"` CustomFields map[string]interface{} `json:"custom_fields,omitempty"` // deprecated - for import only URL string `json:"url,omitempty"` } func (s Group) Filename() string { return fsutil.SanitiseBasename(s.Name) + ".json" } // Backwards Compatible synopsis for the movie type MovieSynopsisBC struct { Synopsis string `json:"sypnopsis,omitempty"` } func LoadGroupFile(filePath string) (*Group, error) { var movie Group file, err := os.Open(filePath) if err != nil { return nil, err } defer file.Close() var json = jsoniter.ConfigCompatibleWithStandardLibrary jsonParser := json.NewDecoder(file) err = jsonParser.Decode(&movie) if err != nil { return nil, err } if movie.Synopsis == "" { // keep backwards compatibility with pre #2664 builds // attempt to get the synopsis from the alternate (sypnopsis) key _, err = file.Seek(0, 0) // seek to start of file if err == nil { var synopsis MovieSynopsisBC err = jsonParser.Decode(&synopsis) if err == nil { movie.Synopsis = synopsis.Synopsis if movie.Synopsis != "" { logger.Debug("Movie synopsis retrieved from alternate key") } } } } return &movie, nil } func SaveGroupFile(filePath string, movie *Group) error { if movie == nil { return fmt.Errorf("movie must not be nil") } return marshalToFile(filePath, movie) } ================================================ FILE: pkg/models/jsonschema/image.go ================================================ package jsonschema import ( "fmt" "os" jsoniter "github.com/json-iterator/go" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/models/json" ) type Image struct { Title string `json:"title,omitempty"` Code string `json:"code,omitempty"` Studio string `json:"studio,omitempty"` Rating int `json:"rating,omitempty"` // deprecated - for import only URL string `json:"url,omitempty"` URLs []string `json:"urls,omitempty"` Date string `json:"date,omitempty"` Details string `json:"details,omitempty"` Photographer string `json:"photographer,omitempty"` Organized bool `json:"organized,omitempty"` OCounter int `json:"o_counter,omitempty"` Galleries []GalleryRef `json:"galleries,omitempty"` Performers []string `json:"performers,omitempty"` Tags []string `json:"tags,omitempty"` Files []string `json:"files,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"` CustomFields map[string]interface{} `json:"custom_fields,omitempty"` } func (s Image) Filename(basename string, hash string) string { ret := fsutil.SanitiseBasename(s.Title) if ret == "" { ret = basename } if hash != "" { ret += "." + hash } return ret + ".json" } func LoadImageFile(filePath string) (*Image, error) { var image Image file, err := os.Open(filePath) if err != nil { return nil, err } defer file.Close() var json = jsoniter.ConfigCompatibleWithStandardLibrary jsonParser := json.NewDecoder(file) err = jsonParser.Decode(&image) if err != nil { return nil, err } return &image, nil } func SaveImageFile(filePath string, image *Image) error { if image == nil { return fmt.Errorf("image must not be nil") } return marshalToFile(filePath, image) } ================================================ FILE: pkg/models/jsonschema/load.go ================================================ package jsonschema import ( "fmt" "os" jsoniter "github.com/json-iterator/go" ) func loadFile[T any](filePath string) (*T, error) { var ret T file, err := os.Open(filePath) if err != nil { return nil, err } defer file.Close() var json = jsoniter.ConfigCompatibleWithStandardLibrary jsonParser := json.NewDecoder(file) err = jsonParser.Decode(&ret) if err != nil { return nil, err } return &ret, nil } func saveFile[T any](filePath string, obj *T) error { if obj == nil { return fmt.Errorf("object must not be nil") } return marshalToFile(filePath, obj) } ================================================ FILE: pkg/models/jsonschema/performer.go ================================================ package jsonschema import ( "fmt" "io" "os" jsoniter "github.com/json-iterator/go" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) type StringOrStringList []string func (s *StringOrStringList) UnmarshalJSON(data []byte) error { var stringList []string var stringVal string err := jsoniter.Unmarshal(data, &stringList) if err == nil { *s = stringList return nil } err = jsoniter.Unmarshal(data, &stringVal) if err == nil { *s = stringslice.FromString(stringVal, ",") return nil } return err } type Performer struct { Name string `json:"name,omitempty"` Disambiguation string `json:"disambiguation,omitempty"` Gender string `json:"gender,omitempty"` URLs []string `json:"urls,omitempty"` Birthdate string `json:"birthdate,omitempty"` Ethnicity string `json:"ethnicity,omitempty"` Country string `json:"country,omitempty"` EyeColor string `json:"eye_color,omitempty"` // this should be int, but keeping string for backwards compatibility Height string `json:"height,omitempty"` Measurements string `json:"measurements,omitempty"` FakeTits string `json:"fake_tits,omitempty"` PenisLength float64 `json:"penis_length,omitempty"` Circumcised string `json:"circumcised,omitempty"` CareerLength string `json:"career_length,omitempty"` // deprecated - for import only CareerStart string `json:"career_start,omitempty"` CareerEnd string `json:"career_end,omitempty"` Tattoos string `json:"tattoos,omitempty"` Piercings string `json:"piercings,omitempty"` Aliases StringOrStringList `json:"aliases,omitempty"` Favorite bool `json:"favorite,omitempty"` Tags []string `json:"tags,omitempty"` Image string `json:"image,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"` Rating int `json:"rating,omitempty"` Details string `json:"details,omitempty"` DeathDate string `json:"death_date,omitempty"` HairColor string `json:"hair_color,omitempty"` Weight int `json:"weight,omitempty"` StashIDs []models.StashID `json:"stash_ids,omitempty"` IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` CustomFields map[string]interface{} `json:"custom_fields,omitempty"` // deprecated - for import only URL string `json:"url,omitempty"` Twitter string `json:"twitter,omitempty"` Instagram string `json:"instagram,omitempty"` } func (s Performer) Filename() string { name := s.Name if s.Disambiguation != "" { name += "_" + s.Disambiguation } return fsutil.SanitiseBasename(name) + ".json" } func LoadPerformerFile(filePath string) (*Performer, error) { file, err := os.Open(filePath) if err != nil { return nil, err } defer file.Close() return loadPerformer(file) } func loadPerformer(r io.ReadSeeker) (*Performer, error) { var json = jsoniter.ConfigCompatibleWithStandardLibrary jsonParser := json.NewDecoder(r) var performer Performer if err := jsonParser.Decode(&performer); err != nil { return nil, err } return &performer, nil } func SavePerformerFile(filePath string, performer *Performer) error { if performer == nil { return fmt.Errorf("performer must not be nil") } return marshalToFile(filePath, performer) } ================================================ FILE: pkg/models/jsonschema/performer_test.go ================================================ package jsonschema import ( "strings" "testing" "github.com/stretchr/testify/assert" ) func Test_loadPerformer(t *testing.T) { tests := []struct { name string input string want Performer wantErr bool }{ { name: "alias list", input: ` { "aliases": ["alias1", "alias2"] }`, want: Performer{ Aliases: []string{"alias1", "alias2"}, }, wantErr: false, }, { name: "alias string list", input: ` { "aliases": "alias1, alias2" }`, want: Performer{ Aliases: []string{"alias1", "alias2"}, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := strings.NewReader(tt.input) got, err := loadPerformer(r) if (err != nil) != tt.wantErr { t.Errorf("loadPerformer() error = %v, wantErr %v", err, tt.wantErr) return } assert.Equal(t, &tt.want, got) }) } } ================================================ FILE: pkg/models/jsonschema/saved_filter.go ================================================ package jsonschema import ( "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/models" ) type SavedFilter struct { Mode models.FilterMode `db:"mode" json:"mode"` Name string `db:"name" json:"name"` FindFilter *models.FindFilterType `json:"find_filter"` ObjectFilter map[string]interface{} `json:"object_filter"` UIOptions map[string]interface{} `json:"ui_options"` } func (s SavedFilter) Filename() string { ret := fsutil.SanitiseBasename(s.Name + "_" + s.Mode.String()) return ret + ".json" } func LoadSavedFilterFile(filePath string) (*SavedFilter, error) { return loadFile[SavedFilter](filePath) } func SaveSavedFilterFile(filePath string, image *SavedFilter) error { return saveFile[SavedFilter](filePath, image) } ================================================ FILE: pkg/models/jsonschema/scene.go ================================================ package jsonschema import ( "fmt" "os" "strconv" jsoniter "github.com/json-iterator/go" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" ) type SceneMarker struct { Title string `json:"title,omitempty"` Seconds string `json:"seconds,omitempty"` EndSeconds string `json:"end_seconds,omitempty"` PrimaryTag string `json:"primary_tag,omitempty"` Tags []string `json:"tags,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"` } type SceneFile struct { ModTime json.JSONTime `json:"mod_time,omitempty"` Size string `json:"size"` Duration string `json:"duration"` VideoCodec string `json:"video_codec"` AudioCodec string `json:"audio_codec"` Format string `json:"format"` Width int `json:"width"` Height int `json:"height"` Framerate string `json:"framerate"` Bitrate int `json:"bitrate"` } type SceneGroup struct { GroupName string `json:"movieName,omitempty"` SceneIndex int `json:"scene_index,omitempty"` } type Scene struct { Title string `json:"title,omitempty"` Code string `json:"code,omitempty"` Studio string `json:"studio,omitempty"` // deprecated - for import only URL string `json:"url,omitempty"` URLs []string `json:"urls,omitempty"` Date string `json:"date,omitempty"` Rating int `json:"rating,omitempty"` Organized bool `json:"organized,omitempty"` // deprecated - for import only OCounter int `json:"o_counter,omitempty"` Details string `json:"details,omitempty"` Director string `json:"director,omitempty"` Galleries []GalleryRef `json:"galleries,omitempty"` Performers []string `json:"performers,omitempty"` Groups []SceneGroup `json:"movies,omitempty"` Tags []string `json:"tags,omitempty"` Markers []SceneMarker `json:"markers,omitempty"` Files []string `json:"files,omitempty"` Cover string `json:"cover,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"` // deprecated - for import only LastPlayedAt json.JSONTime `json:"last_played_at,omitempty"` ResumeTime float64 `json:"resume_time,omitempty"` // deprecated - for import only PlayCount int `json:"play_count,omitempty"` PlayHistory []json.JSONTime `json:"play_history,omitempty"` OHistory []json.JSONTime `json:"o_history,omitempty"` PlayDuration float64 `json:"play_duration,omitempty"` StashIDs []models.StashID `json:"stash_ids,omitempty"` CustomFields map[string]interface{} `json:"custom_fields,omitempty"` } func (s Scene) Filename(id int, basename string, hash string) string { ret := fsutil.SanitiseBasename(s.Title) if ret == "" { ret = basename } if hash != "" { ret += "." + hash } else { // scenes may have no file and therefore no hash ret += "." + strconv.Itoa(id) } return ret + ".json" } func LoadSceneFile(filePath string) (*Scene, error) { var scene Scene file, err := os.Open(filePath) if err != nil { return nil, err } defer file.Close() var json = jsoniter.ConfigCompatibleWithStandardLibrary jsonParser := json.NewDecoder(file) err = jsonParser.Decode(&scene) if err != nil { return nil, err } return &scene, nil } func SaveSceneFile(filePath string, scene *Scene) error { if scene == nil { return fmt.Errorf("scene must not be nil") } return marshalToFile(filePath, scene) } ================================================ FILE: pkg/models/jsonschema/studio.go ================================================ package jsonschema import ( "fmt" "os" jsoniter "github.com/json-iterator/go" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" ) type Studio struct { Name string `json:"name,omitempty"` URLs []string `json:"urls,omitempty"` ParentStudio string `json:"parent_studio,omitempty"` Image string `json:"image,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"` Rating int `json:"rating,omitempty"` Favorite bool `json:"favorite,omitempty"` Details string `json:"details,omitempty"` Aliases []string `json:"aliases,omitempty"` StashIDs []models.StashID `json:"stash_ids,omitempty"` Tags []string `json:"tags,omitempty"` IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` Organized bool `json:"organized,omitempty"` CustomFields map[string]interface{} `json:"custom_fields,omitempty"` // deprecated - for import only URL string `json:"url,omitempty"` } func (s Studio) Filename() string { return fsutil.SanitiseBasename(s.Name) + ".json" } func LoadStudioFile(filePath string) (*Studio, error) { var studio Studio file, err := os.Open(filePath) if err != nil { return nil, err } defer file.Close() var json = jsoniter.ConfigCompatibleWithStandardLibrary jsonParser := json.NewDecoder(file) err = jsonParser.Decode(&studio) if err != nil { return nil, err } return &studio, nil } func SaveStudioFile(filePath string, studio *Studio) error { if studio == nil { return fmt.Errorf("studio must not be nil") } return marshalToFile(filePath, studio) } ================================================ FILE: pkg/models/jsonschema/tag.go ================================================ package jsonschema import ( "fmt" "os" jsoniter "github.com/json-iterator/go" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" ) type Tag struct { Name string `json:"name,omitempty"` SortName string `json:"sort_name,omitempty"` Description string `json:"description,omitempty"` Favorite bool `json:"favorite,omitempty"` Aliases []string `json:"aliases,omitempty"` Image string `json:"image,omitempty"` Parents []string `json:"parents,omitempty"` IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` StashIDs []models.StashID `json:"stash_ids,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"` CustomFields map[string]interface{} `json:"custom_fields,omitempty"` } func (s Tag) Filename() string { return fsutil.SanitiseBasename(s.Name) + ".json" } func LoadTagFile(filePath string) (*Tag, error) { var tag Tag file, err := os.Open(filePath) if err != nil { return nil, err } defer file.Close() var json = jsoniter.ConfigCompatibleWithStandardLibrary jsonParser := json.NewDecoder(file) err = jsonParser.Decode(&tag) if err != nil { return nil, err } return &tag, nil } func SaveTagFile(filePath string, tag *Tag) error { if tag == nil { return fmt.Errorf("tag must not be nil") } return marshalToFile(filePath, tag) } ================================================ FILE: pkg/models/jsonschema/utils.go ================================================ package jsonschema import ( "bytes" "os" jsoniter "github.com/json-iterator/go" ) func CompareJSON(a interface{}, b interface{}) bool { aBuf, _ := encode(a) bBuf, _ := encode(b) return bytes.Equal(aBuf, bBuf) } func marshalToFile(filePath string, j interface{}) error { data, err := encode(j) if err != nil { return err } return os.WriteFile(filePath, data, 0644) } func encode(j interface{}) ([]byte, error) { buffer := &bytes.Buffer{} var json = jsoniter.ConfigCompatibleWithStandardLibrary encoder := json.NewEncoder(buffer) encoder.SetEscapeHTML(false) encoder.SetIndent("", " ") if err := encoder.Encode(j); err != nil { return nil, err } // Strip the newline at the end of the file return bytes.TrimRight(buffer.Bytes(), "\n"), nil } ================================================ FILE: pkg/models/mocks/FileReaderWriter.go ================================================ // Code generated by mockery v2.10.0. DO NOT EDIT. package mocks import ( context "context" fs "io/fs" mock "github.com/stretchr/testify/mock" models "github.com/stashapp/stash/pkg/models" ) // FileReaderWriter is an autogenerated mock type for the FileReaderWriter type type FileReaderWriter struct { mock.Mock } // CountAllInPaths provides a mock function with given fields: ctx, p func (_m *FileReaderWriter) CountAllInPaths(ctx context.Context, p []string) (int, error) { ret := _m.Called(ctx, p) var r0 int if rf, ok := ret.Get(0).(func(context.Context, []string) int); ok { r0 = rf(ctx, p) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []string) error); ok { r1 = rf(ctx, p) } else { r1 = ret.Error(1) } return r0, r1 } // CountByFolderID provides a mock function with given fields: ctx, folderID func (_m *FileReaderWriter) CountByFolderID(ctx context.Context, folderID models.FolderID) (int, error) { ret := _m.Called(ctx, folderID) var r0 int if rf, ok := ret.Get(0).(func(context.Context, models.FolderID) int); ok { r0 = rf(ctx, folderID) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.FolderID) error); ok { r1 = rf(ctx, folderID) } else { r1 = ret.Error(1) } return r0, r1 } // Create provides a mock function with given fields: ctx, f func (_m *FileReaderWriter) Create(ctx context.Context, f models.File) error { ret := _m.Called(ctx, f) var r0 error if rf, ok := ret.Get(0).(func(context.Context, models.File) error); ok { r0 = rf(ctx, f) } else { r0 = ret.Error(0) } return r0 } // Destroy provides a mock function with given fields: ctx, id func (_m *FileReaderWriter) Destroy(ctx context.Context, id models.FileID) error { ret := _m.Called(ctx, id) var r0 error if rf, ok := ret.Get(0).(func(context.Context, models.FileID) error); ok { r0 = rf(ctx, id) } else { r0 = ret.Error(0) } return r0 } // DestroyFingerprints provides a mock function with given fields: ctx, fileID, types func (_m *FileReaderWriter) DestroyFingerprints(ctx context.Context, fileID models.FileID, types []string) error { ret := _m.Called(ctx, fileID, types) var r0 error if rf, ok := ret.Get(0).(func(context.Context, models.FileID, []string) error); ok { r0 = rf(ctx, fileID, types) } else { r0 = ret.Error(0) } return r0 } // Find provides a mock function with given fields: ctx, id func (_m *FileReaderWriter) Find(ctx context.Context, id ...models.FileID) ([]models.File, error) { _va := make([]interface{}, len(id)) for _i := range id { _va[_i] = id[_i] } var _ca []interface{} _ca = append(_ca, ctx) _ca = append(_ca, _va...) ret := _m.Called(_ca...) var r0 []models.File if rf, ok := ret.Get(0).(func(context.Context, ...models.FileID) []models.File); ok { r0 = rf(ctx, id...) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.File) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, ...models.FileID) error); ok { r1 = rf(ctx, id...) } else { r1 = ret.Error(1) } return r0, r1 } // FindAllByPath provides a mock function with given fields: ctx, path, caseSensitive func (_m *FileReaderWriter) FindAllByPath(ctx context.Context, path string, caseSensitive bool) ([]models.File, error) { ret := _m.Called(ctx, path, caseSensitive) var r0 []models.File if rf, ok := ret.Get(0).(func(context.Context, string, bool) []models.File); ok { r0 = rf(ctx, path, caseSensitive) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.File) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok { r1 = rf(ctx, path, caseSensitive) } else { r1 = ret.Error(1) } return r0, r1 } // FindAllInPaths provides a mock function with given fields: ctx, p, includeZipContents, limit, offset func (_m *FileReaderWriter) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit int, offset int) ([]models.File, error) { ret := _m.Called(ctx, p, includeZipContents, limit, offset) var r0 []models.File if rf, ok := ret.Get(0).(func(context.Context, []string, bool, int, int) []models.File); ok { r0 = rf(ctx, p, includeZipContents, limit, offset) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.File) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []string, bool, int, int) error); ok { r1 = rf(ctx, p, includeZipContents, limit, offset) } else { r1 = ret.Error(1) } return r0, r1 } // FindByFileInfo provides a mock function with given fields: ctx, info, size func (_m *FileReaderWriter) FindByFileInfo(ctx context.Context, info fs.FileInfo, size int64) ([]models.File, error) { ret := _m.Called(ctx, info, size) var r0 []models.File if rf, ok := ret.Get(0).(func(context.Context, fs.FileInfo, int64) []models.File); ok { r0 = rf(ctx, info, size) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.File) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, fs.FileInfo, int64) error); ok { r1 = rf(ctx, info, size) } else { r1 = ret.Error(1) } return r0, r1 } // FindByFingerprint provides a mock function with given fields: ctx, fp func (_m *FileReaderWriter) FindByFingerprint(ctx context.Context, fp models.Fingerprint) ([]models.File, error) { ret := _m.Called(ctx, fp) var r0 []models.File if rf, ok := ret.Get(0).(func(context.Context, models.Fingerprint) []models.File); ok { r0 = rf(ctx, fp) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.File) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.Fingerprint) error); ok { r1 = rf(ctx, fp) } else { r1 = ret.Error(1) } return r0, r1 } // FindByPath provides a mock function with given fields: ctx, path, caseSensitive func (_m *FileReaderWriter) FindByPath(ctx context.Context, path string, caseSensitive bool) (models.File, error) { ret := _m.Called(ctx, path, caseSensitive) var r0 models.File if rf, ok := ret.Get(0).(func(context.Context, string, bool) models.File); ok { r0 = rf(ctx, path, caseSensitive) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(models.File) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok { r1 = rf(ctx, path, caseSensitive) } else { r1 = ret.Error(1) } return r0, r1 } // FindByZipFileID provides a mock function with given fields: ctx, zipFileID func (_m *FileReaderWriter) FindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]models.File, error) { ret := _m.Called(ctx, zipFileID) var r0 []models.File if rf, ok := ret.Get(0).(func(context.Context, models.FileID) []models.File); ok { r0 = rf(ctx, zipFileID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.File) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok { r1 = rf(ctx, zipFileID) } else { r1 = ret.Error(1) } return r0, r1 } // GetCaptions provides a mock function with given fields: ctx, fileID func (_m *FileReaderWriter) GetCaptions(ctx context.Context, fileID models.FileID) ([]*models.VideoCaption, error) { ret := _m.Called(ctx, fileID) var r0 []*models.VideoCaption if rf, ok := ret.Get(0).(func(context.Context, models.FileID) []*models.VideoCaption); ok { r0 = rf(ctx, fileID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.VideoCaption) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok { r1 = rf(ctx, fileID) } else { r1 = ret.Error(1) } return r0, r1 } // IsPrimary provides a mock function with given fields: ctx, fileID func (_m *FileReaderWriter) IsPrimary(ctx context.Context, fileID models.FileID) (bool, error) { ret := _m.Called(ctx, fileID) var r0 bool if rf, ok := ret.Get(0).(func(context.Context, models.FileID) bool); ok { r0 = rf(ctx, fileID) } else { r0 = ret.Get(0).(bool) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok { r1 = rf(ctx, fileID) } else { r1 = ret.Error(1) } return r0, r1 } // ModifyFingerprints provides a mock function with given fields: ctx, fileID, fingerprints func (_m *FileReaderWriter) ModifyFingerprints(ctx context.Context, fileID models.FileID, fingerprints []models.Fingerprint) error { ret := _m.Called(ctx, fileID, fingerprints) var r0 error if rf, ok := ret.Get(0).(func(context.Context, models.FileID, []models.Fingerprint) error); ok { r0 = rf(ctx, fileID, fingerprints) } else { r0 = ret.Error(0) } return r0 } // Query provides a mock function with given fields: ctx, options func (_m *FileReaderWriter) Query(ctx context.Context, options models.FileQueryOptions) (*models.FileQueryResult, error) { ret := _m.Called(ctx, options) var r0 *models.FileQueryResult if rf, ok := ret.Get(0).(func(context.Context, models.FileQueryOptions) *models.FileQueryResult); ok { r0 = rf(ctx, options) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.FileQueryResult) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.FileQueryOptions) error); ok { r1 = rf(ctx, options) } else { r1 = ret.Error(1) } return r0, r1 } // Update provides a mock function with given fields: ctx, f func (_m *FileReaderWriter) Update(ctx context.Context, f models.File) error { ret := _m.Called(ctx, f) var r0 error if rf, ok := ret.Get(0).(func(context.Context, models.File) error); ok { r0 = rf(ctx, f) } else { r0 = ret.Error(0) } return r0 } // UpdateCaptions provides a mock function with given fields: ctx, fileID, captions func (_m *FileReaderWriter) UpdateCaptions(ctx context.Context, fileID models.FileID, captions []*models.VideoCaption) error { ret := _m.Called(ctx, fileID, captions) var r0 error if rf, ok := ret.Get(0).(func(context.Context, models.FileID, []*models.VideoCaption) error); ok { r0 = rf(ctx, fileID, captions) } else { r0 = ret.Error(0) } return r0 } ================================================ FILE: pkg/models/mocks/FolderReaderWriter.go ================================================ // Code generated by mockery v2.10.0. DO NOT EDIT. package mocks import ( context "context" models "github.com/stashapp/stash/pkg/models" mock "github.com/stretchr/testify/mock" ) // FolderReaderWriter is an autogenerated mock type for the FolderReaderWriter type type FolderReaderWriter struct { mock.Mock } // CountAllInPaths provides a mock function with given fields: ctx, p func (_m *FolderReaderWriter) CountAllInPaths(ctx context.Context, p []string) (int, error) { ret := _m.Called(ctx, p) var r0 int if rf, ok := ret.Get(0).(func(context.Context, []string) int); ok { r0 = rf(ctx, p) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []string) error); ok { r1 = rf(ctx, p) } else { r1 = ret.Error(1) } return r0, r1 } // Create provides a mock function with given fields: ctx, f func (_m *FolderReaderWriter) Create(ctx context.Context, f *models.Folder) error { ret := _m.Called(ctx, f) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.Folder) error); ok { r0 = rf(ctx, f) } else { r0 = ret.Error(0) } return r0 } // Destroy provides a mock function with given fields: ctx, id func (_m *FolderReaderWriter) Destroy(ctx context.Context, id models.FolderID) error { ret := _m.Called(ctx, id) var r0 error if rf, ok := ret.Get(0).(func(context.Context, models.FolderID) error); ok { r0 = rf(ctx, id) } else { r0 = ret.Error(0) } return r0 } // Find provides a mock function with given fields: ctx, id func (_m *FolderReaderWriter) Find(ctx context.Context, id models.FolderID) (*models.Folder, error) { ret := _m.Called(ctx, id) var r0 *models.Folder if rf, ok := ret.Get(0).(func(context.Context, models.FolderID) *models.Folder); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Folder) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.FolderID) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // FindAllInPaths provides a mock function with given fields: ctx, p, includeZipContents, limit, offset func (_m *FolderReaderWriter) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit int, offset int) ([]*models.Folder, error) { ret := _m.Called(ctx, p, includeZipContents, limit, offset) var r0 []*models.Folder if rf, ok := ret.Get(0).(func(context.Context, []string, bool, int, int) []*models.Folder); ok { r0 = rf(ctx, p, includeZipContents, limit, offset) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Folder) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []string, bool, int, int) error); ok { r1 = rf(ctx, p, includeZipContents, limit, offset) } else { r1 = ret.Error(1) } return r0, r1 } // FindByParentFolderID provides a mock function with given fields: ctx, parentFolderID func (_m *FolderReaderWriter) FindByParentFolderID(ctx context.Context, parentFolderID models.FolderID) ([]*models.Folder, error) { ret := _m.Called(ctx, parentFolderID) var r0 []*models.Folder if rf, ok := ret.Get(0).(func(context.Context, models.FolderID) []*models.Folder); ok { r0 = rf(ctx, parentFolderID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Folder) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.FolderID) error); ok { r1 = rf(ctx, parentFolderID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByPath provides a mock function with given fields: ctx, path, caseSensitive func (_m *FolderReaderWriter) FindByPath(ctx context.Context, path string, caseSensitive bool) (*models.Folder, error) { ret := _m.Called(ctx, path, caseSensitive) var r0 *models.Folder if rf, ok := ret.Get(0).(func(context.Context, string, bool) *models.Folder); ok { r0 = rf(ctx, path, caseSensitive) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Folder) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok { r1 = rf(ctx, path, caseSensitive) } else { r1 = ret.Error(1) } return r0, r1 } // FindByZipFileID provides a mock function with given fields: ctx, zipFileID func (_m *FolderReaderWriter) FindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]*models.Folder, error) { ret := _m.Called(ctx, zipFileID) var r0 []*models.Folder if rf, ok := ret.Get(0).(func(context.Context, models.FileID) []*models.Folder); ok { r0 = rf(ctx, zipFileID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Folder) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok { r1 = rf(ctx, zipFileID) } else { r1 = ret.Error(1) } return r0, r1 } // FindMany provides a mock function with given fields: ctx, id func (_m *FolderReaderWriter) FindMany(ctx context.Context, id []models.FolderID) ([]*models.Folder, error) { ret := _m.Called(ctx, id) var r0 []*models.Folder if rf, ok := ret.Get(0).(func(context.Context, []models.FolderID) []*models.Folder); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Folder) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []models.FolderID) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // GetManyParentFolderIDs provides a mock function with given fields: ctx, folderIDs func (_m *FolderReaderWriter) GetManyParentFolderIDs(ctx context.Context, folderIDs []models.FolderID) ([][]models.FolderID, error) { ret := _m.Called(ctx, folderIDs) var r0 [][]models.FolderID if rf, ok := ret.Get(0).(func(context.Context, []models.FolderID) [][]models.FolderID); ok { r0 = rf(ctx, folderIDs) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([][]models.FolderID) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []models.FolderID) error); ok { r1 = rf(ctx, folderIDs) } else { r1 = ret.Error(1) } return r0, r1 } // Query provides a mock function with given fields: ctx, options func (_m *FolderReaderWriter) Query(ctx context.Context, options models.FolderQueryOptions) (*models.FolderQueryResult, error) { ret := _m.Called(ctx, options) var r0 *models.FolderQueryResult if rf, ok := ret.Get(0).(func(context.Context, models.FolderQueryOptions) *models.FolderQueryResult); ok { r0 = rf(ctx, options) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.FolderQueryResult) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.FolderQueryOptions) error); ok { r1 = rf(ctx, options) } else { r1 = ret.Error(1) } return r0, r1 } // Update provides a mock function with given fields: ctx, f func (_m *FolderReaderWriter) Update(ctx context.Context, f *models.Folder) error { ret := _m.Called(ctx, f) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.Folder) error); ok { r0 = rf(ctx, f) } else { r0 = ret.Error(0) } return r0 } ================================================ FILE: pkg/models/mocks/GalleryChapterReaderWriter.go ================================================ // Code generated by mockery v2.10.0. DO NOT EDIT. package mocks import ( context "context" models "github.com/stashapp/stash/pkg/models" mock "github.com/stretchr/testify/mock" ) // GalleryChapterReaderWriter is an autogenerated mock type for the GalleryChapterReaderWriter type type GalleryChapterReaderWriter struct { mock.Mock } // Create provides a mock function with given fields: ctx, newGalleryChapter func (_m *GalleryChapterReaderWriter) Create(ctx context.Context, newGalleryChapter *models.GalleryChapter) error { ret := _m.Called(ctx, newGalleryChapter) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.GalleryChapter) error); ok { r0 = rf(ctx, newGalleryChapter) } else { r0 = ret.Error(0) } return r0 } // Destroy provides a mock function with given fields: ctx, id func (_m *GalleryChapterReaderWriter) Destroy(ctx context.Context, id int) error { ret := _m.Called(ctx, id) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { r0 = rf(ctx, id) } else { r0 = ret.Error(0) } return r0 } // Find provides a mock function with given fields: ctx, id func (_m *GalleryChapterReaderWriter) Find(ctx context.Context, id int) (*models.GalleryChapter, error) { ret := _m.Called(ctx, id) var r0 *models.GalleryChapter if rf, ok := ret.Get(0).(func(context.Context, int) *models.GalleryChapter); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.GalleryChapter) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // FindByGalleryID provides a mock function with given fields: ctx, galleryID func (_m *GalleryChapterReaderWriter) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.GalleryChapter, error) { ret := _m.Called(ctx, galleryID) var r0 []*models.GalleryChapter if rf, ok := ret.Get(0).(func(context.Context, int) []*models.GalleryChapter); ok { r0 = rf(ctx, galleryID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.GalleryChapter) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, galleryID) } else { r1 = ret.Error(1) } return r0, r1 } // FindMany provides a mock function with given fields: ctx, ids func (_m *GalleryChapterReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.GalleryChapter, error) { ret := _m.Called(ctx, ids) var r0 []*models.GalleryChapter if rf, ok := ret.Get(0).(func(context.Context, []int) []*models.GalleryChapter); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.GalleryChapter) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // Update provides a mock function with given fields: ctx, updatedGalleryChapter func (_m *GalleryChapterReaderWriter) Update(ctx context.Context, updatedGalleryChapter *models.GalleryChapter) error { ret := _m.Called(ctx, updatedGalleryChapter) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.GalleryChapter) error); ok { r0 = rf(ctx, updatedGalleryChapter) } else { r0 = ret.Error(0) } return r0 } // UpdatePartial provides a mock function with given fields: ctx, id, updatedGalleryChapter func (_m *GalleryChapterReaderWriter) UpdatePartial(ctx context.Context, id int, updatedGalleryChapter models.GalleryChapterPartial) (*models.GalleryChapter, error) { ret := _m.Called(ctx, id, updatedGalleryChapter) var r0 *models.GalleryChapter if rf, ok := ret.Get(0).(func(context.Context, int, models.GalleryChapterPartial) *models.GalleryChapter); ok { r0 = rf(ctx, id, updatedGalleryChapter) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.GalleryChapter) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int, models.GalleryChapterPartial) error); ok { r1 = rf(ctx, id, updatedGalleryChapter) } else { r1 = ret.Error(1) } return r0, r1 } ================================================ FILE: pkg/models/mocks/GalleryReaderWriter.go ================================================ // Code generated by mockery v2.10.0. DO NOT EDIT. package mocks import ( context "context" models "github.com/stashapp/stash/pkg/models" mock "github.com/stretchr/testify/mock" ) // GalleryReaderWriter is an autogenerated mock type for the GalleryReaderWriter type type GalleryReaderWriter struct { mock.Mock } // AddFileID provides a mock function with given fields: ctx, id, fileID func (_m *GalleryReaderWriter) AddFileID(ctx context.Context, id int, fileID models.FileID) error { ret := _m.Called(ctx, id, fileID) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, models.FileID) error); ok { r0 = rf(ctx, id, fileID) } else { r0 = ret.Error(0) } return r0 } // AddImages provides a mock function with given fields: ctx, galleryID, imageIDs func (_m *GalleryReaderWriter) AddImages(ctx context.Context, galleryID int, imageIDs ...int) error { _va := make([]interface{}, len(imageIDs)) for _i := range imageIDs { _va[_i] = imageIDs[_i] } var _ca []interface{} _ca = append(_ca, ctx, galleryID) _ca = append(_ca, _va...) ret := _m.Called(_ca...) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, ...int) error); ok { r0 = rf(ctx, galleryID, imageIDs...) } else { r0 = ret.Error(0) } return r0 } // AddSceneIDs provides a mock function with given fields: ctx, galleryID, sceneIDs func (_m *GalleryReaderWriter) AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error { ret := _m.Called(ctx, galleryID, sceneIDs) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok { r0 = rf(ctx, galleryID, sceneIDs) } else { r0 = ret.Error(0) } return r0 } // All provides a mock function with given fields: ctx func (_m *GalleryReaderWriter) All(ctx context.Context) ([]*models.Gallery, error) { ret := _m.Called(ctx) var r0 []*models.Gallery if rf, ok := ret.Get(0).(func(context.Context) []*models.Gallery); ok { r0 = rf(ctx) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Gallery) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // Count provides a mock function with given fields: ctx func (_m *GalleryReaderWriter) Count(ctx context.Context) (int, error) { ret := _m.Called(ctx) var r0 int if rf, ok := ret.Get(0).(func(context.Context) int); ok { r0 = rf(ctx) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // CountByFileID provides a mock function with given fields: ctx, fileID func (_m *GalleryReaderWriter) CountByFileID(ctx context.Context, fileID models.FileID) (int, error) { ret := _m.Called(ctx, fileID) var r0 int if rf, ok := ret.Get(0).(func(context.Context, models.FileID) int); ok { r0 = rf(ctx, fileID) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok { r1 = rf(ctx, fileID) } else { r1 = ret.Error(1) } return r0, r1 } // Create provides a mock function with given fields: ctx, newGallery func (_m *GalleryReaderWriter) Create(ctx context.Context, newGallery *models.CreateGalleryInput) error { ret := _m.Called(ctx, newGallery) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.CreateGalleryInput) error); ok { r0 = rf(ctx, newGallery) } else { r0 = ret.Error(0) } return r0 } // Destroy provides a mock function with given fields: ctx, id func (_m *GalleryReaderWriter) Destroy(ctx context.Context, id int) error { ret := _m.Called(ctx, id) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { r0 = rf(ctx, id) } else { r0 = ret.Error(0) } return r0 } // Find provides a mock function with given fields: ctx, id func (_m *GalleryReaderWriter) Find(ctx context.Context, id int) (*models.Gallery, error) { ret := _m.Called(ctx, id) var r0 *models.Gallery if rf, ok := ret.Get(0).(func(context.Context, int) *models.Gallery); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Gallery) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // FindByChecksum provides a mock function with given fields: ctx, checksum func (_m *GalleryReaderWriter) FindByChecksum(ctx context.Context, checksum string) ([]*models.Gallery, error) { ret := _m.Called(ctx, checksum) var r0 []*models.Gallery if rf, ok := ret.Get(0).(func(context.Context, string) []*models.Gallery); ok { r0 = rf(ctx, checksum) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Gallery) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { r1 = rf(ctx, checksum) } else { r1 = ret.Error(1) } return r0, r1 } // FindByChecksums provides a mock function with given fields: ctx, checksums func (_m *GalleryReaderWriter) FindByChecksums(ctx context.Context, checksums []string) ([]*models.Gallery, error) { ret := _m.Called(ctx, checksums) var r0 []*models.Gallery if rf, ok := ret.Get(0).(func(context.Context, []string) []*models.Gallery); ok { r0 = rf(ctx, checksums) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Gallery) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []string) error); ok { r1 = rf(ctx, checksums) } else { r1 = ret.Error(1) } return r0, r1 } // FindByFileID provides a mock function with given fields: ctx, fileID func (_m *GalleryReaderWriter) FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Gallery, error) { ret := _m.Called(ctx, fileID) var r0 []*models.Gallery if rf, ok := ret.Get(0).(func(context.Context, models.FileID) []*models.Gallery); ok { r0 = rf(ctx, fileID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Gallery) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok { r1 = rf(ctx, fileID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByFingerprints provides a mock function with given fields: ctx, fp func (_m *GalleryReaderWriter) FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Gallery, error) { ret := _m.Called(ctx, fp) var r0 []*models.Gallery if rf, ok := ret.Get(0).(func(context.Context, []models.Fingerprint) []*models.Gallery); ok { r0 = rf(ctx, fp) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Gallery) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []models.Fingerprint) error); ok { r1 = rf(ctx, fp) } else { r1 = ret.Error(1) } return r0, r1 } // FindByFolderID provides a mock function with given fields: ctx, folderID func (_m *GalleryReaderWriter) FindByFolderID(ctx context.Context, folderID models.FolderID) ([]*models.Gallery, error) { ret := _m.Called(ctx, folderID) var r0 []*models.Gallery if rf, ok := ret.Get(0).(func(context.Context, models.FolderID) []*models.Gallery); ok { r0 = rf(ctx, folderID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Gallery) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.FolderID) error); ok { r1 = rf(ctx, folderID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByImageID provides a mock function with given fields: ctx, imageID func (_m *GalleryReaderWriter) FindByImageID(ctx context.Context, imageID int) ([]*models.Gallery, error) { ret := _m.Called(ctx, imageID) var r0 []*models.Gallery if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Gallery); ok { r0 = rf(ctx, imageID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Gallery) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, imageID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByPath provides a mock function with given fields: ctx, path func (_m *GalleryReaderWriter) FindByPath(ctx context.Context, path string) ([]*models.Gallery, error) { ret := _m.Called(ctx, path) var r0 []*models.Gallery if rf, ok := ret.Get(0).(func(context.Context, string) []*models.Gallery); ok { r0 = rf(ctx, path) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Gallery) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { r1 = rf(ctx, path) } else { r1 = ret.Error(1) } return r0, r1 } // FindBySceneID provides a mock function with given fields: ctx, sceneID func (_m *GalleryReaderWriter) FindBySceneID(ctx context.Context, sceneID int) ([]*models.Gallery, error) { ret := _m.Called(ctx, sceneID) var r0 []*models.Gallery if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Gallery); ok { r0 = rf(ctx, sceneID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Gallery) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, sceneID) } else { r1 = ret.Error(1) } return r0, r1 } // FindMany provides a mock function with given fields: ctx, ids func (_m *GalleryReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Gallery, error) { ret := _m.Called(ctx, ids) var r0 []*models.Gallery if rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Gallery); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Gallery) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // FindUserGalleryByTitle provides a mock function with given fields: ctx, title func (_m *GalleryReaderWriter) FindUserGalleryByTitle(ctx context.Context, title string) ([]*models.Gallery, error) { ret := _m.Called(ctx, title) var r0 []*models.Gallery if rf, ok := ret.Get(0).(func(context.Context, string) []*models.Gallery); ok { r0 = rf(ctx, title) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Gallery) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { r1 = rf(ctx, title) } else { r1 = ret.Error(1) } return r0, r1 } // GetCustomFields provides a mock function with given fields: ctx, id func (_m *GalleryReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { ret := _m.Called(ctx, id) var r0 map[string]interface{} if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(map[string]interface{}) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // GetCustomFieldsBulk provides a mock function with given fields: ctx, ids func (_m *GalleryReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { ret := _m.Called(ctx, ids) var r0 []models.CustomFieldMap if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.CustomFieldMap) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetFiles provides a mock function with given fields: ctx, relatedID func (_m *GalleryReaderWriter) GetFiles(ctx context.Context, relatedID int) ([]models.File, error) { ret := _m.Called(ctx, relatedID) var r0 []models.File if rf, ok := ret.Get(0).(func(context.Context, int) []models.File); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.File) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetImageIDs provides a mock function with given fields: ctx, relatedID func (_m *GalleryReaderWriter) GetImageIDs(ctx context.Context, relatedID int) ([]int, error) { ret := _m.Called(ctx, relatedID) var r0 []int if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]int) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetManyFileIDs provides a mock function with given fields: ctx, ids func (_m *GalleryReaderWriter) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) { ret := _m.Called(ctx, ids) var r0 [][]models.FileID if rf, ok := ret.Get(0).(func(context.Context, []int) [][]models.FileID); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([][]models.FileID) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetPerformerIDs provides a mock function with given fields: ctx, relatedID func (_m *GalleryReaderWriter) GetPerformerIDs(ctx context.Context, relatedID int) ([]int, error) { ret := _m.Called(ctx, relatedID) var r0 []int if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]int) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetSceneIDs provides a mock function with given fields: ctx, relatedID func (_m *GalleryReaderWriter) GetSceneIDs(ctx context.Context, relatedID int) ([]int, error) { ret := _m.Called(ctx, relatedID) var r0 []int if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]int) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetTagIDs provides a mock function with given fields: ctx, relatedID func (_m *GalleryReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) { ret := _m.Called(ctx, relatedID) var r0 []int if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]int) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetURLs provides a mock function with given fields: ctx, relatedID func (_m *GalleryReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) { ret := _m.Called(ctx, relatedID) var r0 []string if rf, ok := ret.Get(0).(func(context.Context, int) []string); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]string) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // Query provides a mock function with given fields: ctx, galleryFilter, findFilter func (_m *GalleryReaderWriter) Query(ctx context.Context, galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) ([]*models.Gallery, int, error) { ret := _m.Called(ctx, galleryFilter, findFilter) var r0 []*models.Gallery if rf, ok := ret.Get(0).(func(context.Context, *models.GalleryFilterType, *models.FindFilterType) []*models.Gallery); ok { r0 = rf(ctx, galleryFilter, findFilter) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Gallery) } } var r1 int if rf, ok := ret.Get(1).(func(context.Context, *models.GalleryFilterType, *models.FindFilterType) int); ok { r1 = rf(ctx, galleryFilter, findFilter) } else { r1 = ret.Get(1).(int) } var r2 error if rf, ok := ret.Get(2).(func(context.Context, *models.GalleryFilterType, *models.FindFilterType) error); ok { r2 = rf(ctx, galleryFilter, findFilter) } else { r2 = ret.Error(2) } return r0, r1, r2 } // QueryCount provides a mock function with given fields: ctx, galleryFilter, findFilter func (_m *GalleryReaderWriter) QueryCount(ctx context.Context, galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) (int, error) { ret := _m.Called(ctx, galleryFilter, findFilter) var r0 int if rf, ok := ret.Get(0).(func(context.Context, *models.GalleryFilterType, *models.FindFilterType) int); ok { r0 = rf(ctx, galleryFilter, findFilter) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, *models.GalleryFilterType, *models.FindFilterType) error); ok { r1 = rf(ctx, galleryFilter, findFilter) } else { r1 = ret.Error(1) } return r0, r1 } // RemoveImages provides a mock function with given fields: ctx, galleryID, imageIDs func (_m *GalleryReaderWriter) RemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error { _va := make([]interface{}, len(imageIDs)) for _i := range imageIDs { _va[_i] = imageIDs[_i] } var _ca []interface{} _ca = append(_ca, ctx, galleryID) _ca = append(_ca, _va...) ret := _m.Called(_ca...) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, ...int) error); ok { r0 = rf(ctx, galleryID, imageIDs...) } else { r0 = ret.Error(0) } return r0 } // ResetCover provides a mock function with given fields: ctx, galleryID func (_m *GalleryReaderWriter) ResetCover(ctx context.Context, galleryID int) error { ret := _m.Called(ctx, galleryID) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { r0 = rf(ctx, galleryID) } else { r0 = ret.Error(0) } return r0 } // SetCover provides a mock function with given fields: ctx, galleryID, coverImageID func (_m *GalleryReaderWriter) SetCover(ctx context.Context, galleryID int, coverImageID int) error { ret := _m.Called(ctx, galleryID, coverImageID) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, int) error); ok { r0 = rf(ctx, galleryID, coverImageID) } else { r0 = ret.Error(0) } return r0 } // SetCustomFields provides a mock function with given fields: ctx, id, fields func (_m *GalleryReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error { ret := _m.Called(ctx, id, fields) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok { r0 = rf(ctx, id, fields) } else { r0 = ret.Error(0) } return r0 } // Update provides a mock function with given fields: ctx, updatedGallery func (_m *GalleryReaderWriter) Update(ctx context.Context, updatedGallery *models.UpdateGalleryInput) error { ret := _m.Called(ctx, updatedGallery) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.UpdateGalleryInput) error); ok { r0 = rf(ctx, updatedGallery) } else { r0 = ret.Error(0) } return r0 } // UpdateImages provides a mock function with given fields: ctx, galleryID, imageIDs func (_m *GalleryReaderWriter) UpdateImages(ctx context.Context, galleryID int, imageIDs []int) error { ret := _m.Called(ctx, galleryID, imageIDs) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok { r0 = rf(ctx, galleryID, imageIDs) } else { r0 = ret.Error(0) } return r0 } // UpdatePartial provides a mock function with given fields: ctx, id, updatedGallery func (_m *GalleryReaderWriter) UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error) { ret := _m.Called(ctx, id, updatedGallery) var r0 *models.Gallery if rf, ok := ret.Get(0).(func(context.Context, int, models.GalleryPartial) *models.Gallery); ok { r0 = rf(ctx, id, updatedGallery) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Gallery) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int, models.GalleryPartial) error); ok { r1 = rf(ctx, id, updatedGallery) } else { r1 = ret.Error(1) } return r0, r1 } ================================================ FILE: pkg/models/mocks/GroupReaderWriter.go ================================================ // Code generated by mockery v2.10.0. DO NOT EDIT. package mocks import ( context "context" models "github.com/stashapp/stash/pkg/models" mock "github.com/stretchr/testify/mock" ) // GroupReaderWriter is an autogenerated mock type for the GroupReaderWriter type type GroupReaderWriter struct { mock.Mock } // All provides a mock function with given fields: ctx func (_m *GroupReaderWriter) All(ctx context.Context) ([]*models.Group, error) { ret := _m.Called(ctx) var r0 []*models.Group if rf, ok := ret.Get(0).(func(context.Context) []*models.Group); ok { r0 = rf(ctx) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Group) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // Count provides a mock function with given fields: ctx func (_m *GroupReaderWriter) Count(ctx context.Context) (int, error) { ret := _m.Called(ctx) var r0 int if rf, ok := ret.Get(0).(func(context.Context) int); ok { r0 = rf(ctx) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // CountByPerformerID provides a mock function with given fields: ctx, performerID func (_m *GroupReaderWriter) CountByPerformerID(ctx context.Context, performerID int) (int, error) { ret := _m.Called(ctx, performerID) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { r0 = rf(ctx, performerID) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, performerID) } else { r1 = ret.Error(1) } return r0, r1 } // CountByStudioID provides a mock function with given fields: ctx, studioID func (_m *GroupReaderWriter) CountByStudioID(ctx context.Context, studioID int) (int, error) { ret := _m.Called(ctx, studioID) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { r0 = rf(ctx, studioID) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, studioID) } else { r1 = ret.Error(1) } return r0, r1 } // Create provides a mock function with given fields: ctx, newGroup func (_m *GroupReaderWriter) Create(ctx context.Context, newGroup *models.Group) error { ret := _m.Called(ctx, newGroup) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.Group) error); ok { r0 = rf(ctx, newGroup) } else { r0 = ret.Error(0) } return r0 } // Destroy provides a mock function with given fields: ctx, id func (_m *GroupReaderWriter) Destroy(ctx context.Context, id int) error { ret := _m.Called(ctx, id) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { r0 = rf(ctx, id) } else { r0 = ret.Error(0) } return r0 } // Find provides a mock function with given fields: ctx, id func (_m *GroupReaderWriter) Find(ctx context.Context, id int) (*models.Group, error) { ret := _m.Called(ctx, id) var r0 *models.Group if rf, ok := ret.Get(0).(func(context.Context, int) *models.Group); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Group) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // FindByName provides a mock function with given fields: ctx, name, nocase func (_m *GroupReaderWriter) FindByName(ctx context.Context, name string, nocase bool) (*models.Group, error) { ret := _m.Called(ctx, name, nocase) var r0 *models.Group if rf, ok := ret.Get(0).(func(context.Context, string, bool) *models.Group); ok { r0 = rf(ctx, name, nocase) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Group) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok { r1 = rf(ctx, name, nocase) } else { r1 = ret.Error(1) } return r0, r1 } // FindByNames provides a mock function with given fields: ctx, names, nocase func (_m *GroupReaderWriter) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Group, error) { ret := _m.Called(ctx, names, nocase) var r0 []*models.Group if rf, ok := ret.Get(0).(func(context.Context, []string, bool) []*models.Group); ok { r0 = rf(ctx, names, nocase) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Group) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []string, bool) error); ok { r1 = rf(ctx, names, nocase) } else { r1 = ret.Error(1) } return r0, r1 } // FindByPerformerID provides a mock function with given fields: ctx, performerID func (_m *GroupReaderWriter) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Group, error) { ret := _m.Called(ctx, performerID) var r0 []*models.Group if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Group); ok { r0 = rf(ctx, performerID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Group) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, performerID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByStudioID provides a mock function with given fields: ctx, studioID func (_m *GroupReaderWriter) FindByStudioID(ctx context.Context, studioID int) ([]*models.Group, error) { ret := _m.Called(ctx, studioID) var r0 []*models.Group if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Group); ok { r0 = rf(ctx, studioID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Group) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, studioID) } else { r1 = ret.Error(1) } return r0, r1 } // FindMany provides a mock function with given fields: ctx, ids func (_m *GroupReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Group, error) { ret := _m.Called(ctx, ids) var r0 []*models.Group if rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Group); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Group) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetBackImage provides a mock function with given fields: ctx, groupID func (_m *GroupReaderWriter) GetBackImage(ctx context.Context, groupID int) ([]byte, error) { ret := _m.Called(ctx, groupID) var r0 []byte if rf, ok := ret.Get(0).(func(context.Context, int) []byte); ok { r0 = rf(ctx, groupID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]byte) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, groupID) } else { r1 = ret.Error(1) } return r0, r1 } // GetContainingGroupDescriptions provides a mock function with given fields: ctx, id func (_m *GroupReaderWriter) GetContainingGroupDescriptions(ctx context.Context, id int) ([]models.GroupIDDescription, error) { ret := _m.Called(ctx, id) var r0 []models.GroupIDDescription if rf, ok := ret.Get(0).(func(context.Context, int) []models.GroupIDDescription); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.GroupIDDescription) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // GetCustomFields provides a mock function with given fields: ctx, id func (_m *GroupReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { ret := _m.Called(ctx, id) var r0 map[string]interface{} if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(map[string]interface{}) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // GetCustomFieldsBulk provides a mock function with given fields: ctx, ids func (_m *GroupReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { ret := _m.Called(ctx, ids) var r0 []models.CustomFieldMap if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.CustomFieldMap) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetFrontImage provides a mock function with given fields: ctx, groupID func (_m *GroupReaderWriter) GetFrontImage(ctx context.Context, groupID int) ([]byte, error) { ret := _m.Called(ctx, groupID) var r0 []byte if rf, ok := ret.Get(0).(func(context.Context, int) []byte); ok { r0 = rf(ctx, groupID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]byte) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, groupID) } else { r1 = ret.Error(1) } return r0, r1 } // GetSubGroupDescriptions provides a mock function with given fields: ctx, id func (_m *GroupReaderWriter) GetSubGroupDescriptions(ctx context.Context, id int) ([]models.GroupIDDescription, error) { ret := _m.Called(ctx, id) var r0 []models.GroupIDDescription if rf, ok := ret.Get(0).(func(context.Context, int) []models.GroupIDDescription); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.GroupIDDescription) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // GetTagIDs provides a mock function with given fields: ctx, relatedID func (_m *GroupReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) { ret := _m.Called(ctx, relatedID) var r0 []int if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]int) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetURLs provides a mock function with given fields: ctx, relatedID func (_m *GroupReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) { ret := _m.Called(ctx, relatedID) var r0 []string if rf, ok := ret.Get(0).(func(context.Context, int) []string); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]string) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // HasBackImage provides a mock function with given fields: ctx, groupID func (_m *GroupReaderWriter) HasBackImage(ctx context.Context, groupID int) (bool, error) { ret := _m.Called(ctx, groupID) var r0 bool if rf, ok := ret.Get(0).(func(context.Context, int) bool); ok { r0 = rf(ctx, groupID) } else { r0 = ret.Get(0).(bool) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, groupID) } else { r1 = ret.Error(1) } return r0, r1 } // HasFrontImage provides a mock function with given fields: ctx, groupID func (_m *GroupReaderWriter) HasFrontImage(ctx context.Context, groupID int) (bool, error) { ret := _m.Called(ctx, groupID) var r0 bool if rf, ok := ret.Get(0).(func(context.Context, int) bool); ok { r0 = rf(ctx, groupID) } else { r0 = ret.Get(0).(bool) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, groupID) } else { r1 = ret.Error(1) } return r0, r1 } // Query provides a mock function with given fields: ctx, groupFilter, findFilter func (_m *GroupReaderWriter) Query(ctx context.Context, groupFilter *models.GroupFilterType, findFilter *models.FindFilterType) ([]*models.Group, int, error) { ret := _m.Called(ctx, groupFilter, findFilter) var r0 []*models.Group if rf, ok := ret.Get(0).(func(context.Context, *models.GroupFilterType, *models.FindFilterType) []*models.Group); ok { r0 = rf(ctx, groupFilter, findFilter) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Group) } } var r1 int if rf, ok := ret.Get(1).(func(context.Context, *models.GroupFilterType, *models.FindFilterType) int); ok { r1 = rf(ctx, groupFilter, findFilter) } else { r1 = ret.Get(1).(int) } var r2 error if rf, ok := ret.Get(2).(func(context.Context, *models.GroupFilterType, *models.FindFilterType) error); ok { r2 = rf(ctx, groupFilter, findFilter) } else { r2 = ret.Error(2) } return r0, r1, r2 } // QueryCount provides a mock function with given fields: ctx, groupFilter, findFilter func (_m *GroupReaderWriter) QueryCount(ctx context.Context, groupFilter *models.GroupFilterType, findFilter *models.FindFilterType) (int, error) { ret := _m.Called(ctx, groupFilter, findFilter) var r0 int if rf, ok := ret.Get(0).(func(context.Context, *models.GroupFilterType, *models.FindFilterType) int); ok { r0 = rf(ctx, groupFilter, findFilter) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, *models.GroupFilterType, *models.FindFilterType) error); ok { r1 = rf(ctx, groupFilter, findFilter) } else { r1 = ret.Error(1) } return r0, r1 } // SetCustomFields provides a mock function with given fields: ctx, id, fields func (_m *GroupReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error { ret := _m.Called(ctx, id, fields) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok { r0 = rf(ctx, id, fields) } else { r0 = ret.Error(0) } return r0 } // Update provides a mock function with given fields: ctx, updatedGroup func (_m *GroupReaderWriter) Update(ctx context.Context, updatedGroup *models.Group) error { ret := _m.Called(ctx, updatedGroup) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.Group) error); ok { r0 = rf(ctx, updatedGroup) } else { r0 = ret.Error(0) } return r0 } // UpdateBackImage provides a mock function with given fields: ctx, groupID, backImage func (_m *GroupReaderWriter) UpdateBackImage(ctx context.Context, groupID int, backImage []byte) error { ret := _m.Called(ctx, groupID, backImage) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, []byte) error); ok { r0 = rf(ctx, groupID, backImage) } else { r0 = ret.Error(0) } return r0 } // UpdateFrontImage provides a mock function with given fields: ctx, groupID, frontImage func (_m *GroupReaderWriter) UpdateFrontImage(ctx context.Context, groupID int, frontImage []byte) error { ret := _m.Called(ctx, groupID, frontImage) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, []byte) error); ok { r0 = rf(ctx, groupID, frontImage) } else { r0 = ret.Error(0) } return r0 } // UpdatePartial provides a mock function with given fields: ctx, id, updatedGroup func (_m *GroupReaderWriter) UpdatePartial(ctx context.Context, id int, updatedGroup models.GroupPartial) (*models.Group, error) { ret := _m.Called(ctx, id, updatedGroup) var r0 *models.Group if rf, ok := ret.Get(0).(func(context.Context, int, models.GroupPartial) *models.Group); ok { r0 = rf(ctx, id, updatedGroup) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Group) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int, models.GroupPartial) error); ok { r1 = rf(ctx, id, updatedGroup) } else { r1 = ret.Error(1) } return r0, r1 } ================================================ FILE: pkg/models/mocks/ImageReaderWriter.go ================================================ // Code generated by mockery v2.10.0. DO NOT EDIT. package mocks import ( context "context" models "github.com/stashapp/stash/pkg/models" mock "github.com/stretchr/testify/mock" ) // ImageReaderWriter is an autogenerated mock type for the ImageReaderWriter type type ImageReaderWriter struct { mock.Mock } // AddFileID provides a mock function with given fields: ctx, id, fileID func (_m *ImageReaderWriter) AddFileID(ctx context.Context, id int, fileID models.FileID) error { ret := _m.Called(ctx, id, fileID) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, models.FileID) error); ok { r0 = rf(ctx, id, fileID) } else { r0 = ret.Error(0) } return r0 } // All provides a mock function with given fields: ctx func (_m *ImageReaderWriter) All(ctx context.Context) ([]*models.Image, error) { ret := _m.Called(ctx) var r0 []*models.Image if rf, ok := ret.Get(0).(func(context.Context) []*models.Image); ok { r0 = rf(ctx) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Image) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // Count provides a mock function with given fields: ctx func (_m *ImageReaderWriter) Count(ctx context.Context) (int, error) { ret := _m.Called(ctx) var r0 int if rf, ok := ret.Get(0).(func(context.Context) int); ok { r0 = rf(ctx) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // CountByFileID provides a mock function with given fields: ctx, fileID func (_m *ImageReaderWriter) CountByFileID(ctx context.Context, fileID models.FileID) (int, error) { ret := _m.Called(ctx, fileID) var r0 int if rf, ok := ret.Get(0).(func(context.Context, models.FileID) int); ok { r0 = rf(ctx, fileID) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok { r1 = rf(ctx, fileID) } else { r1 = ret.Error(1) } return r0, r1 } // CountByGalleryID provides a mock function with given fields: ctx, galleryID func (_m *ImageReaderWriter) CountByGalleryID(ctx context.Context, galleryID int) (int, error) { ret := _m.Called(ctx, galleryID) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { r0 = rf(ctx, galleryID) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, galleryID) } else { r1 = ret.Error(1) } return r0, r1 } // CoverByGalleryID provides a mock function with given fields: ctx, galleryId func (_m *ImageReaderWriter) CoverByGalleryID(ctx context.Context, galleryId int) (*models.Image, error) { ret := _m.Called(ctx, galleryId) var r0 *models.Image if rf, ok := ret.Get(0).(func(context.Context, int) *models.Image); ok { r0 = rf(ctx, galleryId) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Image) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, galleryId) } else { r1 = ret.Error(1) } return r0, r1 } // Create provides a mock function with given fields: ctx, newImage func (_m *ImageReaderWriter) Create(ctx context.Context, newImage *models.CreateImageInput) error { ret := _m.Called(ctx, newImage) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.CreateImageInput) error); ok { r0 = rf(ctx, newImage) } else { r0 = ret.Error(0) } return r0 } // DecrementOCounter provides a mock function with given fields: ctx, id func (_m *ImageReaderWriter) DecrementOCounter(ctx context.Context, id int) (int, error) { ret := _m.Called(ctx, id) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { r0 = rf(ctx, id) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // Destroy provides a mock function with given fields: ctx, id func (_m *ImageReaderWriter) Destroy(ctx context.Context, id int) error { ret := _m.Called(ctx, id) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { r0 = rf(ctx, id) } else { r0 = ret.Error(0) } return r0 } // Find provides a mock function with given fields: ctx, id func (_m *ImageReaderWriter) Find(ctx context.Context, id int) (*models.Image, error) { ret := _m.Called(ctx, id) var r0 *models.Image if rf, ok := ret.Get(0).(func(context.Context, int) *models.Image); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Image) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // FindByChecksum provides a mock function with given fields: ctx, checksum func (_m *ImageReaderWriter) FindByChecksum(ctx context.Context, checksum string) ([]*models.Image, error) { ret := _m.Called(ctx, checksum) var r0 []*models.Image if rf, ok := ret.Get(0).(func(context.Context, string) []*models.Image); ok { r0 = rf(ctx, checksum) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Image) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { r1 = rf(ctx, checksum) } else { r1 = ret.Error(1) } return r0, r1 } // FindByFileID provides a mock function with given fields: ctx, fileID func (_m *ImageReaderWriter) FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Image, error) { ret := _m.Called(ctx, fileID) var r0 []*models.Image if rf, ok := ret.Get(0).(func(context.Context, models.FileID) []*models.Image); ok { r0 = rf(ctx, fileID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Image) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok { r1 = rf(ctx, fileID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByFingerprints provides a mock function with given fields: ctx, fp func (_m *ImageReaderWriter) FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Image, error) { ret := _m.Called(ctx, fp) var r0 []*models.Image if rf, ok := ret.Get(0).(func(context.Context, []models.Fingerprint) []*models.Image); ok { r0 = rf(ctx, fp) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Image) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []models.Fingerprint) error); ok { r1 = rf(ctx, fp) } else { r1 = ret.Error(1) } return r0, r1 } // FindByFolderID provides a mock function with given fields: ctx, fileID func (_m *ImageReaderWriter) FindByFolderID(ctx context.Context, fileID models.FolderID) ([]*models.Image, error) { ret := _m.Called(ctx, fileID) var r0 []*models.Image if rf, ok := ret.Get(0).(func(context.Context, models.FolderID) []*models.Image); ok { r0 = rf(ctx, fileID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Image) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.FolderID) error); ok { r1 = rf(ctx, fileID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByGalleryID provides a mock function with given fields: ctx, galleryID func (_m *ImageReaderWriter) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Image, error) { ret := _m.Called(ctx, galleryID) var r0 []*models.Image if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Image); ok { r0 = rf(ctx, galleryID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Image) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, galleryID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByGalleryIDIndex provides a mock function with given fields: ctx, galleryID, index func (_m *ImageReaderWriter) FindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*models.Image, error) { ret := _m.Called(ctx, galleryID, index) var r0 *models.Image if rf, ok := ret.Get(0).(func(context.Context, int, uint) *models.Image); ok { r0 = rf(ctx, galleryID, index) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Image) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int, uint) error); ok { r1 = rf(ctx, galleryID, index) } else { r1 = ret.Error(1) } return r0, r1 } // FindByZipFileID provides a mock function with given fields: ctx, zipFileID func (_m *ImageReaderWriter) FindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]*models.Image, error) { ret := _m.Called(ctx, zipFileID) var r0 []*models.Image if rf, ok := ret.Get(0).(func(context.Context, models.FileID) []*models.Image); ok { r0 = rf(ctx, zipFileID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Image) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok { r1 = rf(ctx, zipFileID) } else { r1 = ret.Error(1) } return r0, r1 } // FindMany provides a mock function with given fields: ctx, ids func (_m *ImageReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Image, error) { ret := _m.Called(ctx, ids) var r0 []*models.Image if rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Image); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Image) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetCustomFields provides a mock function with given fields: ctx, id func (_m *ImageReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { ret := _m.Called(ctx, id) var r0 map[string]interface{} if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(map[string]interface{}) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // GetCustomFieldsBulk provides a mock function with given fields: ctx, ids func (_m *ImageReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { ret := _m.Called(ctx, ids) var r0 []models.CustomFieldMap if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.CustomFieldMap) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetFiles provides a mock function with given fields: ctx, relatedID func (_m *ImageReaderWriter) GetFiles(ctx context.Context, relatedID int) ([]models.File, error) { ret := _m.Called(ctx, relatedID) var r0 []models.File if rf, ok := ret.Get(0).(func(context.Context, int) []models.File); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.File) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetGalleryIDs provides a mock function with given fields: ctx, relatedID func (_m *ImageReaderWriter) GetGalleryIDs(ctx context.Context, relatedID int) ([]int, error) { ret := _m.Called(ctx, relatedID) var r0 []int if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]int) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetManyFileIDs provides a mock function with given fields: ctx, ids func (_m *ImageReaderWriter) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) { ret := _m.Called(ctx, ids) var r0 [][]models.FileID if rf, ok := ret.Get(0).(func(context.Context, []int) [][]models.FileID); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([][]models.FileID) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetPerformerIDs provides a mock function with given fields: ctx, relatedID func (_m *ImageReaderWriter) GetPerformerIDs(ctx context.Context, relatedID int) ([]int, error) { ret := _m.Called(ctx, relatedID) var r0 []int if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]int) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetTagIDs provides a mock function with given fields: ctx, relatedID func (_m *ImageReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) { ret := _m.Called(ctx, relatedID) var r0 []int if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]int) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetURLs provides a mock function with given fields: ctx, relatedID func (_m *ImageReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) { ret := _m.Called(ctx, relatedID) var r0 []string if rf, ok := ret.Get(0).(func(context.Context, int) []string); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]string) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // IncrementOCounter provides a mock function with given fields: ctx, id func (_m *ImageReaderWriter) IncrementOCounter(ctx context.Context, id int) (int, error) { ret := _m.Called(ctx, id) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { r0 = rf(ctx, id) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // OCount provides a mock function with given fields: ctx func (_m *ImageReaderWriter) OCount(ctx context.Context) (int, error) { ret := _m.Called(ctx) var r0 int if rf, ok := ret.Get(0).(func(context.Context) int); ok { r0 = rf(ctx) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // OCountByPerformerID provides a mock function with given fields: ctx, performerID func (_m *ImageReaderWriter) OCountByPerformerID(ctx context.Context, performerID int) (int, error) { ret := _m.Called(ctx, performerID) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { r0 = rf(ctx, performerID) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, performerID) } else { r1 = ret.Error(1) } return r0, r1 } // OCountByStudioID provides a mock function with given fields: ctx, studioID func (_m *ImageReaderWriter) OCountByStudioID(ctx context.Context, studioID int) (int, error) { ret := _m.Called(ctx, studioID) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { r0 = rf(ctx, studioID) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, studioID) } else { r1 = ret.Error(1) } return r0, r1 } // Query provides a mock function with given fields: ctx, options func (_m *ImageReaderWriter) Query(ctx context.Context, options models.ImageQueryOptions) (*models.ImageQueryResult, error) { ret := _m.Called(ctx, options) var r0 *models.ImageQueryResult if rf, ok := ret.Get(0).(func(context.Context, models.ImageQueryOptions) *models.ImageQueryResult); ok { r0 = rf(ctx, options) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.ImageQueryResult) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.ImageQueryOptions) error); ok { r1 = rf(ctx, options) } else { r1 = ret.Error(1) } return r0, r1 } // QueryCount provides a mock function with given fields: ctx, imageFilter, findFilter func (_m *ImageReaderWriter) QueryCount(ctx context.Context, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (int, error) { ret := _m.Called(ctx, imageFilter, findFilter) var r0 int if rf, ok := ret.Get(0).(func(context.Context, *models.ImageFilterType, *models.FindFilterType) int); ok { r0 = rf(ctx, imageFilter, findFilter) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, *models.ImageFilterType, *models.FindFilterType) error); ok { r1 = rf(ctx, imageFilter, findFilter) } else { r1 = ret.Error(1) } return r0, r1 } // RemoveFileID provides a mock function with given fields: ctx, id, fileID func (_m *ImageReaderWriter) RemoveFileID(ctx context.Context, id int, fileID models.FileID) error { ret := _m.Called(ctx, id, fileID) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, models.FileID) error); ok { r0 = rf(ctx, id, fileID) } else { r0 = ret.Error(0) } return r0 } // ResetOCounter provides a mock function with given fields: ctx, id func (_m *ImageReaderWriter) ResetOCounter(ctx context.Context, id int) (int, error) { ret := _m.Called(ctx, id) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { r0 = rf(ctx, id) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // SetCustomFields provides a mock function with given fields: ctx, id, fields func (_m *ImageReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error { ret := _m.Called(ctx, id, fields) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok { r0 = rf(ctx, id, fields) } else { r0 = ret.Error(0) } return r0 } // Size provides a mock function with given fields: ctx func (_m *ImageReaderWriter) Size(ctx context.Context) (float64, error) { ret := _m.Called(ctx) var r0 float64 if rf, ok := ret.Get(0).(func(context.Context) float64); ok { r0 = rf(ctx) } else { r0 = ret.Get(0).(float64) } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // Update provides a mock function with given fields: ctx, updatedImage func (_m *ImageReaderWriter) Update(ctx context.Context, updatedImage *models.Image) error { ret := _m.Called(ctx, updatedImage) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.Image) error); ok { r0 = rf(ctx, updatedImage) } else { r0 = ret.Error(0) } return r0 } // UpdatePartial provides a mock function with given fields: ctx, id, partial func (_m *ImageReaderWriter) UpdatePartial(ctx context.Context, id int, partial models.ImagePartial) (*models.Image, error) { ret := _m.Called(ctx, id, partial) var r0 *models.Image if rf, ok := ret.Get(0).(func(context.Context, int, models.ImagePartial) *models.Image); ok { r0 = rf(ctx, id, partial) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Image) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int, models.ImagePartial) error); ok { r1 = rf(ctx, id, partial) } else { r1 = ret.Error(1) } return r0, r1 } // UpdatePerformers provides a mock function with given fields: ctx, imageID, performerIDs func (_m *ImageReaderWriter) UpdatePerformers(ctx context.Context, imageID int, performerIDs []int) error { ret := _m.Called(ctx, imageID, performerIDs) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok { r0 = rf(ctx, imageID, performerIDs) } else { r0 = ret.Error(0) } return r0 } // UpdateTags provides a mock function with given fields: ctx, imageID, tagIDs func (_m *ImageReaderWriter) UpdateTags(ctx context.Context, imageID int, tagIDs []int) error { ret := _m.Called(ctx, imageID, tagIDs) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok { r0 = rf(ctx, imageID, tagIDs) } else { r0 = ret.Error(0) } return r0 } ================================================ FILE: pkg/models/mocks/PerformerReaderWriter.go ================================================ // Code generated by mockery v2.10.0. DO NOT EDIT. package mocks import ( context "context" models "github.com/stashapp/stash/pkg/models" mock "github.com/stretchr/testify/mock" ) // PerformerReaderWriter is an autogenerated mock type for the PerformerReaderWriter type type PerformerReaderWriter struct { mock.Mock } // All provides a mock function with given fields: ctx func (_m *PerformerReaderWriter) All(ctx context.Context) ([]*models.Performer, error) { ret := _m.Called(ctx) var r0 []*models.Performer if rf, ok := ret.Get(0).(func(context.Context) []*models.Performer); ok { r0 = rf(ctx) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Performer) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // Count provides a mock function with given fields: ctx func (_m *PerformerReaderWriter) Count(ctx context.Context) (int, error) { ret := _m.Called(ctx) var r0 int if rf, ok := ret.Get(0).(func(context.Context) int); ok { r0 = rf(ctx) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // CountByTagID provides a mock function with given fields: ctx, tagID func (_m *PerformerReaderWriter) CountByTagID(ctx context.Context, tagID int) (int, error) { ret := _m.Called(ctx, tagID) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { r0 = rf(ctx, tagID) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, tagID) } else { r1 = ret.Error(1) } return r0, r1 } // Create provides a mock function with given fields: ctx, newPerformer func (_m *PerformerReaderWriter) Create(ctx context.Context, newPerformer *models.CreatePerformerInput) error { ret := _m.Called(ctx, newPerformer) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.CreatePerformerInput) error); ok { r0 = rf(ctx, newPerformer) } else { r0 = ret.Error(0) } return r0 } // Destroy provides a mock function with given fields: ctx, id func (_m *PerformerReaderWriter) Destroy(ctx context.Context, id int) error { ret := _m.Called(ctx, id) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { r0 = rf(ctx, id) } else { r0 = ret.Error(0) } return r0 } // Find provides a mock function with given fields: ctx, id func (_m *PerformerReaderWriter) Find(ctx context.Context, id int) (*models.Performer, error) { ret := _m.Called(ctx, id) var r0 *models.Performer if rf, ok := ret.Get(0).(func(context.Context, int) *models.Performer); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Performer) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // FindByGalleryID provides a mock function with given fields: ctx, galleryID func (_m *PerformerReaderWriter) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Performer, error) { ret := _m.Called(ctx, galleryID) var r0 []*models.Performer if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Performer); ok { r0 = rf(ctx, galleryID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Performer) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, galleryID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByImageID provides a mock function with given fields: ctx, imageID func (_m *PerformerReaderWriter) FindByImageID(ctx context.Context, imageID int) ([]*models.Performer, error) { ret := _m.Called(ctx, imageID) var r0 []*models.Performer if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Performer); ok { r0 = rf(ctx, imageID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Performer) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, imageID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByNames provides a mock function with given fields: ctx, names, nocase func (_m *PerformerReaderWriter) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Performer, error) { ret := _m.Called(ctx, names, nocase) var r0 []*models.Performer if rf, ok := ret.Get(0).(func(context.Context, []string, bool) []*models.Performer); ok { r0 = rf(ctx, names, nocase) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Performer) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []string, bool) error); ok { r1 = rf(ctx, names, nocase) } else { r1 = ret.Error(1) } return r0, r1 } // FindBySceneID provides a mock function with given fields: ctx, sceneID func (_m *PerformerReaderWriter) FindBySceneID(ctx context.Context, sceneID int) ([]*models.Performer, error) { ret := _m.Called(ctx, sceneID) var r0 []*models.Performer if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Performer); ok { r0 = rf(ctx, sceneID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Performer) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, sceneID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByStashID provides a mock function with given fields: ctx, stashID func (_m *PerformerReaderWriter) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Performer, error) { ret := _m.Called(ctx, stashID) var r0 []*models.Performer if rf, ok := ret.Get(0).(func(context.Context, models.StashID) []*models.Performer); ok { r0 = rf(ctx, stashID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Performer) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.StashID) error); ok { r1 = rf(ctx, stashID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByStashIDStatus provides a mock function with given fields: ctx, hasStashID, stashboxEndpoint func (_m *PerformerReaderWriter) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Performer, error) { ret := _m.Called(ctx, hasStashID, stashboxEndpoint) var r0 []*models.Performer if rf, ok := ret.Get(0).(func(context.Context, bool, string) []*models.Performer); ok { r0 = rf(ctx, hasStashID, stashboxEndpoint) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Performer) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, bool, string) error); ok { r1 = rf(ctx, hasStashID, stashboxEndpoint) } else { r1 = ret.Error(1) } return r0, r1 } // FindMany provides a mock function with given fields: ctx, ids func (_m *PerformerReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Performer, error) { ret := _m.Called(ctx, ids) var r0 []*models.Performer if rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Performer); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Performer) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetAliases provides a mock function with given fields: ctx, relatedID func (_m *PerformerReaderWriter) GetAliases(ctx context.Context, relatedID int) ([]string, error) { ret := _m.Called(ctx, relatedID) var r0 []string if rf, ok := ret.Get(0).(func(context.Context, int) []string); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]string) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetCustomFields provides a mock function with given fields: ctx, id func (_m *PerformerReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { ret := _m.Called(ctx, id) var r0 map[string]interface{} if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(map[string]interface{}) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // GetCustomFieldsBulk provides a mock function with given fields: ctx, ids func (_m *PerformerReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { ret := _m.Called(ctx, ids) var r0 []models.CustomFieldMap if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.CustomFieldMap) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetImage provides a mock function with given fields: ctx, performerID func (_m *PerformerReaderWriter) GetImage(ctx context.Context, performerID int) ([]byte, error) { ret := _m.Called(ctx, performerID) var r0 []byte if rf, ok := ret.Get(0).(func(context.Context, int) []byte); ok { r0 = rf(ctx, performerID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]byte) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, performerID) } else { r1 = ret.Error(1) } return r0, r1 } // GetStashIDs provides a mock function with given fields: ctx, relatedID func (_m *PerformerReaderWriter) GetStashIDs(ctx context.Context, relatedID int) ([]models.StashID, error) { ret := _m.Called(ctx, relatedID) var r0 []models.StashID if rf, ok := ret.Get(0).(func(context.Context, int) []models.StashID); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.StashID) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetTagIDs provides a mock function with given fields: ctx, relatedID func (_m *PerformerReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) { ret := _m.Called(ctx, relatedID) var r0 []int if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]int) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetURLs provides a mock function with given fields: ctx, relatedID func (_m *PerformerReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) { ret := _m.Called(ctx, relatedID) var r0 []string if rf, ok := ret.Get(0).(func(context.Context, int) []string); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]string) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // HasImage provides a mock function with given fields: ctx, performerID func (_m *PerformerReaderWriter) HasImage(ctx context.Context, performerID int) (bool, error) { ret := _m.Called(ctx, performerID) var r0 bool if rf, ok := ret.Get(0).(func(context.Context, int) bool); ok { r0 = rf(ctx, performerID) } else { r0 = ret.Get(0).(bool) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, performerID) } else { r1 = ret.Error(1) } return r0, r1 } // Merge provides a mock function with given fields: ctx, source, destination func (_m *PerformerReaderWriter) Merge(ctx context.Context, source []int, destination int) error { ret := _m.Called(ctx, source, destination) var r0 error if rf, ok := ret.Get(0).(func(context.Context, []int, int) error); ok { r0 = rf(ctx, source, destination) } else { r0 = ret.Error(0) } return r0 } // Query provides a mock function with given fields: ctx, performerFilter, findFilter func (_m *PerformerReaderWriter) Query(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) { ret := _m.Called(ctx, performerFilter, findFilter) var r0 []*models.Performer if rf, ok := ret.Get(0).(func(context.Context, *models.PerformerFilterType, *models.FindFilterType) []*models.Performer); ok { r0 = rf(ctx, performerFilter, findFilter) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Performer) } } var r1 int if rf, ok := ret.Get(1).(func(context.Context, *models.PerformerFilterType, *models.FindFilterType) int); ok { r1 = rf(ctx, performerFilter, findFilter) } else { r1 = ret.Get(1).(int) } var r2 error if rf, ok := ret.Get(2).(func(context.Context, *models.PerformerFilterType, *models.FindFilterType) error); ok { r2 = rf(ctx, performerFilter, findFilter) } else { r2 = ret.Error(2) } return r0, r1, r2 } // QueryCount provides a mock function with given fields: ctx, performerFilter, findFilter func (_m *PerformerReaderWriter) QueryCount(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) (int, error) { ret := _m.Called(ctx, performerFilter, findFilter) var r0 int if rf, ok := ret.Get(0).(func(context.Context, *models.PerformerFilterType, *models.FindFilterType) int); ok { r0 = rf(ctx, performerFilter, findFilter) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, *models.PerformerFilterType, *models.FindFilterType) error); ok { r1 = rf(ctx, performerFilter, findFilter) } else { r1 = ret.Error(1) } return r0, r1 } // QueryForAutoTag provides a mock function with given fields: ctx, words func (_m *PerformerReaderWriter) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Performer, error) { ret := _m.Called(ctx, words) var r0 []*models.Performer if rf, ok := ret.Get(0).(func(context.Context, []string) []*models.Performer); ok { r0 = rf(ctx, words) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Performer) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []string) error); ok { r1 = rf(ctx, words) } else { r1 = ret.Error(1) } return r0, r1 } // Update provides a mock function with given fields: ctx, updatedPerformer func (_m *PerformerReaderWriter) Update(ctx context.Context, updatedPerformer *models.UpdatePerformerInput) error { ret := _m.Called(ctx, updatedPerformer) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.UpdatePerformerInput) error); ok { r0 = rf(ctx, updatedPerformer) } else { r0 = ret.Error(0) } return r0 } // UpdateImage provides a mock function with given fields: ctx, performerID, image func (_m *PerformerReaderWriter) UpdateImage(ctx context.Context, performerID int, image []byte) error { ret := _m.Called(ctx, performerID, image) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, []byte) error); ok { r0 = rf(ctx, performerID, image) } else { r0 = ret.Error(0) } return r0 } // UpdatePartial provides a mock function with given fields: ctx, id, updatedPerformer func (_m *PerformerReaderWriter) UpdatePartial(ctx context.Context, id int, updatedPerformer models.PerformerPartial) (*models.Performer, error) { ret := _m.Called(ctx, id, updatedPerformer) var r0 *models.Performer if rf, ok := ret.Get(0).(func(context.Context, int, models.PerformerPartial) *models.Performer); ok { r0 = rf(ctx, id, updatedPerformer) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Performer) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int, models.PerformerPartial) error); ok { r1 = rf(ctx, id, updatedPerformer) } else { r1 = ret.Error(1) } return r0, r1 } ================================================ FILE: pkg/models/mocks/SavedFilterReaderWriter.go ================================================ // Code generated by mockery v2.10.0. DO NOT EDIT. package mocks import ( context "context" models "github.com/stashapp/stash/pkg/models" mock "github.com/stretchr/testify/mock" ) // SavedFilterReaderWriter is an autogenerated mock type for the SavedFilterReaderWriter type type SavedFilterReaderWriter struct { mock.Mock } // All provides a mock function with given fields: ctx func (_m *SavedFilterReaderWriter) All(ctx context.Context) ([]*models.SavedFilter, error) { ret := _m.Called(ctx) var r0 []*models.SavedFilter if rf, ok := ret.Get(0).(func(context.Context) []*models.SavedFilter); ok { r0 = rf(ctx) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.SavedFilter) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // Create provides a mock function with given fields: ctx, obj func (_m *SavedFilterReaderWriter) Create(ctx context.Context, obj *models.SavedFilter) error { ret := _m.Called(ctx, obj) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.SavedFilter) error); ok { r0 = rf(ctx, obj) } else { r0 = ret.Error(0) } return r0 } // Destroy provides a mock function with given fields: ctx, id func (_m *SavedFilterReaderWriter) Destroy(ctx context.Context, id int) error { ret := _m.Called(ctx, id) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { r0 = rf(ctx, id) } else { r0 = ret.Error(0) } return r0 } // Find provides a mock function with given fields: ctx, id func (_m *SavedFilterReaderWriter) Find(ctx context.Context, id int) (*models.SavedFilter, error) { ret := _m.Called(ctx, id) var r0 *models.SavedFilter if rf, ok := ret.Get(0).(func(context.Context, int) *models.SavedFilter); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.SavedFilter) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // FindByMode provides a mock function with given fields: ctx, mode func (_m *SavedFilterReaderWriter) FindByMode(ctx context.Context, mode models.FilterMode) ([]*models.SavedFilter, error) { ret := _m.Called(ctx, mode) var r0 []*models.SavedFilter if rf, ok := ret.Get(0).(func(context.Context, models.FilterMode) []*models.SavedFilter); ok { r0 = rf(ctx, mode) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.SavedFilter) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.FilterMode) error); ok { r1 = rf(ctx, mode) } else { r1 = ret.Error(1) } return r0, r1 } // FindMany provides a mock function with given fields: ctx, ids, ignoreNotFound func (_m *SavedFilterReaderWriter) FindMany(ctx context.Context, ids []int, ignoreNotFound bool) ([]*models.SavedFilter, error) { ret := _m.Called(ctx, ids, ignoreNotFound) var r0 []*models.SavedFilter if rf, ok := ret.Get(0).(func(context.Context, []int, bool) []*models.SavedFilter); ok { r0 = rf(ctx, ids, ignoreNotFound) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.SavedFilter) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int, bool) error); ok { r1 = rf(ctx, ids, ignoreNotFound) } else { r1 = ret.Error(1) } return r0, r1 } // Update provides a mock function with given fields: ctx, obj func (_m *SavedFilterReaderWriter) Update(ctx context.Context, obj *models.SavedFilter) error { ret := _m.Called(ctx, obj) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.SavedFilter) error); ok { r0 = rf(ctx, obj) } else { r0 = ret.Error(0) } return r0 } ================================================ FILE: pkg/models/mocks/SceneMarkerReaderWriter.go ================================================ // Code generated by mockery v2.10.0. DO NOT EDIT. package mocks import ( context "context" models "github.com/stashapp/stash/pkg/models" mock "github.com/stretchr/testify/mock" ) // SceneMarkerReaderWriter is an autogenerated mock type for the SceneMarkerReaderWriter type type SceneMarkerReaderWriter struct { mock.Mock } // All provides a mock function with given fields: ctx func (_m *SceneMarkerReaderWriter) All(ctx context.Context) ([]*models.SceneMarker, error) { ret := _m.Called(ctx) var r0 []*models.SceneMarker if rf, ok := ret.Get(0).(func(context.Context) []*models.SceneMarker); ok { r0 = rf(ctx) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.SceneMarker) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // Count provides a mock function with given fields: ctx func (_m *SceneMarkerReaderWriter) Count(ctx context.Context) (int, error) { ret := _m.Called(ctx) var r0 int if rf, ok := ret.Get(0).(func(context.Context) int); ok { r0 = rf(ctx) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // CountByTagID provides a mock function with given fields: ctx, tagID func (_m *SceneMarkerReaderWriter) CountByTagID(ctx context.Context, tagID int) (int, error) { ret := _m.Called(ctx, tagID) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { r0 = rf(ctx, tagID) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, tagID) } else { r1 = ret.Error(1) } return r0, r1 } // Create provides a mock function with given fields: ctx, newSceneMarker func (_m *SceneMarkerReaderWriter) Create(ctx context.Context, newSceneMarker *models.SceneMarker) error { ret := _m.Called(ctx, newSceneMarker) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.SceneMarker) error); ok { r0 = rf(ctx, newSceneMarker) } else { r0 = ret.Error(0) } return r0 } // Destroy provides a mock function with given fields: ctx, id func (_m *SceneMarkerReaderWriter) Destroy(ctx context.Context, id int) error { ret := _m.Called(ctx, id) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { r0 = rf(ctx, id) } else { r0 = ret.Error(0) } return r0 } // Find provides a mock function with given fields: ctx, id func (_m *SceneMarkerReaderWriter) Find(ctx context.Context, id int) (*models.SceneMarker, error) { ret := _m.Called(ctx, id) var r0 *models.SceneMarker if rf, ok := ret.Get(0).(func(context.Context, int) *models.SceneMarker); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.SceneMarker) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // FindBySceneID provides a mock function with given fields: ctx, sceneID func (_m *SceneMarkerReaderWriter) FindBySceneID(ctx context.Context, sceneID int) ([]*models.SceneMarker, error) { ret := _m.Called(ctx, sceneID) var r0 []*models.SceneMarker if rf, ok := ret.Get(0).(func(context.Context, int) []*models.SceneMarker); ok { r0 = rf(ctx, sceneID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.SceneMarker) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, sceneID) } else { r1 = ret.Error(1) } return r0, r1 } // FindMany provides a mock function with given fields: ctx, ids func (_m *SceneMarkerReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.SceneMarker, error) { ret := _m.Called(ctx, ids) var r0 []*models.SceneMarker if rf, ok := ret.Get(0).(func(context.Context, []int) []*models.SceneMarker); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.SceneMarker) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetMarkerStrings provides a mock function with given fields: ctx, q, sort func (_m *SceneMarkerReaderWriter) GetMarkerStrings(ctx context.Context, q *string, sort *string) ([]*models.MarkerStringsResultType, error) { ret := _m.Called(ctx, q, sort) var r0 []*models.MarkerStringsResultType if rf, ok := ret.Get(0).(func(context.Context, *string, *string) []*models.MarkerStringsResultType); ok { r0 = rf(ctx, q, sort) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.MarkerStringsResultType) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, *string, *string) error); ok { r1 = rf(ctx, q, sort) } else { r1 = ret.Error(1) } return r0, r1 } // GetTagIDs provides a mock function with given fields: ctx, relatedID func (_m *SceneMarkerReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) { ret := _m.Called(ctx, relatedID) var r0 []int if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]int) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // Query provides a mock function with given fields: ctx, sceneMarkerFilter, findFilter func (_m *SceneMarkerReaderWriter) Query(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, findFilter *models.FindFilterType) ([]*models.SceneMarker, int, error) { ret := _m.Called(ctx, sceneMarkerFilter, findFilter) var r0 []*models.SceneMarker if rf, ok := ret.Get(0).(func(context.Context, *models.SceneMarkerFilterType, *models.FindFilterType) []*models.SceneMarker); ok { r0 = rf(ctx, sceneMarkerFilter, findFilter) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.SceneMarker) } } var r1 int if rf, ok := ret.Get(1).(func(context.Context, *models.SceneMarkerFilterType, *models.FindFilterType) int); ok { r1 = rf(ctx, sceneMarkerFilter, findFilter) } else { r1 = ret.Get(1).(int) } var r2 error if rf, ok := ret.Get(2).(func(context.Context, *models.SceneMarkerFilterType, *models.FindFilterType) error); ok { r2 = rf(ctx, sceneMarkerFilter, findFilter) } else { r2 = ret.Error(2) } return r0, r1, r2 } // QueryCount provides a mock function with given fields: ctx, sceneMarkerFilter, findFilter func (_m *SceneMarkerReaderWriter) QueryCount(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, findFilter *models.FindFilterType) (int, error) { ret := _m.Called(ctx, sceneMarkerFilter, findFilter) var r0 int if rf, ok := ret.Get(0).(func(context.Context, *models.SceneMarkerFilterType, *models.FindFilterType) int); ok { r0 = rf(ctx, sceneMarkerFilter, findFilter) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, *models.SceneMarkerFilterType, *models.FindFilterType) error); ok { r1 = rf(ctx, sceneMarkerFilter, findFilter) } else { r1 = ret.Error(1) } return r0, r1 } // Update provides a mock function with given fields: ctx, updatedSceneMarker func (_m *SceneMarkerReaderWriter) Update(ctx context.Context, updatedSceneMarker *models.SceneMarker) error { ret := _m.Called(ctx, updatedSceneMarker) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.SceneMarker) error); ok { r0 = rf(ctx, updatedSceneMarker) } else { r0 = ret.Error(0) } return r0 } // UpdatePartial provides a mock function with given fields: ctx, id, updatedSceneMarker func (_m *SceneMarkerReaderWriter) UpdatePartial(ctx context.Context, id int, updatedSceneMarker models.SceneMarkerPartial) (*models.SceneMarker, error) { ret := _m.Called(ctx, id, updatedSceneMarker) var r0 *models.SceneMarker if rf, ok := ret.Get(0).(func(context.Context, int, models.SceneMarkerPartial) *models.SceneMarker); ok { r0 = rf(ctx, id, updatedSceneMarker) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.SceneMarker) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int, models.SceneMarkerPartial) error); ok { r1 = rf(ctx, id, updatedSceneMarker) } else { r1 = ret.Error(1) } return r0, r1 } // UpdateTags provides a mock function with given fields: ctx, markerID, tagIDs func (_m *SceneMarkerReaderWriter) UpdateTags(ctx context.Context, markerID int, tagIDs []int) error { ret := _m.Called(ctx, markerID, tagIDs) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok { r0 = rf(ctx, markerID, tagIDs) } else { r0 = ret.Error(0) } return r0 } // Wall provides a mock function with given fields: ctx, q func (_m *SceneMarkerReaderWriter) Wall(ctx context.Context, q *string) ([]*models.SceneMarker, error) { ret := _m.Called(ctx, q) var r0 []*models.SceneMarker if rf, ok := ret.Get(0).(func(context.Context, *string) []*models.SceneMarker); ok { r0 = rf(ctx, q) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.SceneMarker) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, *string) error); ok { r1 = rf(ctx, q) } else { r1 = ret.Error(1) } return r0, r1 } ================================================ FILE: pkg/models/mocks/SceneReaderWriter.go ================================================ // Code generated by mockery v2.10.0. DO NOT EDIT. package mocks import ( context "context" models "github.com/stashapp/stash/pkg/models" mock "github.com/stretchr/testify/mock" time "time" ) // SceneReaderWriter is an autogenerated mock type for the SceneReaderWriter type type SceneReaderWriter struct { mock.Mock } // AddFileID provides a mock function with given fields: ctx, id, fileID func (_m *SceneReaderWriter) AddFileID(ctx context.Context, id int, fileID models.FileID) error { ret := _m.Called(ctx, id, fileID) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, models.FileID) error); ok { r0 = rf(ctx, id, fileID) } else { r0 = ret.Error(0) } return r0 } // AddGalleryIDs provides a mock function with given fields: ctx, sceneID, galleryIDs func (_m *SceneReaderWriter) AddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error { ret := _m.Called(ctx, sceneID, galleryIDs) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok { r0 = rf(ctx, sceneID, galleryIDs) } else { r0 = ret.Error(0) } return r0 } // AddO provides a mock function with given fields: ctx, id, dates func (_m *SceneReaderWriter) AddO(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) { ret := _m.Called(ctx, id, dates) var r0 []time.Time if rf, ok := ret.Get(0).(func(context.Context, int, []time.Time) []time.Time); ok { r0 = rf(ctx, id, dates) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]time.Time) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int, []time.Time) error); ok { r1 = rf(ctx, id, dates) } else { r1 = ret.Error(1) } return r0, r1 } // AddViews provides a mock function with given fields: ctx, sceneID, dates func (_m *SceneReaderWriter) AddViews(ctx context.Context, sceneID int, dates []time.Time) ([]time.Time, error) { ret := _m.Called(ctx, sceneID, dates) var r0 []time.Time if rf, ok := ret.Get(0).(func(context.Context, int, []time.Time) []time.Time); ok { r0 = rf(ctx, sceneID, dates) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]time.Time) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int, []time.Time) error); ok { r1 = rf(ctx, sceneID, dates) } else { r1 = ret.Error(1) } return r0, r1 } // All provides a mock function with given fields: ctx func (_m *SceneReaderWriter) All(ctx context.Context) ([]*models.Scene, error) { ret := _m.Called(ctx) var r0 []*models.Scene if rf, ok := ret.Get(0).(func(context.Context) []*models.Scene); ok { r0 = rf(ctx) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Scene) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // AssignFiles provides a mock function with given fields: ctx, sceneID, fileID func (_m *SceneReaderWriter) AssignFiles(ctx context.Context, sceneID int, fileID []models.FileID) error { ret := _m.Called(ctx, sceneID, fileID) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, []models.FileID) error); ok { r0 = rf(ctx, sceneID, fileID) } else { r0 = ret.Error(0) } return r0 } // Count provides a mock function with given fields: ctx func (_m *SceneReaderWriter) Count(ctx context.Context) (int, error) { ret := _m.Called(ctx) var r0 int if rf, ok := ret.Get(0).(func(context.Context) int); ok { r0 = rf(ctx) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // CountAllViews provides a mock function with given fields: ctx func (_m *SceneReaderWriter) CountAllViews(ctx context.Context) (int, error) { ret := _m.Called(ctx) var r0 int if rf, ok := ret.Get(0).(func(context.Context) int); ok { r0 = rf(ctx) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // CountByFileID provides a mock function with given fields: ctx, fileID func (_m *SceneReaderWriter) CountByFileID(ctx context.Context, fileID models.FileID) (int, error) { ret := _m.Called(ctx, fileID) var r0 int if rf, ok := ret.Get(0).(func(context.Context, models.FileID) int); ok { r0 = rf(ctx, fileID) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok { r1 = rf(ctx, fileID) } else { r1 = ret.Error(1) } return r0, r1 } // CountByPerformerID provides a mock function with given fields: ctx, performerID func (_m *SceneReaderWriter) CountByPerformerID(ctx context.Context, performerID int) (int, error) { ret := _m.Called(ctx, performerID) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { r0 = rf(ctx, performerID) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, performerID) } else { r1 = ret.Error(1) } return r0, r1 } // CountMissingChecksum provides a mock function with given fields: ctx func (_m *SceneReaderWriter) CountMissingChecksum(ctx context.Context) (int, error) { ret := _m.Called(ctx) var r0 int if rf, ok := ret.Get(0).(func(context.Context) int); ok { r0 = rf(ctx) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // CountMissingOSHash provides a mock function with given fields: ctx func (_m *SceneReaderWriter) CountMissingOSHash(ctx context.Context) (int, error) { ret := _m.Called(ctx) var r0 int if rf, ok := ret.Get(0).(func(context.Context) int); ok { r0 = rf(ctx) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // CountUniqueViews provides a mock function with given fields: ctx func (_m *SceneReaderWriter) CountUniqueViews(ctx context.Context) (int, error) { ret := _m.Called(ctx) var r0 int if rf, ok := ret.Get(0).(func(context.Context) int); ok { r0 = rf(ctx) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // CountViews provides a mock function with given fields: ctx, id func (_m *SceneReaderWriter) CountViews(ctx context.Context, id int) (int, error) { ret := _m.Called(ctx, id) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { r0 = rf(ctx, id) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // Create provides a mock function with given fields: ctx, newScene, fileIDs func (_m *SceneReaderWriter) Create(ctx context.Context, newScene *models.Scene, fileIDs []models.FileID) error { ret := _m.Called(ctx, newScene, fileIDs) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.Scene, []models.FileID) error); ok { r0 = rf(ctx, newScene, fileIDs) } else { r0 = ret.Error(0) } return r0 } // DeleteAllViews provides a mock function with given fields: ctx, id func (_m *SceneReaderWriter) DeleteAllViews(ctx context.Context, id int) (int, error) { ret := _m.Called(ctx, id) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { r0 = rf(ctx, id) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // DeleteO provides a mock function with given fields: ctx, id, dates func (_m *SceneReaderWriter) DeleteO(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) { ret := _m.Called(ctx, id, dates) var r0 []time.Time if rf, ok := ret.Get(0).(func(context.Context, int, []time.Time) []time.Time); ok { r0 = rf(ctx, id, dates) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]time.Time) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int, []time.Time) error); ok { r1 = rf(ctx, id, dates) } else { r1 = ret.Error(1) } return r0, r1 } // DeleteViews provides a mock function with given fields: ctx, id, dates func (_m *SceneReaderWriter) DeleteViews(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) { ret := _m.Called(ctx, id, dates) var r0 []time.Time if rf, ok := ret.Get(0).(func(context.Context, int, []time.Time) []time.Time); ok { r0 = rf(ctx, id, dates) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]time.Time) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int, []time.Time) error); ok { r1 = rf(ctx, id, dates) } else { r1 = ret.Error(1) } return r0, r1 } // Destroy provides a mock function with given fields: ctx, id func (_m *SceneReaderWriter) Destroy(ctx context.Context, id int) error { ret := _m.Called(ctx, id) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { r0 = rf(ctx, id) } else { r0 = ret.Error(0) } return r0 } // Duration provides a mock function with given fields: ctx func (_m *SceneReaderWriter) Duration(ctx context.Context) (float64, error) { ret := _m.Called(ctx) var r0 float64 if rf, ok := ret.Get(0).(func(context.Context) float64); ok { r0 = rf(ctx) } else { r0 = ret.Get(0).(float64) } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // Find provides a mock function with given fields: ctx, id func (_m *SceneReaderWriter) Find(ctx context.Context, id int) (*models.Scene, error) { ret := _m.Called(ctx, id) var r0 *models.Scene if rf, ok := ret.Get(0).(func(context.Context, int) *models.Scene); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Scene) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // FindByChecksum provides a mock function with given fields: ctx, checksum func (_m *SceneReaderWriter) FindByChecksum(ctx context.Context, checksum string) ([]*models.Scene, error) { ret := _m.Called(ctx, checksum) var r0 []*models.Scene if rf, ok := ret.Get(0).(func(context.Context, string) []*models.Scene); ok { r0 = rf(ctx, checksum) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Scene) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { r1 = rf(ctx, checksum) } else { r1 = ret.Error(1) } return r0, r1 } // FindByFileID provides a mock function with given fields: ctx, fileID func (_m *SceneReaderWriter) FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Scene, error) { ret := _m.Called(ctx, fileID) var r0 []*models.Scene if rf, ok := ret.Get(0).(func(context.Context, models.FileID) []*models.Scene); ok { r0 = rf(ctx, fileID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Scene) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok { r1 = rf(ctx, fileID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByFingerprints provides a mock function with given fields: ctx, fp func (_m *SceneReaderWriter) FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Scene, error) { ret := _m.Called(ctx, fp) var r0 []*models.Scene if rf, ok := ret.Get(0).(func(context.Context, []models.Fingerprint) []*models.Scene); ok { r0 = rf(ctx, fp) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Scene) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []models.Fingerprint) error); ok { r1 = rf(ctx, fp) } else { r1 = ret.Error(1) } return r0, r1 } // FindByGalleryID provides a mock function with given fields: ctx, performerID func (_m *SceneReaderWriter) FindByGalleryID(ctx context.Context, performerID int) ([]*models.Scene, error) { ret := _m.Called(ctx, performerID) var r0 []*models.Scene if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Scene); ok { r0 = rf(ctx, performerID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Scene) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, performerID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByGroupID provides a mock function with given fields: ctx, groupID func (_m *SceneReaderWriter) FindByGroupID(ctx context.Context, groupID int) ([]*models.Scene, error) { ret := _m.Called(ctx, groupID) var r0 []*models.Scene if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Scene); ok { r0 = rf(ctx, groupID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Scene) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, groupID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByIDs provides a mock function with given fields: ctx, ids func (_m *SceneReaderWriter) FindByIDs(ctx context.Context, ids []int) ([]*models.Scene, error) { ret := _m.Called(ctx, ids) var r0 []*models.Scene if rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Scene); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Scene) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // FindByOSHash provides a mock function with given fields: ctx, oshash func (_m *SceneReaderWriter) FindByOSHash(ctx context.Context, oshash string) ([]*models.Scene, error) { ret := _m.Called(ctx, oshash) var r0 []*models.Scene if rf, ok := ret.Get(0).(func(context.Context, string) []*models.Scene); ok { r0 = rf(ctx, oshash) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Scene) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { r1 = rf(ctx, oshash) } else { r1 = ret.Error(1) } return r0, r1 } // FindByPath provides a mock function with given fields: ctx, path func (_m *SceneReaderWriter) FindByPath(ctx context.Context, path string) ([]*models.Scene, error) { ret := _m.Called(ctx, path) var r0 []*models.Scene if rf, ok := ret.Get(0).(func(context.Context, string) []*models.Scene); ok { r0 = rf(ctx, path) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Scene) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { r1 = rf(ctx, path) } else { r1 = ret.Error(1) } return r0, r1 } // FindByPerformerID provides a mock function with given fields: ctx, performerID func (_m *SceneReaderWriter) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Scene, error) { ret := _m.Called(ctx, performerID) var r0 []*models.Scene if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Scene); ok { r0 = rf(ctx, performerID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Scene) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, performerID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByPrimaryFileID provides a mock function with given fields: ctx, fileID func (_m *SceneReaderWriter) FindByPrimaryFileID(ctx context.Context, fileID models.FileID) ([]*models.Scene, error) { ret := _m.Called(ctx, fileID) var r0 []*models.Scene if rf, ok := ret.Get(0).(func(context.Context, models.FileID) []*models.Scene); ok { r0 = rf(ctx, fileID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Scene) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok { r1 = rf(ctx, fileID) } else { r1 = ret.Error(1) } return r0, r1 } // FindDuplicates provides a mock function with given fields: ctx, distance, durationDiff func (_m *SceneReaderWriter) FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*models.Scene, error) { ret := _m.Called(ctx, distance, durationDiff) var r0 [][]*models.Scene if rf, ok := ret.Get(0).(func(context.Context, int, float64) [][]*models.Scene); ok { r0 = rf(ctx, distance, durationDiff) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([][]*models.Scene) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int, float64) error); ok { r1 = rf(ctx, distance, durationDiff) } else { r1 = ret.Error(1) } return r0, r1 } // FindMany provides a mock function with given fields: ctx, ids func (_m *SceneReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Scene, error) { ret := _m.Called(ctx, ids) var r0 []*models.Scene if rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Scene); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Scene) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetAllOCount provides a mock function with given fields: ctx func (_m *SceneReaderWriter) GetAllOCount(ctx context.Context) (int, error) { ret := _m.Called(ctx) var r0 int if rf, ok := ret.Get(0).(func(context.Context) int); ok { r0 = rf(ctx) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // GetCover provides a mock function with given fields: ctx, sceneID func (_m *SceneReaderWriter) GetCover(ctx context.Context, sceneID int) ([]byte, error) { ret := _m.Called(ctx, sceneID) var r0 []byte if rf, ok := ret.Get(0).(func(context.Context, int) []byte); ok { r0 = rf(ctx, sceneID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]byte) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, sceneID) } else { r1 = ret.Error(1) } return r0, r1 } // GetCustomFields provides a mock function with given fields: ctx, id func (_m *SceneReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { ret := _m.Called(ctx, id) var r0 map[string]interface{} if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(map[string]interface{}) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // GetCustomFieldsBulk provides a mock function with given fields: ctx, ids func (_m *SceneReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { ret := _m.Called(ctx, ids) var r0 []models.CustomFieldMap if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.CustomFieldMap) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetFiles provides a mock function with given fields: ctx, relatedID func (_m *SceneReaderWriter) GetFiles(ctx context.Context, relatedID int) ([]*models.VideoFile, error) { ret := _m.Called(ctx, relatedID) var r0 []*models.VideoFile if rf, ok := ret.Get(0).(func(context.Context, int) []*models.VideoFile); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.VideoFile) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetGalleryIDs provides a mock function with given fields: ctx, relatedID func (_m *SceneReaderWriter) GetGalleryIDs(ctx context.Context, relatedID int) ([]int, error) { ret := _m.Called(ctx, relatedID) var r0 []int if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]int) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetGroups provides a mock function with given fields: ctx, id func (_m *SceneReaderWriter) GetGroups(ctx context.Context, id int) ([]models.GroupsScenes, error) { ret := _m.Called(ctx, id) var r0 []models.GroupsScenes if rf, ok := ret.Get(0).(func(context.Context, int) []models.GroupsScenes); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.GroupsScenes) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // GetManyFileIDs provides a mock function with given fields: ctx, ids func (_m *SceneReaderWriter) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) { ret := _m.Called(ctx, ids) var r0 [][]models.FileID if rf, ok := ret.Get(0).(func(context.Context, []int) [][]models.FileID); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([][]models.FileID) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetManyLastViewed provides a mock function with given fields: ctx, ids func (_m *SceneReaderWriter) GetManyLastViewed(ctx context.Context, ids []int) ([]*time.Time, error) { ret := _m.Called(ctx, ids) var r0 []*time.Time if rf, ok := ret.Get(0).(func(context.Context, []int) []*time.Time); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*time.Time) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetManyOCount provides a mock function with given fields: ctx, ids func (_m *SceneReaderWriter) GetManyOCount(ctx context.Context, ids []int) ([]int, error) { ret := _m.Called(ctx, ids) var r0 []int if rf, ok := ret.Get(0).(func(context.Context, []int) []int); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]int) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetManyODates provides a mock function with given fields: ctx, ids func (_m *SceneReaderWriter) GetManyODates(ctx context.Context, ids []int) ([][]time.Time, error) { ret := _m.Called(ctx, ids) var r0 [][]time.Time if rf, ok := ret.Get(0).(func(context.Context, []int) [][]time.Time); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([][]time.Time) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetManyViewCount provides a mock function with given fields: ctx, ids func (_m *SceneReaderWriter) GetManyViewCount(ctx context.Context, ids []int) ([]int, error) { ret := _m.Called(ctx, ids) var r0 []int if rf, ok := ret.Get(0).(func(context.Context, []int) []int); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]int) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetManyViewDates provides a mock function with given fields: ctx, ids func (_m *SceneReaderWriter) GetManyViewDates(ctx context.Context, ids []int) ([][]time.Time, error) { ret := _m.Called(ctx, ids) var r0 [][]time.Time if rf, ok := ret.Get(0).(func(context.Context, []int) [][]time.Time); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([][]time.Time) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetOCount provides a mock function with given fields: ctx, id func (_m *SceneReaderWriter) GetOCount(ctx context.Context, id int) (int, error) { ret := _m.Called(ctx, id) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { r0 = rf(ctx, id) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // GetODates provides a mock function with given fields: ctx, relatedID func (_m *SceneReaderWriter) GetODates(ctx context.Context, relatedID int) ([]time.Time, error) { ret := _m.Called(ctx, relatedID) var r0 []time.Time if rf, ok := ret.Get(0).(func(context.Context, int) []time.Time); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]time.Time) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetPerformerIDs provides a mock function with given fields: ctx, relatedID func (_m *SceneReaderWriter) GetPerformerIDs(ctx context.Context, relatedID int) ([]int, error) { ret := _m.Called(ctx, relatedID) var r0 []int if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]int) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetStashIDs provides a mock function with given fields: ctx, relatedID func (_m *SceneReaderWriter) GetStashIDs(ctx context.Context, relatedID int) ([]models.StashID, error) { ret := _m.Called(ctx, relatedID) var r0 []models.StashID if rf, ok := ret.Get(0).(func(context.Context, int) []models.StashID); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.StashID) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetTagIDs provides a mock function with given fields: ctx, relatedID func (_m *SceneReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) { ret := _m.Called(ctx, relatedID) var r0 []int if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]int) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetURLs provides a mock function with given fields: ctx, relatedID func (_m *SceneReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) { ret := _m.Called(ctx, relatedID) var r0 []string if rf, ok := ret.Get(0).(func(context.Context, int) []string); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]string) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetViewDates provides a mock function with given fields: ctx, relatedID func (_m *SceneReaderWriter) GetViewDates(ctx context.Context, relatedID int) ([]time.Time, error) { ret := _m.Called(ctx, relatedID) var r0 []time.Time if rf, ok := ret.Get(0).(func(context.Context, int) []time.Time); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]time.Time) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // HasCover provides a mock function with given fields: ctx, sceneID func (_m *SceneReaderWriter) HasCover(ctx context.Context, sceneID int) (bool, error) { ret := _m.Called(ctx, sceneID) var r0 bool if rf, ok := ret.Get(0).(func(context.Context, int) bool); ok { r0 = rf(ctx, sceneID) } else { r0 = ret.Get(0).(bool) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, sceneID) } else { r1 = ret.Error(1) } return r0, r1 } // OCountByGroupID provides a mock function with given fields: ctx, groupID func (_m *SceneReaderWriter) OCountByGroupID(ctx context.Context, groupID int) (int, error) { ret := _m.Called(ctx, groupID) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { r0 = rf(ctx, groupID) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, groupID) } else { r1 = ret.Error(1) } return r0, r1 } // OCountByPerformerID provides a mock function with given fields: ctx, performerID func (_m *SceneReaderWriter) OCountByPerformerID(ctx context.Context, performerID int) (int, error) { ret := _m.Called(ctx, performerID) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { r0 = rf(ctx, performerID) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, performerID) } else { r1 = ret.Error(1) } return r0, r1 } // OCountByStudioID provides a mock function with given fields: ctx, studioID func (_m *SceneReaderWriter) OCountByStudioID(ctx context.Context, studioID int) (int, error) { ret := _m.Called(ctx, studioID) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { r0 = rf(ctx, studioID) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, studioID) } else { r1 = ret.Error(1) } return r0, r1 } // PlayDuration provides a mock function with given fields: ctx func (_m *SceneReaderWriter) PlayDuration(ctx context.Context) (float64, error) { ret := _m.Called(ctx) var r0 float64 if rf, ok := ret.Get(0).(func(context.Context) float64); ok { r0 = rf(ctx) } else { r0 = ret.Get(0).(float64) } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // Query provides a mock function with given fields: ctx, options func (_m *SceneReaderWriter) Query(ctx context.Context, options models.SceneQueryOptions) (*models.SceneQueryResult, error) { ret := _m.Called(ctx, options) var r0 *models.SceneQueryResult if rf, ok := ret.Get(0).(func(context.Context, models.SceneQueryOptions) *models.SceneQueryResult); ok { r0 = rf(ctx, options) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.SceneQueryResult) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.SceneQueryOptions) error); ok { r1 = rf(ctx, options) } else { r1 = ret.Error(1) } return r0, r1 } // QueryCount provides a mock function with given fields: ctx, sceneFilter, findFilter func (_m *SceneReaderWriter) QueryCount(ctx context.Context, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) (int, error) { ret := _m.Called(ctx, sceneFilter, findFilter) var r0 int if rf, ok := ret.Get(0).(func(context.Context, *models.SceneFilterType, *models.FindFilterType) int); ok { r0 = rf(ctx, sceneFilter, findFilter) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, *models.SceneFilterType, *models.FindFilterType) error); ok { r1 = rf(ctx, sceneFilter, findFilter) } else { r1 = ret.Error(1) } return r0, r1 } // ResetActivity provides a mock function with given fields: ctx, sceneID, resetResume, resetDuration func (_m *SceneReaderWriter) ResetActivity(ctx context.Context, sceneID int, resetResume bool, resetDuration bool) (bool, error) { ret := _m.Called(ctx, sceneID, resetResume, resetDuration) var r0 bool if rf, ok := ret.Get(0).(func(context.Context, int, bool, bool) bool); ok { r0 = rf(ctx, sceneID, resetResume, resetDuration) } else { r0 = ret.Get(0).(bool) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int, bool, bool) error); ok { r1 = rf(ctx, sceneID, resetResume, resetDuration) } else { r1 = ret.Error(1) } return r0, r1 } // ResetO provides a mock function with given fields: ctx, id func (_m *SceneReaderWriter) ResetO(ctx context.Context, id int) (int, error) { ret := _m.Called(ctx, id) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { r0 = rf(ctx, id) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // SaveActivity provides a mock function with given fields: ctx, sceneID, resumeTime, playDuration func (_m *SceneReaderWriter) SaveActivity(ctx context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error) { ret := _m.Called(ctx, sceneID, resumeTime, playDuration) var r0 bool if rf, ok := ret.Get(0).(func(context.Context, int, *float64, *float64) bool); ok { r0 = rf(ctx, sceneID, resumeTime, playDuration) } else { r0 = ret.Get(0).(bool) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int, *float64, *float64) error); ok { r1 = rf(ctx, sceneID, resumeTime, playDuration) } else { r1 = ret.Error(1) } return r0, r1 } // SetCustomFields provides a mock function with given fields: ctx, id, fields func (_m *SceneReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error { ret := _m.Called(ctx, id, fields) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok { r0 = rf(ctx, id, fields) } else { r0 = ret.Error(0) } return r0 } // Size provides a mock function with given fields: ctx func (_m *SceneReaderWriter) Size(ctx context.Context) (float64, error) { ret := _m.Called(ctx) var r0 float64 if rf, ok := ret.Get(0).(func(context.Context) float64); ok { r0 = rf(ctx) } else { r0 = ret.Get(0).(float64) } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // Update provides a mock function with given fields: ctx, updatedScene func (_m *SceneReaderWriter) Update(ctx context.Context, updatedScene *models.Scene) error { ret := _m.Called(ctx, updatedScene) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.Scene) error); ok { r0 = rf(ctx, updatedScene) } else { r0 = ret.Error(0) } return r0 } // UpdateCover provides a mock function with given fields: ctx, sceneID, cover func (_m *SceneReaderWriter) UpdateCover(ctx context.Context, sceneID int, cover []byte) error { ret := _m.Called(ctx, sceneID, cover) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, []byte) error); ok { r0 = rf(ctx, sceneID, cover) } else { r0 = ret.Error(0) } return r0 } // UpdatePartial provides a mock function with given fields: ctx, id, updatedScene func (_m *SceneReaderWriter) UpdatePartial(ctx context.Context, id int, updatedScene models.ScenePartial) (*models.Scene, error) { ret := _m.Called(ctx, id, updatedScene) var r0 *models.Scene if rf, ok := ret.Get(0).(func(context.Context, int, models.ScenePartial) *models.Scene); ok { r0 = rf(ctx, id, updatedScene) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Scene) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int, models.ScenePartial) error); ok { r1 = rf(ctx, id, updatedScene) } else { r1 = ret.Error(1) } return r0, r1 } // Wall provides a mock function with given fields: ctx, q func (_m *SceneReaderWriter) Wall(ctx context.Context, q *string) ([]*models.Scene, error) { ret := _m.Called(ctx, q) var r0 []*models.Scene if rf, ok := ret.Get(0).(func(context.Context, *string) []*models.Scene); ok { r0 = rf(ctx, q) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Scene) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, *string) error); ok { r1 = rf(ctx, q) } else { r1 = ret.Error(1) } return r0, r1 } ================================================ FILE: pkg/models/mocks/StudioReaderWriter.go ================================================ // Code generated by mockery v2.10.0. DO NOT EDIT. package mocks import ( context "context" models "github.com/stashapp/stash/pkg/models" mock "github.com/stretchr/testify/mock" ) // StudioReaderWriter is an autogenerated mock type for the StudioReaderWriter type type StudioReaderWriter struct { mock.Mock } // All provides a mock function with given fields: ctx func (_m *StudioReaderWriter) All(ctx context.Context) ([]*models.Studio, error) { ret := _m.Called(ctx) var r0 []*models.Studio if rf, ok := ret.Get(0).(func(context.Context) []*models.Studio); ok { r0 = rf(ctx) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Studio) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // Count provides a mock function with given fields: ctx func (_m *StudioReaderWriter) Count(ctx context.Context) (int, error) { ret := _m.Called(ctx) var r0 int if rf, ok := ret.Get(0).(func(context.Context) int); ok { r0 = rf(ctx) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // CountByTagID provides a mock function with given fields: ctx, tagID func (_m *StudioReaderWriter) CountByTagID(ctx context.Context, tagID int) (int, error) { ret := _m.Called(ctx, tagID) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { r0 = rf(ctx, tagID) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, tagID) } else { r1 = ret.Error(1) } return r0, r1 } // Create provides a mock function with given fields: ctx, newStudio func (_m *StudioReaderWriter) Create(ctx context.Context, newStudio *models.CreateStudioInput) error { ret := _m.Called(ctx, newStudio) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.CreateStudioInput) error); ok { r0 = rf(ctx, newStudio) } else { r0 = ret.Error(0) } return r0 } // Destroy provides a mock function with given fields: ctx, id func (_m *StudioReaderWriter) Destroy(ctx context.Context, id int) error { ret := _m.Called(ctx, id) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { r0 = rf(ctx, id) } else { r0 = ret.Error(0) } return r0 } // Find provides a mock function with given fields: ctx, id func (_m *StudioReaderWriter) Find(ctx context.Context, id int) (*models.Studio, error) { ret := _m.Called(ctx, id) var r0 *models.Studio if rf, ok := ret.Get(0).(func(context.Context, int) *models.Studio); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Studio) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // FindByName provides a mock function with given fields: ctx, name, nocase func (_m *StudioReaderWriter) FindByName(ctx context.Context, name string, nocase bool) (*models.Studio, error) { ret := _m.Called(ctx, name, nocase) var r0 *models.Studio if rf, ok := ret.Get(0).(func(context.Context, string, bool) *models.Studio); ok { r0 = rf(ctx, name, nocase) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Studio) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok { r1 = rf(ctx, name, nocase) } else { r1 = ret.Error(1) } return r0, r1 } // FindBySceneID provides a mock function with given fields: ctx, sceneID func (_m *StudioReaderWriter) FindBySceneID(ctx context.Context, sceneID int) (*models.Studio, error) { ret := _m.Called(ctx, sceneID) var r0 *models.Studio if rf, ok := ret.Get(0).(func(context.Context, int) *models.Studio); ok { r0 = rf(ctx, sceneID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Studio) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, sceneID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByStashID provides a mock function with given fields: ctx, stashID func (_m *StudioReaderWriter) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Studio, error) { ret := _m.Called(ctx, stashID) var r0 []*models.Studio if rf, ok := ret.Get(0).(func(context.Context, models.StashID) []*models.Studio); ok { r0 = rf(ctx, stashID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Studio) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.StashID) error); ok { r1 = rf(ctx, stashID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByStashIDStatus provides a mock function with given fields: ctx, hasStashID, stashboxEndpoint func (_m *StudioReaderWriter) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Studio, error) { ret := _m.Called(ctx, hasStashID, stashboxEndpoint) var r0 []*models.Studio if rf, ok := ret.Get(0).(func(context.Context, bool, string) []*models.Studio); ok { r0 = rf(ctx, hasStashID, stashboxEndpoint) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Studio) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, bool, string) error); ok { r1 = rf(ctx, hasStashID, stashboxEndpoint) } else { r1 = ret.Error(1) } return r0, r1 } // FindChildren provides a mock function with given fields: ctx, id func (_m *StudioReaderWriter) FindChildren(ctx context.Context, id int) ([]*models.Studio, error) { ret := _m.Called(ctx, id) var r0 []*models.Studio if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Studio); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Studio) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // FindMany provides a mock function with given fields: ctx, ids func (_m *StudioReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Studio, error) { ret := _m.Called(ctx, ids) var r0 []*models.Studio if rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Studio); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Studio) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetAliases provides a mock function with given fields: ctx, relatedID func (_m *StudioReaderWriter) GetAliases(ctx context.Context, relatedID int) ([]string, error) { ret := _m.Called(ctx, relatedID) var r0 []string if rf, ok := ret.Get(0).(func(context.Context, int) []string); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]string) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetCustomFields provides a mock function with given fields: ctx, id func (_m *StudioReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { ret := _m.Called(ctx, id) var r0 map[string]interface{} if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(map[string]interface{}) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // GetCustomFieldsBulk provides a mock function with given fields: ctx, ids func (_m *StudioReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { ret := _m.Called(ctx, ids) var r0 []models.CustomFieldMap if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.CustomFieldMap) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetImage provides a mock function with given fields: ctx, studioID func (_m *StudioReaderWriter) GetImage(ctx context.Context, studioID int) ([]byte, error) { ret := _m.Called(ctx, studioID) var r0 []byte if rf, ok := ret.Get(0).(func(context.Context, int) []byte); ok { r0 = rf(ctx, studioID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]byte) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, studioID) } else { r1 = ret.Error(1) } return r0, r1 } // GetStashIDs provides a mock function with given fields: ctx, relatedID func (_m *StudioReaderWriter) GetStashIDs(ctx context.Context, relatedID int) ([]models.StashID, error) { ret := _m.Called(ctx, relatedID) var r0 []models.StashID if rf, ok := ret.Get(0).(func(context.Context, int) []models.StashID); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.StashID) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetTagIDs provides a mock function with given fields: ctx, relatedID func (_m *StudioReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) { ret := _m.Called(ctx, relatedID) var r0 []int if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]int) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetURLs provides a mock function with given fields: ctx, relatedID func (_m *StudioReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) { ret := _m.Called(ctx, relatedID) var r0 []string if rf, ok := ret.Get(0).(func(context.Context, int) []string); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]string) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // HasImage provides a mock function with given fields: ctx, studioID func (_m *StudioReaderWriter) HasImage(ctx context.Context, studioID int) (bool, error) { ret := _m.Called(ctx, studioID) var r0 bool if rf, ok := ret.Get(0).(func(context.Context, int) bool); ok { r0 = rf(ctx, studioID) } else { r0 = ret.Get(0).(bool) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, studioID) } else { r1 = ret.Error(1) } return r0, r1 } // Query provides a mock function with given fields: ctx, studioFilter, findFilter func (_m *StudioReaderWriter) Query(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) ([]*models.Studio, int, error) { ret := _m.Called(ctx, studioFilter, findFilter) var r0 []*models.Studio if rf, ok := ret.Get(0).(func(context.Context, *models.StudioFilterType, *models.FindFilterType) []*models.Studio); ok { r0 = rf(ctx, studioFilter, findFilter) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Studio) } } var r1 int if rf, ok := ret.Get(1).(func(context.Context, *models.StudioFilterType, *models.FindFilterType) int); ok { r1 = rf(ctx, studioFilter, findFilter) } else { r1 = ret.Get(1).(int) } var r2 error if rf, ok := ret.Get(2).(func(context.Context, *models.StudioFilterType, *models.FindFilterType) error); ok { r2 = rf(ctx, studioFilter, findFilter) } else { r2 = ret.Error(2) } return r0, r1, r2 } // QueryCount provides a mock function with given fields: ctx, studioFilter, findFilter func (_m *StudioReaderWriter) QueryCount(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) (int, error) { ret := _m.Called(ctx, studioFilter, findFilter) var r0 int if rf, ok := ret.Get(0).(func(context.Context, *models.StudioFilterType, *models.FindFilterType) int); ok { r0 = rf(ctx, studioFilter, findFilter) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, *models.StudioFilterType, *models.FindFilterType) error); ok { r1 = rf(ctx, studioFilter, findFilter) } else { r1 = ret.Error(1) } return r0, r1 } // QueryForAutoTag provides a mock function with given fields: ctx, words func (_m *StudioReaderWriter) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Studio, error) { ret := _m.Called(ctx, words) var r0 []*models.Studio if rf, ok := ret.Get(0).(func(context.Context, []string) []*models.Studio); ok { r0 = rf(ctx, words) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Studio) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []string) error); ok { r1 = rf(ctx, words) } else { r1 = ret.Error(1) } return r0, r1 } // Update provides a mock function with given fields: ctx, updatedStudio func (_m *StudioReaderWriter) Update(ctx context.Context, updatedStudio *models.UpdateStudioInput) error { ret := _m.Called(ctx, updatedStudio) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.UpdateStudioInput) error); ok { r0 = rf(ctx, updatedStudio) } else { r0 = ret.Error(0) } return r0 } // UpdateImage provides a mock function with given fields: ctx, studioID, image func (_m *StudioReaderWriter) UpdateImage(ctx context.Context, studioID int, image []byte) error { ret := _m.Called(ctx, studioID, image) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, []byte) error); ok { r0 = rf(ctx, studioID, image) } else { r0 = ret.Error(0) } return r0 } // UpdatePartial provides a mock function with given fields: ctx, updatedStudio func (_m *StudioReaderWriter) UpdatePartial(ctx context.Context, updatedStudio models.StudioPartial) (*models.Studio, error) { ret := _m.Called(ctx, updatedStudio) var r0 *models.Studio if rf, ok := ret.Get(0).(func(context.Context, models.StudioPartial) *models.Studio); ok { r0 = rf(ctx, updatedStudio) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Studio) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.StudioPartial) error); ok { r1 = rf(ctx, updatedStudio) } else { r1 = ret.Error(1) } return r0, r1 } ================================================ FILE: pkg/models/mocks/TagReaderWriter.go ================================================ // Code generated by mockery v2.10.0. DO NOT EDIT. package mocks import ( context "context" models "github.com/stashapp/stash/pkg/models" mock "github.com/stretchr/testify/mock" ) // TagReaderWriter is an autogenerated mock type for the TagReaderWriter type type TagReaderWriter struct { mock.Mock } // All provides a mock function with given fields: ctx func (_m *TagReaderWriter) All(ctx context.Context) ([]*models.Tag, error) { ret := _m.Called(ctx) var r0 []*models.Tag if rf, ok := ret.Get(0).(func(context.Context) []*models.Tag); ok { r0 = rf(ctx) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Tag) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // Count provides a mock function with given fields: ctx func (_m *TagReaderWriter) Count(ctx context.Context) (int, error) { ret := _m.Called(ctx) var r0 int if rf, ok := ret.Get(0).(func(context.Context) int); ok { r0 = rf(ctx) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context) error); ok { r1 = rf(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // CountByChildTagID provides a mock function with given fields: ctx, childID func (_m *TagReaderWriter) CountByChildTagID(ctx context.Context, childID int) (int, error) { ret := _m.Called(ctx, childID) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { r0 = rf(ctx, childID) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, childID) } else { r1 = ret.Error(1) } return r0, r1 } // CountByParentTagID provides a mock function with given fields: ctx, parentID func (_m *TagReaderWriter) CountByParentTagID(ctx context.Context, parentID int) (int, error) { ret := _m.Called(ctx, parentID) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { r0 = rf(ctx, parentID) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, parentID) } else { r1 = ret.Error(1) } return r0, r1 } // Create provides a mock function with given fields: ctx, newTag func (_m *TagReaderWriter) Create(ctx context.Context, newTag *models.CreateTagInput) error { ret := _m.Called(ctx, newTag) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.CreateTagInput) error); ok { r0 = rf(ctx, newTag) } else { r0 = ret.Error(0) } return r0 } // Destroy provides a mock function with given fields: ctx, id func (_m *TagReaderWriter) Destroy(ctx context.Context, id int) error { ret := _m.Called(ctx, id) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { r0 = rf(ctx, id) } else { r0 = ret.Error(0) } return r0 } // Find provides a mock function with given fields: ctx, id func (_m *TagReaderWriter) Find(ctx context.Context, id int) (*models.Tag, error) { ret := _m.Called(ctx, id) var r0 *models.Tag if rf, ok := ret.Get(0).(func(context.Context, int) *models.Tag); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Tag) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // FindAllAncestors provides a mock function with given fields: ctx, tagID, excludeIDs func (_m *TagReaderWriter) FindAllAncestors(ctx context.Context, tagID int, excludeIDs []int) ([]*models.TagPath, error) { ret := _m.Called(ctx, tagID, excludeIDs) var r0 []*models.TagPath if rf, ok := ret.Get(0).(func(context.Context, int, []int) []*models.TagPath); ok { r0 = rf(ctx, tagID, excludeIDs) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.TagPath) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int, []int) error); ok { r1 = rf(ctx, tagID, excludeIDs) } else { r1 = ret.Error(1) } return r0, r1 } // FindAllDescendants provides a mock function with given fields: ctx, tagID, excludeIDs func (_m *TagReaderWriter) FindAllDescendants(ctx context.Context, tagID int, excludeIDs []int) ([]*models.TagPath, error) { ret := _m.Called(ctx, tagID, excludeIDs) var r0 []*models.TagPath if rf, ok := ret.Get(0).(func(context.Context, int, []int) []*models.TagPath); ok { r0 = rf(ctx, tagID, excludeIDs) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.TagPath) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int, []int) error); ok { r1 = rf(ctx, tagID, excludeIDs) } else { r1 = ret.Error(1) } return r0, r1 } // FindByChildTagID provides a mock function with given fields: ctx, childID func (_m *TagReaderWriter) FindByChildTagID(ctx context.Context, childID int) ([]*models.Tag, error) { ret := _m.Called(ctx, childID) var r0 []*models.Tag if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok { r0 = rf(ctx, childID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Tag) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, childID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByGalleryID provides a mock function with given fields: ctx, galleryID func (_m *TagReaderWriter) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Tag, error) { ret := _m.Called(ctx, galleryID) var r0 []*models.Tag if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok { r0 = rf(ctx, galleryID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Tag) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, galleryID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByGroupID provides a mock function with given fields: ctx, groupID func (_m *TagReaderWriter) FindByGroupID(ctx context.Context, groupID int) ([]*models.Tag, error) { ret := _m.Called(ctx, groupID) var r0 []*models.Tag if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok { r0 = rf(ctx, groupID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Tag) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, groupID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByImageID provides a mock function with given fields: ctx, imageID func (_m *TagReaderWriter) FindByImageID(ctx context.Context, imageID int) ([]*models.Tag, error) { ret := _m.Called(ctx, imageID) var r0 []*models.Tag if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok { r0 = rf(ctx, imageID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Tag) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, imageID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByName provides a mock function with given fields: ctx, name, nocase func (_m *TagReaderWriter) FindByName(ctx context.Context, name string, nocase bool) (*models.Tag, error) { ret := _m.Called(ctx, name, nocase) var r0 *models.Tag if rf, ok := ret.Get(0).(func(context.Context, string, bool) *models.Tag); ok { r0 = rf(ctx, name, nocase) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Tag) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok { r1 = rf(ctx, name, nocase) } else { r1 = ret.Error(1) } return r0, r1 } // FindByNames provides a mock function with given fields: ctx, names, nocase func (_m *TagReaderWriter) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Tag, error) { ret := _m.Called(ctx, names, nocase) var r0 []*models.Tag if rf, ok := ret.Get(0).(func(context.Context, []string, bool) []*models.Tag); ok { r0 = rf(ctx, names, nocase) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Tag) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []string, bool) error); ok { r1 = rf(ctx, names, nocase) } else { r1 = ret.Error(1) } return r0, r1 } // FindByParentTagID provides a mock function with given fields: ctx, parentID func (_m *TagReaderWriter) FindByParentTagID(ctx context.Context, parentID int) ([]*models.Tag, error) { ret := _m.Called(ctx, parentID) var r0 []*models.Tag if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok { r0 = rf(ctx, parentID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Tag) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, parentID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByPerformerID provides a mock function with given fields: ctx, performerID func (_m *TagReaderWriter) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Tag, error) { ret := _m.Called(ctx, performerID) var r0 []*models.Tag if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok { r0 = rf(ctx, performerID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Tag) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, performerID) } else { r1 = ret.Error(1) } return r0, r1 } // FindBySceneID provides a mock function with given fields: ctx, sceneID func (_m *TagReaderWriter) FindBySceneID(ctx context.Context, sceneID int) ([]*models.Tag, error) { ret := _m.Called(ctx, sceneID) var r0 []*models.Tag if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok { r0 = rf(ctx, sceneID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Tag) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, sceneID) } else { r1 = ret.Error(1) } return r0, r1 } // FindBySceneMarkerID provides a mock function with given fields: ctx, sceneMarkerID func (_m *TagReaderWriter) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*models.Tag, error) { ret := _m.Called(ctx, sceneMarkerID) var r0 []*models.Tag if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok { r0 = rf(ctx, sceneMarkerID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Tag) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, sceneMarkerID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByStashID provides a mock function with given fields: ctx, stashID func (_m *TagReaderWriter) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Tag, error) { ret := _m.Called(ctx, stashID) var r0 []*models.Tag if rf, ok := ret.Get(0).(func(context.Context, models.StashID) []*models.Tag); ok { r0 = rf(ctx, stashID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Tag) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, models.StashID) error); ok { r1 = rf(ctx, stashID) } else { r1 = ret.Error(1) } return r0, r1 } // FindByStashIDStatus provides a mock function with given fields: ctx, hasStashID, stashboxEndpoint func (_m *TagReaderWriter) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Tag, error) { ret := _m.Called(ctx, hasStashID, stashboxEndpoint) var r0 []*models.Tag if rf, ok := ret.Get(0).(func(context.Context, bool, string) []*models.Tag); ok { r0 = rf(ctx, hasStashID, stashboxEndpoint) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Tag) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, bool, string) error); ok { r1 = rf(ctx, hasStashID, stashboxEndpoint) } else { r1 = ret.Error(1) } return r0, r1 } // FindByStudioID provides a mock function with given fields: ctx, studioID func (_m *TagReaderWriter) FindByStudioID(ctx context.Context, studioID int) ([]*models.Tag, error) { ret := _m.Called(ctx, studioID) var r0 []*models.Tag if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok { r0 = rf(ctx, studioID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Tag) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, studioID) } else { r1 = ret.Error(1) } return r0, r1 } // FindMany provides a mock function with given fields: ctx, ids func (_m *TagReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Tag, error) { ret := _m.Called(ctx, ids) var r0 []*models.Tag if rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Tag); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Tag) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetAliases provides a mock function with given fields: ctx, relatedID func (_m *TagReaderWriter) GetAliases(ctx context.Context, relatedID int) ([]string, error) { ret := _m.Called(ctx, relatedID) var r0 []string if rf, ok := ret.Get(0).(func(context.Context, int) []string); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]string) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetChildIDs provides a mock function with given fields: ctx, relatedID func (_m *TagReaderWriter) GetChildIDs(ctx context.Context, relatedID int) ([]int, error) { ret := _m.Called(ctx, relatedID) var r0 []int if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]int) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetCustomFields provides a mock function with given fields: ctx, id func (_m *TagReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { ret := _m.Called(ctx, id) var r0 map[string]interface{} if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(map[string]interface{}) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // GetCustomFieldsBulk provides a mock function with given fields: ctx, ids func (_m *TagReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { ret := _m.Called(ctx, ids) var r0 []models.CustomFieldMap if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.CustomFieldMap) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { r1 = rf(ctx, ids) } else { r1 = ret.Error(1) } return r0, r1 } // GetImage provides a mock function with given fields: ctx, tagID func (_m *TagReaderWriter) GetImage(ctx context.Context, tagID int) ([]byte, error) { ret := _m.Called(ctx, tagID) var r0 []byte if rf, ok := ret.Get(0).(func(context.Context, int) []byte); ok { r0 = rf(ctx, tagID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]byte) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, tagID) } else { r1 = ret.Error(1) } return r0, r1 } // GetParentIDs provides a mock function with given fields: ctx, relatedID func (_m *TagReaderWriter) GetParentIDs(ctx context.Context, relatedID int) ([]int, error) { ret := _m.Called(ctx, relatedID) var r0 []int if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]int) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // GetStashIDs provides a mock function with given fields: ctx, relatedID func (_m *TagReaderWriter) GetStashIDs(ctx context.Context, relatedID int) ([]models.StashID, error) { ret := _m.Called(ctx, relatedID) var r0 []models.StashID if rf, ok := ret.Get(0).(func(context.Context, int) []models.StashID); ok { r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.StashID) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } return r0, r1 } // HasImage provides a mock function with given fields: ctx, tagID func (_m *TagReaderWriter) HasImage(ctx context.Context, tagID int) (bool, error) { ret := _m.Called(ctx, tagID) var r0 bool if rf, ok := ret.Get(0).(func(context.Context, int) bool); ok { r0 = rf(ctx, tagID) } else { r0 = ret.Get(0).(bool) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { r1 = rf(ctx, tagID) } else { r1 = ret.Error(1) } return r0, r1 } // Merge provides a mock function with given fields: ctx, source, destination func (_m *TagReaderWriter) Merge(ctx context.Context, source []int, destination int) error { ret := _m.Called(ctx, source, destination) var r0 error if rf, ok := ret.Get(0).(func(context.Context, []int, int) error); ok { r0 = rf(ctx, source, destination) } else { r0 = ret.Error(0) } return r0 } // Query provides a mock function with given fields: ctx, tagFilter, findFilter func (_m *TagReaderWriter) Query(ctx context.Context, tagFilter *models.TagFilterType, findFilter *models.FindFilterType) ([]*models.Tag, int, error) { ret := _m.Called(ctx, tagFilter, findFilter) var r0 []*models.Tag if rf, ok := ret.Get(0).(func(context.Context, *models.TagFilterType, *models.FindFilterType) []*models.Tag); ok { r0 = rf(ctx, tagFilter, findFilter) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Tag) } } var r1 int if rf, ok := ret.Get(1).(func(context.Context, *models.TagFilterType, *models.FindFilterType) int); ok { r1 = rf(ctx, tagFilter, findFilter) } else { r1 = ret.Get(1).(int) } var r2 error if rf, ok := ret.Get(2).(func(context.Context, *models.TagFilterType, *models.FindFilterType) error); ok { r2 = rf(ctx, tagFilter, findFilter) } else { r2 = ret.Error(2) } return r0, r1, r2 } // QueryForAutoTag provides a mock function with given fields: ctx, words func (_m *TagReaderWriter) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Tag, error) { ret := _m.Called(ctx, words) var r0 []*models.Tag if rf, ok := ret.Get(0).(func(context.Context, []string) []*models.Tag); ok { r0 = rf(ctx, words) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Tag) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, []string) error); ok { r1 = rf(ctx, words) } else { r1 = ret.Error(1) } return r0, r1 } // SetCustomFields provides a mock function with given fields: ctx, id, fields func (_m *TagReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error { ret := _m.Called(ctx, id, fields) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok { r0 = rf(ctx, id, fields) } else { r0 = ret.Error(0) } return r0 } // Update provides a mock function with given fields: ctx, updatedTag func (_m *TagReaderWriter) Update(ctx context.Context, updatedTag *models.UpdateTagInput) error { ret := _m.Called(ctx, updatedTag) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.UpdateTagInput) error); ok { r0 = rf(ctx, updatedTag) } else { r0 = ret.Error(0) } return r0 } // UpdateAliases provides a mock function with given fields: ctx, tagID, aliases func (_m *TagReaderWriter) UpdateAliases(ctx context.Context, tagID int, aliases []string) error { ret := _m.Called(ctx, tagID, aliases) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, []string) error); ok { r0 = rf(ctx, tagID, aliases) } else { r0 = ret.Error(0) } return r0 } // UpdateChildTags provides a mock function with given fields: ctx, tagID, parentIDs func (_m *TagReaderWriter) UpdateChildTags(ctx context.Context, tagID int, parentIDs []int) error { ret := _m.Called(ctx, tagID, parentIDs) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok { r0 = rf(ctx, tagID, parentIDs) } else { r0 = ret.Error(0) } return r0 } // UpdateImage provides a mock function with given fields: ctx, tagID, image func (_m *TagReaderWriter) UpdateImage(ctx context.Context, tagID int, image []byte) error { ret := _m.Called(ctx, tagID, image) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, []byte) error); ok { r0 = rf(ctx, tagID, image) } else { r0 = ret.Error(0) } return r0 } // UpdateParentTags provides a mock function with given fields: ctx, tagID, parentIDs func (_m *TagReaderWriter) UpdateParentTags(ctx context.Context, tagID int, parentIDs []int) error { ret := _m.Called(ctx, tagID, parentIDs) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok { r0 = rf(ctx, tagID, parentIDs) } else { r0 = ret.Error(0) } return r0 } // UpdatePartial provides a mock function with given fields: ctx, id, updateTag func (_m *TagReaderWriter) UpdatePartial(ctx context.Context, id int, updateTag models.TagPartial) (*models.Tag, error) { ret := _m.Called(ctx, id, updateTag) var r0 *models.Tag if rf, ok := ret.Get(0).(func(context.Context, int, models.TagPartial) *models.Tag); ok { r0 = rf(ctx, id, updateTag) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Tag) } } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int, models.TagPartial) error); ok { r1 = rf(ctx, id, updateTag) } else { r1 = ret.Error(1) } return r0, r1 } ================================================ FILE: pkg/models/mocks/database.go ================================================ // Package mocks provides mocks for various interfaces in [models]. package mocks import ( "context" "errors" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/txn" "github.com/stretchr/testify/mock" ) type Database struct { File *FileReaderWriter Folder *FolderReaderWriter Gallery *GalleryReaderWriter GalleryChapter *GalleryChapterReaderWriter Image *ImageReaderWriter Group *GroupReaderWriter Performer *PerformerReaderWriter Scene *SceneReaderWriter SceneMarker *SceneMarkerReaderWriter Studio *StudioReaderWriter Tag *TagReaderWriter SavedFilter *SavedFilterReaderWriter } func (*Database) Begin(ctx context.Context, exclusive bool) (context.Context, error) { return ctx, nil } func (*Database) WithDatabase(ctx context.Context) (context.Context, error) { return ctx, nil } func (*Database) Commit(ctx context.Context) error { return nil } func (*Database) Rollback(ctx context.Context) error { return nil } func (*Database) Complete(ctx context.Context) { } func (*Database) AddPostCommitHook(ctx context.Context, hook txn.TxnFunc) { } func (*Database) AddPostRollbackHook(ctx context.Context, hook txn.TxnFunc) { } func (*Database) IsLocked(err error) bool { return false } func (*Database) Reset() error { return nil } func NewDatabase() *Database { return &Database{ File: &FileReaderWriter{}, Folder: &FolderReaderWriter{}, Gallery: &GalleryReaderWriter{}, GalleryChapter: &GalleryChapterReaderWriter{}, Image: &ImageReaderWriter{}, Group: &GroupReaderWriter{}, Performer: &PerformerReaderWriter{}, Scene: &SceneReaderWriter{}, SceneMarker: &SceneMarkerReaderWriter{}, Studio: &StudioReaderWriter{}, Tag: &TagReaderWriter{}, SavedFilter: &SavedFilterReaderWriter{}, } } func (db *Database) AssertExpectations(t mock.TestingT) { db.File.AssertExpectations(t) db.Folder.AssertExpectations(t) db.Gallery.AssertExpectations(t) db.GalleryChapter.AssertExpectations(t) db.Image.AssertExpectations(t) db.Group.AssertExpectations(t) db.Performer.AssertExpectations(t) db.Scene.AssertExpectations(t) db.SceneMarker.AssertExpectations(t) db.Studio.AssertExpectations(t) db.Tag.AssertExpectations(t) db.SavedFilter.AssertExpectations(t) } // WithTxnCtx runs fn with a context that has a transaction hook manager registered, // so code that calls txn.AddPostCommitHook (e.g. plugin cache) won't nil-panic. // Always rolls back to avoid executing the registered hooks. func (db *Database) WithTxnCtx(fn func(ctx context.Context)) { _ = txn.WithTxn(context.Background(), db, func(ctx context.Context) error { fn(ctx) return errors.New("rollback") }) } func (db *Database) Repository() models.Repository { return models.Repository{ TxnManager: db, File: db.File, Folder: db.Folder, Gallery: db.Gallery, GalleryChapter: db.GalleryChapter, Image: db.Image, Group: db.Group, Performer: db.Performer, Scene: db.Scene, SceneMarker: db.SceneMarker, Studio: db.Studio, Tag: db.Tag, SavedFilter: db.SavedFilter, } } ================================================ FILE: pkg/models/mocks/query.go ================================================ package mocks import ( context "context" "github.com/stashapp/stash/pkg/models" ) type sceneResolver struct { scenes []*models.Scene } func (s *sceneResolver) Find(ctx context.Context, id int) (*models.Scene, error) { panic("not implemented") } func (s *sceneResolver) FindMany(ctx context.Context, ids []int) ([]*models.Scene, error) { return s.scenes, nil } func (s *sceneResolver) FindByIDs(ctx context.Context, ids []int) ([]*models.Scene, error) { return s.scenes, nil } func SceneQueryResult(scenes []*models.Scene, count int) *models.SceneQueryResult { ret := models.NewSceneQueryResult(&sceneResolver{ scenes: scenes, }) ret.Count = count return ret } type imageResolver struct { images []*models.Image } func (s *imageResolver) Find(ctx context.Context, id int) (*models.Image, error) { panic("not implemented") } func (s *imageResolver) FindMany(ctx context.Context, ids []int) ([]*models.Image, error) { return s.images, nil } func ImageQueryResult(images []*models.Image, count int) *models.ImageQueryResult { ret := models.NewImageQueryResult(&imageResolver{ images: images, }) ret.Count = count return ret } ================================================ FILE: pkg/models/model_file.go ================================================ package models import ( "bytes" "fmt" "io" "io/fs" "math" "net/http" "strconv" "time" ) type HashAlgorithm string const ( HashAlgorithmMd5 HashAlgorithm = "MD5" // oshash HashAlgorithmOshash HashAlgorithm = "OSHASH" ) var AllHashAlgorithm = []HashAlgorithm{ HashAlgorithmMd5, HashAlgorithmOshash, } func (e HashAlgorithm) IsValid() bool { switch e { case HashAlgorithmMd5, HashAlgorithmOshash: return true } return false } func (e HashAlgorithm) String() string { return string(e) } func (e *HashAlgorithm) UnmarshalGQL(v interface{}) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = HashAlgorithm(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid HashAlgorithm", str) } return nil } func (e HashAlgorithm) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } // ID represents an ID of a file. type FileID int32 func (i FileID) String() string { return strconv.Itoa(int(i)) } func (i *FileID) UnmarshalGQL(v interface{}) (err error) { switch v := v.(type) { case string: var id int id, err = strconv.Atoi(v) *i = FileID(id) return err case int: *i = FileID(v) return nil default: return fmt.Errorf("%T is not an int", v) } } func (i FileID) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(i.String())) } func FileIDsFromInts(ids []int) []FileID { ret := make([]FileID, len(ids)) for i, id := range ids { ret[i] = FileID(id) } return ret } // DirEntry represents a file or directory in the file system. type DirEntry struct { ZipFileID *FileID `json:"zip_file_id"` // transient - not persisted // only guaranteed to have id, path and basename set ZipFile File ModTime time.Time `json:"mod_time"` } func (e *DirEntry) info(fs FS, path string) (fs.FileInfo, error) { if e.ZipFile != nil { zipPath := e.ZipFile.Base().Path zfs, err := fs.OpenZip(zipPath, e.ZipFile.Base().Size) if err != nil { return nil, err } defer zfs.Close() fs = zfs } // else assume os file ret, err := fs.Lstat(path) return ret, err } // File represents a file in the file system. type File interface { Base() *BaseFile SetFingerprints(fp Fingerprints) Open(fs FS) (io.ReadCloser, error) Clone() File } // BaseFile represents a file in the file system. type BaseFile struct { ID FileID `json:"id"` DirEntry // resolved from parent folder and basename only - not stored in DB Path string `json:"path"` Basename string `json:"basename"` ParentFolderID FolderID `json:"parent_folder_id"` Fingerprints Fingerprints `json:"fingerprints"` Size int64 `json:"size"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // SetFingerprints sets the fingerprints of the file. // If a fingerprint of the same type already exists, it is overwritten. func (f *BaseFile) SetFingerprints(fp Fingerprints) { for _, v := range fp { f.SetFingerprint(v) } } // SetFingerprint sets the fingerprint of the file. // If a fingerprint of the same type already exists, it is overwritten. func (f *BaseFile) SetFingerprint(fp Fingerprint) { for i, existing := range f.Fingerprints { if existing.Type == fp.Type { f.Fingerprints[i] = fp return } } f.Fingerprints = append(f.Fingerprints, fp) } // Base is used to fulfil the File interface. func (f *BaseFile) Base() *BaseFile { return f } func (f *BaseFile) Open(fs FS) (io.ReadCloser, error) { if f.ZipFile != nil { zipPath := f.ZipFile.Base().Path zfs, err := fs.OpenZip(zipPath, f.ZipFile.Base().Size) if err != nil { return nil, err } return zfs.OpenOnly(f.Path) } return fs.Open(f.Path) } func (f *BaseFile) Clone() (ret File) { clone := *f ret = &clone return } func (f *BaseFile) Info(fs FS) (fs.FileInfo, error) { return f.info(fs, f.Path) } func (f *BaseFile) Serve(fs FS, w http.ResponseWriter, r *http.Request) error { reader, err := f.Open(fs) if err != nil { return err } defer reader.Close() content, ok := reader.(io.ReadSeeker) if !ok { data, err := io.ReadAll(reader) if err != nil { return err } content = bytes.NewReader(data) } if r.URL.Query().Has("t") { w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "no-cache") } // Set filename if not previously set if w.Header().Get("Content-Disposition") == "" { w.Header().Set("Content-Disposition", fmt.Sprintf(`filename="%s"`, f.Basename)) } http.ServeContent(w, r, f.Basename, f.ModTime, content) return nil } // VisualFile is an interface for files that have a width and height. type VisualFile interface { File GetWidth() int GetHeight() int GetFormat() string } func GetMinResolution(f VisualFile) int { w := f.GetWidth() h := f.GetHeight() if w < h { return w } return h } // ImageFile is an extension of BaseFile to represent image files. type ImageFile struct { *BaseFile Format string `json:"format"` Width int `json:"width"` Height int `json:"height"` } func (f ImageFile) GetWidth() int { return f.Width } func (f ImageFile) GetHeight() int { return f.Height } func (f ImageFile) Megapixels() float64 { return float64(f.Width*f.Height) / 1e6 } func (f ImageFile) GetFormat() string { return f.Format } func (f ImageFile) Clone() (ret File) { clone := f clone.BaseFile = f.BaseFile.Clone().(*BaseFile) ret = &clone return } // VideoFile is an extension of BaseFile to represent video files. type VideoFile struct { *BaseFile Format string `json:"format"` Width int `json:"width"` Height int `json:"height"` Duration float64 `json:"duration"` VideoCodec string `json:"video_codec"` AudioCodec string `json:"audio_codec"` FrameRate float64 `json:"frame_rate"` BitRate int64 `json:"bitrate"` Interactive bool `json:"interactive"` InteractiveSpeed *int `json:"interactive_speed"` } func (f VideoFile) GetWidth() int { return f.Width } func (f VideoFile) GetHeight() int { return f.Height } func (f VideoFile) GetFormat() string { return f.Format } func (f VideoFile) Clone() (ret File) { clone := f clone.BaseFile = f.BaseFile.Clone().(*BaseFile) ret = &clone return } // #1572 - Inf and NaN values cause the JSON marshaller to fail // Replace these values with 0 rather than erroring func (f VideoFile) DurationFinite() float64 { ret := f.Duration if math.IsInf(ret, 0) || math.IsNaN(ret) { return 0 } return ret } func (f VideoFile) FrameRateFinite() float64 { ret := f.FrameRate if math.IsInf(ret, 0) || math.IsNaN(ret) { return 0 } return ret } ================================================ FILE: pkg/models/model_folder.go ================================================ package models import ( "fmt" "io" "io/fs" "strconv" "time" ) // FolderID represents an ID of a folder. type FolderID int32 // String converts the ID to a string. func (i FolderID) String() string { return strconv.Itoa(int(i)) } func (i *FolderID) UnmarshalGQL(v interface{}) (err error) { switch v := v.(type) { case string: var id int id, err = strconv.Atoi(v) *i = FolderID(id) return err case int: *i = FolderID(v) return nil default: return fmt.Errorf("%T is not an int", v) } } func (i FolderID) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(i.String())) } func FolderIDsFromInts(ids []int) []FolderID { ret := make([]FolderID, len(ids)) for i, id := range ids { ret[i] = FolderID(id) } return ret } // Folder represents a folder in the file system. type Folder struct { ID FolderID `json:"id"` DirEntry Path string `json:"path"` ParentFolderID *FolderID `json:"parent_folder_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } func (f *Folder) Info(fs FS) (fs.FileInfo, error) { return f.info(fs, f.Path) } ================================================ FILE: pkg/models/model_gallery.go ================================================ package models import ( "context" "path/filepath" "strconv" "time" ) type Gallery struct { ID int `json:"id"` Title string `json:"title"` Code string `json:"code"` Date *Date `json:"date"` Details string `json:"details"` Photographer string `json:"photographer"` // Rating expressed in 1-100 scale Rating *int `json:"rating"` Organized bool `json:"organized"` StudioID *int `json:"studio_id"` // transient - not persisted Files RelatedFiles // transient - not persisted PrimaryFileID *FileID // transient - path of primary file or folder Path string FolderID *FolderID `json:"folder_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` URLs RelatedStrings `json:"urls"` SceneIDs RelatedIDs `json:"scene_ids"` TagIDs RelatedIDs `json:"tag_ids"` PerformerIDs RelatedIDs `json:"performer_ids"` } func NewGallery() Gallery { currentTime := time.Now() return Gallery{ CreatedAt: currentTime, UpdatedAt: currentTime, } } type CreateGalleryInput struct { *Gallery FileIDs []FileID CustomFields map[string]interface{} `json:"custom_fields"` } type UpdateGalleryInput struct { *Gallery FileIDs []FileID CustomFields CustomFieldsInput `json:"custom_fields"` } // GalleryPartial represents part of a Gallery object. It is used to update // the database entry. Only non-nil fields will be updated. type GalleryPartial struct { // Path OptionalString // Checksum OptionalString // Zip OptionalBool Title OptionalString Code OptionalString URLs *UpdateStrings Date OptionalDate Details OptionalString Photographer OptionalString // Rating expressed in 1-100 scale Rating OptionalInt Organized OptionalBool StudioID OptionalInt // FileModTime OptionalTime CreatedAt OptionalTime UpdatedAt OptionalTime SceneIDs *UpdateIDs TagIDs *UpdateIDs PerformerIDs *UpdateIDs PrimaryFileID *FileID CustomFields CustomFieldsInput } func NewGalleryPartial() GalleryPartial { currentTime := time.Now() return GalleryPartial{ UpdatedAt: NewOptionalTime(currentTime), } } // IsUserCreated returns true if the gallery was created by the user. // This is determined by whether the gallery has a primary file or folder. func (g *Gallery) IsUserCreated() bool { return g.PrimaryFileID == nil && g.FolderID == nil } func (g *Gallery) LoadURLs(ctx context.Context, l URLLoader) error { return g.URLs.load(func() ([]string, error) { return l.GetURLs(ctx, g.ID) }) } func (g *Gallery) LoadFiles(ctx context.Context, l FileLoader) error { return g.Files.load(func() ([]File, error) { return l.GetFiles(ctx, g.ID) }) } func (g *Gallery) LoadPrimaryFile(ctx context.Context, l FileGetter) error { return g.Files.loadPrimary(func() (File, error) { if g.PrimaryFileID == nil { return nil, nil } f, err := l.Find(ctx, *g.PrimaryFileID) if err != nil { return nil, err } if len(f) > 0 { return f[0], nil } return nil, nil }) } func (g *Gallery) LoadSceneIDs(ctx context.Context, l SceneIDLoader) error { return g.SceneIDs.load(func() ([]int, error) { return l.GetSceneIDs(ctx, g.ID) }) } func (g *Gallery) LoadPerformerIDs(ctx context.Context, l PerformerIDLoader) error { return g.PerformerIDs.load(func() ([]int, error) { return l.GetPerformerIDs(ctx, g.ID) }) } func (g *Gallery) LoadTagIDs(ctx context.Context, l TagIDLoader) error { return g.TagIDs.load(func() ([]int, error) { return l.GetTagIDs(ctx, g.ID) }) } func (g Gallery) PrimaryChecksum() string { // renamed from Checksum to prevent gqlgen from using it in the resolver if p := g.Files.Primary(); p != nil { v := p.Base().Fingerprints.Get(FingerprintTypeMD5) if v == nil { return "" } return v.(string) } return "" } // GetTitle returns the title of the scene. If the Title field is empty, // then the base filename is returned. func (g Gallery) GetTitle() string { if g.Title != "" { return g.Title } return filepath.Base(g.Path) } // DisplayName returns a display name for the scene for logging purposes. // It returns the path or title, or otherwise it returns the ID if both of these are empty. func (g Gallery) DisplayName() string { if g.Path != "" { return g.Path } if g.Title != "" { return g.Title } return strconv.Itoa(g.ID) } const DefaultGthumbWidth int = 640 ================================================ FILE: pkg/models/model_gallery_chapter.go ================================================ package models import ( "time" ) type GalleryChapter struct { ID int `json:"id"` Title string `json:"title"` ImageIndex int `json:"image_index"` GalleryID int `json:"gallery_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } func NewGalleryChapter() GalleryChapter { currentTime := time.Now() return GalleryChapter{ CreatedAt: currentTime, UpdatedAt: currentTime, } } // GalleryChapterPartial represents part of a GalleryChapter object. // It is used to update the database entry. type GalleryChapterPartial struct { Title OptionalString ImageIndex OptionalInt GalleryID OptionalInt CreatedAt OptionalTime UpdatedAt OptionalTime } func NewGalleryChapterPartial() GalleryChapterPartial { currentTime := time.Now() return GalleryChapterPartial{ UpdatedAt: NewOptionalTime(currentTime), } } ================================================ FILE: pkg/models/model_group.go ================================================ package models import ( "context" "time" ) type Group struct { ID int `json:"id"` Name string `json:"name"` Aliases string `json:"aliases"` Duration *int `json:"duration"` Date *Date `json:"date"` // Rating expressed in 1-100 scale Rating *int `json:"rating"` StudioID *int `json:"studio_id"` Director string `json:"director"` Synopsis string `json:"synopsis"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` URLs RelatedStrings `json:"urls"` TagIDs RelatedIDs `json:"tag_ids"` ContainingGroups RelatedGroupDescriptions `json:"containing_groups"` SubGroups RelatedGroupDescriptions `json:"sub_groups"` } func NewGroup() Group { currentTime := time.Now() return Group{ CreatedAt: currentTime, UpdatedAt: currentTime, } } type CreateGroupInput struct { *Group CustomFields map[string]interface{} `json:"custom_fields"` FrontImageData []byte BackImageData []byte } func (m *Group) LoadURLs(ctx context.Context, l URLLoader) error { return m.URLs.load(func() ([]string, error) { return l.GetURLs(ctx, m.ID) }) } func (m *Group) LoadTagIDs(ctx context.Context, l TagIDLoader) error { return m.TagIDs.load(func() ([]int, error) { return l.GetTagIDs(ctx, m.ID) }) } func (m *Group) LoadContainingGroupIDs(ctx context.Context, l ContainingGroupLoader) error { return m.ContainingGroups.load(func() ([]GroupIDDescription, error) { return l.GetContainingGroupDescriptions(ctx, m.ID) }) } func (m *Group) LoadSubGroupIDs(ctx context.Context, l SubGroupLoader) error { return m.SubGroups.load(func() ([]GroupIDDescription, error) { return l.GetSubGroupDescriptions(ctx, m.ID) }) } type GroupPartial struct { Name OptionalString Aliases OptionalString Duration OptionalInt Date OptionalDate // Rating expressed in 1-100 scale Rating OptionalInt StudioID OptionalInt Director OptionalString Synopsis OptionalString URLs *UpdateStrings TagIDs *UpdateIDs ContainingGroups *UpdateGroupDescriptions SubGroups *UpdateGroupDescriptions CreatedAt OptionalTime UpdatedAt OptionalTime CustomFields CustomFieldsInput } func NewGroupPartial() GroupPartial { currentTime := time.Now() return GroupPartial{ UpdatedAt: NewOptionalTime(currentTime), } } ================================================ FILE: pkg/models/model_image.go ================================================ package models import ( "context" "path/filepath" "strconv" "time" ) // Image stores the metadata for a single image. type Image struct { ID int `json:"id"` Title string `json:"title"` Code string `json:"code"` Details string `json:"details"` Photographer string `json:"photographer"` // Rating expressed in 1-100 scale Rating *int `json:"rating"` Organized bool `json:"organized"` OCounter int `json:"o_counter"` StudioID *int `json:"studio_id"` URLs RelatedStrings `json:"urls"` Date *Date `json:"date"` // transient - not persisted Files RelatedFiles PrimaryFileID *FileID // transient - path of primary file - empty if no files Path string // transient - checksum of primary file - empty if no files Checksum string CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` GalleryIDs RelatedIDs `json:"gallery_ids"` TagIDs RelatedIDs `json:"tag_ids"` PerformerIDs RelatedIDs `json:"performer_ids"` } func NewImage() Image { currentTime := time.Now() return Image{ CreatedAt: currentTime, UpdatedAt: currentTime, } } type CreateImageInput struct { *Image FileIDs []FileID CustomFields map[string]interface{} `json:"custom_fields"` } type ImagePartial struct { Title OptionalString Code OptionalString // Rating expressed in 1-100 scale Rating OptionalInt URLs *UpdateStrings Date OptionalDate Details OptionalString Photographer OptionalString Organized OptionalBool OCounter OptionalInt StudioID OptionalInt CreatedAt OptionalTime UpdatedAt OptionalTime GalleryIDs *UpdateIDs TagIDs *UpdateIDs PerformerIDs *UpdateIDs PrimaryFileID *FileID CustomFields CustomFieldsInput } func NewImagePartial() ImagePartial { currentTime := time.Now() return ImagePartial{ UpdatedAt: NewOptionalTime(currentTime), } } func (i *Image) LoadURLs(ctx context.Context, l URLLoader) error { return i.URLs.load(func() ([]string, error) { return l.GetURLs(ctx, i.ID) }) } func (i *Image) LoadFiles(ctx context.Context, l FileLoader) error { return i.Files.load(func() ([]File, error) { return l.GetFiles(ctx, i.ID) }) } func (i *Image) LoadPrimaryFile(ctx context.Context, l FileGetter) error { return i.Files.loadPrimary(func() (File, error) { if i.PrimaryFileID == nil { return nil, nil } f, err := l.Find(ctx, *i.PrimaryFileID) if err != nil { return nil, err } if len(f) > 0 { return f[0], nil } return nil, nil }) } func (i *Image) LoadGalleryIDs(ctx context.Context, l GalleryIDLoader) error { return i.GalleryIDs.load(func() ([]int, error) { return l.GetGalleryIDs(ctx, i.ID) }) } func (i *Image) LoadPerformerIDs(ctx context.Context, l PerformerIDLoader) error { return i.PerformerIDs.load(func() ([]int, error) { return l.GetPerformerIDs(ctx, i.ID) }) } func (i *Image) LoadTagIDs(ctx context.Context, l TagIDLoader) error { return i.TagIDs.load(func() ([]int, error) { return l.GetTagIDs(ctx, i.ID) }) } // GetTitle returns the title of the image. If the Title field is empty, // then the base filename is returned. func (i Image) GetTitle() string { if i.Title != "" { return i.Title } if i.Path != "" { return filepath.Base(i.Path) } return "" } // DisplayName returns a display name for the scene for logging purposes. // It returns Path if not empty, otherwise it returns the ID. func (i Image) DisplayName() string { if i.Path != "" { return i.Path } return strconv.Itoa(i.ID) } ================================================ FILE: pkg/models/model_joins.go ================================================ package models import ( "fmt" "strconv" ) type GroupsScenes struct { GroupID int `json:"movie_id"` // SceneID int `json:"scene_id"` SceneIndex *int `json:"scene_index"` } func (s GroupsScenes) SceneMovieInput() SceneMovieInput { return SceneMovieInput{ MovieID: strconv.Itoa(s.GroupID), SceneIndex: s.SceneIndex, } } func (s GroupsScenes) Equal(o GroupsScenes) bool { return o.GroupID == s.GroupID && ((o.SceneIndex == nil && s.SceneIndex == nil) || (o.SceneIndex != nil && s.SceneIndex != nil && *o.SceneIndex == *s.SceneIndex)) } type UpdateGroupIDs struct { Groups []GroupsScenes `json:"movies"` Mode RelationshipUpdateMode `json:"mode"` } func (u *UpdateGroupIDs) SceneMovieInputs() []SceneMovieInput { if u == nil { return nil } ret := make([]SceneMovieInput, 0, len(u.Groups)) for _, id := range u.Groups { ret = append(ret, id.SceneMovieInput()) } return ret } func (u *UpdateGroupIDs) AddUnique(v GroupsScenes) { for _, vv := range u.Groups { if vv.GroupID == v.GroupID { return } } u.Groups = append(u.Groups, v) } func GroupsScenesFromInput(input []SceneMovieInput) ([]GroupsScenes, error) { ret := make([]GroupsScenes, len(input)) for i, v := range input { mID, err := strconv.Atoi(v.MovieID) if err != nil { return nil, fmt.Errorf("invalid movie ID: %s", v.MovieID) } ret[i] = GroupsScenes{ GroupID: mID, SceneIndex: v.SceneIndex, } } return ret, nil } type GroupIDDescription struct { GroupID int `json:"group_id"` Description string `json:"description"` } ================================================ FILE: pkg/models/model_performer.go ================================================ package models import ( "context" "time" ) type Performer struct { ID int `json:"id"` Name string `json:"name"` Disambiguation string `json:"disambiguation"` Gender *GenderEnum `json:"gender"` Birthdate *Date `json:"birthdate"` Ethnicity string `json:"ethnicity"` Country string `json:"country"` EyeColor string `json:"eye_color"` Height *int `json:"height"` Measurements string `json:"measurements"` FakeTits string `json:"fake_tits"` PenisLength *float64 `json:"penis_length"` Circumcised *CircumcisedEnum `json:"circumcised"` CareerStart *Date `json:"career_start"` CareerEnd *Date `json:"career_end"` Tattoos string `json:"tattoos"` Piercings string `json:"piercings"` Favorite bool `json:"favorite"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` // Rating expressed in 1-100 scale Rating *int `json:"rating"` Details string `json:"details"` DeathDate *Date `json:"death_date"` HairColor string `json:"hair_color"` Weight *int `json:"weight"` IgnoreAutoTag bool `json:"ignore_auto_tag"` Aliases RelatedStrings `json:"aliases"` URLs RelatedStrings `json:"urls"` TagIDs RelatedIDs `json:"tag_ids"` StashIDs RelatedStashIDs `json:"stash_ids"` } type CreatePerformerInput struct { *Performer CustomFields map[string]interface{} `json:"custom_fields"` } type UpdatePerformerInput struct { *Performer CustomFields CustomFieldsInput `json:"custom_fields"` } func NewPerformer() Performer { currentTime := time.Now() return Performer{ CreatedAt: currentTime, UpdatedAt: currentTime, } } // PerformerPartial represents part of a Performer object. It is used to update // the database entry. type PerformerPartial struct { Name OptionalString Disambiguation OptionalString Gender OptionalString URLs *UpdateStrings Birthdate OptionalDate Ethnicity OptionalString Country OptionalString EyeColor OptionalString Height OptionalInt Measurements OptionalString FakeTits OptionalString PenisLength OptionalFloat64 Circumcised OptionalString CareerStart OptionalDate CareerEnd OptionalDate Tattoos OptionalString Piercings OptionalString Favorite OptionalBool CreatedAt OptionalTime UpdatedAt OptionalTime // Rating expressed in 1-100 scale Rating OptionalInt Details OptionalString DeathDate OptionalDate HairColor OptionalString Weight OptionalInt IgnoreAutoTag OptionalBool Aliases *UpdateStrings TagIDs *UpdateIDs StashIDs *UpdateStashIDs CustomFields CustomFieldsInput } func NewPerformerPartial() PerformerPartial { currentTime := time.Now() return PerformerPartial{ UpdatedAt: NewOptionalTime(currentTime), } } func (s *Performer) LoadAliases(ctx context.Context, l AliasLoader) error { return s.Aliases.load(func() ([]string, error) { return l.GetAliases(ctx, s.ID) }) } func (s *Performer) LoadURLs(ctx context.Context, l URLLoader) error { return s.URLs.load(func() ([]string, error) { return l.GetURLs(ctx, s.ID) }) } func (s *Performer) LoadTagIDs(ctx context.Context, l TagIDLoader) error { return s.TagIDs.load(func() ([]int, error) { return l.GetTagIDs(ctx, s.ID) }) } func (s *Performer) LoadStashIDs(ctx context.Context, l StashIDLoader) error { return s.StashIDs.load(func() ([]StashID, error) { return l.GetStashIDs(ctx, s.ID) }) } func (s *Performer) LoadRelationships(ctx context.Context, l PerformerReader) error { if err := s.LoadAliases(ctx, l); err != nil { return err } if err := s.LoadTagIDs(ctx, l); err != nil { return err } if err := s.LoadStashIDs(ctx, l); err != nil { return err } return nil } ================================================ FILE: pkg/models/model_saved_filter.go ================================================ package models import ( "fmt" "io" "strconv" ) type FilterMode string const ( FilterModeScenes FilterMode = "SCENES" FilterModePerformers FilterMode = "PERFORMERS" FilterModeStudios FilterMode = "STUDIOS" FilterModeGalleries FilterMode = "GALLERIES" FilterModeSceneMarkers FilterMode = "SCENE_MARKERS" FilterModeMovies FilterMode = "MOVIES" FilterModeGroups FilterMode = "GROUPS" FilterModeTags FilterMode = "TAGS" FilterModeImages FilterMode = "IMAGES" ) var AllFilterMode = []FilterMode{ FilterModeScenes, FilterModePerformers, FilterModeStudios, FilterModeGalleries, FilterModeSceneMarkers, FilterModeGroups, FilterModeMovies, FilterModeTags, FilterModeImages, } func (e FilterMode) IsValid() bool { switch e { case FilterModeScenes, FilterModePerformers, FilterModeStudios, FilterModeGalleries, FilterModeSceneMarkers, FilterModeMovies, FilterModeGroups, FilterModeTags, FilterModeImages: return true } return false } func (e FilterMode) String() string { return string(e) } func (e *FilterMode) UnmarshalGQL(v interface{}) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = FilterMode(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid FilterMode", str) } return nil } func (e FilterMode) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } type SavedFilter struct { ID int `db:"id" json:"id"` Mode FilterMode `db:"mode" json:"mode"` Name string `db:"name" json:"name"` FindFilter *FindFilterType `json:"find_filter"` ObjectFilter map[string]interface{} `json:"object_filter"` UIOptions map[string]interface{} `json:"ui_options"` } ================================================ FILE: pkg/models/model_scene.go ================================================ package models import ( "context" "errors" "path/filepath" "strconv" "time" ) // Scene stores the metadata for a single video scene. type Scene struct { ID int `json:"id"` Title string `json:"title"` Code string `json:"code"` Details string `json:"details"` Director string `json:"director"` Date *Date `json:"date"` // Rating expressed in 1-100 scale Rating *int `json:"rating"` Organized bool `json:"organized"` StudioID *int `json:"studio_id"` // transient - not persisted Files RelatedVideoFiles PrimaryFileID *FileID // transient - path of primary file - empty if no files Path string // transient - oshash of primary file - empty if no files OSHash string // transient - checksum of primary file - empty if no files Checksum string CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` ResumeTime float64 `json:"resume_time"` PlayDuration float64 `json:"play_duration"` URLs RelatedStrings `json:"urls"` GalleryIDs RelatedIDs `json:"gallery_ids"` TagIDs RelatedIDs `json:"tag_ids"` PerformerIDs RelatedIDs `json:"performer_ids"` Groups RelatedGroups `json:"groups"` StashIDs RelatedStashIDs `json:"stash_ids"` } func NewScene() Scene { currentTime := time.Now() return Scene{ CreatedAt: currentTime, UpdatedAt: currentTime, } } type CreateSceneInput struct { *Scene FileIDs []FileID CoverImage []byte CustomFields CustomFieldMap `json:"custom_fields"` } type UpdateSceneInput struct { *Scene CustomFields CustomFieldsInput `json:"custom_fields"` } // ScenePartial represents part of a Scene object. It is used to update // the database entry. type ScenePartial struct { Title OptionalString Code OptionalString Details OptionalString Director OptionalString Date OptionalDate // Rating expressed in 1-100 scale Rating OptionalInt Organized OptionalBool StudioID OptionalInt CreatedAt OptionalTime UpdatedAt OptionalTime ResumeTime OptionalFloat64 PlayDuration OptionalFloat64 URLs *UpdateStrings GalleryIDs *UpdateIDs TagIDs *UpdateIDs PerformerIDs *UpdateIDs GroupIDs *UpdateGroupIDs StashIDs *UpdateStashIDs PrimaryFileID *FileID } func NewScenePartial() ScenePartial { currentTime := time.Now() return ScenePartial{ UpdatedAt: NewOptionalTime(currentTime), } } func (s *Scene) LoadURLs(ctx context.Context, l URLLoader) error { return s.URLs.load(func() ([]string, error) { return l.GetURLs(ctx, s.ID) }) } func (s *Scene) LoadFiles(ctx context.Context, l VideoFileLoader) error { return s.Files.load(func() ([]*VideoFile, error) { return l.GetFiles(ctx, s.ID) }) } func (s *Scene) LoadPrimaryFile(ctx context.Context, l FileGetter) error { return s.Files.loadPrimary(func() (*VideoFile, error) { if s.PrimaryFileID == nil { return nil, nil } f, err := l.Find(ctx, *s.PrimaryFileID) if err != nil { return nil, err } var vf *VideoFile if len(f) > 0 { var ok bool vf, ok = f[0].(*VideoFile) if !ok { return nil, errors.New("not a video file") } } return vf, nil }) } func (s *Scene) LoadGalleryIDs(ctx context.Context, l GalleryIDLoader) error { return s.GalleryIDs.load(func() ([]int, error) { return l.GetGalleryIDs(ctx, s.ID) }) } func (s *Scene) LoadPerformerIDs(ctx context.Context, l PerformerIDLoader) error { return s.PerformerIDs.load(func() ([]int, error) { return l.GetPerformerIDs(ctx, s.ID) }) } func (s *Scene) LoadTagIDs(ctx context.Context, l TagIDLoader) error { return s.TagIDs.load(func() ([]int, error) { return l.GetTagIDs(ctx, s.ID) }) } func (s *Scene) LoadGroups(ctx context.Context, l SceneGroupLoader) error { return s.Groups.load(func() ([]GroupsScenes, error) { return l.GetGroups(ctx, s.ID) }) } func (s *Scene) LoadStashIDs(ctx context.Context, l StashIDLoader) error { return s.StashIDs.load(func() ([]StashID, error) { return l.GetStashIDs(ctx, s.ID) }) } func (s *Scene) LoadRelationships(ctx context.Context, l SceneReader) error { if err := s.LoadURLs(ctx, l); err != nil { return err } if err := s.LoadGalleryIDs(ctx, l); err != nil { return err } if err := s.LoadPerformerIDs(ctx, l); err != nil { return err } if err := s.LoadTagIDs(ctx, l); err != nil { return err } if err := s.LoadGroups(ctx, l); err != nil { return err } if err := s.LoadStashIDs(ctx, l); err != nil { return err } if err := s.LoadFiles(ctx, l); err != nil { return err } return nil } // UpdateInput constructs a SceneUpdateInput using the populated fields in the ScenePartial object. func (s ScenePartial) UpdateInput(id int) SceneUpdateInput { var dateStr *string if s.Date.Set { d := s.Date.Value v := d.String() dateStr = &v } var stashIDs StashIDs if s.StashIDs != nil { stashIDs = StashIDs(s.StashIDs.StashIDs) } ret := SceneUpdateInput{ ID: strconv.Itoa(id), Title: s.Title.Ptr(), Code: s.Code.Ptr(), Details: s.Details.Ptr(), Director: s.Director.Ptr(), Urls: s.URLs.Strings(), Date: dateStr, Rating100: s.Rating.Ptr(), Organized: s.Organized.Ptr(), StudioID: s.StudioID.StringPtr(), GalleryIds: s.GalleryIDs.IDStrings(), PerformerIds: s.PerformerIDs.IDStrings(), Movies: s.GroupIDs.SceneMovieInputs(), TagIds: s.TagIDs.IDStrings(), StashIds: stashIDs.ToStashIDInputs(), } return ret } // GetTitle returns the title of the scene. If the Title field is empty, // then the base filename is returned. func (s Scene) GetTitle() string { if s.Title != "" { return s.Title } return filepath.Base(s.Path) } // DisplayName returns a display name for the scene for logging purposes. // It returns Path if not empty, otherwise it returns the ID. func (s Scene) DisplayName() string { if s.Path != "" { return s.Path } return strconv.Itoa(s.ID) } // GetHash returns the hash of the scene, based on the hash algorithm provided. If // hash algorithm is MD5, then Checksum is returned. Otherwise, OSHash is returned. func (s Scene) GetHash(hashAlgorithm HashAlgorithm) string { switch hashAlgorithm { case HashAlgorithmMd5: return s.Checksum case HashAlgorithmOshash: return s.OSHash } return "" } // SceneFileType represents the file metadata for a scene. type SceneFileType struct { Size *string `graphql:"size" json:"size"` Duration *float64 `graphql:"duration" json:"duration"` VideoCodec *string `graphql:"video_codec" json:"video_codec"` AudioCodec *string `graphql:"audio_codec" json:"audio_codec"` Width *int `graphql:"width" json:"width"` Height *int `graphql:"height" json:"height"` Framerate *float64 `graphql:"framerate" json:"framerate"` Bitrate *int `graphql:"bitrate" json:"bitrate"` } type VideoCaption struct { LanguageCode string `json:"language_code"` Filename string `json:"filename"` CaptionType string `json:"caption_type"` } func (c VideoCaption) Path(filePath string) string { return filepath.Join(filepath.Dir(filePath), c.Filename) } ================================================ FILE: pkg/models/model_scene_marker.go ================================================ package models import ( "time" ) type SceneMarker struct { ID int `json:"id"` Title string `json:"title"` Seconds float64 `json:"seconds"` EndSeconds *float64 `json:"end_seconds"` PrimaryTagID int `json:"primary_tag_id"` SceneID int `json:"scene_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } func NewSceneMarker() SceneMarker { currentTime := time.Now() return SceneMarker{ CreatedAt: currentTime, UpdatedAt: currentTime, } } // SceneMarkerPartial represents part of a SceneMarker object. // It is used to update the database entry. type SceneMarkerPartial struct { Title OptionalString Seconds OptionalFloat64 EndSeconds OptionalFloat64 PrimaryTagID OptionalInt TagIDs *UpdateIDs SceneID OptionalInt CreatedAt OptionalTime UpdatedAt OptionalTime } func NewSceneMarkerPartial() SceneMarkerPartial { currentTime := time.Now() return SceneMarkerPartial{ UpdatedAt: NewOptionalTime(currentTime), } } ================================================ FILE: pkg/models/model_scene_test.go ================================================ package models import ( "reflect" "testing" ) func TestScenePartial_UpdateInput(t *testing.T) { const ( id = 1 idStr = "1" ) var ( title = "title" code = "1337" details = "details" director = "director" url = "url" date = "2001-02-03" rating100 = 80 organized = true studioID = 2 studioIDStr = "2" ) dateObj, _ := ParseDate(date) tests := []struct { name string id int s ScenePartial want SceneUpdateInput }{ { "full", id, ScenePartial{ Title: NewOptionalString(title), Code: NewOptionalString(code), Details: NewOptionalString(details), Director: NewOptionalString(director), URLs: &UpdateStrings{ Values: []string{url}, Mode: RelationshipUpdateModeSet, }, Date: NewOptionalDate(dateObj), Rating: NewOptionalInt(rating100), Organized: NewOptionalBool(organized), StudioID: NewOptionalInt(studioID), }, SceneUpdateInput{ ID: idStr, Title: &title, Code: &code, Details: &details, Director: &director, Urls: []string{url}, Date: &date, Rating100: &rating100, Organized: &organized, StudioID: &studioIDStr, }, }, { "empty", id, ScenePartial{}, SceneUpdateInput{ ID: idStr, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.s.UpdateInput(tt.id); !reflect.DeepEqual(got, tt.want) { t.Errorf("ScenePartial.UpdateInput() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: pkg/models/model_scraped_item.go ================================================ package models import ( "context" "strconv" "strings" "time" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/utils" ) type ScrapedStudio struct { // Set if studio matched StoredID *string `json:"stored_id"` Name string `json:"name"` URL *string `json:"url"` // deprecated URLs []string `json:"urls"` Parent *ScrapedStudio `json:"parent"` Image *string `json:"image"` Images []string `json:"images"` Details *string `json:"details"` Aliases *string `json:"aliases"` Tags []*ScrapedTag `json:"tags"` RemoteSiteID *string `json:"remote_site_id"` } func (ScrapedStudio) IsScrapedContent() {} func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *CreateStudioInput { // Populate a new studio from the input ret := NewCreateStudioInput() ret.Name = strings.TrimSpace(s.Name) if s.RemoteSiteID != nil && endpoint != "" && *s.RemoteSiteID != "" { ret.StashIDs = NewRelatedStashIDs([]StashID{ { Endpoint: endpoint, StashID: *s.RemoteSiteID, UpdatedAt: time.Now(), }, }) } // if URLs are provided, only use those if len(s.URLs) > 0 { if !excluded["urls"] { ret.URLs = NewRelatedStrings(s.URLs) } } else { urls := []string{} if s.URL != nil && !excluded["url"] { urls = append(urls, *s.URL) } if len(urls) > 0 { ret.URLs = NewRelatedStrings(urls) } } if s.Details != nil && !excluded["details"] { ret.Details = *s.Details } if s.Aliases != nil && *s.Aliases != "" && !excluded["aliases"] { ret.Aliases = NewRelatedStrings(stringslice.FromString(*s.Aliases, ",")) } if s.Parent != nil && s.Parent.StoredID != nil && !excluded["parent"] && !excluded["parent_studio"] { parentId, _ := strconv.Atoi(*s.Parent.StoredID) ret.ParentID = &parentId } return &ret } func (s *ScrapedStudio) GetImage(ctx context.Context, excluded map[string]bool) ([]byte, error) { // Process the base 64 encoded image string if len(s.Images) > 0 && !excluded["image"] { var err error img, err := utils.ProcessImageInput(ctx, *s.Image) if err != nil { return nil, err } return img, nil } return nil, nil } func (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[string]bool, existingStashIDs []StashID) StudioPartial { ret := NewStudioPartial() ret.ID, _ = strconv.Atoi(id) currentTime := time.Now() if s.Name != "" && !excluded["name"] { ret.Name = NewOptionalString(strings.TrimSpace(s.Name)) } if len(s.URLs) > 0 { if !excluded["urls"] { ret.URLs = &UpdateStrings{ Values: stringslice.TrimSpace(s.URLs), Mode: RelationshipUpdateModeSet, } } } else { urls := []string{} if s.URL != nil && !excluded["url"] { urls = append(urls, strings.TrimSpace(*s.URL)) } if len(urls) > 0 { ret.URLs = &UpdateStrings{ Values: stringslice.TrimSpace(urls), Mode: RelationshipUpdateModeSet, } } } if s.Details != nil && !excluded["details"] { ret.Details = NewOptionalString(strings.TrimSpace(*s.Details)) } if s.Aliases != nil && *s.Aliases != "" && !excluded["aliases"] { ret.Aliases = &UpdateStrings{ Values: stringslice.TrimSpace(stringslice.FromString(*s.Aliases, ",")), Mode: RelationshipUpdateModeSet, } } if s.Parent != nil && !excluded["parent"] { if s.Parent.StoredID != nil { parentID, _ := strconv.Atoi(*s.Parent.StoredID) if parentID > 0 { // This is to be set directly as we know it has a value and the translator won't have the field ret.ParentID = NewOptionalInt(parentID) } } } if s.RemoteSiteID != nil && endpoint != "" && *s.RemoteSiteID != "" { ret.StashIDs = &UpdateStashIDs{ StashIDs: existingStashIDs, Mode: RelationshipUpdateModeSet, } ret.StashIDs.Set(StashID{ Endpoint: endpoint, StashID: *s.RemoteSiteID, UpdatedAt: currentTime, }) } return ret } // A performer from a scraping operation... type ScrapedPerformer struct { // Set if performer matched StoredID *string `json:"stored_id"` Name *string `json:"name"` Disambiguation *string `json:"disambiguation"` Gender *string `json:"gender"` URLs []string `json:"urls"` URL *string `json:"url"` // deprecated Twitter *string `json:"twitter"` // deprecated Instagram *string `json:"instagram"` // deprecated Birthdate *string `json:"birthdate"` Ethnicity *string `json:"ethnicity"` Country *string `json:"country"` EyeColor *string `json:"eye_color"` Height *string `json:"height"` Measurements *string `json:"measurements"` FakeTits *string `json:"fake_tits"` PenisLength *string `json:"penis_length"` Circumcised *string `json:"circumcised"` CareerLength *string `json:"career_length"` // deprecated: use CareerStart/CareerEnd CareerStart *string `json:"career_start"` CareerEnd *string `json:"career_end"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` Aliases *string `json:"aliases"` Tags []*ScrapedTag `json:"tags"` // This should be a base64 encoded data URL Image *string `json:"image"` // deprecated: use Images Images []string `json:"images"` Details *string `json:"details"` DeathDate *string `json:"death_date"` HairColor *string `json:"hair_color"` Weight *string `json:"weight"` RemoteSiteID *string `json:"remote_site_id"` RemoteDeleted bool `json:"remote_deleted"` RemoteMergedIntoId *string `json:"remote_merged_into_id"` } func (ScrapedPerformer) IsScrapedContent() {} func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool) *Performer { ret := NewPerformer() currentTime := time.Now() ret.Name = strings.TrimSpace(*p.Name) if p.Aliases != nil && !excluded["aliases"] { aliases := stringslice.FromString(*p.Aliases, ",") for i, alias := range aliases { aliases[i] = strings.TrimSpace(alias) } ret.Aliases = NewRelatedStrings(aliases) } if p.Birthdate != nil && !excluded["birthdate"] { date, err := ParseDate(*p.Birthdate) if err == nil { ret.Birthdate = &date } } if p.DeathDate != nil && !excluded["death_date"] { date, err := ParseDate(*p.DeathDate) if err == nil { ret.DeathDate = &date } } // assume that career length is _not_ populated in favour of start/end if p.CareerStart != nil && !excluded["career_start"] { date, err := ParseDate(*p.CareerStart) if err == nil { ret.CareerStart = &date } } if p.CareerEnd != nil && !excluded["career_end"] { date, err := ParseDate(*p.CareerEnd) if err == nil { ret.CareerEnd = &date } } if p.Country != nil && !excluded["country"] { ret.Country = *p.Country } if p.Ethnicity != nil && !excluded["ethnicity"] { ret.Ethnicity = *p.Ethnicity } if p.EyeColor != nil && !excluded["eye_color"] { ret.EyeColor = *p.EyeColor } if p.HairColor != nil && !excluded["hair_color"] { ret.HairColor = *p.HairColor } if p.FakeTits != nil && !excluded["fake_tits"] { ret.FakeTits = *p.FakeTits } if p.Gender != nil && !excluded["gender"] { v := GenderEnum(*p.Gender) if v.IsValid() { ret.Gender = &v } } if p.Height != nil && !excluded["height"] { h, err := strconv.Atoi(*p.Height) if err == nil { ret.Height = &h } } if p.Weight != nil && !excluded["weight"] { w, err := strconv.Atoi(*p.Weight) if err == nil { ret.Weight = &w } } if p.Measurements != nil && !excluded["measurements"] { ret.Measurements = *p.Measurements } if p.Disambiguation != nil && !excluded["disambiguation"] { ret.Disambiguation = *p.Disambiguation } if p.Details != nil && !excluded["details"] { ret.Details = *p.Details } if p.Piercings != nil && !excluded["piercings"] { ret.Piercings = *p.Piercings } if p.Tattoos != nil && !excluded["tattoos"] { ret.Tattoos = *p.Tattoos } if p.PenisLength != nil && !excluded["penis_length"] { l, err := strconv.ParseFloat(*p.PenisLength, 64) if err == nil { ret.PenisLength = &l } } if p.Circumcised != nil && !excluded["circumcised"] { v := CircumcisedEnum(*p.Circumcised) if v.IsValid() { ret.Circumcised = &v } } // if URLs are provided, only use those if len(p.URLs) > 0 { if !excluded["urls"] { ret.URLs = NewRelatedStrings(p.URLs) } } else { urls := []string{} if p.URL != nil && !excluded["url"] { urls = append(urls, *p.URL) } if p.Twitter != nil && !excluded["twitter"] { urls = append(urls, *p.Twitter) } if p.Instagram != nil && !excluded["instagram"] { urls = append(urls, *p.Instagram) } if len(urls) > 0 { ret.URLs = NewRelatedStrings(urls) } } if p.RemoteSiteID != nil && endpoint != "" && *p.RemoteSiteID != "" { ret.StashIDs = NewRelatedStashIDs([]StashID{ { Endpoint: endpoint, StashID: *p.RemoteSiteID, UpdatedAt: currentTime, }, }) } return &ret } func (p *ScrapedPerformer) GetImage(ctx context.Context, excluded map[string]bool) ([]byte, error) { // Process the base 64 encoded image string if len(p.Images) > 0 && !excluded["image"] { var err error img, err := utils.ProcessImageInput(ctx, p.Images[0]) if err != nil { return nil, err } return img, nil } return nil, nil } func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, existingStashIDs []StashID) PerformerPartial { ret := NewPerformerPartial() if p.Aliases != nil && !excluded["aliases"] { ret.Aliases = &UpdateStrings{ Values: stringslice.FromString(*p.Aliases, ","), Mode: RelationshipUpdateModeSet, } } if p.Birthdate != nil && !excluded["birthdate"] { date, err := ParseDate(*p.Birthdate) if err == nil { ret.Birthdate = NewOptionalDate(date) } } if p.DeathDate != nil && !excluded["death_date"] { date, err := ParseDate(*p.DeathDate) if err == nil { ret.DeathDate = NewOptionalDate(date) } } if p.CareerLength != nil && !excluded["career_length"] { // parse career_length into career_start/career_end start, end, err := ParseYearRangeString(*p.CareerLength) if err == nil { if start != nil { ret.CareerStart = NewOptionalDate(*start) } if end != nil { ret.CareerEnd = NewOptionalDate(*end) } } } if p.Country != nil && !excluded["country"] { ret.Country = NewOptionalString(*p.Country) } if p.Ethnicity != nil && !excluded["ethnicity"] { ret.Ethnicity = NewOptionalString(*p.Ethnicity) } if p.EyeColor != nil && !excluded["eye_color"] { ret.EyeColor = NewOptionalString(*p.EyeColor) } if p.HairColor != nil && !excluded["hair_color"] { ret.HairColor = NewOptionalString(*p.HairColor) } if p.FakeTits != nil && !excluded["fake_tits"] { ret.FakeTits = NewOptionalString(*p.FakeTits) } if p.Gender != nil && !excluded["gender"] { ret.Gender = NewOptionalString(*p.Gender) } if p.Height != nil && !excluded["height"] { h, err := strconv.Atoi(*p.Height) if err == nil { ret.Height = NewOptionalInt(h) } } if p.Weight != nil && !excluded["weight"] { w, err := strconv.Atoi(*p.Weight) if err == nil { ret.Weight = NewOptionalInt(w) } } if p.Measurements != nil && !excluded["measurements"] { ret.Measurements = NewOptionalString(*p.Measurements) } if p.Name != nil && !excluded["name"] { ret.Name = NewOptionalString(*p.Name) } if p.Disambiguation != nil && !excluded["disambiguation"] { ret.Disambiguation = NewOptionalString(*p.Disambiguation) } if p.Details != nil && !excluded["details"] { ret.Details = NewOptionalString(*p.Details) } if p.Piercings != nil && !excluded["piercings"] { ret.Piercings = NewOptionalString(*p.Piercings) } if p.Tattoos != nil && !excluded["tattoos"] { ret.Tattoos = NewOptionalString(*p.Tattoos) } // if URLs are provided, only use those if len(p.URLs) > 0 { if !excluded["urls"] { ret.URLs = &UpdateStrings{ Values: p.URLs, Mode: RelationshipUpdateModeSet, } } } else { urls := []string{} if p.URL != nil && !excluded["url"] { urls = append(urls, *p.URL) } if p.Twitter != nil && !excluded["twitter"] { urls = append(urls, *p.Twitter) } if p.Instagram != nil && !excluded["instagram"] { urls = append(urls, *p.Instagram) } if len(urls) > 0 { ret.URLs = &UpdateStrings{ Values: urls, Mode: RelationshipUpdateModeSet, } } } if p.RemoteSiteID != nil && endpoint != "" && *p.RemoteSiteID != "" { ret.StashIDs = &UpdateStashIDs{ StashIDs: existingStashIDs, Mode: RelationshipUpdateModeSet, } ret.StashIDs.Set(StashID{ Endpoint: endpoint, StashID: *p.RemoteSiteID, UpdatedAt: time.Now(), }) } return ret } type ScrapedTag struct { // Set if tag matched StoredID *string `json:"stored_id"` Name string `json:"name"` Description *string `json:"description"` AliasList []string `json:"alias_list"` RemoteSiteID *string `json:"remote_site_id"` Parent *ScrapedTag `json:"parent"` } func (ScrapedTag) IsScrapedContent() {} func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag { currentTime := time.Now() ret := NewTag() ret.Name = t.Name ret.ParentIDs = NewRelatedIDs([]int{}) ret.ChildIDs = NewRelatedIDs([]int{}) ret.Aliases = NewRelatedStrings([]string{}) if t.Description != nil && !excluded["description"] { ret.Description = *t.Description } if len(t.AliasList) > 0 && !excluded["aliases"] { ret.Aliases = NewRelatedStrings(t.AliasList) } if t.Parent != nil && t.Parent.StoredID != nil { parentID, err := strconv.Atoi(*t.Parent.StoredID) if err == nil && parentID > 0 { ret.ParentIDs = NewRelatedIDs([]int{parentID}) } } if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" { ret.StashIDs = NewRelatedStashIDs([]StashID{ { Endpoint: endpoint, StashID: *t.RemoteSiteID, UpdatedAt: currentTime, }, }) } return &ret } func (t *ScrapedTag) ToPartial(storedID string, endpoint string, excluded map[string]bool, existingStashIDs []StashID) TagPartial { ret := NewTagPartial() if t.Name != "" && !excluded["name"] { ret.Name = NewOptionalString(t.Name) } if t.Description != nil && !excluded["description"] { ret.Description = NewOptionalString(*t.Description) } if len(t.AliasList) > 0 && !excluded["aliases"] { ret.Aliases = &UpdateStrings{ Values: t.AliasList, Mode: RelationshipUpdateModeSet, } } if t.Parent != nil && t.Parent.StoredID != nil { parentID, err := strconv.Atoi(*t.Parent.StoredID) if err == nil && parentID > 0 { ret.ParentIDs = &UpdateIDs{ IDs: []int{parentID}, Mode: RelationshipUpdateModeAdd, } } } if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" { ret.StashIDs = &UpdateStashIDs{ StashIDs: existingStashIDs, Mode: RelationshipUpdateModeSet, } ret.StashIDs.Set(StashID{ Endpoint: endpoint, StashID: *t.RemoteSiteID, UpdatedAt: time.Now(), }) } return ret } func ScrapedTagSortFunction(a, b *ScrapedTag) int { return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) } // A movie from a scraping operation... type ScrapedMovie struct { StoredID *string `json:"stored_id"` Name *string `json:"name"` Aliases *string `json:"aliases"` Duration *string `json:"duration"` Date *string `json:"date"` Rating *string `json:"rating"` Director *string `json:"director"` URLs []string `json:"urls"` Synopsis *string `json:"synopsis"` Studio *ScrapedStudio `json:"studio"` Tags []*ScrapedTag `json:"tags"` // This should be a base64 encoded data URL FrontImage *string `json:"front_image"` // This should be a base64 encoded data URL BackImage *string `json:"back_image"` // deprecated URL *string `json:"url"` } func (ScrapedMovie) IsScrapedContent() {} func (m ScrapedMovie) ScrapedGroup() ScrapedGroup { ret := ScrapedGroup{ StoredID: m.StoredID, Name: m.Name, Aliases: m.Aliases, Duration: m.Duration, Date: m.Date, Rating: m.Rating, Director: m.Director, URLs: m.URLs, Synopsis: m.Synopsis, Studio: m.Studio, Tags: m.Tags, FrontImage: m.FrontImage, BackImage: m.BackImage, } if len(m.URLs) == 0 && m.URL != nil { ret.URLs = []string{*m.URL} } return ret } // ScrapedGroup is a group from a scraping operation type ScrapedGroup struct { StoredID *string `json:"stored_id"` Name *string `json:"name"` Aliases *string `json:"aliases"` Duration *string `json:"duration"` Date *string `json:"date"` Rating *string `json:"rating"` Director *string `json:"director"` URL *string `json:"url"` // included for backward compatibility URLs []string `json:"urls"` Synopsis *string `json:"synopsis"` Studio *ScrapedStudio `json:"studio"` Tags []*ScrapedTag `json:"tags"` // This should be a base64 encoded data URL FrontImage *string `json:"front_image"` // This should be a base64 encoded data URL BackImage *string `json:"back_image"` } func (ScrapedGroup) IsScrapedContent() {} func (g ScrapedGroup) ScrapedMovie() ScrapedMovie { ret := ScrapedMovie{ StoredID: g.StoredID, Name: g.Name, Aliases: g.Aliases, Duration: g.Duration, Date: g.Date, Rating: g.Rating, Director: g.Director, URLs: g.URLs, Synopsis: g.Synopsis, Studio: g.Studio, Tags: g.Tags, FrontImage: g.FrontImage, BackImage: g.BackImage, } if len(g.URLs) > 0 { ret.URL = &g.URLs[0] } return ret } type ScrapedScene struct { Title *string `json:"title"` Code *string `json:"code"` Details *string `json:"details"` Director *string `json:"director"` URL *string `json:"url"` URLs []string `json:"urls"` Date *string `json:"date"` // This should be a base64 encoded data URL Image *string `json:"image"` File *SceneFileType `json:"file"` Studio *ScrapedStudio `json:"studio"` Tags []*ScrapedTag `json:"tags"` Performers []*ScrapedPerformer `json:"performers"` Groups []*ScrapedGroup `json:"groups"` Movies []*ScrapedMovie `json:"movies"` RemoteSiteID *string `json:"remote_site_id"` Duration *int `json:"duration"` Fingerprints []*StashBoxFingerprint `json:"fingerprints"` } func (ScrapedScene) IsScrapedContent() {} type ScrapedSceneInput struct { Title *string `json:"title"` Code *string `json:"code"` Details *string `json:"details"` Director *string `json:"director"` URL *string `json:"url"` URLs []string `json:"urls"` Date *string `json:"date"` RemoteSiteID *string `json:"remote_site_id"` } type ScrapedImage struct { Title *string `json:"title"` Code *string `json:"code"` Details *string `json:"details"` Photographer *string `json:"photographer"` URLs []string `json:"urls"` Date *string `json:"date"` Studio *ScrapedStudio `json:"studio"` Tags []*ScrapedTag `json:"tags"` Performers []*ScrapedPerformer `json:"performers"` } func (ScrapedImage) IsScrapedContent() {} type ScrapedImageInput struct { Title *string `json:"title"` Code *string `json:"code"` Details *string `json:"details"` URLs []string `json:"urls"` Date *string `json:"date"` } type ScrapedGallery struct { Title *string `json:"title"` Code *string `json:"code"` Details *string `json:"details"` Photographer *string `json:"photographer"` URLs []string `json:"urls"` Date *string `json:"date"` Studio *ScrapedStudio `json:"studio"` Tags []*ScrapedTag `json:"tags"` Performers []*ScrapedPerformer `json:"performers"` // deprecated URL *string `json:"url"` } func (ScrapedGallery) IsScrapedContent() {} type ScrapedGalleryInput struct { Title *string `json:"title"` Code *string `json:"code"` Details *string `json:"details"` Photographer *string `json:"photographer"` URLs []string `json:"urls"` Date *string `json:"date"` // deprecated URL *string `json:"url"` } ================================================ FILE: pkg/models/model_scraped_item_test.go ================================================ package models import ( "strconv" "testing" "time" "github.com/stretchr/testify/assert" ) func Test_scrapedToStudioInput(t *testing.T) { const name = "name" url := "url" url2 := "url2" emptyEndpoint := "" endpoint := "endpoint" remoteSiteID := "remoteSiteID" tests := []struct { name string studio *ScrapedStudio endpoint string want *Studio }{ { "set all", &ScrapedStudio{ Name: name, URLs: []string{url, url2}, URL: &url, RemoteSiteID: &remoteSiteID, }, endpoint, &Studio{ Name: name, URLs: NewRelatedStrings([]string{url, url2}), StashIDs: NewRelatedStashIDs([]StashID{ { Endpoint: endpoint, StashID: remoteSiteID, }, }), }, }, { "set url instead of urls", &ScrapedStudio{ Name: name, URL: &url, RemoteSiteID: &remoteSiteID, }, endpoint, &Studio{ Name: name, URLs: NewRelatedStrings([]string{url}), StashIDs: NewRelatedStashIDs([]StashID{ { Endpoint: endpoint, StashID: remoteSiteID, }, }), }, }, { "set none", &ScrapedStudio{ Name: name, }, emptyEndpoint, &Studio{ Name: name, }, }, { "missing remoteSiteID", &ScrapedStudio{ Name: name, }, endpoint, &Studio{ Name: name, }, }, { "set stashid", &ScrapedStudio{ Name: name, RemoteSiteID: &remoteSiteID, }, endpoint, &Studio{ Name: name, StashIDs: NewRelatedStashIDs([]StashID{ { Endpoint: endpoint, StashID: remoteSiteID, }, }), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.studio.ToStudio(tt.endpoint, nil) assert.NotEqual(t, time.Time{}, got.CreatedAt) assert.NotEqual(t, time.Time{}, got.UpdatedAt) got.CreatedAt = time.Time{} got.UpdatedAt = time.Time{} if got.StashIDs.Loaded() && len(got.StashIDs.List()) > 0 { for stid := range got.StashIDs.List() { got.StashIDs.List()[stid].UpdatedAt = time.Time{} } } assert.Equal(t, tt.want, got.Studio) }) } } func Test_scrapedToPerformerInput(t *testing.T) { name := "name" emptyEndpoint := "" endpoint := "endpoint" remoteSiteID := "remoteSiteID" const nValues = 19 stringValues := make([]string, nValues) for i := 0; i < nValues; i++ { stringValues[i] = strconv.Itoa(i) } upTo := 0 nextVal := func() *string { ret := stringValues[upTo] upTo = (upTo + 1) % len(stringValues) return &ret } nextIntVal := func() *int { ret := upTo upTo = (upTo + 1) % len(stringValues) return &ret } dateFromInt := func(i int) *Date { t := time.Date(2001, 1, i, 0, 0, 0, 0, time.UTC) d := Date{Time: t} return &d } dateStrFromInt := func(i int) *string { s := dateFromInt(i).String() return &s } genderFromInt := func(i int) *GenderEnum { g := AllGenderEnum[i%len(AllGenderEnum)] return &g } genderStrFromInt := func(i int) *string { s := genderFromInt(i).String() return &s } tests := []struct { name string performer *ScrapedPerformer endpoint string want *Performer }{ { "set all", &ScrapedPerformer{ Name: &name, Disambiguation: nextVal(), Birthdate: dateStrFromInt(*nextIntVal()), DeathDate: dateStrFromInt(*nextIntVal()), Gender: genderStrFromInt(*nextIntVal()), Ethnicity: nextVal(), Country: nextVal(), EyeColor: nextVal(), HairColor: nextVal(), Height: nextVal(), Weight: nextVal(), Measurements: nextVal(), FakeTits: nextVal(), CareerStart: dateStrFromInt(2005), CareerEnd: dateStrFromInt(2015), Tattoos: nextVal(), Piercings: nextVal(), Aliases: nextVal(), URL: nextVal(), Twitter: nextVal(), Instagram: nextVal(), Details: nextVal(), RemoteSiteID: &remoteSiteID, }, endpoint, &Performer{ Name: name, Disambiguation: *nextVal(), Birthdate: dateFromInt(*nextIntVal()), DeathDate: dateFromInt(*nextIntVal()), Gender: genderFromInt(*nextIntVal()), Ethnicity: *nextVal(), Country: *nextVal(), EyeColor: *nextVal(), HairColor: *nextVal(), Height: nextIntVal(), Weight: nextIntVal(), Measurements: *nextVal(), FakeTits: *nextVal(), CareerStart: dateFromInt(2005), CareerEnd: dateFromInt(2015), Tattoos: *nextVal(), // skip CareerLength counter slot Piercings: *nextVal(), Aliases: NewRelatedStrings([]string{*nextVal()}), URLs: NewRelatedStrings([]string{*nextVal(), *nextVal(), *nextVal()}), Details: *nextVal(), StashIDs: NewRelatedStashIDs([]StashID{ { Endpoint: endpoint, StashID: remoteSiteID, }, }), }, }, { "set none", &ScrapedPerformer{ Name: &name, }, emptyEndpoint, &Performer{ Name: name, }, }, { "missing remoteSiteID", &ScrapedPerformer{ Name: &name, }, endpoint, &Performer{ Name: name, }, }, { "set stashid", &ScrapedPerformer{ Name: &name, RemoteSiteID: &remoteSiteID, }, endpoint, &Performer{ Name: name, StashIDs: NewRelatedStashIDs([]StashID{ { Endpoint: endpoint, StashID: remoteSiteID, }, }), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.performer.ToPerformer(tt.endpoint, nil) assert.NotEqual(t, time.Time{}, got.CreatedAt) assert.NotEqual(t, time.Time{}, got.UpdatedAt) got.CreatedAt = time.Time{} got.UpdatedAt = time.Time{} if got.StashIDs.Loaded() && len(got.StashIDs.List()) > 0 { for stid := range got.StashIDs.List() { got.StashIDs.List()[stid].UpdatedAt = time.Time{} } } assert.Equal(t, tt.want, got) }) } } func TestScrapedStudio_ToPartial(t *testing.T) { var ( id = 1000 idStr = strconv.Itoa(id) storedID = "storedID" parentStoredID = 2000 parentStoredIDStr = strconv.Itoa(parentStoredID) name = "name" url = "url" remoteSiteID = "remoteSiteID" endpoint = "endpoint" image = "image" images = []string{image} existingEndpoint = "existingEndpoint" existingStashID = StashID{"existingStashID", existingEndpoint, time.Time{}} existingStashIDs = []StashID{existingStashID} ) fullStudio := ScrapedStudio{ StoredID: &storedID, Name: name, URL: &url, Parent: &ScrapedStudio{ StoredID: &parentStoredIDStr, }, Image: &image, Images: images, RemoteSiteID: &remoteSiteID, } type args struct { id string endpoint string excluded map[string]bool existingStashIDs []StashID } stdArgs := args{ id: idStr, endpoint: endpoint, excluded: map[string]bool{}, existingStashIDs: existingStashIDs, } excludeAll := map[string]bool{ "name": true, "url": true, "parent": true, } tests := []struct { name string o ScrapedStudio args args want StudioPartial }{ { "full no exclusions", fullStudio, stdArgs, StudioPartial{ ID: id, Name: NewOptionalString(name), URLs: &UpdateStrings{ Values: []string{url}, Mode: RelationshipUpdateModeSet, }, ParentID: NewOptionalInt(parentStoredID), StashIDs: &UpdateStashIDs{ StashIDs: append(existingStashIDs, StashID{ Endpoint: endpoint, StashID: remoteSiteID, }), Mode: RelationshipUpdateModeSet, }, }, }, { "exclude all", fullStudio, args{ id: idStr, excluded: excludeAll, }, StudioPartial{ ID: id, }, }, { "overwrite stash id", fullStudio, args{ id: idStr, excluded: excludeAll, endpoint: existingEndpoint, existingStashIDs: existingStashIDs, }, StudioPartial{ ID: id, StashIDs: &UpdateStashIDs{ StashIDs: []StashID{{ Endpoint: existingEndpoint, StashID: remoteSiteID, }}, Mode: RelationshipUpdateModeSet, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := tt.o got := s.ToPartial(tt.args.id, tt.args.endpoint, tt.args.excluded, tt.args.existingStashIDs) // unset updatedAt - we don't need to compare it got.UpdatedAt = OptionalTime{} if got.StashIDs != nil && len(got.StashIDs.StashIDs) > 0 { for stid := range got.StashIDs.StashIDs { got.StashIDs.StashIDs[stid].UpdatedAt = time.Time{} } } assert.Equal(t, tt.want, got) }) } } ================================================ FILE: pkg/models/model_studio.go ================================================ package models import ( "context" "time" ) type Studio struct { ID int `json:"id"` Name string `json:"name"` ParentID *int `json:"parent_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` // Rating expressed in 1-100 scale Rating *int `json:"rating"` Favorite bool `json:"favorite"` Details string `json:"details"` IgnoreAutoTag bool `json:"ignore_auto_tag"` Organized bool `json:"organized"` Aliases RelatedStrings `json:"aliases"` URLs RelatedStrings `json:"urls"` TagIDs RelatedIDs `json:"tag_ids"` StashIDs RelatedStashIDs `json:"stash_ids"` } type CreateStudioInput struct { *Studio CustomFields map[string]interface{} `json:"custom_fields"` } type UpdateStudioInput struct { *Studio CustomFields CustomFieldsInput `json:"custom_fields"` } func NewStudio() Studio { currentTime := time.Now() return Studio{ CreatedAt: currentTime, UpdatedAt: currentTime, } } func NewCreateStudioInput() CreateStudioInput { s := NewStudio() return CreateStudioInput{ Studio: &s, } } // StudioPartial represents part of a Studio object. It is used to update the database entry. type StudioPartial struct { ID int Name OptionalString ParentID OptionalInt // Rating expressed in 1-100 scale Rating OptionalInt Favorite OptionalBool Details OptionalString CreatedAt OptionalTime UpdatedAt OptionalTime IgnoreAutoTag OptionalBool Organized OptionalBool Aliases *UpdateStrings URLs *UpdateStrings TagIDs *UpdateIDs StashIDs *UpdateStashIDs CustomFields CustomFieldsInput } func NewStudioPartial() StudioPartial { currentTime := time.Now() return StudioPartial{ UpdatedAt: NewOptionalTime(currentTime), } } func (s *Studio) LoadAliases(ctx context.Context, l AliasLoader) error { return s.Aliases.load(func() ([]string, error) { return l.GetAliases(ctx, s.ID) }) } func (s *Studio) LoadURLs(ctx context.Context, l URLLoader) error { return s.URLs.load(func() ([]string, error) { return l.GetURLs(ctx, s.ID) }) } func (s *Studio) LoadTagIDs(ctx context.Context, l TagIDLoader) error { return s.TagIDs.load(func() ([]int, error) { return l.GetTagIDs(ctx, s.ID) }) } func (s *Studio) LoadStashIDs(ctx context.Context, l StashIDLoader) error { return s.StashIDs.load(func() ([]StashID, error) { return l.GetStashIDs(ctx, s.ID) }) } func (s *Studio) LoadRelationships(ctx context.Context, l PerformerReader) error { if err := s.LoadAliases(ctx, l); err != nil { return err } if err := s.LoadTagIDs(ctx, l); err != nil { return err } if err := s.LoadStashIDs(ctx, l); err != nil { return err } return nil } ================================================ FILE: pkg/models/model_tag.go ================================================ package models import ( "context" "time" ) type Tag struct { ID int `json:"id"` Name string `json:"name"` SortName string `json:"sort_name"` Favorite bool `json:"favorite"` Description string `json:"description"` IgnoreAutoTag bool `json:"ignore_auto_tag"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Aliases RelatedStrings `json:"aliases"` ParentIDs RelatedIDs `json:"parent_ids"` ChildIDs RelatedIDs `json:"tag_ids"` StashIDs RelatedStashIDs `json:"stash_ids"` } func NewTag() Tag { currentTime := time.Now() return Tag{ CreatedAt: currentTime, UpdatedAt: currentTime, } } type CreateTagInput struct { *Tag CustomFields map[string]interface{} `json:"custom_fields"` } type UpdateTagInput struct { *Tag CustomFields CustomFieldsInput `json:"custom_fields"` } func (s *Tag) LoadAliases(ctx context.Context, l AliasLoader) error { return s.Aliases.load(func() ([]string, error) { return l.GetAliases(ctx, s.ID) }) } func (s *Tag) LoadParentIDs(ctx context.Context, l TagRelationLoader) error { return s.ParentIDs.load(func() ([]int, error) { return l.GetParentIDs(ctx, s.ID) }) } func (s *Tag) LoadChildIDs(ctx context.Context, l TagRelationLoader) error { return s.ChildIDs.load(func() ([]int, error) { return l.GetChildIDs(ctx, s.ID) }) } func (s *Tag) LoadStashIDs(ctx context.Context, l StashIDLoader) error { return s.StashIDs.load(func() ([]StashID, error) { return l.GetStashIDs(ctx, s.ID) }) } type TagPartial struct { Name OptionalString SortName OptionalString Description OptionalString Favorite OptionalBool IgnoreAutoTag OptionalBool CreatedAt OptionalTime UpdatedAt OptionalTime Aliases *UpdateStrings ParentIDs *UpdateIDs ChildIDs *UpdateIDs StashIDs *UpdateStashIDs CustomFields CustomFieldsInput } func NewTagPartial() TagPartial { currentTime := time.Now() return TagPartial{ UpdatedAt: NewOptionalTime(currentTime), } } type TagPath struct { Tag Path string `json:"path"` } ================================================ FILE: pkg/models/orientation.go ================================================ package models type OrientationEnum string const ( OrientationLandscape OrientationEnum = "LANDSCAPE" OrientationPortrait OrientationEnum = "PORTRAIT" OrientationSquare OrientationEnum = "SQUARE" ) func (e OrientationEnum) IsValid() bool { switch e { case OrientationLandscape, OrientationPortrait, OrientationSquare: return true } return false } ================================================ FILE: pkg/models/package.go ================================================ package models type PackageSpecInput struct { ID string `json:"id"` SourceURL string `json:"sourceURL"` } type PackageSource struct { Name *string `json:"name"` LocalPath string `json:"localPath"` URL string `json:"url"` } ================================================ FILE: pkg/models/paths/paths.go ================================================ // Package paths provides functions to return paths to various resources. package paths import ( "path/filepath" "github.com/stashapp/stash/pkg/fsutil" ) type Paths struct { Generated *generatedPaths Scene *scenePaths SceneMarkers *sceneMarkerPaths Blobs string } func NewPaths(generatedPath string, blobsPath string) Paths { p := Paths{} p.Generated = newGeneratedPaths(generatedPath) p.Scene = newScenePaths(p) p.SceneMarkers = newSceneMarkerPaths(p) p.Blobs = blobsPath return p } func GetStashHomeDirectory() string { return filepath.Join(fsutil.GetHomeDirectory(), ".stash") } func GetDefaultDatabaseFilePath() string { return filepath.Join(GetStashHomeDirectory(), "stash-go.sqlite") } ================================================ FILE: pkg/models/paths/paths_generated.go ================================================ package paths import ( "fmt" "os" "path/filepath" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" ) const thumbDirDepth int = 2 const thumbDirLength int = 2 // thumbDirDepth * thumbDirLength must be smaller than the length of checksum type generatedPaths struct { Screenshots string Thumbnails string Vtt string Markers string Transcodes string Downloads string Tmp string InteractiveHeatmap string } func newGeneratedPaths(path string) *generatedPaths { gp := generatedPaths{} gp.Screenshots = filepath.Join(path, "screenshots") gp.Thumbnails = filepath.Join(path, "thumbnails") gp.Vtt = filepath.Join(path, "vtt") gp.Markers = filepath.Join(path, "markers") gp.Transcodes = filepath.Join(path, "transcodes") gp.Downloads = filepath.Join(path, "download_stage") gp.Tmp = filepath.Join(path, "tmp") gp.InteractiveHeatmap = filepath.Join(path, "interactive_heatmaps") return &gp } func (gp *generatedPaths) GetTmpPath(fileName string) string { return filepath.Join(gp.Tmp, fileName) } // TempFile creates a temporary file using os.CreateTemp. // It is the equivalent of calling os.CreateTemp using Tmp and pattern. func (gp *generatedPaths) TempFile(pattern string) (*os.File, error) { if err := gp.EnsureTmpDir(); err != nil { logger.Warnf("Could not ensure existence of a temporary directory: %v", err) } return os.CreateTemp(gp.Tmp, pattern) } func (gp *generatedPaths) EnsureTmpDir() error { return fsutil.EnsureDir(gp.Tmp) } func (gp *generatedPaths) EmptyTmpDir() error { return fsutil.EmptyDir(gp.Tmp) } func (gp *generatedPaths) RemoveTmpDir() error { return fsutil.RemoveDir(gp.Tmp) } func (gp *generatedPaths) TempDir(pattern string) (string, error) { if err := gp.EnsureTmpDir(); err != nil { logger.Warnf("Could not ensure existence of a temporary directory: %v", err) } ret, err := os.MkdirTemp(gp.Tmp, pattern) if err != nil { return "", err } if err = fsutil.EmptyDir(ret); err != nil { logger.Warnf("could not recursively empty dir: %v", err) } return ret, nil } func (gp *generatedPaths) GetThumbnailPath(checksum string, width int) string { fname := fmt.Sprintf("%s_%d.jpg", checksum, width) return filepath.Join(gp.Thumbnails, fsutil.GetIntraDir(checksum, thumbDirDepth, thumbDirLength), fname) } func (gp *generatedPaths) GetClipPreviewPath(checksum string, width int) string { fname := fmt.Sprintf("%s_%d.webm", checksum, width) return filepath.Join(gp.Thumbnails, fsutil.GetIntraDir(checksum, thumbDirDepth, thumbDirLength), fname) } ================================================ FILE: pkg/models/paths/paths_json.go ================================================ package paths import ( "path/filepath" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" ) type JSONPaths struct { Metadata string ScrapedFile string Performers string Scenes string Images string Galleries string Studios string Tags string Groups string Files string SavedFilters string } func newJSONPaths(baseDir string) *JSONPaths { jp := JSONPaths{} jp.Metadata = baseDir jp.ScrapedFile = filepath.Join(baseDir, "scraped.json") jp.Performers = filepath.Join(baseDir, "performers") jp.Scenes = filepath.Join(baseDir, "scenes") jp.Images = filepath.Join(baseDir, "images") jp.Galleries = filepath.Join(baseDir, "galleries") jp.Studios = filepath.Join(baseDir, "studios") jp.Groups = filepath.Join(baseDir, "movies") jp.Tags = filepath.Join(baseDir, "tags") jp.Files = filepath.Join(baseDir, "files") jp.SavedFilters = filepath.Join(baseDir, "saved_filters") return &jp } func GetJSONPaths(baseDir string) *JSONPaths { jp := newJSONPaths(baseDir) return jp } func EmptyJSONDirs(baseDir string) { jsonPaths := GetJSONPaths(baseDir) _ = fsutil.EmptyDir(jsonPaths.Scenes) _ = fsutil.EmptyDir(jsonPaths.Images) _ = fsutil.EmptyDir(jsonPaths.Galleries) _ = fsutil.EmptyDir(jsonPaths.Performers) _ = fsutil.EmptyDir(jsonPaths.Studios) _ = fsutil.EmptyDir(jsonPaths.Groups) _ = fsutil.EmptyDir(jsonPaths.Tags) _ = fsutil.EmptyDir(jsonPaths.Files) _ = fsutil.EmptyDir(jsonPaths.SavedFilters) } func EnsureJSONDirs(baseDir string) { jsonPaths := GetJSONPaths(baseDir) if err := fsutil.EnsureDir(jsonPaths.Metadata); err != nil { logger.Warnf("couldn't create directories for Metadata: %v", err) } if err := fsutil.EnsureDir(jsonPaths.Scenes); err != nil { logger.Warnf("couldn't create directories for Scenes: %v", err) } if err := fsutil.EnsureDir(jsonPaths.Images); err != nil { logger.Warnf("couldn't create directories for Images: %v", err) } if err := fsutil.EnsureDir(jsonPaths.Galleries); err != nil { logger.Warnf("couldn't create directories for Galleries: %v", err) } if err := fsutil.EnsureDir(jsonPaths.Performers); err != nil { logger.Warnf("couldn't create directories for Performers: %v", err) } if err := fsutil.EnsureDir(jsonPaths.Studios); err != nil { logger.Warnf("couldn't create directories for Studios: %v", err) } if err := fsutil.EnsureDir(jsonPaths.Groups); err != nil { logger.Warnf("couldn't create directories for Groups: %v", err) } if err := fsutil.EnsureDir(jsonPaths.Tags); err != nil { logger.Warnf("couldn't create directories for Tags: %v", err) } if err := fsutil.EnsureDir(jsonPaths.Files); err != nil { logger.Warnf("couldn't create directories for Files: %v", err) } if err := fsutil.EnsureDir(jsonPaths.SavedFilters); err != nil { logger.Warnf("couldn't create directories for Saved Filters: %v", err) } } ================================================ FILE: pkg/models/paths/paths_scene_markers.go ================================================ package paths import ( "path/filepath" "strconv" ) type sceneMarkerPaths struct { generatedPaths } func newSceneMarkerPaths(p Paths) *sceneMarkerPaths { sp := sceneMarkerPaths{ generatedPaths: *p.Generated, } return &sp } func (sp *sceneMarkerPaths) GetFolderPath(checksum string) string { return filepath.Join(sp.Markers, checksum) } func (sp *sceneMarkerPaths) GetVideoPreviewPath(checksum string, seconds int) string { return filepath.Join(sp.GetFolderPath(checksum), strconv.Itoa(seconds)+".mp4") } func (sp *sceneMarkerPaths) GetWebpPreviewPath(checksum string, seconds int) string { return filepath.Join(sp.GetFolderPath(checksum), strconv.Itoa(seconds)+".webp") } func (sp *sceneMarkerPaths) GetScreenshotPath(checksum string, seconds int) string { return filepath.Join(sp.GetFolderPath(checksum), strconv.Itoa(seconds)+".jpg") } ================================================ FILE: pkg/models/paths/paths_scenes.go ================================================ package paths import ( "path/filepath" "github.com/stashapp/stash/pkg/fsutil" ) type scenePaths struct { generatedPaths } func newScenePaths(p Paths) *scenePaths { sp := scenePaths{ generatedPaths: *p.Generated, } return &sp } func (sp *scenePaths) GetLegacyScreenshotPath(checksum string) string { return filepath.Join(sp.Screenshots, checksum+".jpg") } func (sp *scenePaths) GetTranscodePath(checksum string) string { return filepath.Join(sp.Transcodes, checksum+".mp4") } func (sp *scenePaths) GetStreamPath(scenePath string, checksum string) string { transcodePath := sp.GetTranscodePath(checksum) transcodeExists, _ := fsutil.FileExists(transcodePath) if transcodeExists { return transcodePath } return scenePath } func (sp *scenePaths) GetVideoPreviewPath(checksum string) string { return filepath.Join(sp.Screenshots, checksum+".mp4") } func (sp *scenePaths) GetWebpPreviewPath(checksum string) string { return filepath.Join(sp.Screenshots, checksum+".webp") } func (sp *scenePaths) GetSpriteImageFilePath(checksum string) string { return filepath.Join(sp.Vtt, checksum+"_sprite.jpg") } func (sp *scenePaths) GetSpriteVttFilePath(checksum string) string { return filepath.Join(sp.Vtt, checksum+"_thumbs.vtt") } func (sp *scenePaths) GetInteractiveHeatmapPath(checksum string) string { return filepath.Join(sp.InteractiveHeatmap, checksum+".png") } ================================================ FILE: pkg/models/performer.go ================================================ package models import ( "fmt" "io" "strconv" ) type GenderEnum string const ( GenderEnumMale GenderEnum = "MALE" GenderEnumFemale GenderEnum = "FEMALE" GenderEnumTransgenderMale GenderEnum = "TRANSGENDER_MALE" GenderEnumTransgenderFemale GenderEnum = "TRANSGENDER_FEMALE" GenderEnumIntersex GenderEnum = "INTERSEX" GenderEnumNonBinary GenderEnum = "NON_BINARY" ) var AllGenderEnum = []GenderEnum{ GenderEnumMale, GenderEnumFemale, GenderEnumTransgenderMale, GenderEnumTransgenderFemale, GenderEnumIntersex, GenderEnumNonBinary, } func (e GenderEnum) IsValid() bool { switch e { case GenderEnumMale, GenderEnumFemale, GenderEnumTransgenderMale, GenderEnumTransgenderFemale, GenderEnumIntersex, GenderEnumNonBinary: return true } return false } func (e GenderEnum) String() string { return string(e) } func (e *GenderEnum) UnmarshalGQL(v interface{}) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = GenderEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid GenderEnum", str) } return nil } func (e GenderEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } type GenderCriterionInput struct { Value GenderEnum `json:"value"` ValueList []GenderEnum `json:"value_list"` Modifier CriterionModifier `json:"modifier"` } type CircumcisedEnum string const ( CircumcisedEnumCut CircumcisedEnum = "CUT" CircumcisedEnumUncut CircumcisedEnum = "UNCUT" ) var AllCircumcisionEnum = []CircumcisedEnum{ CircumcisedEnumCut, CircumcisedEnumUncut, } func (e CircumcisedEnum) IsValid() bool { switch e { case CircumcisedEnumCut, CircumcisedEnumUncut: return true } return false } func (e CircumcisedEnum) String() string { return string(e) } func (e *CircumcisedEnum) UnmarshalGQL(v interface{}) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = CircumcisedEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid CircumcisedEnum", str) } return nil } func (e CircumcisedEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } type CircumcisionCriterionInput struct { Value []CircumcisedEnum `json:"value"` Modifier CriterionModifier `json:"modifier"` } type PerformerFilterType struct { OperatorFilter[PerformerFilterType] Name *StringCriterionInput `json:"name"` Disambiguation *StringCriterionInput `json:"disambiguation"` Details *StringCriterionInput `json:"details"` // Filter by favorite FilterFavorites *bool `json:"filter_favorites"` // Filter by birth year BirthYear *IntCriterionInput `json:"birth_year"` // Filter by age Age *IntCriterionInput `json:"age"` // Filter by ethnicity Ethnicity *StringCriterionInput `json:"ethnicity"` // Filter by country Country *StringCriterionInput `json:"country"` // Filter by eye color EyeColor *StringCriterionInput `json:"eye_color"` // Filter by height - deprecated: use height_cm instead Height *StringCriterionInput `json:"height"` // Filter by height in centimeters HeightCm *IntCriterionInput `json:"height_cm"` // Filter by measurements Measurements *StringCriterionInput `json:"measurements"` // Filter by fake tits value FakeTits *StringCriterionInput `json:"fake_tits"` // Filter by penis length value PenisLength *FloatCriterionInput `json:"penis_length"` // Filter by circumcision Circumcised *CircumcisionCriterionInput `json:"circumcised"` // Filter by career length CareerLength *StringCriterionInput `json:"career_length"` // deprecated // Filter by career start year CareerStart *DateCriterionInput `json:"career_start"` // Filter by career end year CareerEnd *DateCriterionInput `json:"career_end"` // Filter by tattoos Tattoos *StringCriterionInput `json:"tattoos"` // Filter by piercings Piercings *StringCriterionInput `json:"piercings"` // Filter by aliases Aliases *StringCriterionInput `json:"aliases"` // Filter by gender Gender *GenderCriterionInput `json:"gender"` // Filter to only include performers missing this property IsMissing *string `json:"is_missing"` // Filter to only include performers with these tags Tags *HierarchicalMultiCriterionInput `json:"tags"` // Filter by tag count TagCount *IntCriterionInput `json:"tag_count"` // Filter by scene count SceneCount *IntCriterionInput `json:"scene_count"` // Filter by scene marker count (via scene) MarkerCount *IntCriterionInput `json:"marker_count"` // Filter by image count ImageCount *IntCriterionInput `json:"image_count"` // Filter by gallery count GalleryCount *IntCriterionInput `json:"gallery_count"` // Filter by play count PlayCount *IntCriterionInput `json:"play_count"` // Filter by O count OCounter *IntCriterionInput `json:"o_counter"` // Filter by StashID StashID *StringCriterionInput `json:"stash_id"` // Filter by StashID Endpoint StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"` // Filter by StashIDs Endpoint StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"` // Filter by rating expressed as 1-100 Rating100 *IntCriterionInput `json:"rating100"` // Filter by url URL *StringCriterionInput `json:"url"` // Filter by hair color HairColor *StringCriterionInput `json:"hair_color"` // Filter by weight Weight *IntCriterionInput `json:"weight"` // Filter by death year DeathYear *IntCriterionInput `json:"death_year"` // Filter by studios where performer appears in scene/image/gallery Studios *HierarchicalMultiCriterionInput `json:"studios"` // Filter by groups where performer appears in scene Groups *HierarchicalMultiCriterionInput `json:"groups"` // Filter by performers where performer appears with another performer in scene/image/gallery Performers *MultiCriterionInput `json:"performers"` // Filter by autotag ignore value IgnoreAutoTag *bool `json:"ignore_auto_tag"` // Filter by birthdate Birthdate *DateCriterionInput `json:"birth_date"` // Filter by death date DeathDate *DateCriterionInput `json:"death_date"` // Filter by related scenes that meet this criteria ScenesFilter *SceneFilterType `json:"scenes_filter"` // Filter by related images that meet this criteria ImagesFilter *ImageFilterType `json:"images_filter"` // Filter by related galleries that meet this criteria GalleriesFilter *GalleryFilterType `json:"galleries_filter"` // Filter by related tags that meet this criteria TagsFilter *TagFilterType `json:"tags_filter"` // Filter by related scene markers (via scene) that meet this criteria MarkersFilter *SceneMarkerFilterType `json:"markers_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at UpdatedAt *TimestampCriterionInput `json:"updated_at"` // Filter by custom fields CustomFields []CustomFieldCriterionInput `json:"custom_fields"` } type PerformerCreateInput struct { Name string `json:"name"` Disambiguation *string `json:"disambiguation"` URL *string `json:"url"` // deprecated Urls []string `json:"urls"` Gender *GenderEnum `json:"gender"` Birthdate *string `json:"birthdate"` Ethnicity *string `json:"ethnicity"` Country *string `json:"country"` EyeColor *string `json:"eye_color"` Height *string `json:"height"` HeightCm *int `json:"height_cm"` Measurements *string `json:"measurements"` FakeTits *string `json:"fake_tits"` PenisLength *float64 `json:"penis_length"` Circumcised *CircumcisedEnum `json:"circumcised"` CareerLength *string `json:"career_length"` CareerStart *string `json:"career_start"` CareerEnd *string `json:"career_end"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` Aliases *string `json:"aliases"` AliasList []string `json:"alias_list"` Twitter *string `json:"twitter"` // deprecated Instagram *string `json:"instagram"` // deprecated Favorite *bool `json:"favorite"` TagIds []string `json:"tag_ids"` // This should be a URL or a base64 encoded data URL Image *string `json:"image"` StashIds []StashIDInput `json:"stash_ids"` Rating100 *int `json:"rating100"` Details *string `json:"details"` DeathDate *string `json:"death_date"` HairColor *string `json:"hair_color"` Weight *int `json:"weight"` IgnoreAutoTag *bool `json:"ignore_auto_tag"` CustomFields map[string]interface{} `json:"custom_fields"` } type PerformerUpdateInput struct { ID string `json:"id"` Name *string `json:"name"` Disambiguation *string `json:"disambiguation"` URL *string `json:"url"` // deprecated Urls []string `json:"urls"` Gender *GenderEnum `json:"gender"` Birthdate *string `json:"birthdate"` Ethnicity *string `json:"ethnicity"` Country *string `json:"country"` EyeColor *string `json:"eye_color"` Height *string `json:"height"` HeightCm *int `json:"height_cm"` Measurements *string `json:"measurements"` FakeTits *string `json:"fake_tits"` PenisLength *float64 `json:"penis_length"` Circumcised *CircumcisedEnum `json:"circumcised"` CareerLength *string `json:"career_length"` CareerStart *string `json:"career_start"` CareerEnd *string `json:"career_end"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` Aliases *string `json:"aliases"` AliasList []string `json:"alias_list"` Twitter *string `json:"twitter"` // deprecated Instagram *string `json:"instagram"` // deprecated Favorite *bool `json:"favorite"` TagIds []string `json:"tag_ids"` // This should be a URL or a base64 encoded data URL Image *string `json:"image"` StashIds []StashIDInput `json:"stash_ids"` Rating100 *int `json:"rating100"` Details *string `json:"details"` DeathDate *string `json:"death_date"` HairColor *string `json:"hair_color"` Weight *int `json:"weight"` IgnoreAutoTag *bool `json:"ignore_auto_tag"` CustomFields CustomFieldsInput `json:"custom_fields"` } ================================================ FILE: pkg/models/query.go ================================================ package models type QueryOptions struct { FindFilter *FindFilterType Count bool } type QueryResult[T comparable] struct { IDs []T Count int } ================================================ FILE: pkg/models/rating.go ================================================ package models import ( "fmt" "io" "math" "strconv" ) type RatingSystem string const ( FiveStar = "FiveStar" FivePointFiveStar = "FivePointFiveStar" FivePointTwoFiveStar = "FivePointTwoFiveStar" // TenStar = "TenStar" // TenPointFiveStar = "TenPointFiveStar" // TenPointTwoFiveStar = "TenPointTwoFiveStar" TenPointDecimal = "TenPointDecimal" ) func (e RatingSystem) IsValid() bool { switch e { // case FiveStar, FivePointFiveStar, FivePointTwoFiveStar, TenStar, TenPointFiveStar, TenPointTwoFiveStar, TenPointDecimal: case FiveStar, FivePointFiveStar, FivePointTwoFiveStar, TenPointDecimal: return true } return false } func (e RatingSystem) String() string { return string(e) } func (e *RatingSystem) UnmarshalGQL(v interface{}) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = RatingSystem(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid RatingSystem", str) } return nil } func (e RatingSystem) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } const ( maxRating100 = 100 maxRating5 = 5 minRating5 = 1 minRating100 = 20 ) // Rating100To5 converts a 1-100 rating to a 1-5 rating. // Values <= 30 are converted to 1. Otherwise, rating is divided by 20 and rounded to the nearest integer. func Rating100To5(rating100 int) int { val := math.Round((float64(rating100) / 20)) return int(math.Max(minRating5, math.Min(maxRating5, val))) } // Rating5To100 converts a 1-5 rating to a 1-100 rating func Rating5To100(rating5 int) int { return int(math.Max(minRating100, math.Min(maxRating100, float64(rating5*20)))) } ================================================ FILE: pkg/models/rating_test.go ================================================ package models import ( "testing" ) func TestRating100To5(t *testing.T) { tests := []struct { name string rating100 int want int }{ {"20", 20, 1}, {"100", 100, 5}, {"1", 1, 1}, {"10", 10, 1}, {"11", 11, 1}, {"21", 21, 1}, {"31", 31, 2}, {"0", 0, 1}, {"-100", -100, 1}, {"120", 120, 5}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := Rating100To5(tt.rating100); got != tt.want { t.Errorf("Rating100To5() = %v, want %v", got, tt.want) } }) } } func TestRating5To100(t *testing.T) { tests := []struct { name string rating5 int want int }{ {"1", 1, 20}, {"5", 5, 100}, {"2", 2, 40}, {"3", 3, 60}, {"4", 4, 80}, {"6", 6, 100}, {"0", 0, 20}, {"-1", -1, 20}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := Rating5To100(tt.rating5); got != tt.want { t.Errorf("Rating5To100() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: pkg/models/relationships.go ================================================ package models import ( "context" "github.com/stashapp/stash/pkg/sliceutil" ) type SceneIDLoader interface { GetSceneIDs(ctx context.Context, relatedID int) ([]int, error) } type ImageIDLoader interface { GetImageIDs(ctx context.Context, relatedID int) ([]int, error) } type GalleryIDLoader interface { GetGalleryIDs(ctx context.Context, relatedID int) ([]int, error) } type PerformerIDLoader interface { GetPerformerIDs(ctx context.Context, relatedID int) ([]int, error) } type TagIDLoader interface { GetTagIDs(ctx context.Context, relatedID int) ([]int, error) } type TagRelationLoader interface { GetParentIDs(ctx context.Context, relatedID int) ([]int, error) GetChildIDs(ctx context.Context, relatedID int) ([]int, error) } type FileIDLoader interface { GetManyFileIDs(ctx context.Context, ids []int) ([][]FileID, error) } type SceneGroupLoader interface { GetGroups(ctx context.Context, id int) ([]GroupsScenes, error) } type ContainingGroupLoader interface { GetContainingGroupDescriptions(ctx context.Context, id int) ([]GroupIDDescription, error) } type SubGroupLoader interface { GetSubGroupDescriptions(ctx context.Context, id int) ([]GroupIDDescription, error) } type StashIDLoader interface { GetStashIDs(ctx context.Context, relatedID int) ([]StashID, error) } type VideoFileLoader interface { GetFiles(ctx context.Context, relatedID int) ([]*VideoFile, error) } type FileLoader interface { GetFiles(ctx context.Context, relatedID int) ([]File, error) } type AliasLoader interface { GetAliases(ctx context.Context, relatedID int) ([]string, error) } type URLLoader interface { GetURLs(ctx context.Context, relatedID int) ([]string, error) } // RelatedIDs represents a list of related IDs. // TODO - this can be made generic type RelatedIDs struct { list []int } // NewRelatedIDs returns a loaded RelatedIDs object with the provided IDs. // Loaded will return true when called on the returned object if the provided slice is not nil. func NewRelatedIDs(ids []int) RelatedIDs { return RelatedIDs{ list: ids, } } // Loaded returns true if the related IDs have been loaded. func (r RelatedIDs) Loaded() bool { return r.list != nil } func (r RelatedIDs) mustLoaded() { if !r.Loaded() { panic("list has not been loaded") } } // List returns the related IDs. Panics if the relationship has not been loaded. func (r RelatedIDs) List() []int { r.mustLoaded() return r.list } // Add adds the provided ids to the list. Panics if the relationship has not been loaded. func (r *RelatedIDs) Add(ids ...int) { r.mustLoaded() r.list = append(r.list, ids...) } func (r *RelatedIDs) load(fn func() ([]int, error)) error { if r.Loaded() { return nil } ids, err := fn() if err != nil { return err } if ids == nil { ids = []int{} } r.list = ids return nil } // RelatedGroups represents a list of related Groups. type RelatedGroups struct { list []GroupsScenes } // NewRelatedGroups returns a loaded RelateGroups object with the provided groups. // Loaded will return true when called on the returned object if the provided slice is not nil. func NewRelatedGroups(list []GroupsScenes) RelatedGroups { return RelatedGroups{ list: list, } } // Loaded returns true if the relationship has been loaded. func (r RelatedGroups) Loaded() bool { return r.list != nil } func (r RelatedGroups) mustLoaded() { if !r.Loaded() { panic("list has not been loaded") } } // List returns the related Groups. Panics if the relationship has not been loaded. func (r RelatedGroups) List() []GroupsScenes { r.mustLoaded() return r.list } // Add adds the provided ids to the list. Panics if the relationship has not been loaded. func (r *RelatedGroups) Add(groups ...GroupsScenes) { r.mustLoaded() r.list = append(r.list, groups...) } // ForID returns the GroupsScenes object for the given group ID. Returns nil if not found. func (r *RelatedGroups) ForID(id int) *GroupsScenes { r.mustLoaded() for _, v := range r.list { if v.GroupID == id { return &v } } return nil } func (r *RelatedGroups) load(fn func() ([]GroupsScenes, error)) error { if r.Loaded() { return nil } ids, err := fn() if err != nil { return err } if ids == nil { ids = []GroupsScenes{} } r.list = ids return nil } type RelatedGroupDescriptions struct { list []GroupIDDescription } // NewRelatedGroups returns a loaded RelateGroups object with the provided groups. // Loaded will return true when called on the returned object if the provided slice is not nil. func NewRelatedGroupDescriptions(list []GroupIDDescription) RelatedGroupDescriptions { return RelatedGroupDescriptions{ list: list, } } // Loaded returns true if the relationship has been loaded. func (r RelatedGroupDescriptions) Loaded() bool { return r.list != nil } func (r RelatedGroupDescriptions) mustLoaded() { if !r.Loaded() { panic("list has not been loaded") } } // List returns the related Groups. Panics if the relationship has not been loaded. func (r RelatedGroupDescriptions) List() []GroupIDDescription { r.mustLoaded() return r.list } // List returns the related Groups. Panics if the relationship has not been loaded. func (r RelatedGroupDescriptions) IDs() []int { r.mustLoaded() return sliceutil.Map(r.list, func(d GroupIDDescription) int { return d.GroupID }) } // Add adds the provided ids to the list. Panics if the relationship has not been loaded. func (r *RelatedGroupDescriptions) Add(groups ...GroupIDDescription) { r.mustLoaded() r.list = append(r.list, groups...) } // ForID returns the GroupsScenes object for the given group ID. Returns nil if not found. func (r *RelatedGroupDescriptions) ForID(id int) *GroupIDDescription { r.mustLoaded() for _, v := range r.list { if v.GroupID == id { return &v } } return nil } func (r *RelatedGroupDescriptions) load(fn func() ([]GroupIDDescription, error)) error { if r.Loaded() { return nil } ids, err := fn() if err != nil { return err } if ids == nil { ids = []GroupIDDescription{} } r.list = ids return nil } type RelatedStashIDs struct { list []StashID } // NewRelatedStashIDs returns a RelatedStashIDs object with the provided ids. // Loaded will return true when called on the returned object if the provided slice is not nil. func NewRelatedStashIDs(list []StashID) RelatedStashIDs { return RelatedStashIDs{ list: list, } } func (r RelatedStashIDs) mustLoaded() { if !r.Loaded() { panic("list has not been loaded") } } // Loaded returns true if the relationship has been loaded. func (r RelatedStashIDs) Loaded() bool { return r.list != nil } // List returns the related Stash IDs. Panics if the relationship has not been loaded. func (r RelatedStashIDs) List() []StashID { r.mustLoaded() return r.list } // ForID returns the StashID object for the given endpoint. Returns nil if not found. func (r *RelatedStashIDs) ForEndpoint(endpoint string) *StashID { r.mustLoaded() for _, v := range r.list { if v.Endpoint == endpoint { return &v } } return nil } func (r *RelatedStashIDs) load(fn func() ([]StashID, error)) error { if r.Loaded() { return nil } ids, err := fn() if err != nil { return err } if ids == nil { ids = []StashID{} } r.list = ids return nil } type RelatedVideoFiles struct { primaryFile *VideoFile files []*VideoFile primaryLoaded bool } func NewRelatedVideoFiles(files []*VideoFile) RelatedVideoFiles { ret := RelatedVideoFiles{ files: files, primaryLoaded: true, } if len(files) > 0 { ret.primaryFile = files[0] } return ret } func (r *RelatedVideoFiles) SetPrimary(f *VideoFile) { r.primaryFile = f r.primaryLoaded = true } func (r *RelatedVideoFiles) Set(f []*VideoFile) { r.files = f if len(r.files) > 0 { r.primaryFile = r.files[0] } r.primaryLoaded = true } // Loaded returns true if the relationship has been loaded. func (r RelatedVideoFiles) Loaded() bool { return r.files != nil } // Loaded returns true if the primary file relationship has been loaded. func (r RelatedVideoFiles) PrimaryLoaded() bool { return r.primaryLoaded } // List returns the related files. Panics if the relationship has not been loaded. func (r RelatedVideoFiles) List() []*VideoFile { if !r.Loaded() { panic("relationship has not been loaded") } return r.files } // Primary returns the primary file. Panics if the relationship has not been loaded. func (r RelatedVideoFiles) Primary() *VideoFile { if !r.PrimaryLoaded() { panic("relationship has not been loaded") } return r.primaryFile } func (r *RelatedVideoFiles) load(fn func() ([]*VideoFile, error)) error { if r.Loaded() { return nil } var err error r.files, err = fn() if err != nil { return err } if len(r.files) > 0 { r.primaryFile = r.files[0] } r.primaryLoaded = true return nil } func (r *RelatedVideoFiles) loadPrimary(fn func() (*VideoFile, error)) error { if r.PrimaryLoaded() { return nil } var err error r.primaryFile, err = fn() if err != nil { return err } r.primaryLoaded = true return nil } type RelatedFiles struct { primaryFile File files []File primaryLoaded bool } func NewRelatedFiles(files []File) RelatedFiles { ret := RelatedFiles{ files: files, primaryLoaded: true, } if len(files) > 0 { ret.primaryFile = files[0] } return ret } // Loaded returns true if the relationship has been loaded. func (r RelatedFiles) Loaded() bool { return r.files != nil } // Loaded returns true if the primary file relationship has been loaded. func (r RelatedFiles) PrimaryLoaded() bool { return r.primaryLoaded } // List returns the related files. Panics if the relationship has not been loaded. func (r RelatedFiles) List() []File { if !r.Loaded() { panic("relationship has not been loaded") } return r.files } // Primary returns the primary file. Panics if the relationship has not been loaded. func (r RelatedFiles) Primary() File { if !r.PrimaryLoaded() { panic("relationship has not been loaded") } return r.primaryFile } func (r *RelatedFiles) load(fn func() ([]File, error)) error { if r.Loaded() { return nil } var err error r.files, err = fn() if err != nil { return err } if len(r.files) > 0 { r.primaryFile = r.files[0] } r.primaryLoaded = true return nil } func (r *RelatedFiles) loadPrimary(fn func() (File, error)) error { if r.PrimaryLoaded() { return nil } var err error r.primaryFile, err = fn() if err != nil { return err } r.primaryLoaded = true return nil } // RelatedStrings represents a list of related strings. // TODO - this can be made generic type RelatedStrings struct { list []string } // NewRelatedStrings returns a loaded RelatedStrings object with the provided values. // Loaded will return true when called on the returned object if the provided slice is not nil. func NewRelatedStrings(values []string) RelatedStrings { return RelatedStrings{ list: values, } } // Loaded returns true if the related IDs have been loaded. func (r RelatedStrings) Loaded() bool { return r.list != nil } func (r RelatedStrings) mustLoaded() { if !r.Loaded() { panic("list has not been loaded") } } // List returns the related values. Panics if the relationship has not been loaded. func (r RelatedStrings) List() []string { r.mustLoaded() return r.list } // Add adds the provided values to the list. Panics if the relationship has not been loaded. func (r *RelatedStrings) Add(values ...string) { r.mustLoaded() r.list = append(r.list, values...) } func (r *RelatedStrings) load(fn func() ([]string, error)) error { if r.Loaded() { return nil } values, err := fn() if err != nil { return err } if values == nil { values = []string{} } r.list = values return nil } ================================================ FILE: pkg/models/repository.go ================================================ package models import ( "context" "github.com/stashapp/stash/pkg/txn" ) type TxnManager interface { txn.Manager txn.DatabaseProvider } type Repository struct { TxnManager TxnManager Blob BlobReader File FileReaderWriter Folder FolderReaderWriter Gallery GalleryReaderWriter GalleryChapter GalleryChapterReaderWriter Image ImageReaderWriter Group GroupReaderWriter Performer PerformerReaderWriter Scene SceneReaderWriter SceneMarker SceneMarkerReaderWriter Studio StudioReaderWriter Tag TagReaderWriter SavedFilter SavedFilterReaderWriter } func (r *Repository) WithTxn(ctx context.Context, fn txn.TxnFunc) error { return txn.WithTxn(ctx, r.TxnManager, fn) } func (r *Repository) WithReadTxn(ctx context.Context, fn txn.TxnFunc) error { return txn.WithReadTxn(ctx, r.TxnManager, fn) } func (r *Repository) WithDB(ctx context.Context, fn txn.TxnFunc) error { return txn.WithDatabase(ctx, r.TxnManager, fn) } ================================================ FILE: pkg/models/repository_blob.go ================================================ package models import "context" // BlobReader provides methods to get files by ID. type BlobReader interface { EntryExists(ctx context.Context, checksum string) (bool, error) } ================================================ FILE: pkg/models/repository_file.go ================================================ package models import ( "context" "io/fs" ) // FileGetter provides methods to get files by ID. type FileGetter interface { Find(ctx context.Context, id ...FileID) ([]File, error) } // FileFinder provides methods to find files. type FileFinder interface { FileGetter FindAllByPath(ctx context.Context, path string, caseSensitive bool) ([]File, error) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]File, error) FindByPath(ctx context.Context, path string, caseSensitive bool) (File, error) FindByFingerprint(ctx context.Context, fp Fingerprint) ([]File, error) FindByZipFileID(ctx context.Context, zipFileID FileID) ([]File, error) FindByFileInfo(ctx context.Context, info fs.FileInfo, size int64) ([]File, error) } // FileQueryer provides methods to query files. type FileQueryer interface { Query(ctx context.Context, options FileQueryOptions) (*FileQueryResult, error) } // FileCounter provides methods to count files. type FileCounter interface { CountAllInPaths(ctx context.Context, p []string) (int, error) CountByFolderID(ctx context.Context, folderID FolderID) (int, error) } // FileCreator provides methods to create files. type FileCreator interface { Create(ctx context.Context, f File) error } // FileUpdater provides methods to update files. type FileUpdater interface { Update(ctx context.Context, f File) error } // FileDestroyer provides methods to destroy files. type FileDestroyer interface { Destroy(ctx context.Context, id FileID) error } type FileFinderCreator interface { FileFinder FileCreator } type FileFinderUpdater interface { FileFinder FileUpdater } type FileFinderDestroyer interface { FileFinder FileDestroyer } // FileReader provides all methods to read files. type FileReader interface { FileFinder FileQueryer FileCounter GetCaptions(ctx context.Context, fileID FileID) ([]*VideoCaption, error) IsPrimary(ctx context.Context, fileID FileID) (bool, error) } type FileFingerprintWriter interface { ModifyFingerprints(ctx context.Context, fileID FileID, fingerprints []Fingerprint) error DestroyFingerprints(ctx context.Context, fileID FileID, types []string) error } // FileWriter provides all methods to modify files. type FileWriter interface { FileCreator FileUpdater FileDestroyer FileFingerprintWriter UpdateCaptions(ctx context.Context, fileID FileID, captions []*VideoCaption) error } // FileReaderWriter provides all file methods. type FileReaderWriter interface { FileReader FileWriter } ================================================ FILE: pkg/models/repository_folder.go ================================================ package models import "context" // FolderGetter provides methods to get folders by ID. type FolderGetter interface { Find(ctx context.Context, id FolderID) (*Folder, error) FindMany(ctx context.Context, id []FolderID) ([]*Folder, error) } // FolderFinder provides methods to find folders. type FolderFinder interface { FolderGetter FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]*Folder, error) FindByPath(ctx context.Context, path string, caseSensitive bool) (*Folder, error) FindByZipFileID(ctx context.Context, zipFileID FileID) ([]*Folder, error) FindByParentFolderID(ctx context.Context, parentFolderID FolderID) ([]*Folder, error) GetManyParentFolderIDs(ctx context.Context, folderIDs []FolderID) ([][]FolderID, error) } type FolderQueryer interface { Query(ctx context.Context, options FolderQueryOptions) (*FolderQueryResult, error) } type FolderCounter interface { CountAllInPaths(ctx context.Context, p []string) (int, error) } // FolderCreator provides methods to create folders. type FolderCreator interface { Create(ctx context.Context, f *Folder) error } // FolderUpdater provides methods to update folders. type FolderUpdater interface { Update(ctx context.Context, f *Folder) error } type FolderDestroyer interface { Destroy(ctx context.Context, id FolderID) error } type FolderFinderCreator interface { FolderFinder FolderCreator } type FolderFinderDestroyer interface { FolderFinder FolderDestroyer } // FolderReader provides all methods to read folders. type FolderReader interface { FolderFinder FolderQueryer FolderCounter } // FolderWriter provides all methods to modify folders. type FolderWriter interface { FolderCreator FolderUpdater FolderDestroyer } // FolderReaderWriter provides all folder methods. type FolderReaderWriter interface { FolderReader FolderWriter } ================================================ FILE: pkg/models/repository_gallery.go ================================================ package models import "context" // GalleryGetter provides methods to get galleries by ID. type GalleryGetter interface { // TODO - rename this to Find and remove existing method FindMany(ctx context.Context, ids []int) ([]*Gallery, error) Find(ctx context.Context, id int) (*Gallery, error) } // GalleryFinder provides methods to find galleries. type GalleryFinder interface { GalleryGetter FindByFingerprints(ctx context.Context, fp []Fingerprint) ([]*Gallery, error) FindByChecksum(ctx context.Context, checksum string) ([]*Gallery, error) FindByChecksums(ctx context.Context, checksums []string) ([]*Gallery, error) FindByPath(ctx context.Context, path string) ([]*Gallery, error) FindByFileID(ctx context.Context, fileID FileID) ([]*Gallery, error) FindByFolderID(ctx context.Context, folderID FolderID) ([]*Gallery, error) FindBySceneID(ctx context.Context, sceneID int) ([]*Gallery, error) FindByImageID(ctx context.Context, imageID int) ([]*Gallery, error) FindUserGalleryByTitle(ctx context.Context, title string) ([]*Gallery, error) } // GalleryQueryer provides methods to query galleries. type GalleryQueryer interface { Query(ctx context.Context, galleryFilter *GalleryFilterType, findFilter *FindFilterType) ([]*Gallery, int, error) QueryCount(ctx context.Context, galleryFilter *GalleryFilterType, findFilter *FindFilterType) (int, error) } // GalleryCounter provides methods to count galleries. type GalleryCounter interface { Count(ctx context.Context) (int, error) CountByFileID(ctx context.Context, fileID FileID) (int, error) } // GalleryCreator provides methods to create galleries. type GalleryCreator interface { Create(ctx context.Context, newGallery *CreateGalleryInput) error } // GalleryUpdater provides methods to update galleries. type GalleryUpdater interface { Update(ctx context.Context, updatedGallery *UpdateGalleryInput) error UpdatePartial(ctx context.Context, id int, updatedGallery GalleryPartial) (*Gallery, error) UpdateImages(ctx context.Context, galleryID int, imageIDs []int) error } // GalleryDestroyer provides methods to destroy galleries. type GalleryDestroyer interface { Destroy(ctx context.Context, id int) error } type GalleryCreatorUpdater interface { GalleryCreator GalleryUpdater } // GalleryReader provides all methods to read galleries. type GalleryReader interface { GalleryFinder GalleryQueryer GalleryCounter URLLoader FileIDLoader ImageIDLoader SceneIDLoader PerformerIDLoader TagIDLoader FileLoader CustomFieldsReader All(ctx context.Context) ([]*Gallery, error) } // GalleryWriter provides all methods to modify galleries. type GalleryWriter interface { GalleryCreator GalleryUpdater GalleryDestroyer CustomFieldsWriter AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error AddFileID(ctx context.Context, id int, fileID FileID) error AddImages(ctx context.Context, galleryID int, imageIDs ...int) error RemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error SetCover(ctx context.Context, galleryID int, coverImageID int) error ResetCover(ctx context.Context, galleryID int) error } // GalleryReaderWriter provides all gallery methods. type GalleryReaderWriter interface { GalleryReader GalleryWriter } ================================================ FILE: pkg/models/repository_gallery_chapter.go ================================================ package models import "context" // GalleryChapterGetter provides methods to get gallery chapters by ID. type GalleryChapterGetter interface { // TODO - rename this to Find and remove existing method FindMany(ctx context.Context, ids []int) ([]*GalleryChapter, error) Find(ctx context.Context, id int) (*GalleryChapter, error) } // GalleryChapterFinder provides methods to find gallery chapters. type GalleryChapterFinder interface { GalleryChapterGetter FindByGalleryID(ctx context.Context, galleryID int) ([]*GalleryChapter, error) } // GalleryChapterCreator provides methods to create gallery chapters. type GalleryChapterCreator interface { Create(ctx context.Context, newGalleryChapter *GalleryChapter) error } // GalleryChapterUpdater provides methods to update gallery chapters. type GalleryChapterUpdater interface { Update(ctx context.Context, updatedGalleryChapter *GalleryChapter) error UpdatePartial(ctx context.Context, id int, updatedGalleryChapter GalleryChapterPartial) (*GalleryChapter, error) } // GalleryChapterDestroyer provides methods to destroy gallery chapters. type GalleryChapterDestroyer interface { Destroy(ctx context.Context, id int) error } type GalleryChapterCreatorUpdater interface { GalleryChapterCreator GalleryChapterUpdater } // GalleryChapterReader provides all methods to read gallery chapters. type GalleryChapterReader interface { GalleryChapterFinder } // GalleryChapterWriter provides all methods to modify gallery chapters. type GalleryChapterWriter interface { GalleryChapterCreator GalleryChapterUpdater GalleryChapterDestroyer } // GalleryChapterReaderWriter provides all gallery chapter methods. type GalleryChapterReaderWriter interface { GalleryChapterReader GalleryChapterWriter } ================================================ FILE: pkg/models/repository_group.go ================================================ package models import "context" // GroupGetter provides methods to get groups by ID. type GroupGetter interface { // TODO - rename this to Find and remove existing method FindMany(ctx context.Context, ids []int) ([]*Group, error) Find(ctx context.Context, id int) (*Group, error) } // GroupFinder provides methods to find groups. type GroupFinder interface { GroupGetter FindByPerformerID(ctx context.Context, performerID int) ([]*Group, error) FindByStudioID(ctx context.Context, studioID int) ([]*Group, error) FindByName(ctx context.Context, name string, nocase bool) (*Group, error) FindByNames(ctx context.Context, names []string, nocase bool) ([]*Group, error) } // GroupQueryer provides methods to query groups. type GroupQueryer interface { Query(ctx context.Context, groupFilter *GroupFilterType, findFilter *FindFilterType) ([]*Group, int, error) QueryCount(ctx context.Context, groupFilter *GroupFilterType, findFilter *FindFilterType) (int, error) } // GroupCounter provides methods to count groups. type GroupCounter interface { Count(ctx context.Context) (int, error) CountByPerformerID(ctx context.Context, performerID int) (int, error) CountByStudioID(ctx context.Context, studioID int) (int, error) } // GroupCreator provides methods to create groups. type GroupCreator interface { Create(ctx context.Context, newGroup *Group) error } // GroupUpdater provides methods to update groups. type GroupUpdater interface { Update(ctx context.Context, updatedGroup *Group) error UpdatePartial(ctx context.Context, id int, updatedGroup GroupPartial) (*Group, error) UpdateFrontImage(ctx context.Context, groupID int, frontImage []byte) error UpdateBackImage(ctx context.Context, groupID int, backImage []byte) error } // GroupDestroyer provides methods to destroy groups. type GroupDestroyer interface { Destroy(ctx context.Context, id int) error } type GroupCreatorUpdater interface { GroupCreator GroupUpdater } type GroupFinderCreator interface { GroupFinder GroupCreator } // GroupReader provides all methods to read groups. type GroupReader interface { GroupFinder GroupQueryer GroupCounter URLLoader TagIDLoader ContainingGroupLoader SubGroupLoader CustomFieldsReader All(ctx context.Context) ([]*Group, error) GetFrontImage(ctx context.Context, groupID int) ([]byte, error) HasFrontImage(ctx context.Context, groupID int) (bool, error) GetBackImage(ctx context.Context, groupID int) ([]byte, error) HasBackImage(ctx context.Context, groupID int) (bool, error) } // GroupWriter provides all methods to modify groups. type GroupWriter interface { GroupCreator GroupUpdater GroupDestroyer CustomFieldsWriter } // GroupReaderWriter provides all group methods. type GroupReaderWriter interface { GroupReader GroupWriter } ================================================ FILE: pkg/models/repository_image.go ================================================ package models import "context" // ImageGetter provides methods to get images by ID. type ImageGetter interface { // TODO - rename this to Find and remove existing method FindMany(ctx context.Context, ids []int) ([]*Image, error) Find(ctx context.Context, id int) (*Image, error) } // ImageFinder provides methods to find images. type ImageFinder interface { ImageGetter FindByFingerprints(ctx context.Context, fp []Fingerprint) ([]*Image, error) FindByChecksum(ctx context.Context, checksum string) ([]*Image, error) FindByFileID(ctx context.Context, fileID FileID) ([]*Image, error) FindByFolderID(ctx context.Context, fileID FolderID) ([]*Image, error) FindByZipFileID(ctx context.Context, zipFileID FileID) ([]*Image, error) FindByGalleryID(ctx context.Context, galleryID int) ([]*Image, error) FindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*Image, error) } // ImageQueryer provides methods to query images. type ImageQueryer interface { Query(ctx context.Context, options ImageQueryOptions) (*ImageQueryResult, error) QueryCount(ctx context.Context, imageFilter *ImageFilterType, findFilter *FindFilterType) (int, error) } type GalleryCoverFinder interface { CoverByGalleryID(ctx context.Context, galleryId int) (*Image, error) } // ImageCounter provides methods to count images. type ImageCounter interface { Count(ctx context.Context) (int, error) CountByFileID(ctx context.Context, fileID FileID) (int, error) CountByGalleryID(ctx context.Context, galleryID int) (int, error) OCount(ctx context.Context) (int, error) OCountByPerformerID(ctx context.Context, performerID int) (int, error) OCountByStudioID(ctx context.Context, studioID int) (int, error) } // ImageCreator provides methods to create images. type ImageCreator interface { Create(ctx context.Context, newImage *CreateImageInput) error } // ImageUpdater provides methods to update images. type ImageUpdater interface { Update(ctx context.Context, updatedImage *Image) error UpdatePartial(ctx context.Context, id int, partial ImagePartial) (*Image, error) UpdatePerformers(ctx context.Context, imageID int, performerIDs []int) error UpdateTags(ctx context.Context, imageID int, tagIDs []int) error } // ImageDestroyer provides methods to destroy images. type ImageDestroyer interface { Destroy(ctx context.Context, id int) error } type ImageCreatorUpdater interface { ImageCreator ImageUpdater } // ImageReader provides all methods to read images. type ImageReader interface { ImageFinder ImageQueryer ImageCounter URLLoader FileIDLoader GalleryIDLoader PerformerIDLoader TagIDLoader FileLoader GalleryCoverFinder CustomFieldsReader All(ctx context.Context) ([]*Image, error) Size(ctx context.Context) (float64, error) } // ImageWriter provides all methods to modify images. type ImageWriter interface { ImageCreator ImageUpdater ImageDestroyer CustomFieldsWriter AddFileID(ctx context.Context, id int, fileID FileID) error RemoveFileID(ctx context.Context, id int, fileID FileID) error IncrementOCounter(ctx context.Context, id int) (int, error) DecrementOCounter(ctx context.Context, id int) (int, error) ResetOCounter(ctx context.Context, id int) (int, error) } // ImageReaderWriter provides all image methods. type ImageReaderWriter interface { ImageReader ImageWriter } ================================================ FILE: pkg/models/repository_performer.go ================================================ package models import "context" // PerformerGetter provides methods to get performers by ID. type PerformerGetter interface { // TODO - rename this to Find and remove existing method FindMany(ctx context.Context, ids []int) ([]*Performer, error) Find(ctx context.Context, id int) (*Performer, error) } // PerformerFinder provides methods to find performers. type PerformerFinder interface { PerformerGetter FindBySceneID(ctx context.Context, sceneID int) ([]*Performer, error) FindByImageID(ctx context.Context, imageID int) ([]*Performer, error) FindByGalleryID(ctx context.Context, galleryID int) ([]*Performer, error) FindByStashID(ctx context.Context, stashID StashID) ([]*Performer, error) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*Performer, error) FindByNames(ctx context.Context, names []string, nocase bool) ([]*Performer, error) } // PerformerQueryer provides methods to query performers. type PerformerQueryer interface { Query(ctx context.Context, performerFilter *PerformerFilterType, findFilter *FindFilterType) ([]*Performer, int, error) QueryCount(ctx context.Context, performerFilter *PerformerFilterType, findFilter *FindFilterType) (int, error) } type PerformerAutoTagQueryer interface { PerformerQueryer AliasLoader // TODO - this interface is temporary until the filter schema can fully // support the query needed QueryForAutoTag(ctx context.Context, words []string) ([]*Performer, error) } // PerformerCounter provides methods to count performers. type PerformerCounter interface { Count(ctx context.Context) (int, error) CountByTagID(ctx context.Context, tagID int) (int, error) } // PerformerCreator provides methods to create performers. type PerformerCreator interface { Create(ctx context.Context, newPerformer *CreatePerformerInput) error } // PerformerUpdater provides methods to update performers. type PerformerUpdater interface { Update(ctx context.Context, updatedPerformer *UpdatePerformerInput) error UpdatePartial(ctx context.Context, id int, updatedPerformer PerformerPartial) (*Performer, error) UpdateImage(ctx context.Context, performerID int, image []byte) error } // PerformerDestroyer provides methods to destroy performers. type PerformerDestroyer interface { Destroy(ctx context.Context, id int) error } type PerformerFinderCreator interface { PerformerFinder PerformerCreator } type PerformerCreatorUpdater interface { PerformerCreator PerformerUpdater } // PerformerReader provides all methods to read performers. type PerformerReader interface { PerformerFinder PerformerQueryer PerformerAutoTagQueryer PerformerCounter AliasLoader StashIDLoader TagIDLoader URLLoader CustomFieldsReader All(ctx context.Context) ([]*Performer, error) GetImage(ctx context.Context, performerID int) ([]byte, error) HasImage(ctx context.Context, performerID int) (bool, error) } // PerformerWriter provides all methods to modify performers. type PerformerWriter interface { PerformerCreator PerformerUpdater PerformerDestroyer Merge(ctx context.Context, source []int, destination int) error } // PerformerReaderWriter provides all performer methods. type PerformerReaderWriter interface { PerformerReader PerformerWriter } ================================================ FILE: pkg/models/repository_scene.go ================================================ package models import ( "context" "time" ) // SceneGetter provides methods to get scenes by ID. type SceneGetter interface { // TODO - rename this to Find and remove existing method FindMany(ctx context.Context, ids []int) ([]*Scene, error) Find(ctx context.Context, id int) (*Scene, error) // FindByIDs works the same way as FindMany, but it ignores any scenes not found // Scenes are not guaranteed to be in the same order as the input FindByIDs(ctx context.Context, ids []int) ([]*Scene, error) } // SceneFinder provides methods to find scenes. type SceneFinder interface { SceneGetter FindByFingerprints(ctx context.Context, fp []Fingerprint) ([]*Scene, error) FindByChecksum(ctx context.Context, checksum string) ([]*Scene, error) FindByOSHash(ctx context.Context, oshash string) ([]*Scene, error) FindByPath(ctx context.Context, path string) ([]*Scene, error) FindByFileID(ctx context.Context, fileID FileID) ([]*Scene, error) FindByPrimaryFileID(ctx context.Context, fileID FileID) ([]*Scene, error) FindByPerformerID(ctx context.Context, performerID int) ([]*Scene, error) FindByGalleryID(ctx context.Context, performerID int) ([]*Scene, error) FindByGroupID(ctx context.Context, groupID int) ([]*Scene, error) FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*Scene, error) } // SceneQueryer provides methods to query scenes. type SceneQueryer interface { Query(ctx context.Context, options SceneQueryOptions) (*SceneQueryResult, error) QueryCount(ctx context.Context, sceneFilter *SceneFilterType, findFilter *FindFilterType) (int, error) } // SceneCounter provides methods to count scenes. type SceneCounter interface { Count(ctx context.Context) (int, error) CountByPerformerID(ctx context.Context, performerID int) (int, error) CountByFileID(ctx context.Context, fileID FileID) (int, error) CountMissingChecksum(ctx context.Context) (int, error) CountMissingOSHash(ctx context.Context) (int, error) OCountByPerformerID(ctx context.Context, performerID int) (int, error) OCountByGroupID(ctx context.Context, groupID int) (int, error) OCountByStudioID(ctx context.Context, studioID int) (int, error) } // SceneCreator provides methods to create scenes. type SceneCreator interface { Create(ctx context.Context, newScene *Scene, fileIDs []FileID) error } // SceneUpdater provides methods to update scenes. type SceneUpdater interface { Update(ctx context.Context, updatedScene *Scene) error UpdatePartial(ctx context.Context, id int, updatedScene ScenePartial) (*Scene, error) UpdateCover(ctx context.Context, sceneID int, cover []byte) error } // SceneDestroyer provides methods to destroy scenes. type SceneDestroyer interface { Destroy(ctx context.Context, id int) error } type SceneCreatorUpdater interface { SceneCreator SceneUpdater } type ViewDateReader interface { CountViews(ctx context.Context, id int) (int, error) CountAllViews(ctx context.Context) (int, error) CountUniqueViews(ctx context.Context) (int, error) GetManyViewCount(ctx context.Context, ids []int) ([]int, error) GetViewDates(ctx context.Context, relatedID int) ([]time.Time, error) GetManyViewDates(ctx context.Context, ids []int) ([][]time.Time, error) GetManyLastViewed(ctx context.Context, ids []int) ([]*time.Time, error) } type ODateReader interface { GetOCount(ctx context.Context, id int) (int, error) GetManyOCount(ctx context.Context, ids []int) ([]int, error) GetAllOCount(ctx context.Context) (int, error) GetODates(ctx context.Context, relatedID int) ([]time.Time, error) GetManyODates(ctx context.Context, ids []int) ([][]time.Time, error) } // SceneReader provides all methods to read scenes. type SceneReader interface { SceneFinder SceneQueryer SceneCounter URLLoader ViewDateReader ODateReader FileIDLoader GalleryIDLoader PerformerIDLoader TagIDLoader SceneGroupLoader StashIDLoader VideoFileLoader CustomFieldsReader All(ctx context.Context) ([]*Scene, error) Wall(ctx context.Context, q *string) ([]*Scene, error) Size(ctx context.Context) (float64, error) Duration(ctx context.Context) (float64, error) PlayDuration(ctx context.Context) (float64, error) GetCover(ctx context.Context, sceneID int) ([]byte, error) HasCover(ctx context.Context, sceneID int) (bool, error) } type OHistoryWriter interface { AddO(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) DeleteO(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) ResetO(ctx context.Context, id int) (int, error) } type ViewHistoryWriter interface { AddViews(ctx context.Context, sceneID int, dates []time.Time) ([]time.Time, error) DeleteViews(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) DeleteAllViews(ctx context.Context, id int) (int, error) } // SceneWriter provides all methods to modify scenes. type SceneWriter interface { SceneCreator SceneUpdater SceneDestroyer AddFileID(ctx context.Context, id int, fileID FileID) error AddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error AssignFiles(ctx context.Context, sceneID int, fileID []FileID) error OHistoryWriter ViewHistoryWriter SaveActivity(ctx context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error) ResetActivity(ctx context.Context, sceneID int, resetResume bool, resetDuration bool) (bool, error) CustomFieldsWriter } // SceneReaderWriter provides all scene methods. type SceneReaderWriter interface { SceneReader SceneWriter } ================================================ FILE: pkg/models/repository_scene_marker.go ================================================ package models import "context" // SceneMarkerGetter provides methods to get scene markers by ID. type SceneMarkerGetter interface { // TODO - rename this to Find and remove existing method FindMany(ctx context.Context, ids []int) ([]*SceneMarker, error) Find(ctx context.Context, id int) (*SceneMarker, error) } // SceneMarkerFinder provides methods to find scene markers. type SceneMarkerFinder interface { SceneMarkerGetter FindBySceneID(ctx context.Context, sceneID int) ([]*SceneMarker, error) } // SceneMarkerQueryer provides methods to query scene markers. type SceneMarkerQueryer interface { Query(ctx context.Context, sceneMarkerFilter *SceneMarkerFilterType, findFilter *FindFilterType) ([]*SceneMarker, int, error) QueryCount(ctx context.Context, sceneMarkerFilter *SceneMarkerFilterType, findFilter *FindFilterType) (int, error) } // SceneMarkerCounter provides methods to count scene markers. type SceneMarkerCounter interface { Count(ctx context.Context) (int, error) CountByTagID(ctx context.Context, tagID int) (int, error) } // SceneMarkerCreator provides methods to create scene markers. type SceneMarkerCreator interface { Create(ctx context.Context, newSceneMarker *SceneMarker) error } // SceneMarkerUpdater provides methods to update scene markers. type SceneMarkerUpdater interface { Update(ctx context.Context, updatedSceneMarker *SceneMarker) error UpdatePartial(ctx context.Context, id int, updatedSceneMarker SceneMarkerPartial) (*SceneMarker, error) UpdateTags(ctx context.Context, markerID int, tagIDs []int) error } // SceneMarkerDestroyer provides methods to destroy scene markers. type SceneMarkerDestroyer interface { Destroy(ctx context.Context, id int) error } type SceneMarkerCreatorUpdater interface { SceneMarkerCreator SceneMarkerUpdater } // SceneMarkerReader provides all methods to read scene markers. type SceneMarkerReader interface { SceneMarkerFinder SceneMarkerQueryer SceneMarkerCounter TagIDLoader All(ctx context.Context) ([]*SceneMarker, error) Wall(ctx context.Context, q *string) ([]*SceneMarker, error) GetMarkerStrings(ctx context.Context, q *string, sort *string) ([]*MarkerStringsResultType, error) } // SceneMarkerWriter provides all methods to modify scene markers. type SceneMarkerWriter interface { SceneMarkerCreator SceneMarkerUpdater SceneMarkerDestroyer } // SceneMarkerReaderWriter provides all scene marker methods. type SceneMarkerReaderWriter interface { SceneMarkerReader SceneMarkerWriter } ================================================ FILE: pkg/models/repository_studio.go ================================================ package models import "context" // StudioGetter provides methods to get studios by ID. type StudioGetter interface { // TODO - rename this to Find and remove existing method FindMany(ctx context.Context, ids []int) ([]*Studio, error) Find(ctx context.Context, id int) (*Studio, error) } // StudioFinder provides methods to find studios. type StudioFinder interface { StudioGetter FindChildren(ctx context.Context, id int) ([]*Studio, error) FindBySceneID(ctx context.Context, sceneID int) (*Studio, error) FindByStashID(ctx context.Context, stashID StashID) ([]*Studio, error) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*Studio, error) FindByName(ctx context.Context, name string, nocase bool) (*Studio, error) } // StudioQueryer provides methods to query studios. type StudioQueryer interface { Query(ctx context.Context, studioFilter *StudioFilterType, findFilter *FindFilterType) ([]*Studio, int, error) QueryCount(ctx context.Context, studioFilter *StudioFilterType, findFilter *FindFilterType) (int, error) } type StudioAutoTagQueryer interface { StudioQueryer AliasLoader // TODO - this interface is temporary until the filter schema can fully // support the query needed QueryForAutoTag(ctx context.Context, words []string) ([]*Studio, error) } // StudioCounter provides methods to count studios. type StudioCounter interface { Count(ctx context.Context) (int, error) CountByTagID(ctx context.Context, tagID int) (int, error) } // StudioCreator provides methods to create studios. type StudioCreator interface { Create(ctx context.Context, newStudio *CreateStudioInput) error } // StudioUpdater provides methods to update studios. type StudioUpdater interface { Update(ctx context.Context, updatedStudio *UpdateStudioInput) error UpdatePartial(ctx context.Context, updatedStudio StudioPartial) (*Studio, error) UpdateImage(ctx context.Context, studioID int, image []byte) error } // StudioDestroyer provides methods to destroy studios. type StudioDestroyer interface { Destroy(ctx context.Context, id int) error } type StudioFinderCreator interface { StudioFinder StudioCreator } type StudioCreatorUpdater interface { StudioCreator StudioUpdater } // StudioReader provides all methods to read studios. type StudioReader interface { StudioFinder StudioQueryer StudioAutoTagQueryer StudioCounter AliasLoader StashIDLoader TagIDLoader URLLoader CustomFieldsReader All(ctx context.Context) ([]*Studio, error) GetImage(ctx context.Context, studioID int) ([]byte, error) HasImage(ctx context.Context, studioID int) (bool, error) } // StudioWriter provides all methods to modify studios. type StudioWriter interface { StudioCreator StudioUpdater StudioDestroyer } // StudioReaderWriter provides all studio methods. type StudioReaderWriter interface { StudioReader StudioWriter } ================================================ FILE: pkg/models/repository_tag.go ================================================ package models import "context" // TagGetter provides methods to get tags by ID. type TagGetter interface { // TODO - rename this to Find and remove existing method FindMany(ctx context.Context, ids []int) ([]*Tag, error) Find(ctx context.Context, id int) (*Tag, error) } // TagFinder provides methods to find tags. type TagFinder interface { TagGetter FindAllAncestors(ctx context.Context, tagID int, excludeIDs []int) ([]*TagPath, error) FindAllDescendants(ctx context.Context, tagID int, excludeIDs []int) ([]*TagPath, error) FindByParentTagID(ctx context.Context, parentID int) ([]*Tag, error) FindByChildTagID(ctx context.Context, childID int) ([]*Tag, error) FindBySceneID(ctx context.Context, sceneID int) ([]*Tag, error) FindByImageID(ctx context.Context, imageID int) ([]*Tag, error) FindByGalleryID(ctx context.Context, galleryID int) ([]*Tag, error) FindByPerformerID(ctx context.Context, performerID int) ([]*Tag, error) FindByGroupID(ctx context.Context, groupID int) ([]*Tag, error) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*Tag, error) FindByStudioID(ctx context.Context, studioID int) ([]*Tag, error) FindByName(ctx context.Context, name string, nocase bool) (*Tag, error) FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error) FindByStashID(ctx context.Context, stashID StashID) ([]*Tag, error) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*Tag, error) } // TagQueryer provides methods to query tags. type TagQueryer interface { Query(ctx context.Context, tagFilter *TagFilterType, findFilter *FindFilterType) ([]*Tag, int, error) } type TagAutoTagQueryer interface { TagQueryer AliasLoader // TODO - this interface is temporary until the filter schema can fully // support the query needed QueryForAutoTag(ctx context.Context, words []string) ([]*Tag, error) } // TagCounter provides methods to count tags. type TagCounter interface { Count(ctx context.Context) (int, error) CountByParentTagID(ctx context.Context, parentID int) (int, error) CountByChildTagID(ctx context.Context, childID int) (int, error) } // TagCreator provides methods to create tags. type TagCreator interface { Create(ctx context.Context, newTag *CreateTagInput) error } // TagUpdater provides methods to update tags. type TagUpdater interface { Update(ctx context.Context, updatedTag *UpdateTagInput) error UpdatePartial(ctx context.Context, id int, updateTag TagPartial) (*Tag, error) UpdateAliases(ctx context.Context, tagID int, aliases []string) error UpdateImage(ctx context.Context, tagID int, image []byte) error UpdateParentTags(ctx context.Context, tagID int, parentIDs []int) error UpdateChildTags(ctx context.Context, tagID int, parentIDs []int) error } // TagDestroyer provides methods to destroy tags. type TagDestroyer interface { Destroy(ctx context.Context, id int) error } type TagFinderCreator interface { TagFinder TagCreator } type TagCreatorUpdater interface { TagCreator TagUpdater CustomFieldsWriter } // TagReader provides all methods to read tags. type TagReader interface { TagFinder TagQueryer TagAutoTagQueryer TagCounter AliasLoader TagRelationLoader StashIDLoader CustomFieldsReader All(ctx context.Context) ([]*Tag, error) GetImage(ctx context.Context, tagID int) ([]byte, error) HasImage(ctx context.Context, tagID int) (bool, error) } // TagWriter provides all methods to modify tags. type TagWriter interface { TagCreator TagUpdater TagDestroyer CustomFieldsWriter Merge(ctx context.Context, source []int, destination int) error } // TagReaderWriter provides all tags methods. type TagReaderWriter interface { TagReader TagWriter } ================================================ FILE: pkg/models/resolution.go ================================================ package models import ( "fmt" "io" "strconv" ) type ResolutionRange struct { min, max int } var resolutionRanges = map[ResolutionEnum]ResolutionRange{ ResolutionEnum("VERY_LOW"): {144, 239}, ResolutionEnum("LOW"): {240, 359}, ResolutionEnum("R360P"): {360, 479}, ResolutionEnum("STANDARD"): {480, 539}, ResolutionEnum("WEB_HD"): {540, 719}, ResolutionEnum("STANDARD_HD"): {720, 1079}, ResolutionEnum("FULL_HD"): {1080, 1439}, ResolutionEnum("QUAD_HD"): {1440, 1919}, ResolutionEnum("VR_HD"): {1920, 2159}, ResolutionEnum("FOUR_K"): {1920, 2559}, ResolutionEnum("FIVE_K"): {2560, 2999}, ResolutionEnum("SIX_K"): {3000, 3583}, ResolutionEnum("SEVEN_K"): {3584, 3839}, ResolutionEnum("EIGHT_K"): {3840, 6143}, ResolutionEnum("HUGE"): {6144, 9999}, } type ResolutionEnum string const ( // 144p ResolutionEnumVeryLow ResolutionEnum = "VERY_LOW" // 240p ResolutionEnumLow ResolutionEnum = "LOW" // 360p ResolutionEnumR360p ResolutionEnum = "R360P" // 480p ResolutionEnumStandard ResolutionEnum = "STANDARD" // 540p ResolutionEnumWebHd ResolutionEnum = "WEB_HD" // 720p ResolutionEnumStandardHd ResolutionEnum = "STANDARD_HD" // 1080p ResolutionEnumFullHd ResolutionEnum = "FULL_HD" // 1440p ResolutionEnumQuadHd ResolutionEnum = "QUAD_HD" // 1920p - deprecated ResolutionEnumVrHd ResolutionEnum = "VR_HD" // 4k ResolutionEnumFourK ResolutionEnum = "FOUR_K" // 5k ResolutionEnumFiveK ResolutionEnum = "FIVE_K" // 6k ResolutionEnumSixK ResolutionEnum = "SIX_K" // 7k ResolutionEnumSevenK ResolutionEnum = "SEVEN_K" // 8k ResolutionEnumEightK ResolutionEnum = "EIGHT_K" // 8K+ ResolutionEnumHuge ResolutionEnum = "HUGE" ) var AllResolutionEnum = []ResolutionEnum{ ResolutionEnumVeryLow, ResolutionEnumLow, ResolutionEnumR360p, ResolutionEnumStandard, ResolutionEnumWebHd, ResolutionEnumStandardHd, ResolutionEnumFullHd, ResolutionEnumQuadHd, ResolutionEnumVrHd, ResolutionEnumFourK, ResolutionEnumFiveK, ResolutionEnumSixK, ResolutionEnumSevenK, ResolutionEnumEightK, ResolutionEnumHuge, } func (e ResolutionEnum) IsValid() bool { switch e { case ResolutionEnumVeryLow, ResolutionEnumLow, ResolutionEnumR360p, ResolutionEnumStandard, ResolutionEnumWebHd, ResolutionEnumStandardHd, ResolutionEnumFullHd, ResolutionEnumQuadHd, ResolutionEnumVrHd, ResolutionEnumFourK, ResolutionEnumFiveK, ResolutionEnumSixK, ResolutionEnumSevenK, ResolutionEnumEightK, ResolutionEnumHuge: return true } return false } func (e ResolutionEnum) String() string { return string(e) } func (e *ResolutionEnum) UnmarshalGQL(v interface{}) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = ResolutionEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid ResolutionEnum", str) } return nil } func (e ResolutionEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } // GetMaxResolution returns the maximum width or height that media must be // to qualify as this resolution. func (e *ResolutionEnum) GetMaxResolution() int { return resolutionRanges[*e].max } // GetMinResolution returns the minimum width or height that media must be // to qualify as this resolution. func (e *ResolutionEnum) GetMinResolution() int { return resolutionRanges[*e].min } type StreamingResolutionEnum string const ( // 240p StreamingResolutionEnumLow StreamingResolutionEnum = "LOW" // 480p StreamingResolutionEnumStandard StreamingResolutionEnum = "STANDARD" // 720p StreamingResolutionEnumStandardHd StreamingResolutionEnum = "STANDARD_HD" // 1080p StreamingResolutionEnumFullHd StreamingResolutionEnum = "FULL_HD" // 4k StreamingResolutionEnumFourK StreamingResolutionEnum = "FOUR_K" // Original StreamingResolutionEnumOriginal StreamingResolutionEnum = "ORIGINAL" ) var AllStreamingResolutionEnum = []StreamingResolutionEnum{ StreamingResolutionEnumLow, StreamingResolutionEnumStandard, StreamingResolutionEnumStandardHd, StreamingResolutionEnumFullHd, StreamingResolutionEnumFourK, StreamingResolutionEnumOriginal, } func (e StreamingResolutionEnum) IsValid() bool { switch e { case StreamingResolutionEnumLow, StreamingResolutionEnumStandard, StreamingResolutionEnumStandardHd, StreamingResolutionEnumFullHd, StreamingResolutionEnumFourK, StreamingResolutionEnumOriginal: return true } return false } func (e StreamingResolutionEnum) String() string { return string(e) } func (e *StreamingResolutionEnum) UnmarshalGQL(v interface{}) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = StreamingResolutionEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid StreamingResolutionEnum", str) } return nil } func (e StreamingResolutionEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } var streamingResolutionMax = map[StreamingResolutionEnum]int{ StreamingResolutionEnumLow: resolutionRanges[ResolutionEnumLow].min, StreamingResolutionEnumStandard: resolutionRanges[ResolutionEnumStandard].min, StreamingResolutionEnumStandardHd: resolutionRanges[ResolutionEnumStandardHd].min, StreamingResolutionEnumFullHd: resolutionRanges[ResolutionEnumFullHd].min, StreamingResolutionEnumFourK: resolutionRanges[ResolutionEnumFourK].min, StreamingResolutionEnumOriginal: 0, } func (e StreamingResolutionEnum) GetMaxResolution() int { return streamingResolutionMax[e] } ================================================ FILE: pkg/models/saved_filter.go ================================================ package models import "context" type SavedFilterReader interface { All(ctx context.Context) ([]*SavedFilter, error) Find(ctx context.Context, id int) (*SavedFilter, error) FindMany(ctx context.Context, ids []int, ignoreNotFound bool) ([]*SavedFilter, error) FindByMode(ctx context.Context, mode FilterMode) ([]*SavedFilter, error) } type SavedFilterWriter interface { Create(ctx context.Context, obj *SavedFilter) error Update(ctx context.Context, obj *SavedFilter) error Destroy(ctx context.Context, id int) error } type SavedFilterReaderWriter interface { SavedFilterReader SavedFilterWriter } ================================================ FILE: pkg/models/scene.go ================================================ package models import "context" type DuplicationCriterionInput struct { // Deprecated: Use Phash field instead. Kept for backwards compatibility. Duplicated *bool `json:"duplicated"` // Currently unimplemented. Intended for phash distance matching. Distance *int `json:"distance"` // Filter by phash duplication Phash *bool `json:"phash"` // Filter by URL duplication URL *bool `json:"url"` // Filter by Stash ID duplication StashID *bool `json:"stash_id"` // Filter by title duplication Title *bool `json:"title"` } type FileDuplicationCriterionInput struct { // Deprecated: Use Phash field instead. Kept for backwards compatibility. Duplicated *bool `json:"duplicated"` // Currently unimplemented. Intended for phash distance matching. Distance *int `json:"distance"` // Filter by phash duplication Phash *bool `json:"phash"` } type SceneFilterType struct { OperatorFilter[SceneFilterType] ID *IntCriterionInput `json:"id"` Title *StringCriterionInput `json:"title"` Code *StringCriterionInput `json:"code"` Details *StringCriterionInput `json:"details"` Director *StringCriterionInput `json:"director"` // Filter by file oshash Oshash *StringCriterionInput `json:"oshash"` // Filter by file checksum Checksum *StringCriterionInput `json:"checksum"` // Filter by file phash Phash *StringCriterionInput `json:"phash"` // Filter by phash distance PhashDistance *PhashDistanceCriterionInput `json:"phash_distance"` // Filter by path Path *StringCriterionInput `json:"path"` // Filter by file count FileCount *IntCriterionInput `json:"file_count"` // Filter by rating expressed as 1-100 Rating100 *IntCriterionInput `json:"rating100"` // Filter by organized Organized *bool `json:"organized"` // Filter by o-counter OCounter *IntCriterionInput `json:"o_counter"` // Filter Scenes by duplication criteria Duplicated *DuplicationCriterionInput `json:"duplicated"` // Filter by resolution Resolution *ResolutionCriterionInput `json:"resolution"` // Filter by orientation Orientation *OrientationCriterionInput `json:"orientation"` // Filter by framerate Framerate *IntCriterionInput `json:"framerate"` // Filter by bitrate Bitrate *IntCriterionInput `json:"bitrate"` // Filter by video codec VideoCodec *StringCriterionInput `json:"video_codec"` // Filter by audio codec AudioCodec *StringCriterionInput `json:"audio_codec"` // Filter by duration (in seconds) Duration *IntCriterionInput `json:"duration"` // Filter to only include scenes which have markers. `true` or `false` HasMarkers *string `json:"has_markers"` // Filter to only include scenes missing this property IsMissing *string `json:"is_missing"` // Filter to only include scenes with this studio Studios *HierarchicalMultiCriterionInput `json:"studios"` // Filter to only include scenes with this group Groups *HierarchicalMultiCriterionInput `json:"groups"` // Filter to only include scenes with this movie Movies *MultiCriterionInput `json:"movies"` // Filter to only include scenes with this gallery Galleries *MultiCriterionInput `json:"galleries"` // Filter to only include scenes with these tags Tags *HierarchicalMultiCriterionInput `json:"tags"` // Filter by tag count TagCount *IntCriterionInput `json:"tag_count"` // Filter to only include scenes with performers with these tags PerformerTags *HierarchicalMultiCriterionInput `json:"performer_tags"` // Filter scenes that have performers that have been favorited PerformerFavorite *bool `json:"performer_favorite"` // Filter scenes by performer age at time of scene PerformerAge *IntCriterionInput `json:"performer_age"` // Filter to only include scenes with these performers Performers *MultiCriterionInput `json:"performers"` // Filter by performer count PerformerCount *IntCriterionInput `json:"performer_count"` // Filter by StashID StashID *StringCriterionInput `json:"stash_id"` // Filter by StashID Endpoint StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"` // Filter by StashIDs Endpoint StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"` // Filter by StashID count StashIDCount *IntCriterionInput `json:"stash_id_count"` // Filter by url URL *StringCriterionInput `json:"url"` // Filter by interactive Interactive *bool `json:"interactive"` // Filter by InteractiveSpeed InteractiveSpeed *IntCriterionInput `json:"interactive_speed"` // Filter by captions Captions *StringCriterionInput `json:"captions"` // Filter by resume time ResumeTime *IntCriterionInput `json:"resume_time"` // Filter by play count PlayCount *IntCriterionInput `json:"play_count"` // Filter by play duration (in seconds) PlayDuration *IntCriterionInput `json:"play_duration"` // Filter by last played at LastPlayedAt *TimestampCriterionInput `json:"last_played_at"` // Filter by date Date *DateCriterionInput `json:"date"` // Filter by related galleries that meet this criteria GalleriesFilter *GalleryFilterType `json:"galleries_filter"` // Filter by related performers that meet this criteria PerformersFilter *PerformerFilterType `json:"performers_filter"` // Filter by related studios that meet this criteria StudiosFilter *StudioFilterType `json:"studios_filter"` // Filter by related tags that meet this criteria TagsFilter *TagFilterType `json:"tags_filter"` // Filter by related groups that meet this criteria GroupsFilter *GroupFilterType `json:"groups_filter"` // Filter by related movies that meet this criteria MoviesFilter *GroupFilterType `json:"movies_filter"` // Filter by related markers that meet this criteria MarkersFilter *SceneMarkerFilterType `json:"markers_filter"` // Filter by related files that meet this criteria FilesFilter *FileFilterType `json:"files_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at UpdatedAt *TimestampCriterionInput `json:"updated_at"` // Filter by custom fields CustomFields []CustomFieldCriterionInput `json:"custom_fields"` } type SceneQueryOptions struct { QueryOptions SceneFilter *SceneFilterType TotalDuration bool TotalSize bool } type SceneQueryResult struct { QueryResult[int] TotalDuration float64 TotalSize float64 getter SceneGetter scenes []*Scene resolveErr error } // SceneMovieInput is used for groups and movies type SceneMovieInput struct { MovieID string `json:"movie_id"` SceneIndex *int `json:"scene_index"` } type SceneGroupInput struct { GroupID string `json:"group_id"` SceneIndex *int `json:"scene_index"` } type SceneCreateInput struct { Title *string `json:"title"` Code *string `json:"code"` Details *string `json:"details"` Director *string `json:"director"` URL *string `json:"url"` Urls []string `json:"urls"` Date *string `json:"date"` Rating100 *int `json:"rating100"` Organized *bool `json:"organized"` StudioID *string `json:"studio_id"` GalleryIds []string `json:"gallery_ids"` PerformerIds []string `json:"performer_ids"` Movies []SceneMovieInput `json:"movies"` Groups []SceneGroupInput `json:"groups"` TagIds []string `json:"tag_ids"` // This should be a URL or a base64 encoded data URL CoverImage *string `json:"cover_image"` StashIds []StashIDInput `json:"stash_ids"` // The first id will be assigned as primary. // Files will be reassigned from existing scenes if applicable. // Files must not already be primary for another scene. FileIds []string `json:"file_ids"` CustomFields map[string]any `json:"custom_fields,omitempty"` } type SceneUpdateInput struct { ClientMutationID *string `json:"clientMutationId"` ID string `json:"id"` Title *string `json:"title"` Code *string `json:"code"` Details *string `json:"details"` Director *string `json:"director"` URL *string `json:"url"` Urls []string `json:"urls"` Date *string `json:"date"` Rating100 *int `json:"rating100"` OCounter *int `json:"o_counter"` Organized *bool `json:"organized"` StudioID *string `json:"studio_id"` GalleryIds []string `json:"gallery_ids"` PerformerIds []string `json:"performer_ids"` Movies []SceneMovieInput `json:"movies"` Groups []SceneGroupInput `json:"groups"` TagIds []string `json:"tag_ids"` // This should be a URL or a base64 encoded data URL CoverImage *string `json:"cover_image"` StashIds []StashIDInput `json:"stash_ids"` ResumeTime *float64 `json:"resume_time"` PlayDuration *float64 `json:"play_duration"` PlayCount *int `json:"play_count"` PrimaryFileID *string `json:"primary_file_id"` CustomFields *CustomFieldsInput } type SceneDestroyInput struct { ID string `json:"id"` DeleteFile *bool `json:"delete_file"` DeleteGenerated *bool `json:"delete_generated"` DestroyFileEntry *bool `json:"destroy_file_entry"` } type ScenesDestroyInput struct { Ids []string `json:"ids"` DeleteFile *bool `json:"delete_file"` DeleteGenerated *bool `json:"delete_generated"` DestroyFileEntry *bool `json:"destroy_file_entry"` } func NewSceneQueryResult(getter SceneGetter) *SceneQueryResult { return &SceneQueryResult{ getter: getter, } } func (r *SceneQueryResult) Resolve(ctx context.Context) ([]*Scene, error) { // cache results if r.scenes == nil && r.resolveErr == nil { r.scenes, r.resolveErr = r.getter.FindMany(ctx, r.IDs) } return r.scenes, r.resolveErr } ================================================ FILE: pkg/models/scene_marker.go ================================================ package models type SceneMarkerFilterType struct { // Filter to only include scene markers with this tag TagID *string `json:"tag_id"` // Filter to only include scene markers with these tags Tags *HierarchicalMultiCriterionInput `json:"tags"` // Filter to only include scene markers attached to a scene with these tags SceneTags *HierarchicalMultiCriterionInput `json:"scene_tags"` // Filter to only include scene markers with these performers Performers *MultiCriterionInput `json:"performers"` // Filter to only include scene markers from these scenes Scenes *MultiCriterionInput `json:"scenes"` // Filter by duration (in seconds) Duration *FloatCriterionInput `json:"duration"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at UpdatedAt *TimestampCriterionInput `json:"updated_at"` // Filter by scenes date SceneDate *DateCriterionInput `json:"scene_date"` // Filter by scenes created at SceneCreatedAt *TimestampCriterionInput `json:"scene_created_at"` // Filter by scenes updated at SceneUpdatedAt *TimestampCriterionInput `json:"scene_updated_at"` // Filter by related scenes that meet this criteria SceneFilter *SceneFilterType `json:"scene_filter"` } type MarkerStringsResultType struct { Count int `json:"count"` ID string `json:"id"` Title string `json:"title"` } ================================================ FILE: pkg/models/search.go ================================================ package models import "strings" const ( or = "OR" orSymbol = "|" notPrefix = '-' phraseChar = '"' ) // SearchSpecs provides the specifications for text-based searches. type SearchSpecs struct { // MustHave specifies all of the terms that must appear in the results. MustHave []string // AnySets specifies sets of terms where one of each set must appear in the results. AnySets [][]string // MustNot specifies all terms that must not appear in the results. MustNot []string } // combinePhrases detects quote characters at the start and end of // words and combines the contents into a single word. func combinePhrases(words []string) []string { var ret []string startIndex := -1 for i, w := range words { if startIndex == -1 { // looking for start of phrase // this could either be " or -" ww := w if len(w) > 0 && w[0] == notPrefix { ww = w[1:] } if len(ww) > 0 && ww[0] == phraseChar && (len(ww) < 2 || ww[len(ww)-1] != phraseChar) { startIndex = i continue } ret = append(ret, w) } else if len(w) > 0 && w[len(w)-1] == phraseChar { // looking for end of phrase // combine words phrase := strings.Join(words[startIndex:i+1], " ") // add to return value ret = append(ret, phrase) startIndex = -1 } } if startIndex != -1 { ret = append(ret, words[startIndex:]...) } return ret } func extractOrConditions(words []string, searchSpec *SearchSpecs) []string { for foundOr := true; foundOr; { foundOr = false for i, w := range words { if i > 0 && i < len(words)-1 && (strings.EqualFold(w, or) || w == orSymbol) { // found an OR keyword // first operand will be the last word startIndex := i - 1 // find the last operand // this will be the last word not preceded by OR lastIndex := len(words) - 1 for ii := i + 2; ii < len(words); ii += 2 { if !strings.EqualFold(words[ii], or) { lastIndex = ii - 1 break } } foundOr = true // combine the words into an any set var set []string for ii := startIndex; ii <= lastIndex; ii += 2 { word := extractPhrase(words[ii]) if word == "" { continue } set = append(set, word) } searchSpec.AnySets = append(searchSpec.AnySets, set) // take out the OR'd words words = append(words[0:startIndex], words[lastIndex+1:]...) // break and reparse break } } } return words } func extractNotConditions(words []string, searchSpec *SearchSpecs) []string { var ret []string for _, w := range words { if len(w) > 1 && w[0] == notPrefix { word := extractPhrase(w[1:]) if word == "" { continue } searchSpec.MustNot = append(searchSpec.MustNot, word) } else { ret = append(ret, w) } } return ret } func extractPhrase(w string) string { if len(w) > 1 && w[0] == phraseChar && w[len(w)-1] == phraseChar { return w[1 : len(w)-1] } return w } // ParseSearchString parses the Q value and returns a SearchSpecs object. // // By default, any words in the search value must appear in the results. // Words encompassed by quotes (") as treated as a single term. // Where keyword "OR" (case-insensitive) appears (and is not part of a quoted phrase), one of the // OR'd terms must appear in the results. // Where a keyword is prefixed with "-", that keyword must not appear in the results. // Where OR appears as the first or last term, or where one of the OR operands has a // not prefix, then the OR is treated literally. func ParseSearchString(s string) SearchSpecs { s = strings.TrimSpace(s) if s == "" { return SearchSpecs{} } // break into words words := strings.Split(s, " ") // combine phrases first, then extract OR conditions, then extract NOT conditions // and the leftovers will be AND'd ret := SearchSpecs{} words = combinePhrases(words) words = extractOrConditions(words, &ret) words = extractNotConditions(words, &ret) for _, w := range words { // ignore empty quotes word := extractPhrase(w) if word == "" { continue } ret.MustHave = append(ret.MustHave, word) } return ret } ================================================ FILE: pkg/models/search_test.go ================================================ package models import ( "reflect" "testing" ) func TestParseSearchString(t *testing.T) { tests := []struct { name string q string want SearchSpecs }{ { "basic", "a b c", SearchSpecs{ MustHave: []string{"a", "b", "c"}, }, }, { "empty", "", SearchSpecs{}, }, { "whitespace", " ", SearchSpecs{}, }, { "single", "a", SearchSpecs{ MustHave: []string{"a"}, }, }, { "quoted", `"a b" c`, SearchSpecs{ MustHave: []string{"a b", "c"}, }, }, { "quoted double space", `"a b" c`, SearchSpecs{ MustHave: []string{"a b", "c"}, }, }, { "quoted end space", `"a b " c`, SearchSpecs{ MustHave: []string{"a b ", "c"}, }, }, { "no matching end quote", `"a b c`, SearchSpecs{ MustHave: []string{`"a`, "b", "c"}, }, }, { "no matching start quote", `a b c"`, SearchSpecs{ MustHave: []string{"a", "b", `c"`}, }, }, { "or", "a OR b", SearchSpecs{ AnySets: [][]string{ {"a", "b"}, }, }, }, { "multi or", "a OR b c OR d", SearchSpecs{ AnySets: [][]string{ {"a", "b"}, {"c", "d"}, }, }, }, { "lowercase or", "a or b", SearchSpecs{ AnySets: [][]string{ {"a", "b"}, }, }, }, { "or symbol", "a | b", SearchSpecs{ AnySets: [][]string{ {"a", "b"}, }, }, }, { "quoted or", `a "OR" b`, SearchSpecs{ MustHave: []string{"a", "OR", "b"}, }, }, { "quoted or symbol", `a "|" b`, SearchSpecs{ MustHave: []string{"a", "|", "b"}, }, }, { "or phrases", `"a b" OR "c d"`, SearchSpecs{ AnySets: [][]string{ {"a b", "c d"}, }, }, }, { "or at start", "OR a", SearchSpecs{ MustHave: []string{"OR", "a"}, }, }, { "or at end", "a OR", SearchSpecs{ MustHave: []string{"a", "OR"}, }, }, { "or symbol at start", "| a", SearchSpecs{ MustHave: []string{"|", "a"}, }, }, { "or symbol at end", "a |", SearchSpecs{ MustHave: []string{"a", "|"}, }, }, { "nots", "-a -b", SearchSpecs{ MustNot: []string{"a", "b"}, }, }, { "not or", "-a OR b", SearchSpecs{ AnySets: [][]string{ {"-a", "b"}, }, }, }, { "not phrase", `-"a b"`, SearchSpecs{ MustNot: []string{"a b"}, }, }, { "not in phrase", `"-a b"`, SearchSpecs{ MustHave: []string{"-a b"}, }, }, { "double not", "--a", SearchSpecs{ MustNot: []string{"-a"}, }, }, { "empty quote", `"" a`, SearchSpecs{ MustHave: []string{"a"}, }, }, { "not empty quote", `-"" a`, SearchSpecs{ MustHave: []string{"a"}, }, }, { "quote in word", `ab"cd"`, SearchSpecs{ MustHave: []string{`ab"cd"`}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := ParseSearchString(tt.q); !reflect.DeepEqual(got, tt.want) { t.Errorf("FindFilterType.ParseSearchString() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: pkg/models/stash_box.go ================================================ package models type StashBoxFingerprint struct { Algorithm string `json:"algorithm"` Hash string `json:"hash"` Duration int `json:"duration"` } type StashBox struct { Endpoint string `json:"endpoint"` APIKey string `json:"api_key"` Name string `json:"name"` MaxRequestsPerMinute int `json:"max_requests_per_minute" koanf:"max_requests_per_minute"` } ================================================ FILE: pkg/models/stash_ids.go ================================================ package models import ( "slices" "time" ) type StashID struct { StashID string `db:"stash_id" json:"stash_id"` Endpoint string `db:"endpoint" json:"endpoint"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } func (s StashID) ToStashIDInput() StashIDInput { t := s.UpdatedAt return StashIDInput{ StashID: s.StashID, Endpoint: s.Endpoint, UpdatedAt: &t, } } type StashIDs []StashID func (s StashIDs) ToStashIDInputs() StashIDInputs { if s == nil { return nil } ret := make(StashIDInputs, len(s)) for i, v := range s { ret[i] = v.ToStashIDInput() } return ret } // HasSameStashIDs returns true if the two lists of StashIDs are the same, ignoring order and updated at time. func (s StashIDs) HasSameStashIDs(other StashIDs) bool { if len(s) != len(other) { return false } for _, v := range s { if !slices.ContainsFunc(other, func(o StashID) bool { return o.StashID == v.StashID && o.Endpoint == v.Endpoint }) { return false } } return true } type StashIDInput struct { StashID string `db:"stash_id" json:"stash_id"` Endpoint string `db:"endpoint" json:"endpoint"` UpdatedAt *time.Time `db:"updated_at" json:"updated_at"` } func (s StashIDInput) ToStashID() StashID { ret := StashID{ StashID: s.StashID, Endpoint: s.Endpoint, } if s.UpdatedAt != nil { ret.UpdatedAt = *s.UpdatedAt } else { // default to now if not provided ret.UpdatedAt = time.Now() } return ret } type StashIDInputs []StashIDInput func (s StashIDInputs) ToStashIDs() StashIDs { if s == nil { return nil } // #2800 - deduplicate StashIDs based on endpoint and stash_id ret := make(StashIDs, 0, len(s)) seen := make(map[string]map[string]bool) for _, v := range s { stashID := v.ToStashID() if seen[stashID.Endpoint] == nil { seen[stashID.Endpoint] = make(map[string]bool) } if !seen[stashID.Endpoint][stashID.StashID] { seen[stashID.Endpoint][stashID.StashID] = true ret = append(ret, stashID) } } return ret } type UpdateStashIDs struct { StashIDs []StashID `json:"stash_ids"` Mode RelationshipUpdateMode `json:"mode"` } // AddUnique adds the stash id to the list, only if the endpoint/stashid pair does not already exist in the list. func (u *UpdateStashIDs) AddUnique(v StashID) { for _, vv := range u.StashIDs { if vv.StashID == v.StashID && vv.Endpoint == v.Endpoint { return } } u.StashIDs = append(u.StashIDs, v) } // Set sets or replaces the stash id for the endpoint in the provided value. func (u *UpdateStashIDs) Set(v StashID) { for i, vv := range u.StashIDs { if vv.Endpoint == v.Endpoint { u.StashIDs[i] = v return } } u.StashIDs = append(u.StashIDs, v) } type StashIDCriterionInput struct { // If present, this value is treated as a predicate. // That is, it will filter based on stash_id with the matching endpoint Endpoint *string `json:"endpoint"` StashID *string `json:"stash_id"` Modifier CriterionModifier `json:"modifier"` } type StashIDsCriterionInput struct { // If present, this value is treated as a predicate. // That is, it will filter based on stash_ids with the matching endpoint Endpoint *string `json:"endpoint"` StashIDs []*string `json:"stash_ids"` Modifier CriterionModifier `json:"modifier"` } ================================================ FILE: pkg/models/studio.go ================================================ package models type StudioFilterType struct { OperatorFilter[StudioFilterType] Name *StringCriterionInput `json:"name"` Details *StringCriterionInput `json:"details"` // Filter to only include studios with this parent studio Parents *MultiCriterionInput `json:"parents"` // Filter by StashID StashID *StringCriterionInput `json:"stash_id"` // Filter by StashID Endpoint StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"` // Filter by StashIDs Endpoint StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"` // Filter to only include studios missing this property IsMissing *string `json:"is_missing"` // Filter by rating expressed as 1-100 Rating100 *IntCriterionInput `json:"rating100"` // Filter to only include studios with these tags Tags *HierarchicalMultiCriterionInput `json:"tags"` // Filter by tag count TagCount *IntCriterionInput `json:"tag_count"` // Filter by favorite Favorite *bool `json:"favorite"` // Filter by scene count SceneCount *IntCriterionInput `json:"scene_count"` // Filter by image count ImageCount *IntCriterionInput `json:"image_count"` // Filter by gallery count GalleryCount *IntCriterionInput `json:"gallery_count"` // Filter by group count GroupCount *IntCriterionInput `json:"group_count"` // Filter by url URL *StringCriterionInput `json:"url"` // Filter by studio aliases Aliases *StringCriterionInput `json:"aliases"` // Filter by subsidiary studio count ChildCount *IntCriterionInput `json:"child_count"` // Filter by autotag ignore value IgnoreAutoTag *bool `json:"ignore_auto_tag"` // Filter by organized Organized *bool `json:"organized"` // Filter by related scenes that meet this criteria ScenesFilter *SceneFilterType `json:"scenes_filter"` // Filter by related images that meet this criteria ImagesFilter *ImageFilterType `json:"images_filter"` // Filter by related galleries that meet this criteria GalleriesFilter *GalleryFilterType `json:"galleries_filter"` // Filter by related groups that meet this criteria GroupsFilter *GroupFilterType `json:"groups_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at UpdatedAt *TimestampCriterionInput `json:"updated_at"` // Filter by custom fields CustomFields []CustomFieldCriterionInput `json:"custom_fields"` } type StudioCreateInput struct { Name string `json:"name"` URL *string `json:"url"` // deprecated Urls []string `json:"urls"` ParentID *string `json:"parent_id"` // This should be a URL or a base64 encoded data URL Image *string `json:"image"` StashIds []StashIDInput `json:"stash_ids"` Rating100 *int `json:"rating100"` Favorite *bool `json:"favorite"` Details *string `json:"details"` Aliases []string `json:"aliases"` TagIds []string `json:"tag_ids"` IgnoreAutoTag *bool `json:"ignore_auto_tag"` Organized *bool `json:"organized"` CustomFields map[string]interface{} `json:"custom_fields"` } type StudioUpdateInput struct { ID string `json:"id"` Name *string `json:"name"` URL *string `json:"url"` // deprecated Urls []string `json:"urls"` ParentID *string `json:"parent_id"` // This should be a URL or a base64 encoded data URL Image *string `json:"image"` StashIds []StashIDInput `json:"stash_ids"` Rating100 *int `json:"rating100"` Favorite *bool `json:"favorite"` Details *string `json:"details"` Aliases []string `json:"aliases"` TagIds []string `json:"tag_ids"` IgnoreAutoTag *bool `json:"ignore_auto_tag"` Organized *bool `json:"organized"` CustomFields CustomFieldsInput `json:"custom_fields"` } ================================================ FILE: pkg/models/tag.go ================================================ package models type TagFilterType struct { OperatorFilter[TagFilterType] // Filter by tag name Name *StringCriterionInput `json:"name"` // Filter by tag sort_name SortName *StringCriterionInput `json:"sort_name"` // Filter by tag aliases Aliases *StringCriterionInput `json:"aliases"` // Filter by tag favorites Favorite *bool `json:"favorite"` // Filter by tag description Description *StringCriterionInput `json:"description"` // Filter to only include tags missing this property IsMissing *string `json:"is_missing"` // Filter by number of scenes with this tag SceneCount *IntCriterionInput `json:"scene_count"` // Filter by number of images with this tag ImageCount *IntCriterionInput `json:"image_count"` // Filter by number of galleries with this tag GalleryCount *IntCriterionInput `json:"gallery_count"` // Filter by number of performers with this tag PerformerCount *IntCriterionInput `json:"performer_count"` // Filter by number of studios with this tag StudioCount *IntCriterionInput `json:"studio_count"` // Filter by number of groups with this tag GroupCount *IntCriterionInput `json:"group_count"` // Filter by number of movies with this tag MovieCount *IntCriterionInput `json:"movie_count"` // Filter by number of markers with this tag MarkerCount *IntCriterionInput `json:"marker_count"` // Filter by parent tags Parents *HierarchicalMultiCriterionInput `json:"parents"` // Filter by child tags Children *HierarchicalMultiCriterionInput `json:"children"` // Filter by number of parent tags the tag has ParentCount *IntCriterionInput `json:"parent_count"` // Filter by number f child tags the tag has ChildCount *IntCriterionInput `json:"child_count"` // Filter by autotag ignore value IgnoreAutoTag *bool `json:"ignore_auto_tag"` // Filter by StashID Endpoint StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"` // Filter by StashIDs Endpoint StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"` // Filter by related scenes that meet this criteria ScenesFilter *SceneFilterType `json:"scenes_filter"` // Filter by related images that meet this criteria ImagesFilter *ImageFilterType `json:"images_filter"` // Filter by related galleries that meet this criteria GalleriesFilter *GalleryFilterType `json:"galleries_filter"` // Filter by related groups that meet this criteria GroupsFilter *GroupFilterType `json:"groups_filter"` // Filter by related performers that meet this criteria PerformersFilter *PerformerFilterType `json:"performers_filter"` // Filter by related studios that meet this criteria StudiosFilter *StudioFilterType `json:"studios_filter"` // Filter by related scene markers that meet this criteria MarkersFilter *SceneMarkerFilterType `json:"markers_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at UpdatedAt *TimestampCriterionInput `json:"updated_at"` // Filter by custom fields CustomFields []CustomFieldCriterionInput `json:"custom_fields"` } ================================================ FILE: pkg/models/update.go ================================================ package models import ( "fmt" "io" "strconv" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sliceutil/intslice" ) type RelationshipUpdateMode string const ( RelationshipUpdateModeSet RelationshipUpdateMode = "SET" RelationshipUpdateModeAdd RelationshipUpdateMode = "ADD" RelationshipUpdateModeRemove RelationshipUpdateMode = "REMOVE" ) var AllRelationshipUpdateMode = []RelationshipUpdateMode{ RelationshipUpdateModeSet, RelationshipUpdateModeAdd, RelationshipUpdateModeRemove, } func (e RelationshipUpdateMode) IsValid() bool { switch e { case RelationshipUpdateModeSet, RelationshipUpdateModeAdd, RelationshipUpdateModeRemove: return true } return false } func (e RelationshipUpdateMode) String() string { return string(e) } func (e *RelationshipUpdateMode) UnmarshalGQL(v interface{}) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = RelationshipUpdateMode(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid RelationshipUpdateMode", str) } return nil } func (e RelationshipUpdateMode) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } type UpdateIDs struct { IDs []int `json:"ids"` Mode RelationshipUpdateMode `json:"mode"` } func (u *UpdateIDs) IDStrings() []string { if u == nil { return nil } return intslice.IntSliceToStringSlice(u.IDs) } // GetImpactedIDs returns the IDs that will be impacted by the update. // If the update is to add IDs, then the impacted IDs are the IDs being added. // If the update is to remove IDs, then the impacted IDs are the IDs being removed. // If the update is to set IDs, then the impacted IDs are the IDs being removed and the IDs being added. // Any IDs that are already present and are being added are not returned. // Likewise, any IDs that are not present that are being removed are not returned. func (u *UpdateIDs) ImpactedIDs(existing []int) []int { if u == nil { return nil } switch u.Mode { case RelationshipUpdateModeAdd: return sliceutil.Exclude(u.IDs, existing) case RelationshipUpdateModeRemove: return sliceutil.Intersect(existing, u.IDs) case RelationshipUpdateModeSet: // get the difference between the two lists return sliceutil.NotIntersect(existing, u.IDs) } return nil } // Apply applies the update to a list of existing ids, returning the result. func (u *UpdateIDs) Apply(existing []int) []int { if u == nil { return existing } return applyUpdate(u.IDs, u.Mode, existing) } type UpdateStrings struct { Values []string `json:"values"` Mode RelationshipUpdateMode `json:"mode"` } func (u *UpdateStrings) Strings() []string { if u == nil { return nil } return u.Values } // Apply applies the update to a list of existing strings, returning the result. func (u *UpdateStrings) Apply(existing []string) []string { if u == nil { return existing } return applyUpdate(u.Values, u.Mode, existing) } // applyUpdate applies values to existing, using the update mode specified. func applyUpdate[T comparable](values []T, mode RelationshipUpdateMode, existing []T) []T { switch mode { case RelationshipUpdateModeAdd: return sliceutil.AppendUniques(existing, values) case RelationshipUpdateModeRemove: return sliceutil.Exclude(existing, values) case RelationshipUpdateModeSet: return values } return nil } type UpdateGroupDescriptions struct { Groups []GroupIDDescription `json:"groups"` Mode RelationshipUpdateMode `json:"mode"` } // Apply applies the update to a list of existing ids, returning the result. func (u *UpdateGroupDescriptions) Apply(existing []GroupIDDescription) []GroupIDDescription { if u == nil { return existing } switch u.Mode { case RelationshipUpdateModeAdd: return u.applyAdd(existing) case RelationshipUpdateModeRemove: return u.applyRemove(existing) case RelationshipUpdateModeSet: return u.Groups } return nil } func (u *UpdateGroupDescriptions) applyAdd(existing []GroupIDDescription) []GroupIDDescription { // overwrite any existing values with the same id ret := append([]GroupIDDescription{}, existing...) for _, v := range u.Groups { found := false for i, vv := range ret { if vv.GroupID == v.GroupID { ret[i] = v found = true break } } if !found { ret = append(ret, v) } } return ret } func (u *UpdateGroupDescriptions) applyRemove(existing []GroupIDDescription) []GroupIDDescription { // remove any existing values with the same id var ret []GroupIDDescription for _, v := range existing { found := false for _, vv := range u.Groups { if vv.GroupID == v.GroupID { found = true break } } // if not found in the remove list, keep it if !found { ret = append(ret, v) } } return ret } ================================================ FILE: pkg/models/update_test.go ================================================ package models import ( "reflect" "testing" "github.com/stretchr/testify/assert" ) func TestUpdateIDs_ImpactedIDs(t *testing.T) { tests := []struct { name string IDs []int Mode RelationshipUpdateMode existing []int want []int }{ { name: "add", IDs: []int{1, 2, 3}, Mode: RelationshipUpdateModeAdd, existing: []int{1, 2}, want: []int{3}, }, { name: "remove", IDs: []int{1, 2, 3}, Mode: RelationshipUpdateModeRemove, existing: []int{1, 2}, want: []int{1, 2}, }, { name: "set", IDs: []int{1, 2, 3}, Mode: RelationshipUpdateModeSet, existing: []int{1, 2}, want: []int{3}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { u := &UpdateIDs{ IDs: tt.IDs, Mode: tt.Mode, } if got := u.ImpactedIDs(tt.existing); !reflect.DeepEqual(got, tt.want) { t.Errorf("UpdateIDs.ImpactedIDs() = %v, want %v", got, tt.want) } }) } } func TestApplyUpdate(t *testing.T) { tests := []struct { name string values []int mode RelationshipUpdateMode existing []int want []int }{ { name: "add", values: []int{2, 3}, mode: RelationshipUpdateModeAdd, existing: []int{1, 2}, want: []int{1, 2, 3}, }, { name: "remove", values: []int{2, 3}, mode: RelationshipUpdateModeRemove, existing: []int{1, 2}, want: []int{1}, }, { name: "set", values: []int{1, 2, 3}, mode: RelationshipUpdateModeSet, existing: []int{1, 2}, want: []int{1, 2, 3}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := applyUpdate(tt.values, tt.mode, tt.existing) assert.Equal(t, tt.want, got) }) } } ================================================ FILE: pkg/models/value.go ================================================ package models import ( "strconv" "time" ) // OptionalString represents an optional string argument that may be null. // A value is only considered null if both Set and Null is true. type OptionalString struct { Value string Null bool Set bool } // Ptr returns a pointer to the underlying value. Returns nil if Set is false or Null is true. func (o *OptionalString) Ptr() *string { if !o.Set || o.Null { return nil } v := o.Value return &v } // Merge sets the OptionalString if it is not already set, the destination value is empty and the source value is not empty. func (o *OptionalString) Merge(destVal string, srcVal string) { if destVal == "" && srcVal != "" && !o.Set { *o = NewOptionalString(srcVal) } } // NewOptionalString returns a new OptionalString with the given value. func NewOptionalString(v string) OptionalString { return OptionalString{v, false, true} } // NewOptionalStringPtr returns a new OptionalString with the given value. // If the value is nil, the returned OptionalString will be set and null. func NewOptionalStringPtr(v *string) OptionalString { if v == nil { return OptionalString{ Null: true, Set: true, } } return OptionalString{*v, false, true} } // OptionalInt represents an optional int argument that may be null. See OptionalString. type OptionalInt struct { Value int Null bool Set bool } // Ptr returns a pointer to the underlying value. Returns nil if Set is false or Null is true. func (o *OptionalInt) Ptr() *int { if !o.Set || o.Null { return nil } v := o.Value return &v } // MergePtr sets the OptionalInt if it is not already set, the destination value is nil and the source value is not nil. func (o *OptionalInt) MergePtr(destVal *int, srcVal *int) { if destVal == nil && srcVal != nil && !o.Set { *o = NewOptionalInt(*srcVal) } } // NewOptionalInt returns a new OptionalInt with the given value. func NewOptionalInt(v int) OptionalInt { return OptionalInt{v, false, true} } // NewOptionalIntPtr returns a new OptionalInt with the given value. // If the value is nil, the returned OptionalInt will be set and null. func NewOptionalIntPtr(v *int) OptionalInt { if v == nil { return OptionalInt{ Null: true, Set: true, } } return OptionalInt{*v, false, true} } // StringPtr returns a pointer to a string representation of the value. // Returns nil if Set is false or null is true. func (o *OptionalInt) StringPtr() *string { if !o.Set || o.Null { return nil } v := strconv.Itoa(o.Value) return &v } // OptionalInt64 represents an optional int64 argument that may be null. See OptionalString. type OptionalInt64 struct { Value int64 Null bool Set bool } // Ptr returns a pointer to the underlying value. Returns nil if Set is false or Null is true. func (o *OptionalInt64) Ptr() *int64 { if !o.Set || o.Null { return nil } v := o.Value return &v } // NewOptionalInt64 returns a new OptionalInt64 with the given value. func NewOptionalInt64(v int64) OptionalInt64 { return OptionalInt64{v, false, true} } // NewOptionalInt64Ptr returns a new OptionalInt64 with the given value. // If the value is nil, the returned OptionalInt64 will be set and null. func NewOptionalInt64Ptr(v *int64) OptionalInt64 { if v == nil { return OptionalInt64{ Null: true, Set: true, } } return OptionalInt64{*v, false, true} } // OptionalBool represents an optional int64 argument that may be null. See OptionalString. type OptionalBool struct { Value bool Null bool Set bool } func (o *OptionalBool) Ptr() *bool { if !o.Set || o.Null { return nil } v := o.Value return &v } // Merge sets the OptionalBool to true if it is not already set, the destination value is false and the source value is true. func (o *OptionalBool) Merge(destVal bool, srcVal bool) { if !destVal && srcVal && !o.Set { *o = NewOptionalBool(true) } } // NewOptionalBool returns a new OptionalBool with the given value. func NewOptionalBool(v bool) OptionalBool { return OptionalBool{v, false, true} } // NewOptionalBoolPtr returns a new OptionalBool with the given value. // If the value is nil, the returned OptionalBool will be set and null. func NewOptionalBoolPtr(v *bool) OptionalBool { if v == nil { return OptionalBool{ Null: true, Set: true, } } return OptionalBool{*v, false, true} } // OptionalFloat64 represents an optional float64 argument that may be null. See OptionalString. type OptionalFloat64 struct { Value float64 Null bool Set bool } // Ptr returns a pointer to the underlying value. Returns nil if Set is false or Null is true. func (o *OptionalFloat64) Ptr() *float64 { if !o.Set || o.Null { return nil } v := o.Value return &v } // NewOptionalFloat64 returns a new OptionalFloat64 with the given value. func NewOptionalFloat64(v float64) OptionalFloat64 { return OptionalFloat64{v, false, true} } // NewOptionalFloat64 returns a new OptionalFloat64 with the given value. func NewOptionalFloat64Ptr(v *float64) OptionalFloat64 { if v == nil { return OptionalFloat64{ Null: true, Set: true, } } return OptionalFloat64{*v, false, true} } // OptionalDate represents an optional date argument that may be null. See OptionalString. type OptionalDate struct { Value Date Null bool Set bool } // Ptr returns a pointer to the underlying value. Returns nil if Set is false or Null is true. func (o *OptionalDate) Ptr() *Date { if !o.Set || o.Null { return nil } v := o.Value return &v } // NewOptionalDate returns a new OptionalDate with the given value. func NewOptionalDate(v Date) OptionalDate { return OptionalDate{v, false, true} } // Merge sets the OptionalDate if it is not already set, the destination value is nil and the source value is nil. func (o *OptionalDate) MergePtr(destVal *Date, srcVal *Date) { if destVal == nil && srcVal != nil && !o.Set { *o = NewOptionalDate(*srcVal) } } // NewOptionalBoolPtr returns a new OptionalDate with the given value. // If the value is nil, the returned OptionalDate will be set and null. func NewOptionalDatePtr(v *Date) OptionalDate { if v == nil { return OptionalDate{ Null: true, Set: true, } } return OptionalDate{*v, false, true} } // OptionalTime represents an optional time argument that may be null. See OptionalString. type OptionalTime struct { Value time.Time Null bool Set bool } // NewOptionalTime returns a new OptionalTime with the given value. func NewOptionalTime(v time.Time) OptionalTime { return OptionalTime{v, false, true} } // NewOptionalTimePtr returns a new OptionalTime with the given value. // If the value is nil, the returned OptionalTime will be set and null. func NewOptionalTimePtr(v *time.Time) OptionalTime { if v == nil { return OptionalTime{ Null: true, Set: true, } } return OptionalTime{*v, false, true} } // Ptr returns a pointer to the underlying value. Returns nil if Set is false or Null is true. func (o *OptionalTime) Ptr() *time.Time { if !o.Set || o.Null { return nil } v := o.Value return &v } ================================================ FILE: pkg/performer/doc.go ================================================ // Package performer provides the application logic for performer functionality. package performer ================================================ FILE: pkg/performer/export.go ================================================ package performer import ( "context" "fmt" "strconv" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/utils" ) type ImageAliasStashIDGetter interface { GetImage(ctx context.Context, performerID int) ([]byte, error) models.AliasLoader models.StashIDLoader models.URLLoader models.CustomFieldsReader } // ToJSON converts a Performer object into its JSON equivalent. func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *models.Performer) (*jsonschema.Performer, error) { newPerformerJSON := jsonschema.Performer{ Name: performer.Name, Disambiguation: performer.Disambiguation, Ethnicity: performer.Ethnicity, Country: performer.Country, EyeColor: performer.EyeColor, Measurements: performer.Measurements, FakeTits: performer.FakeTits, Tattoos: performer.Tattoos, Piercings: performer.Piercings, Favorite: performer.Favorite, Details: performer.Details, HairColor: performer.HairColor, IgnoreAutoTag: performer.IgnoreAutoTag, CreatedAt: json.JSONTime{Time: performer.CreatedAt}, UpdatedAt: json.JSONTime{Time: performer.UpdatedAt}, } if performer.Gender != nil { newPerformerJSON.Gender = performer.Gender.String() } if performer.Circumcised != nil { newPerformerJSON.Circumcised = performer.Circumcised.String() } if performer.Birthdate != nil { newPerformerJSON.Birthdate = performer.Birthdate.String() } if performer.Rating != nil { newPerformerJSON.Rating = *performer.Rating } if performer.DeathDate != nil { newPerformerJSON.DeathDate = performer.DeathDate.String() } if performer.Height != nil { newPerformerJSON.Height = strconv.Itoa(*performer.Height) } if performer.Weight != nil { newPerformerJSON.Weight = *performer.Weight } if performer.PenisLength != nil { newPerformerJSON.PenisLength = *performer.PenisLength } if performer.CareerStart != nil { newPerformerJSON.CareerStart = performer.CareerStart.String() } if performer.CareerEnd != nil { newPerformerJSON.CareerEnd = performer.CareerEnd.String() } if err := performer.LoadAliases(ctx, reader); err != nil { return nil, fmt.Errorf("loading performer aliases: %w", err) } newPerformerJSON.Aliases = performer.Aliases.List() if err := performer.LoadURLs(ctx, reader); err != nil { return nil, fmt.Errorf("loading performer urls: %w", err) } newPerformerJSON.URLs = performer.URLs.List() if err := performer.LoadStashIDs(ctx, reader); err != nil { return nil, fmt.Errorf("loading performer stash ids: %w", err) } newPerformerJSON.StashIDs = performer.StashIDs.List() var err error newPerformerJSON.CustomFields, err = reader.GetCustomFields(ctx, performer.ID) if err != nil { return nil, fmt.Errorf("getting performer custom fields: %v", err) } image, err := reader.GetImage(ctx, performer.ID) if err != nil { logger.Errorf("Error getting performer image: %v", err) } if len(image) > 0 { newPerformerJSON.Image = utils.GetBase64StringFromData(image) } return &newPerformerJSON, nil } func GetIDs(performers []*models.Performer) []int { var results []int for _, performer := range performers { results = append(results, performer.ID) } return results } func GetNames(performers []*models.Performer) []string { var results []string for _, performer := range performers { if performer.Name != "" { results = append(results, performer.Name) } } return results } ================================================ FILE: pkg/performer/export_test.go ================================================ package performer import ( "errors" "strconv" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "testing" "time" ) const ( performerID = 1 noImageID = 2 errImageID = 3 customFieldsID = 4 errCustomFieldsID = 5 ) const ( performerName = "testPerformer" disambiguation = "disambiguation" url = "url" country = "country" ethnicity = "ethnicity" eyeColor = "eyeColor" fakeTits = "fakeTits" instagram = "instagram" measurements = "measurements" piercings = "piercings" tattoos = "tattoos" twitter = "twitter" details = "details" hairColor = "hairColor" autoTagIgnored = true ) var ( genderEnum = models.GenderEnumFemale gender = genderEnum.String() aliases = []string{"alias1", "alias2"} rating = 5 height = 123 weight = 60 careerStart, _ = models.ParseDate("2005") careerEnd, _ = models.ParseDate("2015") penisLength = 1.23 circumcisedEnum = models.CircumcisedEnumCut circumcised = circumcisedEnum.String() emptyCustomFields = make(map[string]interface{}) customFields = map[string]interface{}{ "customField1": "customValue1", } ) var imageBytes = []byte("imageBytes") var stashID = models.StashID{ StashID: "StashID", Endpoint: "Endpoint", } var stashIDs = []models.StashID{ stashID, } const image = "aW1hZ2VCeXRlcw==" var birthDate, _ = models.ParseDate("2001-01-01") var deathDate, _ = models.ParseDate("2021-02-02") var ( createTime = time.Date(2001, 01, 01, 0, 0, 0, 0, time.Local) updateTime = time.Date(2002, 01, 01, 0, 0, 0, 0, time.Local) ) func createFullPerformer(id int, name string) *models.Performer { return &models.Performer{ ID: id, Name: name, Disambiguation: disambiguation, URLs: models.NewRelatedStrings([]string{url, twitter, instagram}), Aliases: models.NewRelatedStrings(aliases), Birthdate: &birthDate, CareerStart: &careerStart, CareerEnd: &careerEnd, Country: country, Ethnicity: ethnicity, EyeColor: eyeColor, FakeTits: fakeTits, PenisLength: &penisLength, Circumcised: &circumcisedEnum, Favorite: true, Gender: &genderEnum, Height: &height, Measurements: measurements, Piercings: piercings, Tattoos: tattoos, CreatedAt: createTime, UpdatedAt: updateTime, Rating: &rating, Details: details, DeathDate: &deathDate, HairColor: hairColor, Weight: &weight, IgnoreAutoTag: autoTagIgnored, TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs(stashIDs), } } func createEmptyPerformer(id int) models.Performer { return models.Performer{ ID: id, CreatedAt: createTime, UpdatedAt: updateTime, Aliases: models.NewRelatedStrings([]string{}), URLs: models.NewRelatedStrings([]string{}), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), } } func createFullJSONPerformer(name string, image string, withCustomFields bool) *jsonschema.Performer { ret := &jsonschema.Performer{ Name: name, Disambiguation: disambiguation, URLs: []string{url, twitter, instagram}, Aliases: aliases, Birthdate: birthDate.String(), CareerStart: careerStart.String(), CareerEnd: careerEnd.String(), Country: country, Ethnicity: ethnicity, EyeColor: eyeColor, FakeTits: fakeTits, PenisLength: penisLength, Circumcised: circumcised, Favorite: true, Gender: gender, Height: strconv.Itoa(height), Measurements: measurements, Piercings: piercings, Tattoos: tattoos, CreatedAt: json.JSONTime{ Time: createTime, }, UpdatedAt: json.JSONTime{ Time: updateTime, }, Rating: rating, Image: image, Details: details, DeathDate: deathDate.String(), HairColor: hairColor, Weight: weight, StashIDs: stashIDs, IgnoreAutoTag: autoTagIgnored, CustomFields: emptyCustomFields, } if withCustomFields { ret.CustomFields = customFields } return ret } func createEmptyJSONPerformer() *jsonschema.Performer { return &jsonschema.Performer{ Aliases: []string{}, URLs: []string{}, StashIDs: []models.StashID{}, CreatedAt: json.JSONTime{ Time: createTime, }, UpdatedAt: json.JSONTime{ Time: updateTime, }, CustomFields: emptyCustomFields, } } type testScenario struct { input models.Performer customFields map[string]interface{} expected *jsonschema.Performer err bool } var scenarios []testScenario func initTestTable() { scenarios = []testScenario{ { *createFullPerformer(performerID, performerName), emptyCustomFields, createFullJSONPerformer(performerName, image, false), false, }, { *createFullPerformer(customFieldsID, performerName), customFields, createFullJSONPerformer(performerName, image, true), false, }, { createEmptyPerformer(noImageID), emptyCustomFields, createEmptyJSONPerformer(), false, }, { *createFullPerformer(errImageID, performerName), emptyCustomFields, createFullJSONPerformer(performerName, "", false), // failure to get image should not cause an error false, }, { *createFullPerformer(errCustomFieldsID, performerName), customFields, nil, // failure to get custom fields should cause an error true, }, } } func TestToJSON(t *testing.T) { initTestTable() db := mocks.NewDatabase() imageErr := errors.New("error getting image") customFieldsErr := errors.New("error getting custom fields") db.Performer.On("GetImage", testCtx, performerID).Return(imageBytes, nil).Once() db.Performer.On("GetImage", testCtx, customFieldsID).Return(imageBytes, nil).Once() db.Performer.On("GetImage", testCtx, noImageID).Return(nil, nil).Once() db.Performer.On("GetImage", testCtx, errImageID).Return(nil, imageErr).Once() db.Performer.On("GetCustomFields", testCtx, performerID).Return(emptyCustomFields, nil).Once() db.Performer.On("GetCustomFields", testCtx, customFieldsID).Return(customFields, nil).Once() db.Performer.On("GetCustomFields", testCtx, noImageID).Return(emptyCustomFields, nil).Once() db.Performer.On("GetCustomFields", testCtx, errImageID).Return(emptyCustomFields, nil).Once() db.Performer.On("GetCustomFields", testCtx, errCustomFieldsID).Return(nil, customFieldsErr).Once() for i, s := range scenarios { tag := s.input json, err := ToJSON(testCtx, db.Performer, &tag) switch { case !s.err && err != nil: t.Errorf("[%d] unexpected error: %s", i, err.Error()) case s.err && err == nil: t.Errorf("[%d] expected error not returned", i) default: assert.Equal(t, s.expected, json, "[%d]", i) } } db.AssertExpectations(t) } ================================================ FILE: pkg/performer/import.go ================================================ package performer import ( "context" "fmt" "slices" "strconv" "strings" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/utils" ) type ImporterReaderWriter interface { models.PerformerCreatorUpdater models.PerformerQueryer } type Importer struct { ReaderWriter ImporterReaderWriter TagWriter models.TagFinderCreator Input jsonschema.Performer MissingRefBehaviour models.ImportMissingRefEnum ID int performer models.Performer customFields models.CustomFieldMap imageData []byte } func (i *Importer) PreImport(ctx context.Context) error { var err error i.performer, err = performerJSONToPerformer(i.Input) if err != nil { return err } i.customFields = i.Input.CustomFields if err := i.populateTags(ctx); err != nil { return err } if len(i.Input.Image) > 0 { i.imageData, err = utils.ProcessBase64Image(i.Input.Image) if err != nil { return fmt.Errorf("invalid image: %v", err) } } return nil } func (i *Importer) populateTags(ctx context.Context) error { if len(i.Input.Tags) > 0 { tags, err := importTags(ctx, i.TagWriter, i.Input.Tags, i.MissingRefBehaviour) if err != nil { return err } for _, p := range tags { i.performer.TagIDs.Add(p.ID) } } return nil } func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string, missingRefBehaviour models.ImportMissingRefEnum) ([]*models.Tag, error) { tags, err := tagWriter.FindByNames(ctx, names, false) if err != nil { return nil, err } var pluckedNames []string for _, tag := range tags { pluckedNames = append(pluckedNames, tag.Name) } missingTags := sliceutil.Filter(names, func(name string) bool { return !slices.Contains(pluckedNames, name) }) if len(missingTags) > 0 { if missingRefBehaviour == models.ImportMissingRefEnumFail { return nil, fmt.Errorf("tags [%s] not found", strings.Join(missingTags, ", ")) } if missingRefBehaviour == models.ImportMissingRefEnumCreate { createdTags, err := createTags(ctx, tagWriter, missingTags) if err != nil { return nil, fmt.Errorf("error creating tags: %v", err) } tags = append(tags, createdTags...) } // ignore if MissingRefBehaviour set to Ignore } return tags, nil } func createTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string) ([]*models.Tag, error) { var ret []*models.Tag for _, name := range names { newTag := models.NewTag() newTag.Name = name err := tagWriter.Create(ctx, &models.CreateTagInput{ Tag: &newTag, }) if err != nil { return nil, err } ret = append(ret, &newTag) } return ret, nil } func (i *Importer) PostImport(ctx context.Context, id int) error { if len(i.imageData) > 0 { if err := i.ReaderWriter.UpdateImage(ctx, id, i.imageData); err != nil { return fmt.Errorf("error setting performer image: %v", err) } } return nil } func (i *Importer) Name() string { return i.Input.Name } func (i *Importer) FindExistingID(ctx context.Context) (*int, error) { // use disambiguation as well performerFilter := models.PerformerFilterType{ Name: &models.StringCriterionInput{ Value: i.Input.Name, Modifier: models.CriterionModifierEquals, }, } if i.Input.Disambiguation != "" { performerFilter.Disambiguation = &models.StringCriterionInput{ Value: i.Input.Disambiguation, Modifier: models.CriterionModifierEquals, } } pp := 1 findFilter := models.FindFilterType{ PerPage: &pp, } existing, _, err := i.ReaderWriter.Query(ctx, &performerFilter, &findFilter) if err != nil { return nil, err } if len(existing) > 0 { id := existing[0].ID return &id, nil } return nil, nil } func (i *Importer) Create(ctx context.Context) (*int, error) { err := i.ReaderWriter.Create(ctx, &models.CreatePerformerInput{ Performer: &i.performer, CustomFields: i.customFields, }) if err != nil { return nil, fmt.Errorf("error creating performer: %v", err) } id := i.performer.ID return &id, nil } func (i *Importer) Update(ctx context.Context, id int) error { i.performer.ID = id err := i.ReaderWriter.Update(ctx, &models.UpdatePerformerInput{ Performer: &i.performer, CustomFields: models.CustomFieldsInput{ Full: i.customFields, }, }) if err != nil { return fmt.Errorf("error updating existing performer: %v", err) } return nil } func performerJSONToPerformer(performerJSON jsonschema.Performer) (models.Performer, error) { newPerformer := models.Performer{ Name: performerJSON.Name, Disambiguation: performerJSON.Disambiguation, Ethnicity: performerJSON.Ethnicity, Country: performerJSON.Country, EyeColor: performerJSON.EyeColor, Measurements: performerJSON.Measurements, FakeTits: performerJSON.FakeTits, Tattoos: performerJSON.Tattoos, Piercings: performerJSON.Piercings, Aliases: models.NewRelatedStrings(performerJSON.Aliases), Details: performerJSON.Details, HairColor: performerJSON.HairColor, Favorite: performerJSON.Favorite, IgnoreAutoTag: performerJSON.IgnoreAutoTag, CreatedAt: performerJSON.CreatedAt.GetTime(), UpdatedAt: performerJSON.UpdatedAt.GetTime(), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs(performerJSON.StashIDs), } if len(performerJSON.URLs) > 0 { newPerformer.URLs = models.NewRelatedStrings(performerJSON.URLs) } else { urls := []string{} if performerJSON.URL != "" { urls = append(urls, performerJSON.URL) } if performerJSON.Twitter != "" { urls = append(urls, performerJSON.Twitter) } if performerJSON.Instagram != "" { urls = append(urls, performerJSON.Instagram) } if len(urls) > 0 { newPerformer.URLs = models.NewRelatedStrings(urls) } } if performerJSON.Gender != "" { v := models.GenderEnum(performerJSON.Gender) newPerformer.Gender = &v } if performerJSON.Circumcised != "" { v := models.CircumcisedEnum(performerJSON.Circumcised) newPerformer.Circumcised = &v } if performerJSON.Birthdate != "" { date, err := models.ParseDate(performerJSON.Birthdate) if err == nil { newPerformer.Birthdate = &date } } if performerJSON.Rating != 0 { newPerformer.Rating = &performerJSON.Rating } if performerJSON.DeathDate != "" { date, err := models.ParseDate(performerJSON.DeathDate) if err == nil { newPerformer.DeathDate = &date } } if performerJSON.Weight != 0 { newPerformer.Weight = &performerJSON.Weight } if performerJSON.PenisLength != 0 { newPerformer.PenisLength = &performerJSON.PenisLength } if performerJSON.Height != "" { h, err := strconv.Atoi(performerJSON.Height) if err == nil { newPerformer.Height = &h } else { logger.Warnf("error parsing height %q: %v", performerJSON.Height, err) } } // prefer explicit career_start/career_end, fall back to parsing legacy career_length if performerJSON.CareerStart != "" || performerJSON.CareerEnd != "" { careerStart, err := models.ParseDate(performerJSON.CareerStart) if err == nil { newPerformer.CareerStart = &careerStart } careerEnd, err := models.ParseDate(performerJSON.CareerEnd) if err == nil { newPerformer.CareerEnd = &careerEnd } } else if performerJSON.CareerLength != "" { start, end, err := models.ParseYearRangeString(performerJSON.CareerLength) if err != nil { return models.Performer{}, fmt.Errorf("invalid career_length %q: %w", performerJSON.CareerLength, err) } newPerformer.CareerStart = start newPerformer.CareerEnd = end } return newPerformer, nil } ================================================ FILE: pkg/performer/import_test.go ================================================ package performer import ( "context" "errors" "github.com/stretchr/testify/mock" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "testing" ) const invalidImage = "aW1hZ2VCeXRlcw&&" const ( existingPerformerID = 100 existingTagID = 105 errTagsID = 106 existingPerformerName = "existingPerformerName" performerNameErr = "performerNameErr" existingTagName = "existingTagName" existingTagErr = "existingTagErr" missingTagName = "missingTagName" ) var testCtx = context.Background() func TestImporterName(t *testing.T) { i := Importer{ Input: jsonschema.Performer{ Name: performerName, }, } assert.Equal(t, performerName, i.Name()) } func TestImporterPreImport(t *testing.T) { i := Importer{ Input: jsonschema.Performer{ Name: performerName, Image: invalidImage, }, } err := i.PreImport(testCtx) assert.NotNil(t, err) i.Input = *createFullJSONPerformer(performerName, image, true) err = i.PreImport(testCtx) assert.Nil(t, err) expectedPerformer := *createFullPerformer(0, performerName) assert.Equal(t, expectedPerformer, i.performer) assert.Equal(t, models.CustomFieldMap(customFields), i.customFields) } func TestImporterPreImportWithTag(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Performer, TagWriter: db.Tag, MissingRefBehaviour: models.ImportMissingRefEnumFail, Input: jsonschema.Performer{ Tags: []string{ existingTagName, }, }, } db.Tag.On("FindByNames", testCtx, []string{existingTagName}, false).Return([]*models.Tag{ { ID: existingTagID, Name: existingTagName, }, }, nil).Once() db.Tag.On("FindByNames", testCtx, []string{existingTagErr}, false).Return(nil, errors.New("FindByNames error")).Once() err := i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, existingTagID, i.performer.TagIDs.List()[0]) i.Input.Tags = []string{existingTagErr} err = i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithMissingTag(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Performer, TagWriter: db.Tag, Input: jsonschema.Performer{ Tags: []string{ missingTagName, }, }, MissingRefBehaviour: models.ImportMissingRefEnumFail, } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3) db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Run(func(args mock.Arguments) { t := args.Get(1).(*models.CreateTagInput) t.Tag.ID = existingTagID }).Return(nil) err := i.PreImport(testCtx) assert.NotNil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore err = i.PreImport(testCtx) assert.Nil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumCreate err = i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, existingTagID, i.performer.TagIDs.List()[0]) db.AssertExpectations(t) } func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Performer, TagWriter: db.Tag, Input: jsonschema.Performer{ Tags: []string{ missingTagName, }, }, MissingRefBehaviour: models.ImportMissingRefEnumCreate, } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once() db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPostImport(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Performer, TagWriter: db.Tag, imageData: imageBytes, } updatePerformerImageErr := errors.New("UpdateImage error") db.Performer.On("UpdateImage", testCtx, performerID, imageBytes).Return(nil).Once() db.Performer.On("UpdateImage", testCtx, errImageID, imageBytes).Return(updatePerformerImageErr).Once() err := i.PostImport(testCtx, performerID) assert.Nil(t, err) err = i.PostImport(testCtx, errImageID) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterFindExistingID(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Performer, TagWriter: db.Tag, Input: jsonschema.Performer{ Name: performerName, }, } pp := 1 findFilter := &models.FindFilterType{ PerPage: &pp, } performerFilter := func(name string) *models.PerformerFilterType { return &models.PerformerFilterType{ Name: &models.StringCriterionInput{ Value: name, Modifier: models.CriterionModifierEquals, }, } } errFindByNames := errors.New("FindByNames error") db.Performer.On("Query", testCtx, performerFilter(performerName), findFilter).Return(nil, 0, nil).Once() db.Performer.On("Query", testCtx, performerFilter(existingPerformerName), findFilter).Return([]*models.Performer{ { ID: existingPerformerID, }, }, 1, nil).Once() db.Performer.On("Query", testCtx, performerFilter(performerNameErr), findFilter).Return(nil, 0, errFindByNames).Once() id, err := i.FindExistingID(testCtx) assert.Nil(t, id) assert.Nil(t, err) i.Input.Name = existingPerformerName id, err = i.FindExistingID(testCtx) assert.Equal(t, existingPerformerID, *id) assert.Nil(t, err) i.Input.Name = performerNameErr id, err = i.FindExistingID(testCtx) assert.Nil(t, id) assert.NotNil(t, err) db.AssertExpectations(t) } func TestCreate(t *testing.T) { db := mocks.NewDatabase() performer := models.Performer{ Name: performerName, } performerInput := models.CreatePerformerInput{ Performer: &performer, } performerErr := models.Performer{ Name: performerNameErr, } performerErrInput := models.CreatePerformerInput{ Performer: &performerErr, } i := Importer{ ReaderWriter: db.Performer, TagWriter: db.Tag, performer: performer, } errCreate := errors.New("Create error") db.Performer.On("Create", testCtx, &performerInput).Run(func(args mock.Arguments) { arg := args.Get(1).(*models.CreatePerformerInput) arg.ID = performerID }).Return(nil).Once() db.Performer.On("Create", testCtx, &performerErrInput).Return(errCreate).Once() id, err := i.Create(testCtx) assert.Equal(t, performerID, *id) assert.Nil(t, err) i.performer = performerErr id, err = i.Create(testCtx) assert.Nil(t, id) assert.NotNil(t, err) db.AssertExpectations(t) } func TestUpdate(t *testing.T) { db := mocks.NewDatabase() performer := models.Performer{ Name: performerName, } performerErr := models.Performer{ Name: performerNameErr, } i := Importer{ ReaderWriter: db.Performer, TagWriter: db.Tag, performer: performer, } errUpdate := errors.New("Update error") // id needs to be set for the mock input performer.ID = performerID performerInput := models.UpdatePerformerInput{ Performer: &performer, } db.Performer.On("Update", testCtx, &performerInput).Return(nil).Once() err := i.Update(testCtx, performerID) assert.Nil(t, err) i.performer = performerErr // need to set id separately performerErr.ID = errImageID performerErrInput := models.UpdatePerformerInput{ Performer: &performerErr, } db.Performer.On("Update", testCtx, &performerErrInput).Return(errUpdate).Once() err = i.Update(testCtx, errImageID) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImportCareerFields(t *testing.T) { startYear, _ := models.ParseDate("2005") endYear, _ := models.ParseDate("2015") // explicit career_start/career_end should be used directly t.Run("explicit fields", func(t *testing.T) { input := jsonschema.Performer{ Name: "test", CareerStart: startYear.String(), CareerEnd: endYear.String(), } p, err := performerJSONToPerformer(input) assert.Nil(t, err) assert.Equal(t, &startYear, p.CareerStart) assert.Equal(t, &endYear, p.CareerEnd) }) // explicit fields take priority over legacy career_length t.Run("explicit fields override legacy", func(t *testing.T) { input := jsonschema.Performer{ Name: "test", CareerStart: startYear.String(), CareerEnd: endYear.String(), CareerLength: "1990 - 1995", } p, err := performerJSONToPerformer(input) assert.Nil(t, err) assert.Equal(t, &startYear, p.CareerStart) assert.Equal(t, &endYear, p.CareerEnd) }) // legacy career_length should be parsed when explicit fields are absent t.Run("legacy career_length fallback", func(t *testing.T) { input := jsonschema.Performer{ Name: "test", CareerLength: "2005 - 2015", } p, err := performerJSONToPerformer(input) assert.Nil(t, err) assert.Equal(t, &startYear, p.CareerStart) assert.Equal(t, &endYear, p.CareerEnd) }) // legacy career_length with only start year t.Run("legacy career_length start only", func(t *testing.T) { input := jsonschema.Performer{ Name: "test", CareerLength: "2005 -", } p, err := performerJSONToPerformer(input) assert.Nil(t, err) assert.Equal(t, &startYear, p.CareerStart) assert.Nil(t, p.CareerEnd) }) // unparseable career_length should return an error t.Run("legacy career_length unparseable", func(t *testing.T) { input := jsonschema.Performer{ Name: "test", CareerLength: "not a year range", } _, err := performerJSONToPerformer(input) assert.NotNil(t, err) }) // no career fields at all t.Run("no career fields", func(t *testing.T) { input := jsonschema.Performer{ Name: "test", } p, err := performerJSONToPerformer(input) assert.Nil(t, err) assert.Nil(t, p.CareerStart) assert.Nil(t, p.CareerEnd) }) } ================================================ FILE: pkg/performer/query.go ================================================ package performer import ( "context" "strconv" "github.com/stashapp/stash/pkg/models" ) func CountByStudioID(ctx context.Context, r models.PerformerQueryer, id int, depth *int) (int, error) { filter := &models.PerformerFilterType{ Studios: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, Depth: depth, }, } return r.QueryCount(ctx, filter, nil) } func CountByGroupID(ctx context.Context, r models.PerformerQueryer, id int, depth *int) (int, error) { filter := &models.PerformerFilterType{ Groups: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, Depth: depth, }, } return r.QueryCount(ctx, filter, nil) } func CountByTagID(ctx context.Context, r models.PerformerQueryer, id int, depth *int) (int, error) { filter := &models.PerformerFilterType{ Tags: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, Depth: depth, }, } return r.QueryCount(ctx, filter, nil) } func CountByAppearsWith(ctx context.Context, r models.PerformerQueryer, id int) (int, error) { filter := &models.PerformerFilterType{ Performers: &models.MultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, }, } return r.QueryCount(ctx, filter, nil) } func ByAlias(ctx context.Context, r models.PerformerQueryer, alias string) ([]*models.Performer, error) { f := &models.PerformerFilterType{ Aliases: &models.StringCriterionInput{ Value: alias, Modifier: models.CriterionModifierEquals, }, } ret, count, err := r.Query(ctx, f, nil) if err != nil { return nil, err } if count > 0 { return ret, nil } return nil, nil } ================================================ FILE: pkg/performer/url.go ================================================ package performer import ( "regexp" ) var ( twitterURLRE = regexp.MustCompile(`^https?:\/\/(?:www\.)?twitter\.com\/`) instagramURLRE = regexp.MustCompile(`^https?:\/\/(?:www\.)?instagram\.com\/`) ) func IsTwitterURL(url string) bool { return twitterURLRE.MatchString(url) } func IsInstagramURL(url string) bool { return instagramURLRE.MatchString(url) } ================================================ FILE: pkg/performer/validate.go ================================================ package performer import ( "context" "errors" "fmt" "strings" "github.com/stashapp/stash/pkg/models" ) var ( ErrNameMissing = errors.New("performer name must not be blank") ) type NotFoundError struct { id int } func (e *NotFoundError) Error() string { return fmt.Sprintf("performer with id %d not found", e.id) } type NameExistsError struct { Name string Disambiguation string } func (e *NameExistsError) Error() string { if e.Disambiguation != "" { return fmt.Sprintf("performer with name '%s' and disambiguation '%s' already exists", e.Name, e.Disambiguation) } return fmt.Sprintf("performer with name '%s' already exists", e.Name) } type DuplicateAliasError struct { Alias string } func (e *DuplicateAliasError) Error() string { return fmt.Sprintf("performer contains duplicate alias '%s'", e.Alias) } type DeathDateError struct { Birthdate models.Date DeathDate models.Date } func (e *DeathDateError) Error() string { return fmt.Sprintf("death date %s should be after birthdate %s", e.DeathDate, e.Birthdate) } func ValidateCreate(ctx context.Context, performer models.Performer, qb models.PerformerReader) error { if err := ValidateName(ctx, performer.Name, performer.Disambiguation, qb); err != nil { return err } if err := ValidateAliases(performer.Name, performer.Aliases); err != nil { return err } if err := ValidateDeathDate(performer.Birthdate, performer.DeathDate); err != nil { return err } return nil } func ValidateUpdate(ctx context.Context, id int, partial models.PerformerPartial, qb models.PerformerReader) error { existing, err := qb.Find(ctx, id) if err != nil { return err } if existing == nil { return &NotFoundError{id} } if err := ValidateUpdateName(ctx, *existing, partial.Name, partial.Disambiguation, qb); err != nil { return err } if err := existing.LoadAliases(ctx, qb); err != nil { return err } if err := ValidateUpdateAliases(*existing, partial.Name, partial.Aliases); err != nil { return err } if err := ValidateUpdateDeathDate(*existing, partial.Birthdate, partial.DeathDate); err != nil { return err } return nil } func validateName(ctx context.Context, name string, disambig string, existingID *int, qb models.PerformerQueryer) error { performerFilter := models.PerformerFilterType{ Name: &models.StringCriterionInput{ Value: name, Modifier: models.CriterionModifierEquals, }, } modifier := models.CriterionModifierIsNull if disambig != "" { modifier = models.CriterionModifierEquals } performerFilter.Disambiguation = &models.StringCriterionInput{ Value: disambig, Modifier: modifier, } if existingID == nil { // creating: error if any existing performer matches pp := 1 findFilter := models.FindFilterType{ PerPage: &pp, } count, err := qb.QueryCount(ctx, &performerFilter, &findFilter) if err != nil { return err } if count > 0 { return &NameExistsError{ Name: name, Disambiguation: disambig, } } return nil } else { // updating: check for matches, but ignore self pp := 2 findFilter := models.FindFilterType{ PerPage: &pp, } conflicts, _, err := qb.Query(ctx, &performerFilter, &findFilter) if err != nil { return err } if len(conflicts) > 0 { // valid if the only conflict is the existing performer if len(conflicts) > 1 || conflicts[0].ID != *existingID { return &NameExistsError{ Name: name, Disambiguation: disambig, } } } return nil } } // ValidateName returns an error if the performer name and disambiguation provided is used by another performer. func ValidateName(ctx context.Context, name string, disambig string, qb models.PerformerQueryer) error { if name == "" { return ErrNameMissing } return validateName(ctx, name, disambig, nil, qb) } // ValidateUpdateName performs the same check as ValidateName, but is used when modifying an existing performer. func ValidateUpdateName(ctx context.Context, existing models.Performer, name models.OptionalString, disambig models.OptionalString, qb models.PerformerQueryer) error { // if neither name nor disambig is set, don't check anything if !name.Set && !disambig.Set { return nil } newName := existing.Name if name.Set { newName = name.Value } if newName == "" { return ErrNameMissing } newDisambig := existing.Disambiguation if disambig.Set { newDisambig = disambig.Value } return validateName(ctx, newName, newDisambig, &existing.ID, qb) } func ValidateAliases(name string, aliases models.RelatedStrings) error { if !aliases.Loaded() { return nil } m := make(map[string]bool) nameL := strings.ToLower(name) m[nameL] = true for _, alias := range aliases.List() { aliasL := strings.ToLower(alias) if m[aliasL] { return &DuplicateAliasError{alias} } m[aliasL] = true } return nil } func ValidateUpdateAliases(existing models.Performer, name models.OptionalString, aliases *models.UpdateStrings) error { // if neither name nor aliases is set, don't check anything if !name.Set && aliases == nil { return nil } newName := existing.Name if name.Set { newName = name.Value } // If aliases is nil, we're only changing the name - check existing aliases against new name if aliases == nil { return ValidateAliases(newName, existing.Aliases) } newAliases := aliases.Apply(existing.Aliases.List()) return ValidateAliases(newName, models.NewRelatedStrings(newAliases)) } // ValidateDeathDate returns an error if the birthdate is after the death date. func ValidateDeathDate(birthdate *models.Date, deathDate *models.Date) error { if birthdate == nil || deathDate == nil { return nil } if birthdate.After(*deathDate) { return &DeathDateError{Birthdate: *birthdate, DeathDate: *deathDate} } return nil } // ValidateUpdateDeathDate performs the same check as ValidateDeathDate, but is used when modifying an existing performer. func ValidateUpdateDeathDate(existing models.Performer, birthdate models.OptionalDate, deathDate models.OptionalDate) error { // if neither birthdate nor deathDate is set, don't check anything if !birthdate.Set && !deathDate.Set { return nil } newBirthdate := existing.Birthdate if birthdate.Set { newBirthdate = birthdate.Ptr() } newDeathDate := existing.DeathDate if deathDate.Set { newDeathDate = deathDate.Ptr() } return ValidateDeathDate(newBirthdate, newDeathDate) } ================================================ FILE: pkg/performer/validate_test.go ================================================ package performer import ( "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) func nameFilter(n string) *models.PerformerFilterType { return &models.PerformerFilterType{ Name: &models.StringCriterionInput{ Value: n, Modifier: models.CriterionModifierEquals, }, Disambiguation: &models.StringCriterionInput{ Modifier: models.CriterionModifierIsNull, }, } } func disambigFilter(n string, d string) *models.PerformerFilterType { return &models.PerformerFilterType{ Name: &models.StringCriterionInput{ Value: n, Modifier: models.CriterionModifierEquals, }, Disambiguation: &models.StringCriterionInput{ Value: d, Modifier: models.CriterionModifierEquals, }, } } func TestValidateName(t *testing.T) { db := mocks.NewDatabase() const ( name1 = "name 1" name2 = "name 2" disambig = "disambiguation" newName = "new name" newDisambig = "new disambiguation" ) pp := 1 findFilter := &models.FindFilterType{ PerPage: &pp, } db.Performer.On("QueryCount", testCtx, nameFilter(name1), findFilter).Return(1, nil) db.Performer.On("QueryCount", testCtx, nameFilter(name2), findFilter).Return(1, nil) db.Performer.On("QueryCount", testCtx, disambigFilter(name2, disambig), findFilter).Return(1, nil) db.Performer.On("QueryCount", testCtx, mock.Anything, findFilter).Return(0, nil) tests := []struct { tName string name string disambig string want error }{ {"missing name", "", newDisambig, ErrNameMissing}, {"new name", newName, "", nil}, {"new name new disambig", newName, newDisambig, nil}, {"new name existing disambig", newName, disambig, nil}, {"existing name", name1, "", &NameExistsError{name1, ""}}, {"existing name new disambig", name1, newDisambig, nil}, {"existing name existing disambig", name1, disambig, nil}, {"existing name and disambig", name2, disambig, &NameExistsError{name2, disambig}}, } for _, tt := range tests { t.Run(tt.tName, func(t *testing.T) { got := ValidateName(testCtx, tt.name, tt.disambig, db.Performer) assert.Equal(t, tt.want, got) }) } } func TestValidateUpdateName(t *testing.T) { db := mocks.NewDatabase() const ( name1 = "name 1" name2 = "name 2" disambig1 = "disambiguation 1" disambig2 = "disambiguation 2" newName = "new name" newDisambig = "new disambiguation" ) osUnset := models.OptionalString{} osNull := models.OptionalString{Set: true, Null: true} osName1 := models.NewOptionalString(name1) osName2 := models.NewOptionalString(name2) osDisambig1 := models.NewOptionalString(disambig1) osDisambig2 := models.NewOptionalString(disambig2) osNewName := models.NewOptionalString(newName) osNewDisambig := models.NewOptionalString(newDisambig) existing1 := models.Performer{ ID: 1, Name: name1, } existing2 := models.Performer{ ID: 2, Name: name2, Disambiguation: disambig1, } existing3 := models.Performer{ ID: 3, Name: name2, Disambiguation: disambig2, } pp := 2 findFilter := &models.FindFilterType{ PerPage: &pp, } db.Performer.On("Query", testCtx, nameFilter(name1), findFilter).Return([]*models.Performer{&existing1}, 1, nil) db.Performer.On("Query", testCtx, nameFilter(name2), findFilter).Return([]*models.Performer{&existing2, &existing3}, 2, nil) db.Performer.On("Query", testCtx, disambigFilter(name2, disambig1), findFilter).Return([]*models.Performer{&existing2}, 1, nil) db.Performer.On("Query", testCtx, disambigFilter(name2, disambig2), findFilter).Return([]*models.Performer{&existing3}, 1, nil) db.Performer.On("Query", testCtx, mock.Anything, findFilter).Return(nil, 0, nil) tests := []struct { tName string performer models.Performer name models.OptionalString disambig models.OptionalString want error }{ {"missing name", existing1, osNull, osUnset, ErrNameMissing}, {"same name", existing3, osName2, osUnset, nil}, {"same disambig", existing2, osUnset, osDisambig1, nil}, {"same name same disambig", existing2, osName2, osDisambig1, nil}, {"new name", existing1, osNewName, osUnset, nil}, {"new disambig", existing1, osUnset, osNewDisambig, nil}, {"new name new disambig", existing1, osNewName, osNewDisambig, nil}, {"remove disambig", existing3, osUnset, osNull, &NameExistsError{name2, ""}}, {"existing name keep disambig", existing3, osName1, osUnset, nil}, {"existing name remove disambig", existing3, osName1, osNull, &NameExistsError{name1, ""}}, {"existing disambig", existing2, osUnset, osDisambig2, &NameExistsError{name2, disambig2}}, {"existing name and disambig", existing1, osName2, osDisambig1, &NameExistsError{name2, disambig1}}, } for _, tt := range tests { t.Run(tt.tName, func(t *testing.T) { got := ValidateUpdateName(testCtx, tt.performer, tt.name, tt.disambig, db.Performer) assert.Equal(t, tt.want, got) }) } } func TestValidateAliases(t *testing.T) { const ( name1 = "name 1" name1U = "NAME 1" name2 = "name 2" name3 = "name 3" name4 = "name 4" ) tests := []struct { tName string name string aliases []string want error }{ {"no aliases", name1, nil, nil}, {"valid aliases", name2, []string{name3, name4}, nil}, {"duplicate alias", name1, []string{name2, name3, name2}, &DuplicateAliasError{name2}}, {"duplicate name", name4, []string{name4, name3}, &DuplicateAliasError{name4}}, {"duplicate alias caps", name2, []string{name1, name1U}, &DuplicateAliasError{name1U}}, {"duplicate name caps", name1U, []string{name1}, &DuplicateAliasError{name1}}, } for _, tt := range tests { t.Run(tt.tName, func(t *testing.T) { got := ValidateAliases(tt.name, models.NewRelatedStrings(tt.aliases)) assert.Equal(t, tt.want, got) }) } } func TestValidateUpdateAliases(t *testing.T) { const ( name1 = "name 1" name1U = "NAME 1" name2 = "name 2" name3 = "name 3" name4 = "name 4" ) existing := models.Performer{ Name: name1, Aliases: models.NewRelatedStrings([]string{name2}), } osUnset := models.OptionalString{} os1 := models.NewOptionalString(name1) os2 := models.NewOptionalString(name2) os3 := models.NewOptionalString(name3) os4 := models.NewOptionalString(name4) tests := []struct { tName string name models.OptionalString aliases []string want error }{ {"both unset", osUnset, nil, nil}, {"name conflicts with alias", os2, nil, &DuplicateAliasError{name2}}, {"valid name set", os3, nil, nil}, {"valid aliases empty", os1, []string{}, nil}, {"alias matches name", osUnset, []string{name1U}, &DuplicateAliasError{name1U}}, {"valid aliases set", osUnset, []string{name3, name2}, nil}, {"alias matches new name", os4, []string{name4}, &DuplicateAliasError{name4}}, {"valid both set", os2, []string{name1}, nil}, } for _, tt := range tests { t.Run(tt.tName, func(t *testing.T) { var aliases *models.UpdateStrings if tt.aliases != nil { aliases = &models.UpdateStrings{ Values: tt.aliases, Mode: models.RelationshipUpdateModeSet, } } got := ValidateUpdateAliases(existing, tt.name, aliases) assert.Equal(t, tt.want, got) }) } } func TestValidateDeathDate(t *testing.T) { date1, _ := models.ParseDate("2001-01-01") date2, _ := models.ParseDate("2002-01-01") date3, _ := models.ParseDate("2003-01-01") date4, _ := models.ParseDate("2004-01-01") tests := []struct { name string birthdate *models.Date deathdate *models.Date want error }{ {"both nil", nil, nil, nil}, {"birthdate nil", nil, &date1, nil}, {"deathdate nil", nil, &date2, nil}, {"valid", &date3, &date4, nil}, {"invalid", &date3, &date2, &DeathDateError{date3, date2}}, {"same date", &date1, &date1, nil}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := ValidateDeathDate(tt.birthdate, tt.deathdate) assert.Equal(t, tt.want, got) }) } } func TestValidateUpdateDeathDate(t *testing.T) { date1, _ := models.ParseDate("2001-01-01") date2, _ := models.ParseDate("2002-01-01") date3, _ := models.ParseDate("2003-01-01") date4, _ := models.ParseDate("2004-01-01") existing := models.Performer{ Birthdate: &date2, DeathDate: &date3, } odUnset := models.OptionalDate{} odNull := models.OptionalDate{Set: true, Null: true} od1 := models.NewOptionalDate(date1) od2 := models.NewOptionalDate(date2) od3 := models.NewOptionalDate(date3) od4 := models.NewOptionalDate(date4) tests := []struct { name string birthdate models.OptionalDate deathdate models.OptionalDate want error }{ {"both unset", odUnset, odUnset, nil}, {"invalid birthdate set", od4, odUnset, &DeathDateError{date4, date3}}, {"valid birthdate set", od1, odUnset, nil}, {"valid birthdate set null", odNull, odUnset, nil}, {"invalid deathdate set", odUnset, od1, &DeathDateError{date2, date1}}, {"valid deathdate set", odUnset, od4, nil}, {"valid deathdate set null", odUnset, odNull, nil}, {"invalid both set", od3, od2, &DeathDateError{date3, date2}}, {"valid both set", od2, od3, nil}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := ValidateUpdateDeathDate(existing, tt.birthdate, tt.deathdate) assert.Equal(t, tt.want, got) }) } } ================================================ FILE: pkg/pkg/cache.go ================================================ package pkg import ( "time" ) type cacheEntry struct { lastModified time.Time data []RemotePackage } type repositoryCache struct { // cache maps the URL to the last modified time and the data cache map[string]cacheEntry } func (c *repositoryCache) ensureCache() { if c.cache == nil { c.cache = make(map[string]cacheEntry) } } func (c *repositoryCache) lastModified(url string) *time.Time { if c == nil { return nil } c.ensureCache() e, found := c.cache[url] if !found { return nil } return &e.lastModified } func (c *repositoryCache) getPackageList(url string) []RemotePackage { c.ensureCache() e, found := c.cache[url] if !found { return nil } return e.data } func (c *repositoryCache) cacheList(url string, lastModified time.Time, data []RemotePackage) { if c == nil { return } c.ensureCache() c.cache[url] = cacheEntry{ lastModified: lastModified, data: data, } } ================================================ FILE: pkg/pkg/manager.go ================================================ package pkg import ( "archive/zip" "bytes" "context" "crypto/sha256" "fmt" "io" "net/http" "net/url" "path/filepath" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) // SourcePathGetter gets the source path for a given package URL. type SourcePathGetter interface { // GetAllSourcePaths gets all source paths. GetAllSourcePaths() []string // GetSourcePath gets the source path for the given package URL. GetSourcePath(srcURL string) string } // Manager manages the installation of paks. type Manager struct { Local *Store PackagePathGetter SourcePathGetter Client *http.Client cache *repositoryCache } func (m *Manager) getCache() *repositoryCache { if m.cache == nil { m.cache = &repositoryCache{} } return m.cache } func (m *Manager) remoteFromURL(path string) (*httpRepository, error) { u, err := url.Parse(path) if err != nil { return nil, fmt.Errorf("parsing path: %w", err) } return newHttpRepository(*u, m.Client, m.getCache()), nil } func (m *Manager) ListInstalled(ctx context.Context) (LocalPackageIndex, error) { paths := m.PackagePathGetter.GetAllSourcePaths() var installedList []Manifest for _, p := range paths { store := m.Local.sub(p) srcList, err := store.List(ctx) if err != nil { return nil, fmt.Errorf("listing local packages: %w", err) } installedList = append(installedList, srcList...) } return localPackageIndexFromList(installedList), nil } func (m *Manager) ListRemote(ctx context.Context, remoteURL string) (RemotePackageIndex, error) { r, err := m.remoteFromURL(remoteURL) if err != nil { return nil, fmt.Errorf("creating remote repository: %w", err) } list, err := r.List(ctx) if err != nil { return nil, fmt.Errorf("listing remote packages: %w", err) } // add link to RemotePackage for i := range list { list[i].Repository = r } ret := remotePackageIndexFromList(list) return ret, nil } func (m *Manager) ListInstalledRemotes(ctx context.Context, installed LocalPackageIndex) (RemotePackageIndex, error) { // get remotes for all installed packages allRemoteList := make(RemotePackageIndex) remoteURLs := installed.remoteURLs() for _, remoteURL := range remoteURLs { remoteList, err := m.ListRemote(ctx, remoteURL) if err != nil { logger.Warnf("error listing remote package %s: %v", remoteURL, err) continue } allRemoteList.merge(remoteList) } return allRemoteList, nil } func (m *Manager) InstalledStatus(ctx context.Context) (PackageStatusIndex, error) { // get all installed packages installed, err := m.ListInstalled(ctx) if err != nil { return nil, err } // get remotes for all installed packages allRemoteList, err := m.ListInstalledRemotes(ctx, installed) if err != nil { return nil, err } ret := MakePackageStatusIndex(installed, allRemoteList) return ret, nil } func (m *Manager) packageByID(ctx context.Context, spec models.PackageSpecInput) (*RemotePackage, error) { l, err := m.ListRemote(ctx, spec.SourceURL) if err != nil { return nil, err } pkg, found := l[spec] if !found { return nil, nil } return &pkg, nil } func (m *Manager) getStore(remoteURL string) *Store { srcPath := m.PackagePathGetter.GetSourcePath(remoteURL) store := m.Local.sub(srcPath) return store } func (m *Manager) Install(ctx context.Context, spec models.PackageSpecInput) error { remote, err := m.remoteFromURL(spec.SourceURL) if err != nil { return fmt.Errorf("creating remote repository: %w", err) } pkg, err := m.packageByID(ctx, spec) if err != nil { return fmt.Errorf("getting remote package: %w", err) } fromRemote, err := remote.GetPackageZip(ctx, *pkg) if err != nil { return fmt.Errorf("getting remote package: %w", err) } defer fromRemote.Close() d, err := io.ReadAll(fromRemote) if err != nil { return fmt.Errorf("reading package data: %w", err) } sha := fmt.Sprintf("%x", sha256.Sum256(d)) if sha != pkg.Sha256 { return fmt.Errorf("package data (%s) does not match expected SHA256 (%s)", sha, pkg.Sha256) } zr, err := zip.NewReader(bytes.NewReader(d), int64(len(d))) if err != nil { return fmt.Errorf("reading zip data: %w", err) } store := m.getStore(spec.SourceURL) // uninstall existing package if present if _, err := store.getManifest(ctx, pkg.ID); err == nil { if err := m.deletePackageFiles(ctx, store, pkg.ID); err != nil { return fmt.Errorf("uninstalling existing package: %w", err) } } if err := m.installPackage(*pkg, store, zr); err != nil { return fmt.Errorf("installing package: %w", err) } return nil } func (m *Manager) installPackage(pkg RemotePackage, store *Store, zr *zip.Reader) error { manifest := Manifest{ ID: pkg.ID, Name: pkg.Name, Metadata: pkg.Metadata, PackageVersion: pkg.PackageVersion, RepositoryURL: pkg.Repository.Path(), } for _, f := range zr.File { if f.FileInfo().IsDir() { continue } i, err := f.Open() if err != nil { return err } fn := filepath.Clean(f.Name) if err := store.writeFile(pkg.ID, fn, f.Mode(), i); err != nil { i.Close() return fmt.Errorf("writing file %q: %w", fn, err) } i.Close() manifest.Files = append(manifest.Files, fn) } if err := store.writeManifest(pkg.ID, manifest); err != nil { return fmt.Errorf("writing manifest: %w", err) } return nil } // Uninstall uninstalls the given package. func (m *Manager) Uninstall(ctx context.Context, spec models.PackageSpecInput) error { store := m.getStore(spec.SourceURL) if err := m.deletePackageFiles(ctx, store, spec.ID); err != nil { return fmt.Errorf("deleting local package: %w", err) } // also delete the directory // ignore errors _ = store.deletePackageDir(spec.ID) return nil } func (m *Manager) deletePackageFiles(ctx context.Context, store *Store, id string) error { manifest, err := store.getManifest(ctx, id) if err != nil { return fmt.Errorf("getting manifest: %w", err) } for _, f := range manifest.Files { if err := store.deleteFile(id, f); err != nil { // ignore continue } } if err := store.deleteManifest(id); err != nil { return fmt.Errorf("deleting manifest: %w", err) } return nil } ================================================ FILE: pkg/pkg/pkg.go ================================================ // Package pkg provides interfaces to interact with the package system used for plugins and scrapers. package pkg import ( "fmt" "time" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil" ) const ( // TimeFormat is the format used for marshalling/unmarshalling time.Time. // Times are stored in UTC. TimeFormat = "2006-01-02 15:04:05" // timeFormatLegacy is the old format that may exist in some manifests. timeFormatLegacy = "2006-01-02 15:04:05 -0700" ) // Time is a wrapper around time.Time that allows for custom YAML marshalling/unmarshalling using TimeFormat. type Time struct { time.Time } func (t *Time) UnmarshalYAML(unmarshal func(interface{}) error) error { var s string if err := unmarshal(&s); err != nil { return err } // times are stored in UTC parsed, err := time.Parse(TimeFormat, s) if err != nil { // try to parse using the legacy format var legacyErr error parsed, legacyErr = time.Parse(timeFormatLegacy, s) if legacyErr != nil { // if we can't parse using the legacy format, return the original error return err } // convert timezoned time to UTC parsed = parsed.UTC() } t.Time = parsed return nil } func (t Time) MarshalYAML() (interface{}, error) { return t.Format(TimeFormat), nil } type PackageMetadata map[string]interface{} type PackageVersion struct { Version string `yaml:"version"` Date Time `yaml:"date"` } func (v PackageVersion) Upgradable(o PackageVersion) bool { return o.Date.After(v.Date.Time) } func (v PackageVersion) String() string { ret := v.Version if !v.Date.IsZero() { date := v.Date.Format("2006-01-02") if ret != "" { ret += fmt.Sprintf(" (%s)", date) } else { ret = date } } return ret } type PackageLocation struct { // Path is the path to the package zip file. // This may be relative or absolute. Path string `yaml:"path"` Sha256 string `yaml:"sha256"` } type RemotePackage struct { ID string `yaml:"id"` Name string `yaml:"name"` Repository remoteRepository `yaml:"-"` Requires []string `yaml:"requires"` Metadata PackageMetadata `yaml:"metadata"` PackageVersion `yaml:",inline"` PackageLocation `yaml:",inline"` } func (p RemotePackage) PackageSpecInput() models.PackageSpecInput { return models.PackageSpecInput{ ID: p.ID, SourceURL: p.Repository.Path(), } } type Manifest struct { ID string `yaml:"id"` Name string `yaml:"name"` Metadata PackageMetadata `yaml:"metadata"` PackageVersion `yaml:",inline"` Requires []string `yaml:"requires"` RepositoryURL string `yaml:"source_repository"` Files []string `yaml:"files"` } func (m Manifest) PackageSpecInput() models.PackageSpecInput { return models.PackageSpecInput{ ID: m.ID, SourceURL: m.RepositoryURL, } } // RemotePackageIndex is a map of package name to RemotePackage type RemotePackageIndex map[models.PackageSpecInput]RemotePackage func (i RemotePackageIndex) merge(o RemotePackageIndex) { for id, pkg := range o { if existing, found := i[id]; found { if existing.Date.After(pkg.Date.Time) { continue } } i[id] = pkg } } func remotePackageIndexFromList(packages []RemotePackage) RemotePackageIndex { index := make(RemotePackageIndex) for _, pkg := range packages { specInput := pkg.PackageSpecInput() // if package already exists in map, choose the newest if existing, found := index[specInput]; found { if existing.Date.After(pkg.Date.Time) { continue } } index[specInput] = pkg } return index } // LocalPackageIndex is a map of package name to RemotePackage type LocalPackageIndex map[models.PackageSpecInput]Manifest func (i LocalPackageIndex) remoteURLs() []string { var ret []string for _, pkg := range i { ret = sliceutil.AppendUnique(ret, pkg.RepositoryURL) } return ret } func localPackageIndexFromList(packages []Manifest) LocalPackageIndex { index := make(LocalPackageIndex) for _, pkg := range packages { index[pkg.PackageSpecInput()] = pkg } return index } type PackageStatus struct { Local *Manifest Remote *RemotePackage } func (s PackageStatus) Upgradable() bool { if s.Local == nil || s.Remote == nil { return false } return s.Local.Upgradable(s.Remote.PackageVersion) } type PackageStatusIndex map[models.PackageSpecInput]PackageStatus func MakePackageStatusIndex(installed LocalPackageIndex, remote RemotePackageIndex) PackageStatusIndex { i := make(PackageStatusIndex) for spec, pkg := range installed { pkgCopy := pkg s := PackageStatus{ Local: &pkgCopy, } if remotePkg, found := remote[spec]; found { s.Remote = &remotePkg } i[spec] = s } return i } func (i PackageStatusIndex) Upgradable() []PackageStatus { var ret []PackageStatus for _, s := range i { if s.Upgradable() { ret = append(ret, s) } } return ret } ================================================ FILE: pkg/pkg/repository.go ================================================ package pkg import ( "context" "io" ) // remoteRepository is a repository that can be used to get paks from. type remoteRepository interface { RemotePackageLister RemotePackageGetter Path() string } type RemotePackageLister interface { // List returns all specs in the repository. List(ctx context.Context) ([]RemotePackage, error) } type RemotePackageGetter interface { GetPackageZip(ctx context.Context, pkg RemotePackage) (io.ReadCloser, error) } ================================================ FILE: pkg/pkg/repository_http.go ================================================ // Package http provides a repository implementation for HTTP. package pkg import ( "context" "fmt" "io" "io/fs" "net/http" "net/url" "os" "path" "time" "github.com/stashapp/stash/pkg/logger" "gopkg.in/yaml.v2" ) // httpRepository is a HTTP based repository. // It is configured with a package list URL. Packages are located from the Path field of the package. // // The index is cached for the duration of CacheTTL. The first request after the cache expires will cause the index to be reloaded. type httpRepository struct { packageListURL url.URL client *http.Client cache *repositoryCache } // newHttpRepository creates a new Repository. If client is nil then http.DefaultClient is used. func newHttpRepository(packageListURL url.URL, client *http.Client, cache *repositoryCache) *httpRepository { if client == nil { client = http.DefaultClient } return &httpRepository{ packageListURL: packageListURL, client: client, cache: cache, } } func (r *httpRepository) Path() string { return r.packageListURL.String() } func (r *httpRepository) List(ctx context.Context) ([]RemotePackage, error) { u := r.packageListURL // the package list URL may be file://, in which case we need to use the local file system var ( f io.ReadCloser modTime *time.Time err error ) isLocal := u.Scheme == "file" if isLocal { f, err = r.getLocalFile(ctx, u.Path) } else { // try to get the cached list first var cachedList []RemotePackage cachedList, err = r.getCachedList(ctx, u) if err != nil { return nil, fmt.Errorf("failed to get cached package list: %w", err) } if cachedList != nil { return cachedList, nil } f, modTime, err = r.getFile(ctx, u) } if err != nil { return nil, fmt.Errorf("failed to get package list: %w", err) } defer f.Close() data, err := io.ReadAll(f) if err != nil { return nil, fmt.Errorf("failed to read package list: %w", err) } var index []RemotePackage if err := yaml.Unmarshal(data, &index); err != nil { return nil, fmt.Errorf("reading package list: %w", err) } // cache if not local file if !isLocal { r.cache.cacheList(u.String(), *modTime, index) } return index, nil } func isURL(s string) bool { u, err := url.Parse(s) return err == nil && u.Scheme != "" && (u.Scheme == "file" || u.Host != "") } func (r *httpRepository) resolvePath(p string) url.URL { // if the path can be resolved to a URL, then use that if isURL(p) { // isURL ensures URL is valid u, _ := url.Parse(p) return *u } // otherwise, determine if the path is relative or absolute // if it's relative, then join it with the package list URL u := r.packageListURL if path.IsAbs(p) { u.Path = p } else { u.Path = path.Join(path.Dir(u.Path), p) } return u } func (r *httpRepository) GetPackageZip(ctx context.Context, pkg RemotePackage) (io.ReadCloser, error) { p := pkg.Path u := r.resolvePath(p) var ( f io.ReadCloser err error ) // the package list URL may be file://, in which case we need to use the local file system // the package zip path may be a URL. A remotely hosted list may _not_ use local files. if u.Scheme == "file" { if r.packageListURL.Scheme != "file" { return nil, fmt.Errorf("%s is invalid for a remotely hosted package list", u.String()) } f, err = r.getLocalFile(ctx, u.Path) } else { f, _, err = r.getFile(ctx, u) } if err != nil { return nil, fmt.Errorf("failed to get package file: %w", err) } return f, nil } // getFileCached tries to get the list from the local cache. // If it is not found or is stale, then nil is returned. func (r *httpRepository) getCachedList(ctx context.Context, u url.URL) ([]RemotePackage, error) { // check if the file is in the cache first localModTime := r.cache.lastModified(u.String()) if localModTime != nil { // get the update time of the file req, err := http.NewRequestWithContext(ctx, http.MethodHead, u.String(), nil) if err != nil { // shouldn't happen return nil, err } resp, err := r.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to get remote file: %w", err) } if resp.StatusCode >= 400 { return nil, fmt.Errorf("failed to get remote file: %s", resp.Status) } lastModified := resp.Header.Get("Last-Modified") if lastModified != "" { remoteModTime, _ := time.Parse(http.TimeFormat, lastModified) if !remoteModTime.After(*localModTime) { logger.Debugf("cached version of %s is equal or newer than remote", u.String()) return r.cache.getPackageList(u.String()), nil } } logger.Debugf("cached version of %s is older than remote", u.String()) } return nil, nil } // getFile gets the file from the remote server. Returns the file and the last modified time. func (r *httpRepository) getFile(ctx context.Context, u url.URL) (io.ReadCloser, *time.Time, error) { logger.Debugf("fetching %s", u.String()) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { // shouldn't happen return nil, nil, err } resp, err := r.client.Do(req) if err != nil { return nil, nil, fmt.Errorf("failed to get remote file: %w", err) } if resp.StatusCode >= 400 { return nil, nil, fmt.Errorf("failed to get remote file: %s", resp.Status) } lastModified := resp.Header.Get("Last-Modified") var remoteModTime time.Time if lastModified != "" { remoteModTime, _ = time.Parse(http.TimeFormat, lastModified) } return resp.Body, &remoteModTime, nil } func (r *httpRepository) getLocalFile(ctx context.Context, path string) (fs.File, error) { ret, err := os.Open(path) if err != nil { return nil, fmt.Errorf("failed to get file %q: %w", path, err) } return ret, nil } var _ = remoteRepository(&httpRepository{}) ================================================ FILE: pkg/pkg/repository_http_test.go ================================================ // Package http provides a repository implementation for HTTP. package pkg import ( "net/url" "reflect" "testing" ) func TestHttpRepository_resolvePath(t *testing.T) { mustParse := func(s string) url.URL { u, err := url.Parse(s) if err != nil { panic(err) } return *u } tests := []struct { name string packageListURL url.URL p string want url.URL }{ { name: "relative", packageListURL: mustParse("https://example.com/foo/packages.yaml"), p: "bar", want: mustParse("https://example.com/foo/bar"), }, { name: "absolute", packageListURL: mustParse("https://example.com/foo/packages.yaml"), p: "/bar", want: mustParse("https://example.com/bar"), }, { name: "different server", packageListURL: mustParse("https://example.com/foo/packages.yaml"), p: "http://example.org/bar", want: mustParse("http://example.org/bar"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &httpRepository{ packageListURL: tt.packageListURL, } got := r.resolvePath(tt.p) if !reflect.DeepEqual(got, tt.want) { t.Errorf("HttpRepository.resolvePath() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: pkg/pkg/store.go ================================================ package pkg import ( "context" "errors" "fmt" "io" "io/fs" "os" "path/filepath" "gopkg.in/yaml.v2" ) // ManifestFile is the default filename for the package manifest. const ManifestFile = "manifest" // Store is a folder-based local repository. // Packages are installed in their own directory under BaseDir. // The package details are stored in a file named based on PackageFile. type Store struct { BaseDir string // ManifestFile is the filename of the package file. ManifestFile string } // sub returns a new Store with the given path appended to the BaseDir. func (r *Store) sub(path string) *Store { if path == "" || path == "." { return r } return &Store{ BaseDir: filepath.Join(r.BaseDir, path), ManifestFile: r.ManifestFile, } } func (r *Store) List(ctx context.Context) ([]Manifest, error) { e, err := os.ReadDir(r.BaseDir) // ignore if directory cannot be read if err != nil { return nil, nil } var ret []Manifest for _, ee := range e { if !ee.IsDir() { // ignore non-directories continue } pkg, err := r.getManifest(ctx, ee.Name()) if err != nil { // ignore if manifest does not exist if errors.Is(err, os.ErrNotExist) { continue } return nil, err } ret = append(ret, *pkg) } return ret, nil } func (r *Store) packageDir(id string) string { return filepath.Join(r.BaseDir, id) } func (r *Store) manifestPath(id string) string { return filepath.Join(r.packageDir(id), r.ManifestFile) } func (r *Store) getManifest(ctx context.Context, packageID string) (*Manifest, error) { pfp := r.manifestPath(packageID) data, err := os.ReadFile(pfp) if err != nil { return nil, fmt.Errorf("reading manifest file %q: %w", pfp, err) } var manifest Manifest if err := yaml.Unmarshal(data, &manifest); err != nil { return nil, fmt.Errorf("reading manifest file %q: %w", pfp, err) } return &manifest, nil } func (r *Store) ensurePackageExists(packageID string) error { // ensure the manifest file exists if _, err := os.Stat(r.manifestPath(packageID)); err != nil { if os.IsNotExist(err) { return fmt.Errorf("package %q does not exist", packageID) } } return nil } func (r *Store) writeFile(packageID string, name string, mode fs.FileMode, i io.Reader) error { fn := filepath.Join(r.packageDir(packageID), name) if err := os.MkdirAll(filepath.Dir(fn), os.ModePerm); err != nil { return fmt.Errorf("creating directory %v: %w", fn, err) } o, err := os.OpenFile(fn, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) if err != nil { return err } defer o.Close() if _, err := io.Copy(o, i); err != nil { return err } return nil } func (r *Store) writeManifest(packageID string, m Manifest) error { pfp := r.manifestPath(packageID) data, err := yaml.Marshal(m) if err != nil { return fmt.Errorf("marshaling manifest: %w", err) } if err := os.WriteFile(pfp, data, os.ModePerm); err != nil { return fmt.Errorf("writing manifest file %q: %w", pfp, err) } return nil } func (r *Store) deleteFile(packageID string, name string) error { // ensure the package exists if err := r.ensurePackageExists(packageID); err != nil { return err } pkgDir := r.packageDir(packageID) fp := filepath.Join(pkgDir, name) return os.Remove(fp) } func (r *Store) deleteManifest(packageID string) error { return r.deleteFile(packageID, r.ManifestFile) } func (r *Store) deletePackageDir(packageID string) error { return os.Remove(r.packageDir(packageID)) } ================================================ FILE: pkg/plugin/args.go ================================================ package plugin type OperationInput map[string]interface{} type PluginArgInput struct { Key string `json:"key"` Value *PluginValueInput `json:"value"` } type PluginValueInput struct { Str *string `json:"str"` I *int `json:"i"` B *bool `json:"b"` F *float64 `json:"f"` O []*PluginArgInput `json:"o"` A []*PluginValueInput `json:"a"` } func applyDefaultArgs(args OperationInput, defaultArgs map[string]string) { for k, v := range defaultArgs { _, found := args[k] if !found { args[k] = v } } } ================================================ FILE: pkg/plugin/common/doc.go ================================================ // Package common encapulates data structures and functions that will be used // by plugin executables and the plugin subsystem in the stash server. package common ================================================ FILE: pkg/plugin/common/log/log.go ================================================ // Package log provides a number of logging utility functions for encoding and // decoding log messages between a stash server and a plugin instance. // // Log messages sent from a plugin instance are transmitted via stderr and are // encoded with a prefix consisting of special character SOH, then the log // level (one of t, d, i, w, e, or p - corresponding to trace, debug, info, // warning, error and progress levels respectively), then special character // STX. // // The Trace, Debug, Info, Warning, and Error methods, and their equivalent // formatted methods are intended for use by plugin instances to transmit log // messages. The Progress method is also intended for sending progress data. // // Conversely, LevelFromName and DetectLogLevel are intended for use by the // stash server. package log import ( "math" "github.com/stashapp/stash/pkg/logger" ) // Level represents a logging level for plugin outputs. type Level struct { *logger.PluginLogLevel } // Valid Level values. var ( TraceLevel = Level{ &logger.TraceLevel, } DebugLevel = Level{ &logger.DebugLevel, } InfoLevel = Level{ &logger.InfoLevel, } WarningLevel = Level{ &logger.WarningLevel, } ErrorLevel = Level{ &logger.ErrorLevel, } ProgressLevel = Level{ &logger.ProgressLevel, } NoneLevel = Level{ &logger.NoneLevel, } ) // Trace outputs a trace logging message to os.Stderr. Message is encoded with a // prefix that signifies to the server that it is a trace message. func Trace(args ...interface{}) { TraceLevel.Log(args...) } // Tracef is the equivalent of Printf outputting as a trace logging message. func Tracef(format string, args ...interface{}) { TraceLevel.Logf(format, args...) } // Debug outputs a debug logging message to os.Stderr. Message is encoded with a // prefix that signifies to the server that it is a debug message. func Debug(args ...interface{}) { DebugLevel.Log(args...) } // Debugf is the equivalent of Printf outputting as a debug logging message. func Debugf(format string, args ...interface{}) { DebugLevel.Logf(format, args...) } // Info outputs an info logging message to os.Stderr. Message is encoded with a // prefix that signifies to the server that it is an info message. func Info(args ...interface{}) { InfoLevel.Log(args...) } // Infof is the equivalent of Printf outputting as an info logging message. func Infof(format string, args ...interface{}) { InfoLevel.Logf(format, args...) } // Warn outputs a warning logging message to os.Stderr. Message is encoded with a // prefix that signifies to the server that it is a warning message. func Warn(args ...interface{}) { WarningLevel.Log(args...) } // Warnf is the equivalent of Printf outputting as a warning logging message. func Warnf(format string, args ...interface{}) { WarningLevel.Logf(format, args...) } // Error outputs an error logging message to os.Stderr. Message is encoded with a // prefix that signifies to the server that it is an error message. func Error(args ...interface{}) { ErrorLevel.Log(args...) } // Errorf is the equivalent of Printf outputting as an error logging message. func Errorf(format string, args ...interface{}) { ErrorLevel.Logf(format, args...) } // Progress logs the current progress value. The progress value should be // between 0 and 1.0 inclusively, with 1 representing that the task is // complete. Values outside of this range will be clamp to be within it. func Progress(progress float64) { progress = math.Min(math.Max(0, progress), 1) ProgressLevel.Log(progress) } // LevelFromName returns the Level that matches the provided name or nil if // the name does not match a valid value. func LevelFromName(name string) *Level { l := logger.PluginLogLevelFromName(name) if l != nil { return &Level{ l, } } return nil } ================================================ FILE: pkg/plugin/common/msg.go ================================================ package common import "net/http" const ( HookContextKey = "hookContext" ) // StashServerConnection represents the connection details needed for a // plugin instance to connect to its parent stash server. type StashServerConnection struct { // http or https Scheme string Host string Port int // Cookie for authentication purposes SessionCookie *http.Cookie // Dir specifies the directory containing the stash server's configuration // file. Dir string // PluginDir specifies the directory containing the plugin configuration // file. PluginDir string } // PluginArgValue represents a single value parameter for plugin operations. type PluginArgValue interface{} // ArgsMap is a map of argument key to value. type ArgsMap map[string]PluginArgValue // String returns the string field or an empty string if the string field is // nil func (m ArgsMap) String(key string) string { v, found := m[key] var ret string if !found { return ret } ret, _ = v.(string) return ret } // Int returns the int field or 0 if the int field is nil func (m ArgsMap) Int(key string) int { v, found := m[key] var ret int if !found { return ret } ret, _ = v.(int) return ret } // Bool returns the boolean field or false if the boolean field is nil func (m ArgsMap) Bool(key string) bool { v, found := m[key] var ret bool if !found { return ret } ret, _ = v.(bool) return ret } // Float returns the float field or 0 if the float field is nil func (m ArgsMap) Float(key string) float64 { v, found := m[key] var ret float64 if !found { return ret } ret, _ = v.(float64) return ret } func (m ArgsMap) ToMap() map[string]interface{} { ret := make(map[string]interface{}) for k, v := range m { ret[k] = v } return ret } // PluginInput is the data structure that is sent to plugin instances when they // are spawned. type PluginInput struct { // Server details to connect to the stash server. ServerConnection StashServerConnection `json:"server_connection"` // Arguments to the plugin operation. Args ArgsMap `json:"args"` } // PluginOutput is the data structure that is expected to be output by plugin // processes when execution has concluded. It is expected that this data will // be encoded as JSON. type PluginOutput struct { Error *string `json:"error"` Output interface{} `json:"output"` } // SetError is a convenience method that sets the Error field based on the // provided error. func (o *PluginOutput) SetError(err error) { errStr := err.Error() o.Error = &errStr } // HookContext is passed as a PluginArgValue and indicates what hook triggered // this plugin task. type HookContext struct { ID int `json:"id,omitempty"` Type string `json:"type"` Input interface{} `json:"input"` InputFields []string `json:"inputFields,omitempty"` } ================================================ FILE: pkg/plugin/common/rpc.go ================================================ package common import ( "net/rpc/jsonrpc" "github.com/natefinch/pie" ) // RPCRunner is the interface that RPC plugins are expected to fulfil. type RPCRunner interface { // Perform the operation, using the provided input and populating the // output object. Run(input PluginInput, output *PluginOutput) error // Stop any running operations, if possible. No input is sent and any // output is ignored. Stop(input struct{}, output *bool) error } // ServePlugin is used by plugin instances to serve the plugin via RPC, using // the provided RPCRunner interface. func ServePlugin(iface RPCRunner) error { p := pie.NewProvider() if err := p.RegisterName("RPCRunner", iface); err != nil { return err } p.ServeCodec(jsonrpc.NewServerCodec) return nil } ================================================ FILE: pkg/plugin/config.go ================================================ package plugin import ( "fmt" "io" "os" "os/exec" "path/filepath" "sort" "strings" "github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/python" "github.com/stashapp/stash/pkg/utils" "gopkg.in/yaml.v2" ) // Config describes the configuration for a single plugin. type Config struct { id string // path to the configuration file path string // The name of the plugin. This will be displayed in the UI. Name string `yaml:"name"` // An optional description of what the plugin does. Description *string `yaml:"description"` // An optional URL for the plugin. URL *string `yaml:"url"` // An optional version string. Version *string `yaml:"version"` // The communication interface used when communicating with the spawned // plugin process. Defaults to 'raw' if not provided. Interface interfaceEnum `yaml:"interface"` // The command to execute for the operations in this plugin. The first // element should be the program name, and subsequent elements are passed // as arguments. // // Note: the execution process will search the path for the program, // then will attempt to find the program in the plugins // directory. The exe extension is not necessary on Windows platforms. // The current working directory is set to that of the stash process. Exec []string `yaml:"exec,flow"` // The default log level to output the plugin process's stderr stream. // Only used if the plugin does not encode its output using log level // control characters. // See package common/log for valid values. // If left unset, defaults to log.ErrorLevel. PluginErrLogLevel string `yaml:"errLog"` // The task configurations for tasks provided by this plugin. Tasks []*OperationConfig `yaml:"tasks"` // The hooks configurations for hooks registered by this plugin. Hooks []*HookConfig `yaml:"hooks"` // Javascript files that will be injected into the stash UI. UI UIConfig `yaml:"ui"` // Settings that will be used to configure the plugin. Settings map[string]SettingConfig `yaml:"settings"` } type PluginCSP struct { ScriptSrc []string `json:"script-src" yaml:"script-src"` StyleSrc []string `json:"style-src" yaml:"style-src"` ConnectSrc []string `json:"connect-src" yaml:"connect-src"` } type UIConfig struct { // Requires is a list of plugin IDs that this plugin depends on. // These plugins will be loaded before this plugin. Requires []string `yaml:"requires"` // Content Security Policy configuration for the plugin. CSP PluginCSP `yaml:"csp"` // Javascript files that will be injected into the stash UI. // These may be URLs or paths to files relative to the plugin configuration file. Javascript []string `yaml:"javascript"` // CSS files that will be injected into the stash UI. // These may be URLs or paths to files relative to the plugin configuration file. CSS []string `yaml:"css"` // Assets is a map of URL prefixes to hosted directories. // This allows plugins to serve static assets from a URL path. // Plugin assets are exposed via the /plugin/{pluginId}/assets path. // For example, if the plugin configuration file contains: // /foo: bar // /bar: baz // /: root // Then the following requests will be mapped to the following files: // /plugin/{pluginId}/assets/foo/file.txt -> {pluginDir}/foo/file.txt // /plugin/{pluginId}/assets/bar/file.txt -> {pluginDir}/baz/file.txt // /plugin/{pluginId}/assets/file.txt -> {pluginDir}/root/file.txt Assets utils.URLMap `yaml:"assets"` } func isURL(s string) bool { return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") } func (c UIConfig) getCSSFiles(parent Config) []string { var ret []string for _, v := range c.CSS { if !isURL(v) { ret = append(ret, filepath.Join(parent.getConfigPath(), v)) } } return ret } func (c UIConfig) getExternalCSS() []string { var ret []string for _, v := range c.CSS { if isURL(v) { ret = append(ret, v) } } return ret } func (c UIConfig) getJavascriptFiles(parent Config) []string { var ret []string for _, v := range c.Javascript { if !isURL(v) { ret = append(ret, filepath.Join(parent.getConfigPath(), v)) } } return ret } func (c UIConfig) getExternalScripts() []string { var ret []string for _, v := range c.Javascript { if isURL(v) { ret = append(ret, v) } } return ret } type SettingConfig struct { // defaults to string Type PluginSettingTypeEnum `yaml:"type"` // defaults to key name DisplayName string `yaml:"displayName"` Description string `yaml:"description"` } func (c Config) getPluginTasks(includePlugin bool) []*PluginTask { var ret []*PluginTask for _, o := range c.Tasks { task := &PluginTask{ Name: o.Name, Description: &o.Description, } if includePlugin { task.Plugin = c.toPlugin() } ret = append(ret, task) } return ret } func (c Config) getPluginHooks(includePlugin bool) []*PluginHook { var ret []*PluginHook for _, o := range c.Hooks { hook := &PluginHook{ Name: o.Name, Description: &o.Description, Hooks: convertHooks(o.TriggeredBy), } if includePlugin { hook.Plugin = c.toPlugin() } ret = append(ret, hook) } return ret } func convertHooks(hooks []hook.TriggerEnum) []string { var ret []string for _, h := range hooks { ret = append(ret, h.String()) } return ret } func (c Config) getPluginSettings() []PluginSetting { ret := []PluginSetting{} var keys []string for k := range c.Settings { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { o := c.Settings[k] t := o.Type if t == "" { t = PluginSettingTypeEnumString } s := PluginSetting{ Name: k, DisplayName: o.DisplayName, Description: o.Description, Type: t, } ret = append(ret, s) } return ret } func (c Config) getName() string { if c.Name != "" { return c.Name } return c.id } func (c Config) toPlugin() *Plugin { return &Plugin{ ID: c.id, Name: c.getName(), Description: c.Description, URL: c.URL, Version: c.Version, Tasks: c.getPluginTasks(false), Hooks: c.getPluginHooks(false), UI: PluginUI{ Requires: c.UI.Requires, ExternalScript: c.UI.getExternalScripts(), ExternalCSS: c.UI.getExternalCSS(), Javascript: c.UI.getJavascriptFiles(c), CSS: c.UI.getCSSFiles(c), CSP: c.UI.CSP, Assets: c.UI.Assets, }, Settings: c.getPluginSettings(), ConfigPath: c.path, } } func (c Config) getTask(name string) *OperationConfig { for _, o := range c.Tasks { if o.Name == name { return o } } return nil } func (c Config) getHooks(hookType hook.TriggerEnum) []*HookConfig { var ret []*HookConfig for _, h := range c.Hooks { for _, t := range h.TriggeredBy { if hookType == t { ret = append(ret, h) } } } return ret } func (c Config) getConfigPath() string { return filepath.Dir(c.path) } func (c Config) getExecCommand(task *OperationConfig) []string { // #4859 - don't modify the original exec command ret := append([]string{}, c.Exec...) if task != nil { ret = append(ret, task.ExecArgs...) } // #4859 - don't use the plugin path in the exec command if it is a python command if len(ret) > 0 && !python.IsPythonCommand(ret[0]) { _, err := exec.LookPath(ret[0]) if err != nil { // change command to run from the plugin path pluginPath := filepath.Dir(c.path) ret[0] = filepath.Join(pluginPath, ret[0]) } } // replace {pluginDir} in arguments with that of the plugin directory dir := c.getConfigPath() for i, arg := range ret { if i == 0 { continue } ret[i] = strings.ReplaceAll(arg, "{pluginDir}", dir) } return ret } func (c Config) valid() error { if c.Interface != "" && !c.Interface.Valid() { return fmt.Errorf("invalid interface type %s", c.Interface) } for k, o := range c.Settings { if o.Type != "" && !o.Type.IsValid() { return fmt.Errorf("invalid type %s for setting %s", k, o.Type) } } return nil } type interfaceEnum string // Valid interfaceEnum values const ( // InterfaceEnumRPC indicates that the plugin uses the RPCRunner interface // declared in common/rpc.go. InterfaceEnumRPC interfaceEnum = "rpc" // InterfaceEnumRaw interfaces will have the common.PluginInput encoded as // json (but may be ignored), and output will be decoded as // common.PluginOutput. If this decoding fails, then the raw output will be // treated as the output. InterfaceEnumRaw interfaceEnum = "raw" InterfaceEnumJS interfaceEnum = "js" ) func (i interfaceEnum) Valid() bool { return i == InterfaceEnumRPC || i == InterfaceEnumRaw || i == InterfaceEnumJS } func (i *interfaceEnum) getTaskBuilder() taskBuilder { if *i == InterfaceEnumRaw { return &rawTaskBuilder{} } if *i == InterfaceEnumRPC { return &rpcTaskBuilder{} } if *i == InterfaceEnumJS { return &jsTaskBuilder{} } // shouldn't happen return nil } // OperationConfig describes the configuration for a single plugin operation // provided by a plugin. type OperationConfig struct { // Used to identify the operation. Must be unique within a plugin // configuration. This name is shown in the button for the operation // in the UI. Name string `yaml:"name"` // A short description of the operation. This description is shown below // the button in the UI. Description string `yaml:"description"` // A list of arguments that will be appended to the plugin's Exec arguments // when executing this operation. ExecArgs []string `yaml:"execArgs"` // A map of argument keys to their default values. The default value is // used if the applicable argument is not provided during the operation // call. DefaultArgs map[string]string `yaml:"defaultArgs"` } type HookConfig struct { OperationConfig `yaml:",inline"` // A list of stash operations that will be used to trigger this hook operation. TriggeredBy []hook.TriggerEnum `yaml:"triggeredBy"` } func loadPluginFromYAML(reader io.Reader) (*Config, error) { ret := &Config{} parser := yaml.NewDecoder(reader) parser.SetStrict(true) err := parser.Decode(&ret) if err != nil { return nil, err } if ret.Interface == "" { ret.Interface = InterfaceEnumRaw } if err := ret.valid(); err != nil { return nil, err } return ret, nil } func loadPluginFromYAMLFile(path string) (*Config, error) { file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() ret, err := loadPluginFromYAML(file) if err != nil { return nil, err } // set id to the filename id := filepath.Base(path) ret.id = id[:strings.LastIndex(id, ".")] ret.path = path return ret, nil } ================================================ FILE: pkg/plugin/convert.go ================================================ package plugin import ( "github.com/stashapp/stash/pkg/plugin/common" ) func toPluginArgs(args OperationInput) common.ArgsMap { ret := make(common.ArgsMap) for k, a := range args { ret[k] = common.PluginArgValue(a) } return ret } ================================================ FILE: pkg/plugin/examples/README.md ================================================ # Building From the base stash source directory: ``` go build -tags=plugin_example -o plugin_goraw.exe ./pkg/plugin/examples/goraw/... go build -tags=plugin_example -o plugin_gorpc.exe ./pkg/plugin/examples/gorpc/... ``` Place the resulting binaries together with the yml files in the `plugins` subdirectory of your stash directory. ================================================ FILE: pkg/plugin/examples/common/graphql.go ================================================ //go:build plugin_example // +build plugin_example package common import ( "context" "errors" "fmt" graphql "github.com/hasura/go-graphql-client" "github.com/stashapp/stash/pkg/plugin/common/log" ) const tagName = "Hawwwwt" // graphql inputs and returns type TagCreate struct { ID graphql.ID `graphql:"id"` } type TagCreateInput struct { Name graphql.String `graphql:"name" json:"name"` } type TagDestroyInput struct { ID graphql.ID `graphql:"id" json:"id"` } type FindScenesResultType struct { Count graphql.Int DurationSeconds graphql.Float `graphql:"duration" json:"duration"` FilesizeBytes graphql.Float `graphql:"filesize" json:"filesize"` Scenes []Scene } type Tag struct { ID graphql.ID `graphql:"id"` Name graphql.String `graphql:"name"` } type Scene struct { ID graphql.ID Tags []Tag } func (s Scene) getTagIds() []graphql.ID { ret := []graphql.ID{} for _, t := range s.Tags { ret = append(ret, t.ID) } return ret } type FindFilterType struct { PerPage *graphql.Int `graphql:"per_page" json:"per_page"` Sort *graphql.String `graphql:"sort" json:"sort"` } type SceneUpdate struct { ID graphql.ID `graphql:"id"` } type SceneUpdateInput struct { ID graphql.ID `graphql:"id" json:"id"` TagIds []graphql.ID `graphql:"tag_ids" json:"tag_ids"` } func getTagID(ctx context.Context, client *graphql.Client, create bool) (*graphql.ID, error) { log.Info("Checking if tag exists already") // see if tag exists already var q struct { AllTags []Tag `graphql:"allTags"` } err := client.Query(ctx, &q, nil) if err != nil { return nil, fmt.Errorf("Error getting tags: %s\n", err.Error()) } for _, t := range q.AllTags { if t.Name == tagName { id := t.ID return &id, nil } } if !create { log.Info("Not found and not creating") return nil, nil } // create the tag var m struct { TagCreate TagCreate `graphql:"tagCreate(input: $s)"` } input := TagCreateInput{ Name: tagName, } vars := map[string]interface{}{ "s": input, } log.Info("Creating new tag") err = client.Mutate(ctx, &m, vars) if err != nil { return nil, fmt.Errorf("Error mutating scene: %s\n", err.Error()) } return &m.TagCreate.ID, nil } func findRandomScene(ctx context.Context, client *graphql.Client) (*Scene, error) { // get a random scene var q struct { FindScenes FindScenesResultType `graphql:"findScenes(filter: $c)"` } pp := graphql.Int(1) sort := graphql.String("random") filterInput := &FindFilterType{ PerPage: &pp, Sort: &sort, } vars := map[string]interface{}{ "c": filterInput, } log.Info("Finding a random scene") err := client.Query(ctx, &q, vars) if err != nil { return nil, fmt.Errorf("Error getting random scene: %s\n", err.Error()) } if q.FindScenes.Count == 0 { return nil, nil } return &q.FindScenes.Scenes[0], nil } func addTagId(tagIds []graphql.ID, tagId graphql.ID) []graphql.ID { for _, t := range tagIds { if t == tagId { return tagIds } } tagIds = append(tagIds, tagId) return tagIds } func AddTag(ctx context.Context, client *graphql.Client) error { tagID, err := getTagID(ctx, client, true) if err != nil { return err } scene, err := findRandomScene(ctx, client) if err != nil { return err } if scene == nil { return errors.New("no scenes to add tag to") } var m struct { SceneUpdate SceneUpdate `graphql:"sceneUpdate(input: $s)"` } input := SceneUpdateInput{ ID: scene.ID, TagIds: scene.getTagIds(), } input.TagIds = addTagId(input.TagIds, *tagID) vars := map[string]interface{}{ "s": input, } log.Infof("Adding tag to scene %v", scene.ID) err = client.Mutate(ctx, &m, vars) if err != nil { return fmt.Errorf("Error mutating scene: %v", err) } return nil } func RemoveTag(ctx context.Context, client *graphql.Client) error { tagID, err := getTagID(ctx, client, false) if err != nil { return err } if tagID == nil { log.Info("Tag does not exist. Nothing to remove") return nil } // destroy the tag var m struct { TagDestroy bool `graphql:"tagDestroy(input: $s)"` } input := TagDestroyInput{ ID: *tagID, } vars := map[string]interface{}{ "s": input, } log.Info("Destroying tag") err = client.Mutate(ctx, &m, vars) if err != nil { return fmt.Errorf("Error destroying tag: %v", err) } return nil } ================================================ FILE: pkg/plugin/examples/goraw/goraw.yml ================================================ # example plugin config name: Hawwwwt Tagger (Raw edition) description: Ultimate Hawwwwt tagging utility (using raw interface). version: 1.0 url: http://www.github.com/stashapp/stash exec: - plugin_goraw interface: raw tasks: - name: Add hawwwwt tag to random scene description: Creates a "Hawwwwt" tag if not present and adds to a random scene. defaultArgs: mode: add - name: Remove hawwwwt tag from system description: Removes the "Hawwwwt" tag from all scenes and deletes the tag. defaultArgs: mode: remove - name: Indefinite task description: Sleeps indefinitely - interruptable # we'll try command-line argument for this one execArgs: - indef - "{pluginDir}" - name: Long task description: Sleeps for 100 seconds - interruptable defaultArgs: mode: long ================================================ FILE: pkg/plugin/examples/goraw/main.go ================================================ //go:build plugin_example // +build plugin_example package main import ( "context" "encoding/json" "io" "os" "time" exampleCommon "github.com/stashapp/stash/pkg/plugin/examples/common" "github.com/stashapp/stash/pkg/plugin/common" "github.com/stashapp/stash/pkg/plugin/common/log" "github.com/stashapp/stash/pkg/plugin/util" ) // raw plugins may accept the plugin input from stdin, or they can elect // to ignore it entirely. In this case it optionally reads from the // command-line parameters. func main() { input := common.PluginInput{} if len(os.Args) < 2 { inData, _ := io.ReadAll(os.Stdin) log.Debugf("Raw input: %s", string(inData)) decodeErr := json.Unmarshal(inData, &input) if decodeErr != nil { panic("missing mode argument") } } else { log.Debug("Using command line inputs") mode := os.Args[1] log.Debugf("Command line inputs: %v", os.Args[1:]) input.Args = common.ArgsMap{ "mode": mode, } // just some hard-coded values input.ServerConnection = common.StashServerConnection{ Scheme: "http", Port: 9999, } } output := common.PluginOutput{} Run(input, &output) out, _ := json.Marshal(output) os.Stdout.WriteString(string(out)) } func Run(input common.PluginInput, output *common.PluginOutput) error { modeArg := input.Args.String("mode") ctx := context.TODO() var err error if modeArg == "" || modeArg == "add" { client := util.NewClient(input.ServerConnection) err = exampleCommon.AddTag(ctx, client) } else if modeArg == "remove" { client := util.NewClient(input.ServerConnection) err = exampleCommon.RemoveTag(ctx, client) } else if modeArg == "long" { err = doLongTask() } else if modeArg == "indef" { err = doIndefiniteTask() } if err != nil { errStr := err.Error() *output = common.PluginOutput{ Error: &errStr, } return nil } outputStr := "ok" *output = common.PluginOutput{ Output: &outputStr, } return nil } func doLongTask() error { const total = 100 upTo := 0 log.Info("Doing long task") for upTo < total { time.Sleep(time.Second) log.Progress(float64(upTo) / float64(total)) upTo++ } return nil } func doIndefiniteTask() error { log.Warn("Sleeping indefinitely") for { time.Sleep(time.Second) } } ================================================ FILE: pkg/plugin/examples/gorpc/gorpc.yml ================================================ # example plugin config name: Hawwwwt Tagger description: Ultimate Hawwwwt tagging utility. version: 1.0 url: http://www.github.com/stashapp/stash exec: - plugin_gorpc interface: rpc tasks: - name: Add hawwwwt tag to random scene description: Creates a "Hawwwwt" tag if not present and adds to a random scene. defaultArgs: mode: add - name: Remove hawwwwt tag from system description: Removes the "Hawwwwt" tag from all scenes and deletes the tag. defaultArgs: mode: remove - name: Indefinite task description: Sleeps indefinitely - interruptable defaultArgs: mode: indef - name: Long task description: Sleeps for 100 seconds - interruptable defaultArgs: mode: long ================================================ FILE: pkg/plugin/examples/gorpc/main.go ================================================ //go:build plugin_example // +build plugin_example package main import ( "context" "time" exampleCommon "github.com/stashapp/stash/pkg/plugin/examples/common" "github.com/stashapp/stash/pkg/plugin/common" "github.com/stashapp/stash/pkg/plugin/common/log" "github.com/stashapp/stash/pkg/plugin/util" ) func main() { // serves the plugin, providing an object that satisfies the // common.RPCRunner interface err := common.ServePlugin(&api{}) if err != nil { panic(err) } } type api struct { stopping bool } func (a *api) Stop(input struct{}, output *bool) error { log.Info("Stopping...") a.stopping = true *output = true return nil } // Run is the main work function of the plugin. It interprets the input and // acts accordingly. func (a *api) Run(input common.PluginInput, output *common.PluginOutput) error { modeArg := input.Args.String("mode") ctx := context.TODO() var err error if modeArg == "" || modeArg == "add" { client := util.NewClient(input.ServerConnection) err = exampleCommon.AddTag(ctx, client) } else if modeArg == "remove" { client := util.NewClient(input.ServerConnection) err = exampleCommon.RemoveTag(ctx, client) } else if modeArg == "long" { err = a.doLongTask() } else if modeArg == "indef" { err = a.doIndefiniteTask() } if err != nil { errStr := err.Error() *output = common.PluginOutput{ Error: &errStr, } return nil } outputStr := "ok" *output = common.PluginOutput{ Output: &outputStr, } return nil } func (a *api) doLongTask() error { const total = 100 upTo := 0 log.Info("Doing long task") for upTo < total { time.Sleep(time.Second) if a.stopping { return nil } log.Progress(float64(upTo) / float64(total)) upTo++ } return nil } func (a *api) doIndefiniteTask() error { log.Warn("Sleeping indefinitely") for { time.Sleep(time.Second) if a.stopping { return nil } } return nil } ================================================ FILE: pkg/plugin/examples/js/js.js ================================================ var tagName = "Hawwwwt" function main() { var modeArg = input.Args.mode; if (modeArg !== undefined) { try { if (modeArg == "" || modeArg == "add") { addTag(); } else if (modeArg == "remove") { removeTag(); } else if (modeArg == "long") { doLongTask(); } else if (modeArg == "indef") { doIndefiniteTask(); } else if (modeArg == "hook") { doHookTask(); } } catch (err) { return { Error: err }; } return { Output: "ok" }; } if (input.Args.error) { return { Error: input.Args.error }; } // immediate mode // just return the args return { Output: input.Args }; } function getResult(result) { if (result[1]) { throw result[1]; } return result[0]; } function getTagID(create) { log.Info("Checking if tag exists already (via GQL)") // see if tag exists already var query = "\ query {\ allTags {\ id\ name\ }\ }" var result = gql.Do(query); var allTags = result["allTags"]; var tag; for (var i = 0; i < allTags.length; ++i) { if (allTags[i].name === tagName) { tag = allTags[i]; break; } } if (tag) { log.Info("found existing tag"); return tag.id; } if (!create) { log.Info("Not found and not creating"); return null; } log.Info("Creating new tag"); var mutation = "\ mutation tagCreate($input: TagCreateInput!) {\ tagCreate(input: $input) {\ id\ }\ }"; var variables = { input: { 'name': tagName } }; result = gql.Do(mutation, variables); log.Info("tag id = " + result.tagCreate.id); return result.tagCreate.id; } function addTag() { var tagID = getTagID(true) var scene = findRandomScene(); if (scene === null) { throw "no scenes to add tag to"; } var tagIds = [] var found = false; for (var i = 0; i < scene.tags.length; ++i) { var sceneTagID = scene.tags[i].id; if (tagID === sceneTagID) { found = true; } tagIds.push(sceneTagID); } if (found) { log.Info("already has tag"); return; } tagIds.push(tagID) var mutation = "\ mutation sceneUpdate($input: SceneUpdateInput!) {\ sceneUpdate(input: $input) {\ id\ }\ }"; var variables = { input: { id: scene.id, tag_ids: tagIds, } }; log.Info("Adding tag to scene " + scene.id); gql.Do(mutation, variables); } function removeTag() { var tagID = getTagID(false); if (tagID == null) { log.Info("Tag does not exist. Nothing to remove"); return } log.Info("Destroying tag"); var mutation = "\ mutation tagDestroy($input: TagDestroyInput!) {\ tagDestroy(input: $input)\ }"; var variables = { input: { id: tagID } }; gql.Do(mutation, variables); } function findRandomScene() { // get a random scene log.Info("Finding a random scene") var query = "\ query findScenes($filter: FindFilterType!) {\ findScenes(filter: $filter) {\ count\ scenes {\ id\ tags {\ id\ }\ }\ }\ }" var variables = { filter: { per_page: 1, sort: 'random' } }; var result = gql.Do(query, variables); var findScenes = result["findScenes"]; if (findScenes.Count === 0) { return null; } return findScenes.scenes[0]; } function doLongTask() { var total = 100; var upTo = 0; log.Info("Doing long task"); while (upTo < total) { util.Sleep(1000); log.Progress(upTo / total); upTo = upTo + 1; } } function doIndefiniteTask() { log.Info("Sleeping indefinitely"); while (true) { util.Sleep(1000); } } function doHookTask() { log.Info("JS Hook called!"); log.Info(input.Args); } main(); ================================================ FILE: pkg/plugin/examples/js/js.yml ================================================ # example plugin config name: Hawwwwt Tagger (Javascript edition) description: Javascript Hawwwwt tagging utility (using raw interface). version: 1.0 url: http://www.github.com/stashapp/stash exec: - js.js interface: js tasks: - name: Add hawwwwt tag to random scene description: Creates a "Hawwwwt" tag if not present and adds to a random scene. defaultArgs: mode: add - name: Remove hawwwwt tag from system description: Removes the "Hawwwwt" tag from all scenes and deletes the tag. defaultArgs: mode: remove - name: Indefinite task description: Sleeps indefinitely - interruptable # we'll try command-line argument for this one defaultArgs: mode: indef - name: Long task description: Sleeps for 100 seconds - interruptable defaultArgs: mode: long hooks: - name: Log scene marker create/update description: Logs some stuff when creating/updating scene marker. triggeredBy: - SceneMarker.Create.Post - SceneMarker.Update.Post - SceneMarker.Delete.Post - Scene.Create.Post - Scene.Update.Post - Scene.Destroy.Post - Image.Create.Post - Image.Update.Post - Image.Destroy.Post - Gallery.Create.Post - Gallery.Update.Post - Gallery.Destroy.Post - Movie.Create.Post - Movie.Update.Post - Movie.Destroy.Post - Performer.Create.Post - Performer.Update.Post - Performer.Destroy.Post - Studio.Create.Post - Studio.Update.Post - Studio.Destroy.Post - Tag.Create.Post - Tag.Update.Post - Tag.Destroy.Post defaultArgs: mode: hook ================================================ FILE: pkg/plugin/examples/python/log.py ================================================ import sys # Log messages sent from a plugin instance are transmitted via stderr and are # encoded with a prefix consisting of special character SOH, then the log # level (one of t, d, i, w, e, or p - corresponding to trace, debug, info, # warning, error and progress levels respectively), then special character # STX. # # The LogTrace, LogDebug, LogInfo, LogWarning, and LogError methods, and their equivalent # formatted methods are intended for use by plugin instances to transmit log # messages. The LogProgress method is also intended for sending progress data. # def __prefix(levelChar): startLevelChar = b'\x01' endLevelChar = b'\x02' ret = startLevelChar + levelChar + endLevelChar return ret.decode() def __log(levelChar, s): if levelChar == "": return print(__prefix(levelChar) + s + "\n", file=sys.stderr, flush=True) def LogTrace(s): __log(b't', s) def LogDebug(s): __log(b'd', s) def LogInfo(s): __log(b'i', s) def LogWarning(s): __log(b'w', s) def LogError(s): __log(b'e', s) def LogProgress(p): progress = min(max(0, p), 1) __log(b'p', str(progress)) ================================================ FILE: pkg/plugin/examples/python/pyplugin.py ================================================ import json import sys import time import log from stash_interface import StashInterface # raw plugins may accept the plugin input from stdin, or they can elect # to ignore it entirely. In this case it optionally reads from the # command-line parameters. def main(): input = None if len(sys.argv) < 2: input = readJSONInput() log.LogDebug("Raw input: %s" % json.dumps(input)) else: log.LogDebug("Using command line inputs") mode = sys.argv[1] log.LogDebug("Command line inputs: {}".format(sys.argv[1:])) input = {} input['args'] = { "mode": mode } # just some hard-coded values input['server_connection'] = { "Scheme": "http", "Port": 9999, } output = {} run(input, output) out = json.dumps(output) print(out + "\n") def readJSONInput(): input = sys.stdin.read() return json.loads(input) def run(input, output): modeArg = input['args']["mode"] try: if modeArg == "" or modeArg == "add": client = StashInterface(input["server_connection"]) addTag(client) elif modeArg == "remove": client = StashInterface(input["server_connection"]) removeTag(client) elif modeArg == "long": doLongTask() elif modeArg == "indef": doIndefiniteTask() except Exception as e: raise #output["error"] = str(e) #return output["output"] = "ok" def doLongTask(): total = 100 upTo = 0 log.LogInfo("Doing long task") while upTo < total: time.sleep(1) log.LogProgress(float(upTo) / float(total)) upTo = upTo + 1 def doIndefiniteTask(): log.LogWarning("Sleeping indefinitely") while True: time.sleep(1) def addTag(client): tagName = "Hawwwwt" tagID = client.findTagIdWithName(tagName) if tagID == None: tagID = client.createTagWithName(tagName) scene = client.findRandomSceneId() if scene == None: raise Exception("no scenes to add tag to") tagIds = [] for t in scene["tags"]: tagIds.append(t["id"]) # remove first to ensure we don't re-add the same id try: tagIds.remove(tagID) except ValueError: pass tagIds.append(tagID) input = { "id": scene["id"], "tag_ids": tagIds } log.LogInfo("Adding tag to scene {}".format(scene["id"])) client.updateScene(input) def removeTag(client): tagName = "Hawwwwt" tagID = client.findTagIdWithName(tagName) if tagID == None: log.LogInfo("Tag does not exist. Nothing to remove") return log.LogInfo("Destroying tag") client.destroyTag(tagID) main() ================================================ FILE: pkg/plugin/examples/python/pyraw.yml ================================================ # example plugin config name: Hawwwwt Tagger (Raw Python edition) description: Python Hawwwwt tagging utility (using raw interface). version: 1.0 url: http://www.github.com/stashapp/stash exec: - python - "{pluginDir}/pyplugin.py" interface: raw tasks: - name: Add hawwwwt tag to random scene description: Creates a "Hawwwwt" tag if not present and adds to a random scene. defaultArgs: mode: add - name: Remove hawwwwt tag from system description: Removes the "Hawwwwt" tag from all scenes and deletes the tag. defaultArgs: mode: remove - name: Indefinite task description: Sleeps indefinitely - interruptable # we'll try command-line argument for this one execArgs: - indef - "{pluginDir}" - name: Long task description: Sleeps for 100 seconds - interruptable defaultArgs: mode: long ================================================ FILE: pkg/plugin/examples/python/stash_interface.py ================================================ import requests class StashInterface: port = "" url = "" headers = { "Accept-Encoding": "gzip, deflate, br", "Content-Type": "application/json", "Accept": "application/json", "Connection": "keep-alive", "DNT": "1" } def __init__(self, conn): self.port = conn['Port'] scheme = conn['Scheme'] self.url = scheme + "://localhost:" + str(self.port) + "/graphql" # Session cookie for authentication self.cookies = { 'session': conn.get('SessionCookie').get('Value') } def __callGraphQL(self, query, variables = None): json = {} json['query'] = query if variables != None: json['variables'] = variables # handle cookies response = requests.post(self.url, json=json, headers=self.headers, cookies=self.cookies) if response.status_code == 200: result = response.json() if result.get("error", None): for error in result["error"]["errors"]: raise Exception("GraphQL error: {}".format(error)) if result.get("data", None): return result.get("data") else: raise Exception("GraphQL query failed:{} - {}. Query: {}. Variables: {}".format(response.status_code, response.content, query, variables)) def findTagIdWithName(self, name): query = """ query { allTags { id name } } """ result = self.__callGraphQL(query) for tag in result["allTags"]: if tag["name"] == name: return tag["id"] return None def createTagWithName(self, name): query = """ mutation tagCreate($input:TagCreateInput!) { tagCreate(input: $input){ id } } """ variables = {'input': { 'name': name }} result = self.__callGraphQL(query, variables) return result["tagCreate"]["id"] def destroyTag(self, id): query = """ mutation tagDestroy($input: TagDestroyInput!) { tagDestroy(input: $input) } """ variables = {'input': { 'id': id }} self.__callGraphQL(query, variables) def findRandomSceneId(self): query = """ query findScenes($filter: FindFilterType!) { findScenes(filter: $filter) { count scenes { id tags { id } } } } """ variables = {'filter': { 'per_page': 1, 'sort': 'random' }} result = self.__callGraphQL(query, variables) if result["findScenes"]["count"] == 0: return None return result["findScenes"]["scenes"][0] def updateScene(self, sceneData): query = """ mutation sceneUpdate($input:SceneUpdateInput!) { sceneUpdate(input: $input) { id } } """ variables = {'input': sceneData} self.__callGraphQL(query, variables) ================================================ FILE: pkg/plugin/examples/react-component/README.md ================================================ This is a reference React component plugin. It replaces the `details` part of scene cards with a list of performers and tags. To build: - run `pnpm install --frozen-lockfile` - run `npm run build` This will copy the plugin files into the `dist` directory. These files can be copied to a `plugins` directory. ================================================ FILE: pkg/plugin/examples/react-component/package.json ================================================ { "name": "react-component", "version": "1.0.0", "main": "index.js", "author": "WithoutPants", "license": "AGPL-3.0", "scripts": { "compile:ts": "npm run tsc", "compile:sass": "npm run sass src/testReact.scss dist/testReact.css", "copy:yml": "cpx \"src/testReact.yml\" \"dist\"", "compile": "npm run compile:ts && npm run compile:sass", "build": "npm run compile && npm run copy:yml" }, "devDependencies": { "@types/react": "^18.2.31", "@types/react-dom": "^18.2.14", "cpx": "^1.5.0", "sass": "^1.69.4", "typescript": "^5.2.2" } } ================================================ FILE: pkg/plugin/examples/react-component/src/testReact.scss ================================================ .scene-card__date { color: #bfccd6;; font-size: 0.85em; } .scene-card__performer { display: inline-block; font-weight: 500; margin-right: 0.5em; a { color: #137cbd; } } .scene-card__performers, .scene-card__tags { -webkit-box-orient: vertical; display: -webkit-box; -webkit-line-clamp: 1; overflow: hidden; &:hover { -webkit-line-clamp: unset; overflow: visible; } } .scene-card__tags .tag-item { margin-left: 0; } .scene-performer-popover .image-thumbnail { margin: 1em; } .example-react-component-custom-overlay { display: block; font-weight: 900; height: 100%; opacity: 0.25; position: absolute; text-align: center; top: 0; width: 100%; z-index: 8; } ================================================ FILE: pkg/plugin/examples/react-component/src/testReact.tsx ================================================ interface IPluginApi { React: typeof React; GQL: any; Event: { addEventListener: (event: string, callback: (e: CustomEvent) => void) => void; }; libraries: { ReactRouterDOM: { Link: React.FC; Route: React.FC; NavLink: React.FC; }, Bootstrap: { Button: React.FC; Nav: React.FC & { Link: React.FC; Item: React.FC; }; Tab: React.FC & { Pane: React.FC; } }, FontAwesomeSolid: { faEthernet: any; }, Intl: { FormattedMessage: React.FC; } }, loadableComponents: any; components: Record>; utils: { NavUtils: any; loadComponents: any; }, hooks: any; patch: { before: (target: string, fn: Function) => void; instead: (target: string, fn: Function) => void; after: (target: string, fn: Function) => void; }, register: { route: (path: string, component: React.FC) => void; } } (function () { const PluginApi = (window as any).PluginApi as IPluginApi; const React = PluginApi.React; const GQL = PluginApi.GQL; const { Button, Nav, Tab } = PluginApi.libraries.Bootstrap; const { faEthernet } = PluginApi.libraries.FontAwesomeSolid; const { Link, NavLink, } = PluginApi.libraries.ReactRouterDOM; const { NavUtils } = PluginApi.utils; PluginApi.Event.addEventListener("stash:location", (e) => console.log("Page Changed", e.detail.data.location.pathname, e.detail.data.location.search)) const ScenePerformer: React.FC<{ performer: any; }> = ({ performer }) => { // PluginApi.components may not be registered when the outside function is run // need to initialise these inside the function component const { HoverPopover, } = PluginApi.components; const popoverContent = React.useMemo( () => (
{performer.name
), [performer] ); return ( {performer.name} ); }; function SceneDetails(props: any) { const { TagLink, } = PluginApi.components; function maybeRenderPerformers() { if (props.scene.performers.length <= 0) return; return (
{props.scene.performers.map((performer: any) => ( ))}
); } function maybeRenderTags() { if (props.scene.tags.length <= 0) return; return (
{props.scene.tags.map((tag: any) => ( ))}
); } return (
{props.scene.date} {maybeRenderPerformers()} {maybeRenderTags()}
); } function Overlays() { return Custom overlay; } PluginApi.patch.instead("SceneCard.Details", function (props: any, _: any, original: any) { return ; }); PluginApi.patch.instead("SceneCard.Overlays", function (props: any, _: any, original: (props: any) => any) { return <>{original({...props})}; }); PluginApi.patch.instead("FrontPage", function (props: any, _: any, original: (props: any) => any) { return <>

Hello from Test React!

{original({...props})}; }); const TestPage: React.FC = () => { const componentsToLoad = [ PluginApi.loadableComponents.SceneCard, PluginApi.loadableComponents.PerformerSelect, ]; const componentsLoading = PluginApi.hooks.useLoadComponents(componentsToLoad); const { SceneCard, LoadingIndicator, PerformerSelect, } = PluginApi.components; // read a random scene and show a scene card for it const { data } = GQL.useFindScenesQuery({ variables: { filter: { per_page: 1, sort: "random", }, }, }); const scene = data?.findScenes.scenes[0]; if (componentsLoading) return ( ); return (
This is a test page.
{!!scene && }
{}} values={[]} />
); }; PluginApi.register.route("/plugins/test-react", TestPage); PluginApi.patch.before("SettingsToolsSection", function (props: any) { const { Setting, } = PluginApi.components; return [ { children: ( <> {props.children} } /> ), }, ]; }); PluginApi.patch.before("MainNavBar.UtilityItems", function (props: any) { const { Icon, } = PluginApi.components; return [ { children: ( <> {props.children} ) } ] }); PluginApi.patch.before("ScenePage.Tabs", function (props: any) { return [ { children: ( <> {props.children} Test React tab ), }, ]; }); PluginApi.patch.before("ScenePage.TabContent", function (props: any) { return [ { children: ( <> {props.children} Test React tab content {props.scene.id} ), }, ]; }); })(); ================================================ FILE: pkg/plugin/examples/react-component/src/testReact.yml ================================================ name: Test React description: Adds a React component url: https://github.com/stashapp/CommunityScripts version: 1.0 ui: javascript: - testReact.js css: - testReact.css ================================================ FILE: pkg/plugin/examples/react-component/tsconfig.json ================================================ { "compilerOptions": { "target": "es2019", "outDir": "dist", // "lib": ["dom", "dom.iterable", "esnext"], "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, // "module": "es2020", "module": "None", "moduleResolution": "node", // "resolveJsonModule": true, // "noEmit": true, "jsx": "react", "experimentalDecorators": true, "baseUrl": ".", "sourceMap": true, "allowJs": true, "isolatedModules": true, "noFallthroughCasesInSwitch": true, "useDefineForClassFields": true, // "types": ["React"] }, "include": ["src"] } ================================================ FILE: pkg/plugin/hook/hooks.go ================================================ package hook type TriggerEnum string // Scan-related hooks are current disabled until post-hook execution is // integrated. const ( SceneMarkerCreatePost TriggerEnum = "SceneMarker.Create.Post" SceneMarkerUpdatePost TriggerEnum = "SceneMarker.Update.Post" SceneMarkerDestroyPost TriggerEnum = "SceneMarker.Destroy.Post" SceneCreatePost TriggerEnum = "Scene.Create.Post" SceneUpdatePost TriggerEnum = "Scene.Update.Post" SceneDestroyPost TriggerEnum = "Scene.Destroy.Post" ImageCreatePost TriggerEnum = "Image.Create.Post" ImageUpdatePost TriggerEnum = "Image.Update.Post" ImageDestroyPost TriggerEnum = "Image.Destroy.Post" GalleryCreatePost TriggerEnum = "Gallery.Create.Post" GalleryUpdatePost TriggerEnum = "Gallery.Update.Post" GalleryDestroyPost TriggerEnum = "Gallery.Destroy.Post" GalleryChapterCreatePost TriggerEnum = "GalleryChapter.Create.Post" GalleryChapterUpdatePost TriggerEnum = "GalleryChapter.Update.Post" GalleryChapterDestroyPost TriggerEnum = "GalleryChapter.Destroy.Post" // deprecated - use Group hooks instead // for now, both movie and group hooks will be executed MovieCreatePost TriggerEnum = "Movie.Create.Post" MovieUpdatePost TriggerEnum = "Movie.Update.Post" MovieDestroyPost TriggerEnum = "Movie.Destroy.Post" GroupCreatePost TriggerEnum = "Group.Create.Post" GroupUpdatePost TriggerEnum = "Group.Update.Post" GroupDestroyPost TriggerEnum = "Group.Destroy.Post" PerformerCreatePost TriggerEnum = "Performer.Create.Post" PerformerUpdatePost TriggerEnum = "Performer.Update.Post" PerformerDestroyPost TriggerEnum = "Performer.Destroy.Post" StudioCreatePost TriggerEnum = "Studio.Create.Post" StudioUpdatePost TriggerEnum = "Studio.Update.Post" StudioDestroyPost TriggerEnum = "Studio.Destroy.Post" TagCreatePost TriggerEnum = "Tag.Create.Post" TagUpdatePost TriggerEnum = "Tag.Update.Post" TagMergePost TriggerEnum = "Tag.Merge.Post" TagDestroyPost TriggerEnum = "Tag.Destroy.Post" ) var AllHookTriggerEnum = []TriggerEnum{ SceneMarkerCreatePost, SceneMarkerUpdatePost, SceneMarkerDestroyPost, SceneCreatePost, SceneUpdatePost, SceneDestroyPost, ImageCreatePost, ImageUpdatePost, ImageDestroyPost, GalleryCreatePost, GalleryUpdatePost, GalleryDestroyPost, GalleryChapterCreatePost, GalleryChapterUpdatePost, GalleryChapterDestroyPost, MovieCreatePost, MovieUpdatePost, MovieDestroyPost, PerformerCreatePost, PerformerUpdatePost, PerformerDestroyPost, StudioCreatePost, StudioUpdatePost, StudioDestroyPost, TagCreatePost, TagUpdatePost, TagMergePost, TagDestroyPost, } func (e TriggerEnum) IsValid() bool { switch e { case SceneMarkerCreatePost, SceneMarkerUpdatePost, SceneMarkerDestroyPost, SceneCreatePost, SceneUpdatePost, SceneDestroyPost, ImageCreatePost, ImageUpdatePost, ImageDestroyPost, GalleryCreatePost, GalleryUpdatePost, GalleryDestroyPost, GalleryChapterCreatePost, GalleryChapterUpdatePost, GalleryChapterDestroyPost, MovieCreatePost, MovieUpdatePost, MovieDestroyPost, PerformerCreatePost, PerformerUpdatePost, PerformerDestroyPost, StudioCreatePost, StudioUpdatePost, StudioDestroyPost, TagCreatePost, TagUpdatePost, TagDestroyPost: return true } return false } func (e TriggerEnum) String() string { return string(e) } ================================================ FILE: pkg/plugin/hooks.go ================================================ package plugin import ( "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin/common" ) type PluginHook struct { Name string `json:"name"` Description *string `json:"description"` Hooks []string `json:"hooks"` Plugin *Plugin `json:"plugin"` } func addHookContext(argsMap common.ArgsMap, hookContext common.HookContext) { argsMap[common.HookContextKey] = hookContext } // types for destroy hooks, to provide a little more information type SceneDestroyInput struct { models.SceneDestroyInput Checksum string `json:"checksum"` OSHash string `json:"oshash"` Path string `json:"path"` } type ScenesDestroyInput struct { models.ScenesDestroyInput Checksum string `json:"checksum"` OSHash string `json:"oshash"` Path string `json:"path"` } type GalleryDestroyInput struct { models.GalleryDestroyInput Checksum string `json:"checksum"` Path string `json:"path"` } type ImageDestroyInput struct { models.ImageDestroyInput Checksum string `json:"checksum"` Path string `json:"path"` } type ImagesDestroyInput struct { models.ImagesDestroyInput Checksum string `json:"checksum"` Path string `json:"path"` } ================================================ FILE: pkg/plugin/js.go ================================================ package plugin import ( "context" "errors" "fmt" "path/filepath" "sync" "github.com/dop251/goja" "github.com/stashapp/stash/pkg/javascript" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/plugin/common" ) var errStop = errors.New("stop") type jsTaskBuilder struct{} func (*jsTaskBuilder) build(task pluginTask) Task { return &jsPluginTask{ pluginTask: task, } } type jsPluginTask struct { pluginTask started bool waitGroup sync.WaitGroup vm *javascript.VM } func (t *jsPluginTask) onError(err error) { errString := err.Error() t.result = &common.PluginOutput{ Error: &errString, } } func (t *jsPluginTask) makeOutput(o goja.Value) { t.result = &common.PluginOutput{} asObj := o.ToObject(t.vm.Runtime) if asObj == nil { return } t.result.Output = asObj.Get("Output") err := asObj.Get("Error") if !goja.IsNull(err) && !goja.IsUndefined(err) { errStr := err.String() t.result.Error = &errStr } } func (t *jsPluginTask) initVM() error { // converting the Args field to map[string]interface{} is required, otherwise // it gets converted to an empty object // ideally this should have included json tags with the correct casing but changing // it now will result in a breaking change type pluginInput struct { // Server details to connect to the stash server. ServerConnection common.StashServerConnection // Arguments to the plugin operation. Args map[string]interface{} } input := pluginInput{ ServerConnection: t.input.ServerConnection, Args: t.input.Args.ToMap(), } if err := t.vm.Set("input", input); err != nil { return fmt.Errorf("error setting input: %w", err) } const pluginPrefix = "[Plugin / %s] " log := &javascript.Log{ Logger: logger.Logger, Prefix: fmt.Sprintf(pluginPrefix, t.plugin.Name), ProgressChan: t.progress, } if err := log.AddToVM("log", t.vm); err != nil { return fmt.Errorf("error adding log API: %w", err) } util := &javascript.Util{} if err := util.AddToVM("util", t.vm); err != nil { return fmt.Errorf("error adding util API: %w", err) } gql := &javascript.GQL{ Context: context.TODO(), Cookie: t.input.ServerConnection.SessionCookie, GQLHandler: t.gqlHandler, } if err := gql.AddToVM("gql", t.vm); err != nil { return fmt.Errorf("error adding GraphQL API: %w", err) } return nil } func (t *jsPluginTask) Start() error { if t.started { return errors.New("task already started") } t.started = true if len(t.plugin.Exec) == 0 { return errors.New("no script specified in exec") } scriptFile := t.plugin.Exec[0] t.vm = javascript.NewVM() pluginPath := t.plugin.getConfigPath() script, err := javascript.Compile(filepath.Join(pluginPath, scriptFile)) if err != nil { return err } if err := t.initVM(); err != nil { return err } t.waitGroup.Add(1) go func() { defer func() { t.waitGroup.Done() if caught := recover(); caught != nil { if err, ok := caught.(error); ok && errors.Is(err, errStop) { // TODO - log this return } } }() output, err := t.vm.RunProgram(script) if err != nil { t.onError(err) } else { t.makeOutput(output) } }() return nil } func (t *jsPluginTask) Wait() { t.waitGroup.Wait() } func (t *jsPluginTask) Stop() error { t.vm.Interrupt(errStop) return nil } ================================================ FILE: pkg/plugin/log.go ================================================ package plugin import ( "fmt" "io" "github.com/stashapp/stash/pkg/logger" ) func (t *pluginTask) handlePluginStderr(name string, pluginOutputReader io.ReadCloser) { logLevel := logger.PluginLogLevelFromName(t.plugin.PluginErrLogLevel) if logLevel == nil { // default log level to error logLevel = &logger.ErrorLevel } const pluginPrefix = "[Plugin / %s] " lgr := logger.PluginLogger{ Logger: logger.Logger, Prefix: fmt.Sprintf(pluginPrefix, name), DefaultLogLevel: logLevel, ProgressChan: t.progress, } lgr.ReadLogMessages(pluginOutputReader) } ================================================ FILE: pkg/plugin/plugins.go ================================================ // Package plugin implements functions and types for maintaining and running // stash plugins. // // Stash plugins are configured using yml files in the configured plugins // directory. These yml files must follow the Config structure format. // // The main entry into the plugin sub-system is via the Cache type. package plugin import ( "context" "errors" "fmt" "net/http" "os" "path/filepath" "slices" "strconv" "strings" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin/common" "github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/session" "github.com/stashapp/stash/pkg/txn" "github.com/stashapp/stash/pkg/utils" ) type Plugin struct { ID string `json:"id"` Name string `json:"name"` Description *string `json:"description"` URL *string `json:"url"` Version *string `json:"version"` Tasks []*PluginTask `json:"tasks"` Hooks []*PluginHook `json:"hooks"` UI PluginUI `json:"ui"` Settings []PluginSetting `json:"settings"` Enabled bool `json:"enabled"` // ConfigPath is the path to the plugin's configuration file. ConfigPath string `json:"-"` } type PluginUI struct { // Requires is a list of plugin IDs that this plugin depends on. // These plugins will be loaded before this plugin. Requires []string `json:"requires"` // Content Security Policy configuration for the plugin. CSP PluginCSP `json:"csp"` // External Javascript files that will be injected into the stash UI. ExternalScript []string `json:"external_script"` // External CSS files that will be injected into the stash UI. ExternalCSS []string `json:"external_css"` // Javascript files that will be injected into the stash UI. Javascript []string `json:"javascript"` // CSS files that will be injected into the stash UI. CSS []string `json:"css"` // Assets is a map of URL prefixes to hosted directories. // This allows plugins to serve static assets from a URL path. // Plugin assets are exposed via the /plugin/{pluginId}/assets path. // For example, if the plugin configuration file contains: // /foo: bar // /bar: baz // /: root // Then the following requests will be mapped to the following files: // /plugin/{pluginId}/assets/foo/file.txt -> {pluginDir}/foo/file.txt // /plugin/{pluginId}/assets/bar/file.txt -> {pluginDir}/baz/file.txt // /plugin/{pluginId}/assets/file.txt -> {pluginDir}/root/file.txt Assets utils.URLMap `json:"assets"` } type PluginSetting struct { Name string `json:"name"` // defaults to string Type PluginSettingTypeEnum `json:"type"` // defaults to key name DisplayName string `json:"displayName"` Description string `json:"description"` } type ServerConfig interface { GetHost() string GetPort() int GetConfigPathAbs() string HasTLSConfig() bool GetPluginsPath() string GetDisabledPlugins() []string GetPythonPath() string } // Cache stores plugin details. type Cache struct { config ServerConfig plugins []Config sessionStore *session.Store gqlHandler http.Handler } // NewCache returns a new Cache. // // Plugins configurations are loaded from yml files in the plugin // directory in the config and any subdirectories. // // Does not load plugins. Plugins will need to be // loaded explicitly using ReloadPlugins. func NewCache(config ServerConfig) *Cache { return &Cache{ config: config, } } func (c *Cache) RegisterGQLHandler(handler http.Handler) { c.gqlHandler = handler } func (c *Cache) RegisterSessionStore(sessionStore *session.Store) { c.sessionStore = sessionStore } // ReloadPlugins clears the plugin cache and loads from the plugin path. // If a plugin cannot be loaded, an error is logged and the plugin is skipped. func (c *Cache) ReloadPlugins() { path := c.config.GetPluginsPath() // # 4484 - ensure plugin ids are unique plugins := make([]Config, 0) pluginIDs := make(map[string]bool) logger.Debugf("Reading plugin configs from %s", path) err := fsutil.SymWalk(path, func(fp string, f os.FileInfo, err error) error { if filepath.Ext(fp) == ".yml" { plugin, err := loadPluginFromYAMLFile(fp) // use case insensitive plugin IDs if err != nil { logger.Errorf("Error loading plugin %s: %v", fp, err) } else { pluginID := strings.ToLower(plugin.id) if _, exists := pluginIDs[pluginID]; exists { logger.Errorf("Error loading plugin %s: plugin ID %s already exists", fp, plugin.id) return nil } pluginIDs[pluginID] = true plugins = append(plugins, *plugin) } } return nil }) if err != nil { logger.Errorf("Error reading plugin configs: %v", err) } c.plugins = plugins } func (c Cache) enabledPlugins() []Config { disabledPlugins := c.config.GetDisabledPlugins() var ret []Config for _, p := range c.plugins { disabled := slices.Contains(disabledPlugins, p.id) if !disabled { ret = append(ret, p) } } return ret } func (c Cache) pluginDisabled(id string) bool { disabledPlugins := c.config.GetDisabledPlugins() return slices.Contains(disabledPlugins, id) } // ListPlugins returns plugin details for all of the loaded plugins. func (c Cache) ListPlugins() []*Plugin { disabledPlugins := c.config.GetDisabledPlugins() var ret []*Plugin for _, s := range c.plugins { p := s.toPlugin() disabled := slices.Contains(disabledPlugins, p.ID) p.Enabled = !disabled ret = append(ret, p) } return ret } // GetPlugin returns the plugin with the given ID. // Returns nil if the plugin is not found. func (c Cache) GetPlugin(id string) *Plugin { disabledPlugins := c.config.GetDisabledPlugins() plugin := c.getPlugin(id) if plugin != nil { p := plugin.toPlugin() disabled := slices.Contains(disabledPlugins, p.ID) p.Enabled = !disabled return p } return nil } // ListPluginTasks returns all runnable plugin tasks in all loaded plugins. func (c Cache) ListPluginTasks() []*PluginTask { var ret []*PluginTask for _, s := range c.enabledPlugins() { ret = append(ret, s.getPluginTasks(true)...) } return ret } func buildPluginInput(plugin *Config, operation *OperationConfig, serverConnection common.StashServerConnection, args OperationInput) common.PluginInput { if args == nil { args = make(OperationInput) } if operation != nil { applyDefaultArgs(args, operation.DefaultArgs) } serverConnection.PluginDir = plugin.getConfigPath() return common.PluginInput{ ServerConnection: serverConnection, Args: toPluginArgs(args), } } func (c Cache) makeServerConnection(ctx context.Context) common.StashServerConnection { cookie := c.sessionStore.MakePluginCookie(ctx) serverConnection := common.StashServerConnection{ Scheme: "http", Host: c.config.GetHost(), Port: c.config.GetPort(), SessionCookie: cookie, Dir: c.config.GetConfigPathAbs(), } if c.config.HasTLSConfig() { serverConnection.Scheme = "https" } return serverConnection } // CreateTask runs the plugin operation for the pluginID and operation // name provided. Returns an error if the plugin or the operation could not be // resolved. func (c Cache) CreateTask(ctx context.Context, pluginID string, operationName *string, args OperationInput, progress chan float64) (Task, error) { serverConnection := c.makeServerConnection(ctx) if c.pluginDisabled(pluginID) { return nil, fmt.Errorf("plugin %s is disabled", pluginID) } // find the plugin and operation plugin := c.getPlugin(pluginID) if plugin == nil { return nil, fmt.Errorf("no plugin with ID %s", pluginID) } var operation *OperationConfig if operationName != nil { operation = plugin.getTask(*operationName) if operation == nil { return nil, fmt.Errorf("no task with name %s in plugin %s", *operationName, plugin.getName()) } } task := pluginTask{ plugin: plugin, operation: operation, input: buildPluginInput(plugin, operation, serverConnection, args), progress: progress, gqlHandler: c.gqlHandler, serverConfig: c.config, } return task.createTask(), nil } func (c Cache) RunPlugin(ctx context.Context, pluginID string, args OperationInput) (interface{}, error) { serverConnection := c.makeServerConnection(ctx) if c.pluginDisabled(pluginID) { return nil, fmt.Errorf("plugin %s is disabled", pluginID) } // find the plugin plugin := c.getPlugin(pluginID) pluginInput := buildPluginInput(plugin, nil, serverConnection, args) pt := pluginTask{ plugin: plugin, input: pluginInput, gqlHandler: c.gqlHandler, serverConfig: c.config, } task := pt.createTask() if err := task.Start(); err != nil { return nil, err } if err := waitForTask(ctx, task); err != nil { return nil, err } output := task.GetResult() if output == nil { logger.Debugf("%s: returned no result", pluginID) return nil, nil } else { if output.Error != nil { return nil, errors.New(*output.Error) } return output.Output, nil } } func waitForTask(ctx context.Context, task Task) error { // handle cancel from context c := make(chan struct{}) go func() { task.Wait() close(c) }() select { case <-ctx.Done(): if err := task.Stop(); err != nil { logger.Warnf("could not stop task: %v", err) } return fmt.Errorf("operation cancelled") case <-c: // task finished normally } return nil } func (c Cache) ExecutePostHooks(ctx context.Context, id int, hookType hook.TriggerEnum, input interface{}, inputFields []string) { if err := c.executePostHooks(ctx, hookType, common.HookContext{ ID: id, Type: hookType.String(), Input: input, InputFields: inputFields, }); err != nil { logger.Errorf("error executing post hooks: %s", err.Error()) } } func (c Cache) RegisterPostHooks(ctx context.Context, id int, hookType hook.TriggerEnum, input interface{}, inputFields []string) { txn.AddPostCommitHook(ctx, func(ctx context.Context) { c.ExecutePostHooks(ctx, id, hookType, input, inputFields) }) } func (c Cache) ExecuteSceneUpdatePostHooks(ctx context.Context, input models.SceneUpdateInput, inputFields []string) { id, err := strconv.Atoi(input.ID) if err != nil { logger.Errorf("error converting id in SceneUpdatePostHooks: %v", err) return } c.ExecutePostHooks(ctx, id, hook.SceneUpdatePost, input, inputFields) } // maxCyclicLoopDepth is the maximum number of identical plugin hook calls that // can be made before a cyclic loop is detected. It is set to an arbitrary value // that should not be hit under normal circumstances. const maxCyclicLoopDepth = 10 func (c Cache) executePostHooks(ctx context.Context, hookType hook.TriggerEnum, hookContext common.HookContext) error { visitedPluginHookCounts := getVisitedPluginHookCounts(ctx) for _, p := range c.enabledPlugins() { hooks := p.getHooks(hookType) // don't revisit a plugin we've already visited // only log if there's hooks that we're skipping if len(hooks) > 0 && visitedPluginHookCounts.For(p.id, hookType) >= maxCyclicLoopDepth { logger.Debugf("cyclic loop detected: plugin ID '%s' hook %s, not re-triggering", p.id, hookType) continue } for _, h := range hooks { newCtx := session.AddVisitedPluginHook(ctx, p.id, hookType) serverConnection := c.makeServerConnection(newCtx) pluginInput := buildPluginInput(&p, &h.OperationConfig, serverConnection, nil) addHookContext(pluginInput.Args, hookContext) pt := pluginTask{ plugin: &p, operation: &h.OperationConfig, input: pluginInput, gqlHandler: c.gqlHandler, serverConfig: c.config, } task := pt.createTask() if err := task.Start(); err != nil { return err } if err := waitForTask(ctx, task); err != nil { return err } output := task.GetResult() if output == nil { logger.Debugf("%s [%s]: returned no result", hookType.String(), p.Name) } else { if output.Error != nil { logger.Errorf("%s [%s]: returned error: %s", hookType.String(), p.Name, *output.Error) } else if output.Output != nil { logger.Debugf("%s [%s]: returned: %v", hookType.String(), p.Name, output.Output) } } } } return nil } type visitedPluginHookCount struct { session.VisitedPluginHook Count int } type visitedPluginHookCounts []visitedPluginHookCount func (v visitedPluginHookCounts) For(pluginID string, hookType hook.TriggerEnum) int { for _, c := range v { if c.VisitedPluginHook.PluginID == pluginID && c.VisitedPluginHook.HookType == hookType { return c.Count } } return 0 } func getVisitedPluginHookCounts(ctx context.Context) visitedPluginHookCounts { visitedPluginHooks := session.GetVisitedPluginHooks(ctx) visitedPluginHookCounts := make([]visitedPluginHookCount, 0) for _, p := range visitedPluginHooks { found := false for i, v := range visitedPluginHookCounts { if v.VisitedPluginHook == p { visitedPluginHookCounts[i].Count++ found = true break } } if !found { visitedPluginHookCounts = append(visitedPluginHookCounts, visitedPluginHookCount{ VisitedPluginHook: p, Count: 1, }) } } return visitedPluginHookCounts } func (c Cache) getPlugin(pluginID string) *Config { for _, s := range c.plugins { if s.id == pluginID { return &s } } return nil } ================================================ FILE: pkg/plugin/raw.go ================================================ package plugin import ( "context" "encoding/json" "errors" "fmt" "io" "os/exec" "path/filepath" "strings" "sync" stashExec "github.com/stashapp/stash/pkg/exec" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/plugin/common" "github.com/stashapp/stash/pkg/python" ) type rawTaskBuilder struct{} func (*rawTaskBuilder) build(task pluginTask) Task { return &rawPluginTask{ pluginTask: task, } } type rawPluginTask struct { pluginTask started bool waitGroup sync.WaitGroup cmd *exec.Cmd done chan bool } func (t *rawPluginTask) Start() error { if t.started { return errors.New("task already started") } command := t.plugin.getExecCommand(t.operation) if len(command) == 0 { return fmt.Errorf("empty exec value") } var cmd *exec.Cmd if python.IsPythonCommand(command[0]) { pythonPath := t.serverConfig.GetPythonPath() p, err := python.Resolve(pythonPath) if err != nil { logger.Warnf("%s", err) } else { cmd = p.Command(context.TODO(), command[1:]) envVariable, _ := filepath.Abs(filepath.Dir(filepath.Dir(t.plugin.path))) python.AppendPythonPath(cmd, envVariable) } } if cmd == nil { // if could not find python, just use the command args as-is cmd = stashExec.Command(command[0], command[1:]...) } stdin, err := cmd.StdinPipe() if err != nil { return fmt.Errorf("error getting plugin process stdin: %v", err) } go func() { defer stdin.Close() inBytes, err := json.Marshal(t.input) if err != nil { logger.Warnf("error marshalling raw command input") } if k, err := stdin.Write(inBytes); err != nil { logger.Warnf("error writing input to plugins stdin (wrote %v bytes out of %v): %v", k, len(string(inBytes)), err) } }() stderr, err := cmd.StderrPipe() if err != nil { logger.Error("plugin stderr not available: " + err.Error()) } stdout, err := cmd.StdoutPipe() if nil != err { logger.Error("plugin stdout not available: " + err.Error()) } t.waitGroup.Add(1) t.done = make(chan bool, 1) if err = cmd.Start(); err != nil { return fmt.Errorf("error running plugin: %v", err) } go t.handlePluginStderr(t.plugin.Name, stderr) t.cmd = cmd logger.Debugf("Plugin %s started: %s", t.plugin.Name, strings.Join(cmd.Args, " ")) // send the stdout to the plugin output go func() { defer t.waitGroup.Done() defer close(t.done) stdoutData, _ := io.ReadAll(stdout) stdoutString := string(stdoutData) output := t.getOutput(stdoutString) err := cmd.Wait() if err != nil && output.Error == nil { errStr := err.Error() output.Error = &errStr } logger.Debugf("Plugin %s finished", t.plugin.Name) t.result = &output }() t.started = true return nil } func (t *rawPluginTask) getOutput(output string) common.PluginOutput { // try to parse the output as a PluginOutput json. If it fails just // get the raw output ret := common.PluginOutput{} decodeErr := json.Unmarshal([]byte(output), &ret) if decodeErr != nil { ret.Output = &output } return ret } func (t *rawPluginTask) Wait() { t.waitGroup.Wait() } func (t *rawPluginTask) Stop() error { if t.cmd == nil { return nil } return t.cmd.Process.Kill() } ================================================ FILE: pkg/plugin/rpc.go ================================================ package plugin import ( "errors" "fmt" "io" "net/rpc" "net/rpc/jsonrpc" "sync" "github.com/natefinch/pie" "github.com/stashapp/stash/pkg/plugin/common" ) type rpcTaskBuilder struct{} func (*rpcTaskBuilder) build(task pluginTask) Task { return &rpcPluginTask{ pluginTask: task, } } type rpcPluginClient struct { Client *rpc.Client } func (p rpcPluginClient) Run(input common.PluginInput, output *common.PluginOutput) error { return p.Client.Call("RPCRunner.Run", input, output) } func (p rpcPluginClient) RunAsync(input common.PluginInput, output *common.PluginOutput, done chan *rpc.Call) *rpc.Call { return p.Client.Go("RPCRunner.Run", input, output, done) } func (p rpcPluginClient) Stop() error { var resp interface{} return p.Client.Call("RPCRunner.Stop", nil, &resp) } type rpcPluginTask struct { pluginTask started bool client *rpc.Client waitGroup sync.WaitGroup done chan *rpc.Call } func (t *rpcPluginTask) Start() error { if t.started { return errors.New("task already started") } command := t.plugin.getExecCommand(t.operation) if len(command) == 0 { return fmt.Errorf("empty exec value") } pluginErrReader, pluginErrWriter := io.Pipe() var err error t.client, err = pie.StartProviderCodec(jsonrpc.NewClientCodec, pluginErrWriter, command[0], command[1:]...) if err != nil { return err } go t.handlePluginStderr(t.plugin.Name, pluginErrReader) iface := rpcPluginClient{ Client: t.client, } t.done = make(chan *rpc.Call, 1) result := common.PluginOutput{} t.waitGroup.Add(1) iface.RunAsync(t.input, &result, t.done) go t.waitToFinish(&result) t.started = true return nil } func (t *rpcPluginTask) waitToFinish(result *common.PluginOutput) { defer t.client.Close() defer t.waitGroup.Done() <-t.done t.result = result } func (t *rpcPluginTask) Wait() { t.waitGroup.Wait() } func (t *rpcPluginTask) Stop() error { iface := rpcPluginClient{ Client: t.client, } return iface.Stop() } ================================================ FILE: pkg/plugin/setting.go ================================================ package plugin import ( "fmt" "io" "strconv" ) type PluginSettingTypeEnum string const ( PluginSettingTypeEnumString PluginSettingTypeEnum = "STRING" PluginSettingTypeEnumNumber PluginSettingTypeEnum = "NUMBER" PluginSettingTypeEnumBoolean PluginSettingTypeEnum = "BOOLEAN" ) var AllPluginSettingTypeEnum = []PluginSettingTypeEnum{ PluginSettingTypeEnumString, PluginSettingTypeEnumNumber, PluginSettingTypeEnumBoolean, } func (e PluginSettingTypeEnum) IsValid() bool { switch e { case PluginSettingTypeEnumString, PluginSettingTypeEnumNumber, PluginSettingTypeEnumBoolean: return true } return false } func (e PluginSettingTypeEnum) String() string { return string(e) } func (e *PluginSettingTypeEnum) UnmarshalGQL(v interface{}) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = PluginSettingTypeEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid PluginSettingTypeEnum", str) } return nil } func (e PluginSettingTypeEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } ================================================ FILE: pkg/plugin/task.go ================================================ package plugin import ( "net/http" "github.com/stashapp/stash/pkg/plugin/common" ) type PluginTask struct { Name string `json:"name"` Description *string `json:"description"` Plugin *Plugin `json:"plugin"` } // Task is the interface that handles management of a single plugin task. type Task interface { // Start starts the plugin task. Returns an error if task could not be // started or the task has already been started. Start() error // Stop instructs a running plugin task to stop and returns immediately. // Use Wait to subsequently wait for the task to stop. Stop() error // Wait blocks until the plugin task is complete. Returns immediately if // task has not been started. Wait() // GetResult returns the output of the plugin task. Returns nil if the task // has not completed. GetResult() *common.PluginOutput } type taskBuilder interface { build(task pluginTask) Task } type pluginTask struct { plugin *Config operation *OperationConfig input common.PluginInput gqlHandler http.Handler serverConfig ServerConfig progress chan float64 result *common.PluginOutput } func (t *pluginTask) GetResult() *common.PluginOutput { return t.result } func (t *pluginTask) createTask() Task { return t.plugin.Interface.getTaskBuilder().build(*t) } ================================================ FILE: pkg/plugin/util/client.go ================================================ // Package util implements utility and convenience methods for plugins. It is // not intended for the main stash code to access. package util import ( "net/http" "net/http/cookiejar" "net/url" "strconv" graphql "github.com/hasura/go-graphql-client" "github.com/stashapp/stash/pkg/plugin/common" ) // NewClient creates a graphql Client connecting to the stash server using // the provided server connection details. // Always connects to the graphql endpoint of the localhost. func NewClient(provider common.StashServerConnection) *graphql.Client { portStr := strconv.Itoa(provider.Port) u, _ := url.Parse("http://" + provider.Host + ":" + portStr + "/graphql") u.Scheme = provider.Scheme cookieJar, _ := cookiejar.New(nil) cookie := provider.SessionCookie if cookie != nil { cookieJar.SetCookies(u, []*http.Cookie{ cookie, }) } httpClient := &http.Client{ Jar: cookieJar, } return graphql.NewClient(u.String(), httpClient) } ================================================ FILE: pkg/python/env.go ================================================ package python import ( "fmt" "os" "os/exec" ) func AppendPythonPath(cmd *exec.Cmd, path string) { // Respect the users PYTHONPATH if set if currentValue, set := os.LookupEnv("PYTHONPATH"); set { path = fmt.Sprintf("%s%c%s", currentValue, os.PathListSeparator, path) } cmd.Env = append(os.Environ(), fmt.Sprintf("PYTHONPATH=%s", path)) } ================================================ FILE: pkg/python/exec.go ================================================ // Package python provides utilities for working with the python executable. package python import ( "context" "fmt" "os/exec" stashExec "github.com/stashapp/stash/pkg/exec" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" ) type Python string func (p *Python) Command(ctx context.Context, args []string) *exec.Cmd { return stashExec.CommandContext(ctx, string(*p), args...) } // New returns a new Python instance at the given path. func New(path string) *Python { ret := Python(path) return &ret } // Resolve tries to find the python executable in the system. // It first checks for python3, then python. // Returns nil and an exec.ErrNotFound error if not found. func Resolve(configuredPythonPath string) (*Python, error) { if configuredPythonPath != "" { isFile, err := fsutil.FileExists(configuredPythonPath) switch { case err == nil && isFile: logger.Tracef("using configured python path: %s", configuredPythonPath) return New(configuredPythonPath), nil case err == nil && !isFile: logger.Warnf("configured python path is not a file: %s", configuredPythonPath) case err != nil: logger.Warnf("unable to use configured python path: %v", err) } } python3, err := exec.LookPath("python3") if err != nil { python, err := exec.LookPath("python") if err != nil { return nil, fmt.Errorf("python executable not in PATH: %w", err) } ret := Python(python) return &ret, nil } ret := Python(python3) return &ret, nil } // IsPythonCommand returns true if arg is "python" or "python3" func IsPythonCommand(arg string) bool { return arg == "python" || arg == "python3" } ================================================ FILE: pkg/savedfilter/export.go ================================================ package savedfilter import ( "context" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" ) // ToJSON converts a SavedFilter object into its JSON equivalent. func ToJSON(ctx context.Context, filter *models.SavedFilter) (*jsonschema.SavedFilter, error) { return &jsonschema.SavedFilter{ Name: filter.Name, Mode: filter.Mode, FindFilter: filter.FindFilter, ObjectFilter: filter.ObjectFilter, UIOptions: filter.UIOptions, }, nil } ================================================ FILE: pkg/savedfilter/export_test.go ================================================ package savedfilter import ( "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "testing" ) const ( savedFilterID = 1 noImageID = 2 errImageID = 3 errAliasID = 4 withParentsID = 5 errParentsID = 6 ) const ( filterName = "testFilter" mode = models.FilterModeGalleries ) var ( findFilter = models.FindFilterType{} objectFilter = make(map[string]interface{}) uiOptions = make(map[string]interface{}) ) func createSavedFilter(id int) models.SavedFilter { return models.SavedFilter{ ID: id, Name: filterName, Mode: mode, FindFilter: &findFilter, ObjectFilter: objectFilter, UIOptions: uiOptions, } } func createJSONSavedFilter() *jsonschema.SavedFilter { return &jsonschema.SavedFilter{ Name: filterName, Mode: mode, FindFilter: &findFilter, ObjectFilter: objectFilter, UIOptions: uiOptions, } } type testScenario struct { savedFilter models.SavedFilter expected *jsonschema.SavedFilter err bool } var scenarios []testScenario func initTestTable() { scenarios = []testScenario{ { createSavedFilter(savedFilterID), createJSONSavedFilter(), false, }, } } func TestToJSON(t *testing.T) { initTestTable() db := mocks.NewDatabase() for i, s := range scenarios { savedFilter := s.savedFilter json, err := ToJSON(testCtx, &savedFilter) switch { case !s.err && err != nil: t.Errorf("[%d] unexpected error: %s", i, err.Error()) case s.err && err == nil: t.Errorf("[%d] expected error not returned", i) default: assert.Equal(t, s.expected, json, "[%d]", i) } } db.AssertExpectations(t) } ================================================ FILE: pkg/savedfilter/import.go ================================================ package savedfilter import ( "context" "fmt" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" ) type ImporterReaderWriter interface { models.SavedFilterWriter } type Importer struct { ReaderWriter ImporterReaderWriter Input jsonschema.SavedFilter MissingRefBehaviour models.ImportMissingRefEnum savedFilter models.SavedFilter } func (i *Importer) PreImport(ctx context.Context) error { i.savedFilter = models.SavedFilter{ Name: i.Input.Name, Mode: i.Input.Mode, FindFilter: i.Input.FindFilter, ObjectFilter: i.Input.ObjectFilter, UIOptions: i.Input.UIOptions, } return nil } func (i *Importer) PostImport(ctx context.Context, id int) error { return nil } func (i *Importer) Name() string { return i.Input.Name } func (i *Importer) FindExistingID(ctx context.Context) (*int, error) { // for now, assume this is only imported in full, so we don't support updating existing filters return nil, nil } func (i *Importer) Create(ctx context.Context) (*int, error) { err := i.ReaderWriter.Create(ctx, &i.savedFilter) if err != nil { return nil, fmt.Errorf("error creating saved filter: %v", err) } id := i.savedFilter.ID return &id, nil } func (i *Importer) Update(ctx context.Context, id int) error { return fmt.Errorf("updating existing saved filters is not supported") } ================================================ FILE: pkg/savedfilter/import_test.go ================================================ package savedfilter import ( "context" "errors" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) const ( savedFilterNameErr = "savedFilterNameErr" existingSavedFilterName = "existingSavedFilterName" existingFilterID = 100 ) var testCtx = context.Background() func TestImporterName(t *testing.T) { i := Importer{ Input: jsonschema.SavedFilter{ Name: filterName, }, } assert.Equal(t, filterName, i.Name()) } func TestImporterPreImport(t *testing.T) { i := Importer{ Input: jsonschema.SavedFilter{ Name: filterName, }, } err := i.PreImport(testCtx) assert.Nil(t, err) } func TestImporterPostImport(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.SavedFilter, Input: jsonschema.SavedFilter{}, } err := i.PostImport(testCtx, savedFilterID) assert.Nil(t, err) db.AssertExpectations(t) } func TestImporterFindExistingID(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.SavedFilter, Input: jsonschema.SavedFilter{ Name: filterName, }, } id, err := i.FindExistingID(testCtx) assert.Nil(t, id) assert.Nil(t, err) } func TestCreate(t *testing.T) { db := mocks.NewDatabase() savedFilter := models.SavedFilter{ Name: filterName, } savedFilterErr := models.SavedFilter{ Name: savedFilterNameErr, } i := Importer{ ReaderWriter: db.SavedFilter, savedFilter: savedFilter, } errCreate := errors.New("Create error") db.SavedFilter.On("Create", testCtx, &savedFilter).Run(func(args mock.Arguments) { t := args.Get(1).(*models.SavedFilter) t.ID = savedFilterID }).Return(nil).Once() db.SavedFilter.On("Create", testCtx, &savedFilterErr).Return(errCreate).Once() id, err := i.Create(testCtx) assert.Equal(t, savedFilterID, *id) assert.Nil(t, err) i.savedFilter = savedFilterErr id, err = i.Create(testCtx) assert.Nil(t, id) assert.NotNil(t, err) db.AssertExpectations(t) } func TestUpdate(t *testing.T) { db := mocks.NewDatabase() savedFilterErr := models.SavedFilter{ Name: savedFilterNameErr, } i := Importer{ ReaderWriter: db.SavedFilter, savedFilter: savedFilterErr, } // Update is not currently supported err := i.Update(testCtx, existingFilterID) assert.NotNil(t, err) } ================================================ FILE: pkg/scene/create.go ================================================ package scene import ( "context" "errors" "fmt" "time" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin/hook" ) func (s *Service) Create(ctx context.Context, input models.CreateSceneInput) (*models.Scene, error) { // title must be set if no files are provided if input.Scene.Title == "" && len(input.FileIDs) == 0 { return nil, errors.New("title must be set if scene has no files") } now := time.Now() newScene := *input.Scene newScene.CreatedAt = now newScene.UpdatedAt = now // don't pass the file ids since they may be already assigned // assign them afterwards if err := s.Repository.Create(ctx, &newScene, nil); err != nil { return nil, fmt.Errorf("creating new scene: %w", err) } if len(input.CustomFields) > 0 { if err := s.Repository.SetCustomFields(ctx, newScene.ID, models.CustomFieldsInput{ Full: input.CustomFields, }); err != nil { return nil, fmt.Errorf("setting custom fields on new scene: %w", err) } } for _, f := range input.FileIDs { if err := s.AssignFile(ctx, newScene.ID, f); err != nil { return nil, fmt.Errorf("assigning file %d to new scene: %w", f, err) } } if len(input.FileIDs) > 0 { // assign the primary to the first if _, err := s.Repository.UpdatePartial(ctx, newScene.ID, models.ScenePartial{ PrimaryFileID: &input.FileIDs[0], }); err != nil { return nil, fmt.Errorf("setting primary file on new scene: %w", err) } } // re-find the scene so that it correctly returns file-related fields ret, err := s.Repository.Find(ctx, newScene.ID) if err != nil { return nil, err } if len(input.CoverImage) > 0 { if err := s.Repository.UpdateCover(ctx, ret.ID, input.CoverImage); err != nil { return nil, fmt.Errorf("setting cover on new scene: %w", err) } } s.PluginCache.RegisterPostHooks(ctx, ret.ID, hook.SceneCreatePost, nil, nil) // re-find the scene so that it correctly returns file-related fields return ret, nil } ================================================ FILE: pkg/scene/delete.go ================================================ package scene import ( "context" "path/filepath" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/file/video" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/paths" ) // FileDeleter is an extension of file.Deleter that handles deletion of scene files. type FileDeleter struct { *file.Deleter FileNamingAlgo models.HashAlgorithm Paths *paths.Paths } // MarkGeneratedFiles marks for deletion the generated files for the provided scene. // Generated files bypass trash and are permanently deleted since they can be regenerated. func (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error { sceneHash := scene.GetHash(d.FileNamingAlgo) if sceneHash == "" { return nil } markersFolder := filepath.Join(d.Paths.Generated.Markers, sceneHash) exists, _ := fsutil.FileExists(markersFolder) if exists { if err := d.DirsWithoutTrash([]string{markersFolder}); err != nil { return err } } var files []string streamPreviewPath := d.Paths.Scene.GetVideoPreviewPath(sceneHash) exists, _ = fsutil.FileExists(streamPreviewPath) if exists { files = append(files, streamPreviewPath) } streamPreviewImagePath := d.Paths.Scene.GetWebpPreviewPath(sceneHash) exists, _ = fsutil.FileExists(streamPreviewImagePath) if exists { files = append(files, streamPreviewImagePath) } transcodePath := d.Paths.Scene.GetTranscodePath(sceneHash) exists, _ = fsutil.FileExists(transcodePath) if exists { files = append(files, transcodePath) } spritePath := d.Paths.Scene.GetSpriteImageFilePath(sceneHash) exists, _ = fsutil.FileExists(spritePath) if exists { files = append(files, spritePath) } vttPath := d.Paths.Scene.GetSpriteVttFilePath(sceneHash) exists, _ = fsutil.FileExists(vttPath) if exists { files = append(files, vttPath) } heatmapPath := d.Paths.Scene.GetInteractiveHeatmapPath(sceneHash) exists, _ = fsutil.FileExists(heatmapPath) if exists { files = append(files, heatmapPath) } return d.FilesWithoutTrash(files) } // MarkMarkerFiles deletes generated files for a scene marker with the // provided scene and timestamp. // Generated files bypass trash and are permanently deleted since they can be regenerated. func (d *FileDeleter) MarkMarkerFiles(scene *models.Scene, seconds int) error { videoPath := d.Paths.SceneMarkers.GetVideoPreviewPath(scene.GetHash(d.FileNamingAlgo), seconds) imagePath := d.Paths.SceneMarkers.GetWebpPreviewPath(scene.GetHash(d.FileNamingAlgo), seconds) screenshotPath := d.Paths.SceneMarkers.GetScreenshotPath(scene.GetHash(d.FileNamingAlgo), seconds) var files []string exists, _ := fsutil.FileExists(videoPath) if exists { files = append(files, videoPath) } exists, _ = fsutil.FileExists(imagePath) if exists { files = append(files, imagePath) } exists, _ = fsutil.FileExists(screenshotPath) if exists { files = append(files, screenshotPath) } return d.FilesWithoutTrash(files) } // Destroy deletes a scene and its associated relationships from the // database. func (s *Service) Destroy(ctx context.Context, scene *models.Scene, fileDeleter *FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error { mqb := s.MarkerRepository markers, err := mqb.FindBySceneID(ctx, scene.ID) if err != nil { return err } for _, m := range markers { if err := DestroyMarker(ctx, scene, m, mqb, fileDeleter); err != nil { return err } } if deleteFile { if err := s.deleteFiles(ctx, scene, fileDeleter); err != nil { return err } } else if destroyFileEntry { if err := s.destroyFileEntries(ctx, scene); err != nil { return err } } if deleteGenerated { if err := fileDeleter.MarkGeneratedFiles(scene); err != nil { return err } } if err := s.Repository.Destroy(ctx, scene.ID); err != nil { return err } return nil } // deleteFiles deletes files from the database and file system func (s *Service) deleteFiles(ctx context.Context, scene *models.Scene, fileDeleter *FileDeleter) error { if err := scene.LoadFiles(ctx, s.Repository); err != nil { return err } for _, f := range scene.Files.List() { // only delete files where there is no other associated scene otherScenes, err := s.Repository.FindByFileID(ctx, f.ID) if err != nil { return err } if len(otherScenes) > 1 { // other scenes associated, don't remove continue } const deleteFile = true logger.Info("Deleting scene file: ", f.Path) if err := file.Destroy(ctx, s.File, f, fileDeleter.Deleter, deleteFile); err != nil { return err } // don't delete files in zip archives if f.ZipFileID == nil { funscriptPath := video.GetFunscriptPath(f.Path) funscriptExists, _ := fsutil.FileExists(funscriptPath) if funscriptExists { if err := fileDeleter.Files([]string{funscriptPath}); err != nil { return err } } } } return nil } // destroyFileEntries destroys file entries from the database without deleting // the files from the filesystem func (s *Service) destroyFileEntries(ctx context.Context, scene *models.Scene) error { if err := scene.LoadFiles(ctx, s.Repository); err != nil { return err } for _, f := range scene.Files.List() { // only destroy file entries where there is no other associated scene otherScenes, err := s.Repository.FindByFileID(ctx, f.ID) if err != nil { return err } if len(otherScenes) > 1 { // other scenes associated, don't remove continue } const deleteFile = false logger.Info("Destroying scene file entry: ", f.Path) if err := file.Destroy(ctx, s.File, f, nil, deleteFile); err != nil { return err } } return nil } // DestroyMarker deletes the scene marker from the database and returns a // function that removes the generated files, to be executed after the // transaction is successfully committed. func DestroyMarker(ctx context.Context, scene *models.Scene, sceneMarker *models.SceneMarker, qb models.SceneMarkerDestroyer, fileDeleter *FileDeleter) error { if err := qb.Destroy(ctx, sceneMarker.ID); err != nil { return err } // delete the preview for the marker seconds := int(sceneMarker.Seconds) return fileDeleter.MarkMarkerFiles(scene, seconds) } ================================================ FILE: pkg/scene/export.go ================================================ package scene import ( "context" "fmt" "math" "strconv" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/utils" ) type ExportGetter interface { models.ViewDateReader models.ODateReader models.CustomFieldsReader GetCover(ctx context.Context, sceneID int) ([]byte, error) } type TagFinder interface { models.TagGetter FindBySceneID(ctx context.Context, sceneID int) ([]*models.Tag, error) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*models.Tag, error) } // ToBasicJSON converts a scene object into its JSON object equivalent. It // does not convert the relationships to other objects, with the exception // of cover image. func ToBasicJSON(ctx context.Context, reader ExportGetter, scene *models.Scene) (*jsonschema.Scene, error) { newSceneJSON := jsonschema.Scene{ Title: scene.Title, Code: scene.Code, URLs: scene.URLs.List(), Details: scene.Details, Director: scene.Director, CreatedAt: json.JSONTime{Time: scene.CreatedAt}, UpdatedAt: json.JSONTime{Time: scene.UpdatedAt}, } if scene.Date != nil { newSceneJSON.Date = scene.Date.String() } if scene.Rating != nil { newSceneJSON.Rating = *scene.Rating } newSceneJSON.Organized = scene.Organized for _, f := range scene.Files.List() { newSceneJSON.Files = append(newSceneJSON.Files, f.Base().Path) } cover, err := reader.GetCover(ctx, scene.ID) if err != nil { logger.Errorf("Error getting scene cover: %v", err) } if len(cover) > 0 { newSceneJSON.Cover = utils.GetBase64StringFromData(cover) } var ret []models.StashID for _, stashID := range scene.StashIDs.List() { newJoin := models.StashID{ StashID: stashID.StashID, Endpoint: stashID.Endpoint, } ret = append(ret, newJoin) } newSceneJSON.StashIDs = ret dates, err := reader.GetViewDates(ctx, scene.ID) if err != nil { return nil, fmt.Errorf("error getting view dates: %v", err) } for _, date := range dates { newSceneJSON.PlayHistory = append(newSceneJSON.PlayHistory, json.JSONTime{Time: date}) } odates, err := reader.GetODates(ctx, scene.ID) if err != nil { return nil, fmt.Errorf("error getting o dates: %v", err) } for _, date := range odates { newSceneJSON.OHistory = append(newSceneJSON.OHistory, json.JSONTime{Time: date}) } newSceneJSON.CustomFields, err = reader.GetCustomFields(ctx, scene.ID) if err != nil { return nil, fmt.Errorf("getting scene custom fields: %v", err) } return &newSceneJSON, nil } // GetStudioName returns the name of the provided scene's studio. It returns an // empty string if there is no studio assigned to the scene. func GetStudioName(ctx context.Context, reader models.StudioGetter, scene *models.Scene) (string, error) { if scene.StudioID != nil { studio, err := reader.Find(ctx, *scene.StudioID) if err != nil { return "", err } if studio != nil { return studio.Name, nil } } return "", nil } // GetTagNames returns a slice of tag names corresponding to the provided // scene's tags. func GetTagNames(ctx context.Context, reader TagFinder, scene *models.Scene) ([]string, error) { tags, err := reader.FindBySceneID(ctx, scene.ID) if err != nil { return nil, fmt.Errorf("error getting scene tags: %v", err) } return getTagNames(tags), nil } func getTagNames(tags []*models.Tag) []string { var results []string for _, tag := range tags { if tag.Name != "" { results = append(results, tag.Name) } } return results } // GetDependentTagIDs returns a slice of unique tag IDs that this scene references. func GetDependentTagIDs(ctx context.Context, tags TagFinder, markerReader models.SceneMarkerFinder, scene *models.Scene) ([]int, error) { var ret []int t, err := tags.FindBySceneID(ctx, scene.ID) if err != nil { return nil, err } for _, tt := range t { ret = sliceutil.AppendUnique(ret, tt.ID) } sm, err := markerReader.FindBySceneID(ctx, scene.ID) if err != nil { return nil, err } for _, smm := range sm { ret = sliceutil.AppendUnique(ret, smm.PrimaryTagID) smmt, err := tags.FindBySceneMarkerID(ctx, smm.ID) if err != nil { return nil, fmt.Errorf("invalid tags for scene marker: %v", err) } for _, smmtt := range smmt { ret = sliceutil.AppendUnique(ret, smmtt.ID) } } return ret, nil } // GetSceneGroupsJSON returns a slice of SceneGroup JSON representation objects // corresponding to the provided scene's scene group relationships. func GetSceneGroupsJSON(ctx context.Context, groupReader models.GroupGetter, scene *models.Scene) ([]jsonschema.SceneGroup, error) { sceneGroups := scene.Groups.List() var results []jsonschema.SceneGroup for _, sceneGroup := range sceneGroups { group, err := groupReader.Find(ctx, sceneGroup.GroupID) if err != nil { return nil, fmt.Errorf("error getting group: %v", err) } if group != nil { sceneGroupJSON := jsonschema.SceneGroup{ GroupName: group.Name, } if sceneGroup.SceneIndex != nil { sceneGroupJSON.SceneIndex = *sceneGroup.SceneIndex } results = append(results, sceneGroupJSON) } } return results, nil } // GetDependentGroupIDs returns a slice of group IDs that this scene references. func GetDependentGroupIDs(ctx context.Context, scene *models.Scene) ([]int, error) { var ret []int m := scene.Groups.List() for _, mm := range m { ret = append(ret, mm.GroupID) } return ret, nil } // GetSceneMarkersJSON returns a slice of SceneMarker JSON representation // objects corresponding to the provided scene's markers. func GetSceneMarkersJSON(ctx context.Context, markerReader models.SceneMarkerFinder, tagReader TagFinder, scene *models.Scene) ([]jsonschema.SceneMarker, error) { sceneMarkers, err := markerReader.FindBySceneID(ctx, scene.ID) if err != nil { return nil, fmt.Errorf("error getting scene markers: %v", err) } var results []jsonschema.SceneMarker for _, sceneMarker := range sceneMarkers { primaryTag, err := tagReader.Find(ctx, sceneMarker.PrimaryTagID) if err != nil { return nil, fmt.Errorf("invalid primary tag for scene marker: %v", err) } sceneMarkerTags, err := tagReader.FindBySceneMarkerID(ctx, sceneMarker.ID) if err != nil { return nil, fmt.Errorf("invalid tags for scene marker: %v", err) } sceneMarkerJSON := jsonschema.SceneMarker{ Title: sceneMarker.Title, Seconds: getDecimalString(sceneMarker.Seconds), PrimaryTag: primaryTag.Name, Tags: getTagNames(sceneMarkerTags), CreatedAt: json.JSONTime{Time: sceneMarker.CreatedAt}, UpdatedAt: json.JSONTime{Time: sceneMarker.UpdatedAt}, } if sceneMarker.EndSeconds != nil { sceneMarkerJSON.EndSeconds = getDecimalString(*sceneMarker.EndSeconds) } results = append(results, sceneMarkerJSON) } return results, nil } func getDecimalString(num float64) string { if num == 0 { return "" } precision := getPrecision(num) if precision == 0 { precision = 1 } return fmt.Sprintf("%."+strconv.Itoa(precision)+"f", num) } func getPrecision(num float64) int { if num == 0 { return 0 } e := 1.0 p := 0 for (math.Round(num*e) / e) != num { e *= 10 p++ } return p } ================================================ FILE: pkg/scene/export_test.go ================================================ package scene import ( "errors" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "testing" "time" ) const ( sceneID = 1 noImageID = 2 errImageID = 3 studioID = 4 missingStudioID = 5 errStudioID = 6 customFieldsID = 7 noTagsID = 11 errTagsID = 12 noGroupsID = 13 errFindGroupID = 15 noMarkersID = 16 errMarkersID = 17 errFindPrimaryTagID = 18 errFindByMarkerID = 19 errCustomFieldsID = 20 ) var ( url = "url" title = "title" date = "2001-01-01" dateObj, _ = models.ParseDate(date) rating = 5 organized = true details = "details" ) var ( studioName = "studioName" // galleryChecksum = "galleryChecksum" validGroup1 = 1 validGroup2 = 2 invalidGroup = 3 group1Name = "group1Name" group2Name = "group2Name" group1Scene = 1 group2Scene = 2 ) var names = []string{ "name1", "name2", } var imageBytes = []byte("imageBytes") var stashID = models.StashID{ StashID: "StashID", Endpoint: "Endpoint", } const ( path = "path" imageBase64 = "aW1hZ2VCeXRlcw==" ) var ( createTime = time.Date(2001, 01, 01, 0, 0, 0, 0, time.UTC) updateTime = time.Date(2002, 01, 01, 0, 0, 0, 0, time.UTC) ) var ( emptyCustomFields = make(map[string]interface{}) customFields = map[string]interface{}{ "customField1": "customValue1", } ) func createFullScene(id int) models.Scene { return models.Scene{ ID: id, Title: title, Date: &dateObj, Details: details, Rating: &rating, Organized: organized, URLs: models.NewRelatedStrings([]string{url}), Files: models.NewRelatedVideoFiles([]*models.VideoFile{ { BaseFile: &models.BaseFile{ Path: path, }, }, }), StashIDs: models.NewRelatedStashIDs([]models.StashID{ stashID, }), CreatedAt: createTime, UpdatedAt: updateTime, } } func createEmptyScene(id int) models.Scene { return models.Scene{ ID: id, Files: models.NewRelatedVideoFiles([]*models.VideoFile{ { BaseFile: &models.BaseFile{ Path: path, }, }, }), URLs: models.NewRelatedStrings([]string{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), CreatedAt: createTime, UpdatedAt: updateTime, } } func createFullJSONScene(image string, customFields map[string]interface{}) *jsonschema.Scene { return &jsonschema.Scene{ Title: title, Files: []string{path}, Date: date, Details: details, Rating: rating, Organized: organized, URLs: []string{url}, CreatedAt: json.JSONTime{ Time: createTime, }, UpdatedAt: json.JSONTime{ Time: updateTime, }, Cover: image, StashIDs: []models.StashID{ stashID, }, CustomFields: customFields, } } func createEmptyJSONScene() *jsonschema.Scene { return &jsonschema.Scene{ URLs: []string{}, Files: []string{path}, CreatedAt: json.JSONTime{ Time: createTime, }, UpdatedAt: json.JSONTime{ Time: updateTime, }, CustomFields: emptyCustomFields, } } type basicTestScenario struct { input models.Scene customFields map[string]interface{} expected *jsonschema.Scene err bool } var scenarios = []basicTestScenario{ { createFullScene(sceneID), emptyCustomFields, createFullJSONScene(imageBase64, emptyCustomFields), false, }, { createFullScene(customFieldsID), customFields, createFullJSONScene("", customFields), false, }, { createEmptyScene(noImageID), emptyCustomFields, createEmptyJSONScene(), false, }, { createFullScene(errImageID), emptyCustomFields, createFullJSONScene("", emptyCustomFields), // failure to get image should not cause an error false, }, { createFullScene(errCustomFieldsID), customFields, createFullJSONScene("", customFields), true, }, } func TestToJSON(t *testing.T) { db := mocks.NewDatabase() imageErr := errors.New("error getting image") db.Scene.On("GetCover", testCtx, sceneID).Return(imageBytes, nil).Once() db.Scene.On("GetCover", testCtx, noImageID).Return(nil, nil).Once() db.Scene.On("GetCover", testCtx, errImageID).Return(nil, imageErr).Once() db.Scene.On("GetCover", testCtx, mock.Anything).Return(nil, nil) db.Scene.On("GetViewDates", testCtx, mock.Anything).Return(nil, nil) db.Scene.On("GetODates", testCtx, mock.Anything).Return(nil, nil) db.Scene.On("GetCustomFields", testCtx, customFieldsID).Return(customFields, nil).Once() db.Scene.On("GetCustomFields", testCtx, errCustomFieldsID).Return(nil, errors.New("error getting custom fields")).Once() db.Scene.On("GetCustomFields", testCtx, mock.Anything).Return(emptyCustomFields, nil) for i, s := range scenarios { scene := s.input json, err := ToBasicJSON(testCtx, db.Scene, &scene) switch { case !s.err && err != nil: t.Errorf("[%d] unexpected error: %s", i, err.Error()) case s.err && err == nil: t.Errorf("[%d] expected error not returned", i) case err != nil: // error case already handled, no need for assertion default: assert.Equal(t, s.expected, json, "[%d]", i) } } db.AssertExpectations(t) } func createStudioScene(studioID int) models.Scene { return models.Scene{ StudioID: &studioID, } } type stringTestScenario struct { input models.Scene expected string err bool } var getStudioScenarios = []stringTestScenario{ { createStudioScene(studioID), studioName, false, }, { createStudioScene(missingStudioID), "", false, }, { createStudioScene(errStudioID), "", true, }, } func TestGetStudioName(t *testing.T) { db := mocks.NewDatabase() studioErr := errors.New("error getting image") db.Studio.On("Find", testCtx, studioID).Return(&models.Studio{ Name: studioName, }, nil).Once() db.Studio.On("Find", testCtx, missingStudioID).Return(nil, nil).Once() db.Studio.On("Find", testCtx, errStudioID).Return(nil, studioErr).Once() for i, s := range getStudioScenarios { scene := s.input json, err := GetStudioName(testCtx, db.Studio, &scene) switch { case !s.err && err != nil: t.Errorf("[%d] unexpected error: %s", i, err.Error()) case s.err && err == nil: t.Errorf("[%d] expected error not returned", i) default: assert.Equal(t, s.expected, json, "[%d]", i) } } db.AssertExpectations(t) } type stringSliceTestScenario struct { input models.Scene expected []string err bool } var getTagNamesScenarios = []stringSliceTestScenario{ { createEmptyScene(sceneID), names, false, }, { createEmptyScene(noTagsID), nil, false, }, { createEmptyScene(errTagsID), nil, true, }, } func getTags(names []string) []*models.Tag { var ret []*models.Tag for _, n := range names { ret = append(ret, &models.Tag{ Name: n, }) } return ret } func TestGetTagNames(t *testing.T) { db := mocks.NewDatabase() tagErr := errors.New("error getting tag") db.Tag.On("FindBySceneID", testCtx, sceneID).Return(getTags(names), nil).Once() db.Tag.On("FindBySceneID", testCtx, noTagsID).Return(nil, nil).Once() db.Tag.On("FindBySceneID", testCtx, errTagsID).Return(nil, tagErr).Once() for i, s := range getTagNamesScenarios { scene := s.input json, err := GetTagNames(testCtx, db.Tag, &scene) switch { case !s.err && err != nil: t.Errorf("[%d] unexpected error: %s", i, err.Error()) case s.err && err == nil: t.Errorf("[%d] expected error not returned", i) default: assert.Equal(t, s.expected, json, "[%d]", i) } } db.AssertExpectations(t) } type sceneGroupsTestScenario struct { input models.Scene expected []jsonschema.SceneGroup err bool } var validGroups = models.NewRelatedGroups([]models.GroupsScenes{ { GroupID: validGroup1, SceneIndex: &group1Scene, }, { GroupID: validGroup2, SceneIndex: &group2Scene, }, }) var invalidGroups = models.NewRelatedGroups([]models.GroupsScenes{ { GroupID: invalidGroup, SceneIndex: &group1Scene, }, }) var getSceneGroupsJSONScenarios = []sceneGroupsTestScenario{ { models.Scene{ ID: sceneID, Groups: validGroups, }, []jsonschema.SceneGroup{ { GroupName: group1Name, SceneIndex: group1Scene, }, { GroupName: group2Name, SceneIndex: group2Scene, }, }, false, }, { models.Scene{ ID: noGroupsID, Groups: models.NewRelatedGroups([]models.GroupsScenes{}), }, nil, false, }, { models.Scene{ ID: errFindGroupID, Groups: invalidGroups, }, nil, true, }, } func TestGetSceneGroupsJSON(t *testing.T) { db := mocks.NewDatabase() groupErr := errors.New("error getting group") db.Group.On("Find", testCtx, validGroup1).Return(&models.Group{ Name: group1Name, }, nil).Once() db.Group.On("Find", testCtx, validGroup2).Return(&models.Group{ Name: group2Name, }, nil).Once() db.Group.On("Find", testCtx, invalidGroup).Return(nil, groupErr).Once() for i, s := range getSceneGroupsJSONScenarios { scene := s.input json, err := GetSceneGroupsJSON(testCtx, db.Group, &scene) switch { case !s.err && err != nil: t.Errorf("[%d] unexpected error: %s", i, err.Error()) case s.err && err == nil: t.Errorf("[%d] expected error not returned", i) default: assert.Equal(t, s.expected, json, "[%d]", i) } } db.AssertExpectations(t) } const ( validMarkerID1 = 1 validMarkerID2 = 2 invalidMarkerID1 = 3 invalidMarkerID2 = 4 validTagID1 = 1 validTagID2 = 2 validTagName1 = "validTagName1" validTagName2 = "validTagName2" invalidTagID = 3 markerTitle1 = "markerTitle1" markerTitle2 = "markerTitle2" markerSeconds1 = 1.0 markerSeconds2 = 2.3 markerSeconds1Str = "1.0" markerSeconds2Str = "2.3" ) type sceneMarkersTestScenario struct { input models.Scene expected []jsonschema.SceneMarker err bool } var getSceneMarkersJSONScenarios = []sceneMarkersTestScenario{ { createEmptyScene(sceneID), []jsonschema.SceneMarker{ { Title: markerTitle1, PrimaryTag: validTagName1, Seconds: markerSeconds1Str, Tags: []string{ validTagName1, validTagName2, }, CreatedAt: json.JSONTime{ Time: createTime, }, UpdatedAt: json.JSONTime{ Time: updateTime, }, }, { Title: markerTitle2, PrimaryTag: validTagName2, Seconds: markerSeconds2Str, Tags: []string{ validTagName2, }, CreatedAt: json.JSONTime{ Time: createTime, }, UpdatedAt: json.JSONTime{ Time: updateTime, }, }, }, false, }, { createEmptyScene(noMarkersID), nil, false, }, { createEmptyScene(errMarkersID), nil, true, }, { createEmptyScene(errFindPrimaryTagID), nil, true, }, { createEmptyScene(errFindByMarkerID), nil, true, }, } var validMarkers = []*models.SceneMarker{ { ID: validMarkerID1, Title: markerTitle1, PrimaryTagID: validTagID1, Seconds: markerSeconds1, CreatedAt: createTime, UpdatedAt: updateTime, }, { ID: validMarkerID2, Title: markerTitle2, PrimaryTagID: validTagID2, Seconds: markerSeconds2, CreatedAt: createTime, UpdatedAt: updateTime, }, } var invalidMarkers1 = []*models.SceneMarker{ { ID: invalidMarkerID1, PrimaryTagID: invalidTagID, }, } var invalidMarkers2 = []*models.SceneMarker{ { ID: invalidMarkerID2, PrimaryTagID: validTagID1, }, } func TestGetSceneMarkersJSON(t *testing.T) { db := mocks.NewDatabase() markersErr := errors.New("error getting scene markers") tagErr := errors.New("error getting tags") db.SceneMarker.On("FindBySceneID", testCtx, sceneID).Return(validMarkers, nil).Once() db.SceneMarker.On("FindBySceneID", testCtx, noMarkersID).Return(nil, nil).Once() db.SceneMarker.On("FindBySceneID", testCtx, errMarkersID).Return(nil, markersErr).Once() db.SceneMarker.On("FindBySceneID", testCtx, errFindPrimaryTagID).Return(invalidMarkers1, nil).Once() db.SceneMarker.On("FindBySceneID", testCtx, errFindByMarkerID).Return(invalidMarkers2, nil).Once() db.Tag.On("Find", testCtx, validTagID1).Return(&models.Tag{ Name: validTagName1, }, nil) db.Tag.On("Find", testCtx, validTagID2).Return(&models.Tag{ Name: validTagName2, }, nil) db.Tag.On("Find", testCtx, invalidTagID).Return(nil, tagErr) db.Tag.On("FindBySceneMarkerID", testCtx, validMarkerID1).Return([]*models.Tag{ { Name: validTagName1, }, { Name: validTagName2, }, }, nil) db.Tag.On("FindBySceneMarkerID", testCtx, validMarkerID2).Return([]*models.Tag{ { Name: validTagName2, }, }, nil) db.Tag.On("FindBySceneMarkerID", testCtx, invalidMarkerID2).Return(nil, tagErr).Once() for i, s := range getSceneMarkersJSONScenarios { scene := s.input json, err := GetSceneMarkersJSON(testCtx, db.SceneMarker, db.Tag, &scene) switch { case !s.err && err != nil: t.Errorf("[%d] unexpected error: %s", i, err.Error()) case s.err && err == nil: t.Errorf("[%d] expected error not returned", i) default: assert.Equal(t, s.expected, json, "[%d]", i) } } db.AssertExpectations(t) } ================================================ FILE: pkg/scene/filename_parser.go ================================================ package scene import ( "context" "errors" "path/filepath" "regexp" "strconv" "strings" "time" "github.com/stashapp/stash/pkg/studio" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/tag" ) type parserField struct { field string fieldRegex *regexp.Regexp regex string isFullDateField bool isCaptured bool } func newParserField(field string, regex string, captured bool) parserField { ret := parserField{ field: field, isFullDateField: false, isCaptured: captured, } ret.fieldRegex, _ = regexp.Compile(`\{` + ret.field + `\}`) regexStr := regex if captured { regexStr = "(" + regexStr + ")" } ret.regex = regexStr return ret } func newFullDateParserField(field string, regex string) parserField { ret := newParserField(field, regex, true) ret.isFullDateField = true return ret } func (f parserField) replaceInPattern(pattern string) string { return string(f.fieldRegex.ReplaceAllString(pattern, f.regex)) } var validFields map[string]parserField var escapeCharRE *regexp.Regexp var capitalizeTitleRE *regexp.Regexp var multiWSRE *regexp.Regexp var delimiterRE *regexp.Regexp func compileREs() { const escapeCharPattern = `([\-\.\(\)\[\]])` escapeCharRE = regexp.MustCompile(escapeCharPattern) const capitaliseTitlePattern = `(?:^| )\w` capitalizeTitleRE = regexp.MustCompile(capitaliseTitlePattern) const multiWSPattern = ` {2,}` multiWSRE = regexp.MustCompile(multiWSPattern) const delimiterPattern = `(?:\.|-|_)` delimiterRE = regexp.MustCompile(delimiterPattern) } func initParserFields() { if validFields != nil { return } ret := make(map[string]parserField) ret["title"] = newParserField("title", ".*", true) ret["ext"] = newParserField("ext", ".*$", false) ret["d"] = newParserField("d", `(?:\.|-|_)`, false) ret["rating"] = newParserField("rating", `\d`, true) ret["rating100"] = newParserField("rating100", `\d`, true) ret["performer"] = newParserField("performer", ".*", true) ret["studio"] = newParserField("studio", ".*", true) ret["movie"] = newParserField("movie", ".*", true) ret["tag"] = newParserField("tag", ".*", true) // date fields ret["date"] = newParserField("date", `\d{4}-\d{2}-\d{2}`, true) ret["yyyy"] = newParserField("yyyy", `\d{4}`, true) ret["yy"] = newParserField("yy", `\d{2}`, true) ret["mm"] = newParserField("mm", `\d{2}`, true) ret["mmm"] = newParserField("mmm", `\w{3}`, true) ret["dd"] = newParserField("dd", `\d{2}`, true) ret["yyyymmdd"] = newFullDateParserField("yyyymmdd", `\d{8}`) ret["yymmdd"] = newFullDateParserField("yymmdd", `\d{6}`) ret["ddmmyyyy"] = newFullDateParserField("ddmmyyyy", `\d{8}`) ret["ddmmyy"] = newFullDateParserField("ddmmyy", `\d{6}`) ret["mmddyyyy"] = newFullDateParserField("mmddyyyy", `\d{8}`) ret["mmddyy"] = newFullDateParserField("mmddyy", `\d{6}`) validFields = ret } func replacePatternWithRegex(pattern string, ignoreWords []string) string { initParserFields() for _, field := range validFields { pattern = field.replaceInPattern(pattern) } ignoreClause := getIgnoreClause(ignoreWords) ignoreField := newParserField("i", ignoreClause, false) pattern = ignoreField.replaceInPattern(pattern) return pattern } type parseMapper struct { fields []string regexString string regex *regexp.Regexp } func getIgnoreClause(ignoreFields []string) string { if len(ignoreFields) == 0 { return "" } var ignoreClauses []string for _, v := range ignoreFields { newVal := string(escapeCharRE.ReplaceAllString(v, `\$1`)) newVal = strings.TrimSpace(newVal) newVal = "(?:" + newVal + ")" ignoreClauses = append(ignoreClauses, newVal) } return "(?:" + strings.Join(ignoreClauses, "|") + ")" } func newParseMapper(pattern string, ignoreFields []string) (*parseMapper, error) { ret := &parseMapper{} // escape control characters regex := escapeCharRE.ReplaceAllString(pattern, `\$1`) // replace {} with wildcard braceRE := regexp.MustCompile(`\{\}`) regex = braceRE.ReplaceAllString(regex, ".*") // replace all known fields with applicable regexes regex = replacePatternWithRegex(regex, ignoreFields) ret.regexString = regex // make case insensitive regex = "(?i)" + regex var err error ret.regex, err = regexp.Compile(regex) if err != nil { return nil, err } // find invalid fields invalidRE := regexp.MustCompile(`\{[A-Za-z]+\}`) foundInvalid := invalidRE.FindAllString(regex, -1) if len(foundInvalid) > 0 { return nil, errors.New("Invalid fields: " + strings.Join(foundInvalid, ", ")) } fieldExtractor := regexp.MustCompile(`\{([A-Za-z]+)\}`) result := fieldExtractor.FindAllStringSubmatch(pattern, -1) var fields []string for _, v := range result { field := v[1] // only add to fields if it is captured parserField, found := validFields[field] if found && parserField.isCaptured { fields = append(fields, field) } } ret.fields = fields return ret, nil } type sceneHolder struct { scene *models.Scene result *models.Scene yyyy string mm string dd string performers []string groups []string studio string tags []string } func newSceneHolder(scene *models.Scene) *sceneHolder { sceneCopy := models.Scene{ ID: scene.ID, Files: scene.Files, // Checksum: scene.Checksum, // Path: scene.Path, } ret := sceneHolder{ scene: scene, result: &sceneCopy, } return &ret } func validateRating(rating int) bool { return rating >= 1 && rating <= 5 } func validateRating100(rating100 int) bool { return rating100 >= 1 && rating100 <= 100 } // returns nil if invalid func parseDate(dateStr string) *models.Date { splits := strings.Split(dateStr, "-") if len(splits) != 3 { return nil } year, _ := strconv.Atoi(splits[0]) month, _ := strconv.Atoi(splits[1]) d, _ := strconv.Atoi(splits[2]) // assume year must be between 1900 and 2100 if year < 1900 || year > 2100 { return nil } if month < 1 || month > 12 { return nil } // not checking individual months to ensure date is in the correct range if d < 1 || d > 31 { return nil } ret, err := models.ParseDate(dateStr) if err != nil { return nil } return &ret } func (h *sceneHolder) setDate(field *parserField, value string) { yearIndex := 0 yearLength := len(strings.Split(field.field, "y")) - 1 dateIndex := 0 monthIndex := 0 switch field.field { case "yyyymmdd", "yymmdd": monthIndex = yearLength dateIndex = monthIndex + 2 case "ddmmyyyy", "ddmmyy": monthIndex = 2 yearIndex = monthIndex + 2 case "mmddyyyy", "mmddyy": dateIndex = monthIndex + 2 yearIndex = dateIndex + 2 } yearValue := value[yearIndex : yearIndex+yearLength] monthValue := value[monthIndex : monthIndex+2] dateValue := value[dateIndex : dateIndex+2] fullDate := yearValue + "-" + monthValue + "-" + dateValue // ensure the date is valid // only set if new value is different from the old newDate := parseDate(fullDate) if newDate != nil && h.scene.Date != nil && *h.scene.Date != *newDate { h.result.Date = newDate } } func mmmToMonth(mmm string) string { format := "02-Jan-2006" dateStr := "01-" + mmm + "-2000" t, err := time.Parse(format, dateStr) if err != nil { return "" } // expect month in two-digit format format = "01-02-2006" return t.Format(format)[0:2] } func (h *sceneHolder) setField(field parserField, value interface{}) { if field.isFullDateField { h.setDate(&field, value.(string)) return } switch field.field { case "title": v := value.(string) h.result.Title = v case "date": h.result.Date = parseDate(value.(string)) case "rating": rating, _ := strconv.Atoi(value.(string)) if validateRating(rating) { // convert to 1-100 scale rating = models.Rating5To100(rating) h.result.Rating = &rating } case "rating100": rating, _ := strconv.Atoi(value.(string)) if validateRating100(rating) { h.result.Rating = &rating } case "performer": // add performer to list h.performers = append(h.performers, value.(string)) case "studio": h.studio = value.(string) case "movie": h.groups = append(h.groups, value.(string)) case "tag": h.tags = append(h.tags, value.(string)) case "yyyy": h.yyyy = value.(string) case "yy": v := value.(string) v = "20" + v h.yyyy = v case "mmm": h.mm = mmmToMonth(value.(string)) case "mm": h.mm = value.(string) case "dd": h.dd = value.(string) } } func (h *sceneHolder) postParse() { // set the date if the components are set if h.yyyy != "" && h.mm != "" && h.dd != "" { fullDate := h.yyyy + "-" + h.mm + "-" + h.dd h.setField(validFields["date"], fullDate) } } func (m parseMapper) parse(scene *models.Scene) *sceneHolder { // #302 - if the pattern includes a path separator, then include the entire // scene path in the match. Otherwise, use the default behaviour of just // the file's basename // must be double \ because of the regex escaping filename := filepath.Base(scene.Path) if strings.Contains(m.regexString, `\\`) || strings.Contains(m.regexString, "/") { filename = scene.Path } result := m.regex.FindStringSubmatch(filename) if len(result) == 0 { return nil } initParserFields() sceneHolder := newSceneHolder(scene) for index, match := range result { if index == 0 { // skip entire match continue } field := m.fields[index-1] parserField, found := validFields[field] if found { sceneHolder.setField(parserField, match) } } sceneHolder.postParse() return sceneHolder } type FilenameParser struct { Pattern string ParserInput models.SceneParserInput Filter *models.FindFilterType whitespaceRE *regexp.Regexp repository FilenameParserRepository performerCache map[string]*models.Performer studioCache map[string]*models.Studio groupCache map[string]*models.Group tagCache map[string]*models.Tag } func NewFilenameParser(filter *models.FindFilterType, config models.SceneParserInput, repo FilenameParserRepository) *FilenameParser { p := &FilenameParser{ Pattern: *filter.Q, ParserInput: config, Filter: filter, repository: repo, } p.performerCache = make(map[string]*models.Performer) p.studioCache = make(map[string]*models.Studio) p.groupCache = make(map[string]*models.Group) p.tagCache = make(map[string]*models.Tag) p.initWhiteSpaceRegex() return p } func (p *FilenameParser) initWhiteSpaceRegex() { compileREs() wsChars := "" if p.ParserInput.WhitespaceCharacters != nil { wsChars = *p.ParserInput.WhitespaceCharacters wsChars = strings.TrimSpace(wsChars) } if len(wsChars) > 0 { wsRegExp := escapeCharRE.ReplaceAllString(wsChars, `\$1`) wsRegExp = "[" + wsRegExp + "]" p.whitespaceRE = regexp.MustCompile(wsRegExp) } } type FilenameParserRepository struct { Scene models.SceneQueryer Performer PerformerNamesFinder Studio models.StudioQueryer Group GroupNameFinder Tag models.TagQueryer } func NewFilenameParserRepository(repo models.Repository) FilenameParserRepository { return FilenameParserRepository{ Scene: repo.Scene, Performer: repo.Performer, Studio: repo.Studio, Group: repo.Group, Tag: repo.Tag, } } func (p *FilenameParser) Parse(ctx context.Context) ([]*models.SceneParserResult, int, error) { // perform the query to find the scenes mapper, err := newParseMapper(p.Pattern, p.ParserInput.IgnoreWords) if err != nil { return nil, 0, err } sceneFilter := &models.SceneFilterType{ Path: &models.StringCriterionInput{ Modifier: models.CriterionModifierMatchesRegex, Value: "(?i)" + mapper.regexString, }, } if p.ParserInput.IgnoreOrganized != nil && *p.ParserInput.IgnoreOrganized { organized := false sceneFilter.Organized = &organized } p.Filter.Q = nil scenes, total, err := QueryWithCount(ctx, p.repository.Scene, sceneFilter, p.Filter) if err != nil { return nil, 0, err } ret := p.parseScenes(ctx, scenes, mapper) return ret, total, nil } func (p *FilenameParser) parseScenes(ctx context.Context, scenes []*models.Scene, mapper *parseMapper) []*models.SceneParserResult { var ret []*models.SceneParserResult for _, scene := range scenes { sceneHolder := mapper.parse(scene) if sceneHolder != nil { r := &models.SceneParserResult{ Scene: scene, } p.setParserResult(ctx, *sceneHolder, r) ret = append(ret, r) } } return ret } func (p FilenameParser) replaceWhitespaceCharacters(value string) string { if p.whitespaceRE != nil { value = p.whitespaceRE.ReplaceAllString(value, " ") // remove consecutive spaces value = multiWSRE.ReplaceAllString(value, " ") } return value } type PerformerNamesFinder interface { FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Performer, error) } func (p *FilenameParser) queryPerformer(ctx context.Context, qb PerformerNamesFinder, performerName string) *models.Performer { // massage the performer name performerName = delimiterRE.ReplaceAllString(performerName, " ") // check cache first if ret, found := p.performerCache[performerName]; found { return ret } // perform an exact match and grab the first performers, _ := qb.FindByNames(ctx, []string{performerName}, true) var ret *models.Performer if len(performers) > 0 { ret = performers[0] } // add result to cache p.performerCache[performerName] = ret return ret } func (p *FilenameParser) queryStudio(ctx context.Context, qb models.StudioQueryer, studioName string) *models.Studio { // massage the performer name studioName = delimiterRE.ReplaceAllString(studioName, " ") // check cache first if ret, found := p.studioCache[studioName]; found { return ret } ret, _ := studio.ByName(ctx, qb, studioName) // try to match on alias if ret == nil { ret, _ = studio.ByAlias(ctx, qb, studioName) } // add result to cache p.studioCache[studioName] = ret return ret } type GroupNameFinder interface { FindByName(ctx context.Context, name string, nocase bool) (*models.Group, error) } func (p *FilenameParser) queryGroup(ctx context.Context, qb GroupNameFinder, groupName string) *models.Group { // massage the group name groupName = delimiterRE.ReplaceAllString(groupName, " ") // check cache first if ret, found := p.groupCache[groupName]; found { return ret } ret, _ := qb.FindByName(ctx, groupName, true) // add result to cache p.groupCache[groupName] = ret return ret } func (p *FilenameParser) queryTag(ctx context.Context, qb models.TagQueryer, tagName string) *models.Tag { // massage the tag name tagName = delimiterRE.ReplaceAllString(tagName, " ") // check cache first if ret, found := p.tagCache[tagName]; found { return ret } // match tag name exactly ret, _ := tag.ByName(ctx, qb, tagName) // try to match on alias if ret == nil { ret, _ = tag.ByAlias(ctx, qb, tagName) } // add result to cache p.tagCache[tagName] = ret return ret } func (p *FilenameParser) setPerformers(ctx context.Context, qb PerformerNamesFinder, h sceneHolder, result *models.SceneParserResult) { // query for each performer performersSet := make(map[int]bool) for _, performerName := range h.performers { if performerName != "" { performer := p.queryPerformer(ctx, qb, performerName) if performer != nil { if _, found := performersSet[performer.ID]; !found { result.PerformerIds = append(result.PerformerIds, strconv.Itoa(performer.ID)) performersSet[performer.ID] = true } } } } } func (p *FilenameParser) setTags(ctx context.Context, qb models.TagQueryer, h sceneHolder, result *models.SceneParserResult) { // query for each performer tagsSet := make(map[int]bool) for _, tagName := range h.tags { if tagName != "" { tag := p.queryTag(ctx, qb, tagName) if tag != nil { if _, found := tagsSet[tag.ID]; !found { result.TagIds = append(result.TagIds, strconv.Itoa(tag.ID)) tagsSet[tag.ID] = true } } } } } func (p *FilenameParser) setStudio(ctx context.Context, qb models.StudioQueryer, h sceneHolder, result *models.SceneParserResult) { // query for each performer if h.studio != "" { studio := p.queryStudio(ctx, qb, h.studio) if studio != nil { studioID := strconv.Itoa(studio.ID) result.StudioID = &studioID } } } func (p *FilenameParser) setGroups(ctx context.Context, qb GroupNameFinder, h sceneHolder, result *models.SceneParserResult) { // query for each group groupsSet := make(map[int]bool) for _, groupName := range h.groups { if groupName != "" { group := p.queryGroup(ctx, qb, groupName) if group != nil { if _, found := groupsSet[group.ID]; !found { result.Movies = append(result.Movies, &models.SceneMovieID{ MovieID: strconv.Itoa(group.ID), }) groupsSet[group.ID] = true } } } } } func (p *FilenameParser) setParserResult(ctx context.Context, h sceneHolder, result *models.SceneParserResult) { if h.result.Title != "" { title := h.result.Title title = p.replaceWhitespaceCharacters(title) if p.ParserInput.CapitalizeTitle != nil && *p.ParserInput.CapitalizeTitle { title = capitalizeTitleRE.ReplaceAllStringFunc(title, strings.ToUpper) } result.Title = &title } if h.result.Date != nil { dateStr := h.result.Date.String() result.Date = &dateStr } if h.result.Rating != nil { result.Rating = h.result.Rating } r := p.repository if len(h.performers) > 0 { p.setPerformers(ctx, r.Performer, h, result) } if len(h.tags) > 0 { p.setTags(ctx, r.Tag, h, result) } p.setStudio(ctx, r.Studio, h, result) if len(h.groups) > 0 { p.setGroups(ctx, r.Group, h, result) } } ================================================ FILE: pkg/scene/filter.go ================================================ package scene import ( "path/filepath" "strings" "github.com/stashapp/stash/pkg/models" ) func PathsFilter(paths []string) *models.SceneFilterType { if paths == nil { return nil } sep := string(filepath.Separator) var ret *models.SceneFilterType var or *models.SceneFilterType for _, p := range paths { newOr := &models.SceneFilterType{} if or != nil { or.Or = newOr } else { ret = newOr } or = newOr if !strings.HasSuffix(p, sep) { p += sep } or.Path = &models.StringCriterionInput{ Modifier: models.CriterionModifierEquals, Value: p + "%", } } return ret } ================================================ FILE: pkg/scene/find.go ================================================ package scene import ( "context" "fmt" "github.com/stashapp/stash/pkg/models" ) type LoadRelationshipOption func(context.Context, *models.Scene, models.SceneReader) error func LoadURLs(ctx context.Context, scene *models.Scene, r models.SceneReader) error { if err := scene.LoadURLs(ctx, r); err != nil { return fmt.Errorf("loading scene URLs: %w", err) } return nil } func LoadStashIDs(ctx context.Context, scene *models.Scene, r models.SceneReader) error { if err := scene.LoadStashIDs(ctx, r); err != nil { return fmt.Errorf("failed to load stash IDs for scene %d: %w", scene.ID, err) } return nil } func LoadFiles(ctx context.Context, scene *models.Scene, r models.SceneReader) error { if err := scene.LoadFiles(ctx, r); err != nil { return fmt.Errorf("failed to load files for scene %d: %w", scene.ID, err) } return nil } // FindByIDs retrieves multiple scenes by their IDs. // Missing scenes will be ignored, and the returned scenes are unsorted. // This method will load the specified relationships for each scene. func (s *Service) FindByIDs(ctx context.Context, ids []int, load ...LoadRelationshipOption) ([]*models.Scene, error) { var scenes []*models.Scene qb := s.Repository var err error scenes, err = qb.FindByIDs(ctx, ids) if err != nil { return nil, err } // TODO - we should bulk load these relationships for _, scene := range scenes { if err := s.LoadRelationships(ctx, scene, load...); err != nil { return nil, err } } return scenes, nil } // FindMany retrieves multiple scenes by their IDs. Return value is guaranteed to be in the same order as the input. // Missing scenes will return an error. // This method will load the specified relationships for each scene. func (s *Service) FindMany(ctx context.Context, ids []int, load ...LoadRelationshipOption) ([]*models.Scene, error) { var scenes []*models.Scene qb := s.Repository var err error scenes, err = qb.FindMany(ctx, ids) if err != nil { return nil, err } // TODO - we should bulk load these relationships for _, scene := range scenes { if err := s.LoadRelationships(ctx, scene, load...); err != nil { return nil, err } } return scenes, nil } func (s *Service) LoadRelationships(ctx context.Context, scene *models.Scene, load ...LoadRelationshipOption) error { for _, l := range load { if err := l(ctx, scene, s.Repository); err != nil { return err } } return nil } ================================================ FILE: pkg/scene/fingerprints.go ================================================ package scene import ( "context" "fmt" "github.com/stashapp/stash/pkg/models" ) // GetFingerprints returns the fingerprints for the given scene ids. func (s *Service) GetScenesFingerprints(ctx context.Context, ids []int) ([]models.Fingerprints, error) { fingerprints := make([]models.Fingerprints, len(ids)) qb := s.Repository for i, sceneID := range ids { scene, err := qb.Find(ctx, sceneID) if err != nil { return nil, err } if scene == nil { return nil, fmt.Errorf("scene with id %d not found", sceneID) } if err := scene.LoadFiles(ctx, qb); err != nil { return nil, err } var sceneFPs models.Fingerprints for _, f := range scene.Files.List() { sceneFPs = append(sceneFPs, f.Fingerprints...) } fingerprints[i] = sceneFPs } return fingerprints, nil } ================================================ FILE: pkg/scene/generate/generator.go ================================================ // Package generate provides functions to generate media assets from scenes. package generate import ( "bytes" "errors" "fmt" "os" "os/exec" "strings" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/fsutil" ) const ( mp4Pattern = "*.mp4" webpPattern = "*.webp" jpgPattern = "*.jpg" txtPattern = "*.txt" vttPattern = "*.vtt" ) type Paths interface { TempFile(pattern string) (*os.File, error) } type MarkerPaths interface { Paths GetVideoPreviewPath(checksum string, seconds int) string GetWebpPreviewPath(checksum string, seconds int) string GetScreenshotPath(checksum string, seconds int) string } type ScenePaths interface { Paths GetVideoPreviewPath(checksum string) string GetWebpPreviewPath(checksum string) string GetSpriteImageFilePath(checksum string) string GetSpriteVttFilePath(checksum string) string GetTranscodePath(checksum string) string } type FFMpegConfig interface { GetTranscodeInputArgs() []string GetTranscodeOutputArgs() []string } type Generator struct { Encoder *ffmpeg.FFMpeg FFMpegConfig FFMpegConfig LockManager *fsutil.ReadLockManager MarkerPaths MarkerPaths ScenePaths ScenePaths Overwrite bool } type generateFn func(lockCtx *fsutil.LockContext, tmpFn string) error func (g Generator) tempFile(p Paths, pattern string) (*os.File, error) { tmpFile, err := p.TempFile(pattern) // tmp output in case the process ends abruptly if err != nil { return nil, fmt.Errorf("creating temporary file: %w", err) } _ = tmpFile.Close() return tmpFile, err } // generateFile performs a generate operation by generating a temporary file using p and pattern, then // moving it to output on success. func (g Generator) generateFile(lockCtx *fsutil.LockContext, p Paths, pattern string, output string, generateFn generateFn) error { tmpFile, err := g.tempFile(p, pattern) // tmp output in case the process ends abruptly if err != nil { return err } tmpFn := tmpFile.Name() defer func() { _ = os.Remove(tmpFn) }() if err := generateFn(lockCtx, tmpFn); err != nil { return err } // check if generated empty file stat, err := os.Stat(tmpFn) if err != nil { return fmt.Errorf("error getting file stat: %w", err) } if stat.Size() == 0 { return fmt.Errorf("ffmpeg command produced no output") } if err := fsutil.SafeMove(tmpFn, output); err != nil { return fmt.Errorf("moving %s to %s failed: %w", tmpFn, output, err) } return nil } // generateBytes performs a generate operation by generating a temporary file using p and pattern, returns the contents, then deletes it. func (g Generator) generateBytes(lockCtx *fsutil.LockContext, p Paths, pattern string, generateFn generateFn) ([]byte, error) { tmpFile, err := g.tempFile(p, pattern) // tmp output in case the process ends abruptly if err != nil { return nil, err } tmpFn := tmpFile.Name() defer func() { _ = os.Remove(tmpFn) }() if err := generateFn(lockCtx, tmpFn); err != nil { return nil, err } defer os.Remove(tmpFn) return os.ReadFile(tmpFn) } // generate runs ffmpeg with the given args and waits for it to finish. // Returns an error if the command fails. If the command fails, the return // value will be of type *exec.ExitError. func (g Generator) generate(ctx *fsutil.LockContext, args []string) error { cmd := g.Encoder.Command(ctx, args) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Start(); err != nil { return fmt.Errorf("error starting command: %w", err) } ctx.AttachCommand(cmd) if err := cmd.Wait(); err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { exitErr.Stderr = stderr.Bytes() err = exitErr } return fmt.Errorf("error running ffmpeg command <%s>: %w", strings.Join(args, " "), err) } return nil } // GenerateOutput runs ffmpeg with the given args and returns it standard output. func (g Generator) generateOutput(lockCtx *fsutil.LockContext, args []string) ([]byte, error) { cmd := g.Encoder.Command(lockCtx, args) var stdout bytes.Buffer cmd.Stdout = &stdout var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Start(); err != nil { return nil, fmt.Errorf("error starting command: %w", err) } lockCtx.AttachCommand(cmd) if err := cmd.Wait(); err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { exitErr.Stderr = stderr.Bytes() err = exitErr } return nil, fmt.Errorf("error running ffmpeg command <%s>: %w", strings.Join(args, " "), err) } if stdout.Len() == 0 { return nil, fmt.Errorf("ffmpeg command produced no output: <%s>", strings.Join(args, " ")) } return stdout.Bytes(), nil } ================================================ FILE: pkg/scene/generate/marker_preview.go ================================================ package generate import ( "context" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/ffmpeg/transcoder" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" ) const ( markerPreviewWidth = 640 maxMarkerPreviewDuration = 20 markerPreviewAudioBitrate = "64k" markerImageDuration = 5 markerWebpFPS = 12 markerScreenshotQuality = 2 ) func (g Generator) MarkerPreviewVideo(ctx context.Context, input string, hash string, seconds float64, endSeconds *float64, includeAudio bool) error { lockCtx := g.LockManager.ReadLock(ctx, input) defer lockCtx.Cancel() output := g.MarkerPaths.GetVideoPreviewPath(hash, int(seconds)) if !g.Overwrite { if exists, _ := fsutil.FileExists(output); exists { return nil } } duration := float64(maxMarkerPreviewDuration) // don't allow preview to exceed max duration if endSeconds != nil && *endSeconds-seconds < maxMarkerPreviewDuration { duration = float64(*endSeconds) - seconds } if err := g.generateFile(lockCtx, g.MarkerPaths, mp4Pattern, output, g.markerPreviewVideo(input, sceneMarkerOptions{ Seconds: seconds, Duration: duration, Audio: includeAudio, })); err != nil { return err } logger.Debug("created marker video: ", output) return nil } type sceneMarkerOptions struct { Seconds float64 Duration float64 Audio bool } func (g Generator) markerPreviewVideo(input string, options sceneMarkerOptions) generateFn { return func(lockCtx *fsutil.LockContext, tmpFn string) error { var videoFilter ffmpeg.VideoFilter videoFilter = videoFilter.ScaleWidth(markerPreviewWidth) var videoArgs ffmpeg.Args videoArgs = videoArgs.VideoFilter(videoFilter) videoArgs = append(videoArgs, "-pix_fmt", "yuv420p", "-profile:v", "high", "-level", "4.2", "-preset", "veryslow", "-crf", "24", "-movflags", "+faststart", "-threads", "4", "-sws_flags", "lanczos", "-strict", "-2", ) trimOptions := transcoder.TranscodeOptions{ Duration: options.Duration, StartTime: options.Seconds, OutputPath: tmpFn, VideoCodec: ffmpeg.VideoCodecLibX264, VideoArgs: videoArgs, } if options.Audio { var audioArgs ffmpeg.Args audioArgs = audioArgs.AudioBitrate(markerPreviewAudioBitrate) trimOptions.AudioCodec = ffmpeg.AudioCodecAAC trimOptions.AudioArgs = audioArgs } args := transcoder.Transcode(input, trimOptions) return g.generate(lockCtx, args) } } func (g Generator) SceneMarkerWebp(ctx context.Context, input string, hash string, seconds float64) error { lockCtx := g.LockManager.ReadLock(ctx, input) defer lockCtx.Cancel() output := g.MarkerPaths.GetWebpPreviewPath(hash, int(seconds)) if !g.Overwrite { if exists, _ := fsutil.FileExists(output); exists { return nil } } if err := g.generateFile(lockCtx, g.MarkerPaths, webpPattern, output, g.sceneMarkerWebp(input, sceneMarkerOptions{ Seconds: seconds, })); err != nil { return err } logger.Debug("created marker image: ", output) return nil } func (g Generator) sceneMarkerWebp(input string, options sceneMarkerOptions) generateFn { return func(lockCtx *fsutil.LockContext, tmpFn string) error { var videoFilter ffmpeg.VideoFilter videoFilter = videoFilter.ScaleWidth(markerPreviewWidth) videoFilter = videoFilter.Fps(markerWebpFPS) var videoArgs ffmpeg.Args videoArgs = videoArgs.VideoFilter(videoFilter) videoArgs = append(videoArgs, "-lossless", "1", "-q:v", "70", "-compression_level", "6", "-preset", "default", "-loop", "0", "-threads", "4", ) trimOptions := transcoder.TranscodeOptions{ Duration: markerImageDuration, StartTime: float64(options.Seconds), OutputPath: tmpFn, VideoCodec: ffmpeg.VideoCodecLibWebP, VideoArgs: videoArgs, } args := transcoder.Transcode(input, trimOptions) return g.generate(lockCtx, args) } } func (g Generator) SceneMarkerScreenshot(ctx context.Context, input string, hash string, seconds float64, width int) error { lockCtx := g.LockManager.ReadLock(ctx, input) defer lockCtx.Cancel() output := g.MarkerPaths.GetScreenshotPath(hash, int(seconds)) if !g.Overwrite { if exists, _ := fsutil.FileExists(output); exists { return nil } } if err := g.generateFile(lockCtx, g.MarkerPaths, jpgPattern, output, g.sceneMarkerScreenshot(input, SceneMarkerScreenshotOptions{ Seconds: seconds, Width: width, })); err != nil { return err } logger.Debug("created marker screenshot: ", output) return nil } type SceneMarkerScreenshotOptions struct { Seconds float64 Width int } func (g Generator) sceneMarkerScreenshot(input string, options SceneMarkerScreenshotOptions) generateFn { return func(lockCtx *fsutil.LockContext, tmpFn string) error { ssOptions := transcoder.ScreenshotOptions{ OutputPath: tmpFn, OutputType: transcoder.ScreenshotOutputTypeImage2, Quality: markerScreenshotQuality, Width: options.Width, } args := transcoder.ScreenshotTime(input, options.Seconds, ssOptions) return g.generate(lockCtx, args) } } ================================================ FILE: pkg/scene/generate/preview.go ================================================ package generate import ( "bufio" "context" "fmt" "os" "path/filepath" "strconv" "strings" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/ffmpeg/transcoder" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" ) const ( scenePreviewWidth = 640 scenePreviewAudioBitrate = "128k" scenePreviewImageFPS = 12 minSegmentDuration = 0.75 ) type PreviewOptions struct { Segments int SegmentDuration float64 ExcludeStart string ExcludeEnd string Preset string Audio bool } func getExcludeValue(videoDuration float64, v string) float64 { if strings.HasSuffix(v, "%") && len(v) > 1 { // proportion of video duration v = v[0 : len(v)-1] prop, _ := strconv.ParseFloat(v, 64) return prop / 100.0 * videoDuration } prop, _ := strconv.ParseFloat(v, 64) return prop } // getStepSizeAndOffset calculates the step size for preview generation and // the starting offset. // // Step size is calculated based on the duration of the video file, minus the // excluded duration. The offset is based on the ExcludeStart. If the total // excluded duration exceeds the duration of the video, then offset is 0, and // the video duration is used to calculate the step size. func (g PreviewOptions) getStepSizeAndOffset(videoDuration float64) (stepSize float64, offset float64) { excludeStart := getExcludeValue(videoDuration, g.ExcludeStart) excludeEnd := getExcludeValue(videoDuration, g.ExcludeEnd) duration := videoDuration if videoDuration > excludeStart+excludeEnd { duration = duration - excludeStart - excludeEnd offset = excludeStart } stepSize = duration / float64(g.Segments) return } func (g Generator) PreviewVideo(ctx context.Context, input string, videoDuration float64, hash string, options PreviewOptions, fallback bool, useVsync2 bool) error { lockCtx := g.LockManager.ReadLock(ctx, input) defer lockCtx.Cancel() output := g.ScenePaths.GetVideoPreviewPath(hash) if !g.Overwrite { if exists, _ := fsutil.FileExists(output); exists { return nil } } logger.Infof("[generator] generating video preview for %s", input) if err := g.generateFile(lockCtx, g.ScenePaths, mp4Pattern, output, g.previewVideo(input, videoDuration, options, fallback, useVsync2)); err != nil { return err } logger.Debug("created video preview: ", output) return nil } func (g *Generator) previewVideo(input string, videoDuration float64, options PreviewOptions, fallback bool, useVsync2 bool) generateFn { // #2496 - generate a single preview video for videos shorter than segments * segment duration if videoDuration < options.SegmentDuration*float64(options.Segments) { return g.previewVideoSingle(input, videoDuration, options, fallback, useVsync2) } return func(lockCtx *fsutil.LockContext, tmpFn string) error { // a list of tmp files used during the preview generation var tmpFiles []string // remove tmpFiles when done defer func() { removeFiles(tmpFiles) }() stepSize, offset := options.getStepSizeAndOffset(videoDuration) segmentDuration := options.SegmentDuration // TODO - move this out into calling function // a very short duration can create files without a video stream if segmentDuration < minSegmentDuration { segmentDuration = minSegmentDuration logger.Warnf("[generator] Segment duration (%f) too short. Using %f instead.", options.SegmentDuration, minSegmentDuration) } for i := 0; i < options.Segments; i++ { chunkFile, err := g.tempFile(g.ScenePaths, mp4Pattern) if err != nil { return fmt.Errorf("generating video preview chunk file: %w", err) } tmpFiles = append(tmpFiles, chunkFile.Name()) time := offset + (float64(i) * stepSize) chunkOptions := previewChunkOptions{ StartTime: time, Duration: segmentDuration, OutputPath: chunkFile.Name(), Audio: options.Audio, Preset: options.Preset, } if err := g.previewVideoChunk(lockCtx, input, chunkOptions, fallback, useVsync2); err != nil { return err } } // generate concat file based on generated video chunks concatFilePath, err := g.generateConcatFile(tmpFiles) if concatFilePath != "" { tmpFiles = append(tmpFiles, concatFilePath) } if err != nil { return err } return g.previewVideoChunkCombine(lockCtx, concatFilePath, tmpFn) } } func (g *Generator) previewVideoSingle(input string, videoDuration float64, options PreviewOptions, fallback bool, useVsync2 bool) generateFn { return func(lockCtx *fsutil.LockContext, tmpFn string) error { chunkOptions := previewChunkOptions{ StartTime: 0, Duration: videoDuration, OutputPath: tmpFn, Audio: options.Audio, Preset: options.Preset, } return g.previewVideoChunk(lockCtx, input, chunkOptions, fallback, useVsync2) } } type previewChunkOptions struct { StartTime float64 Duration float64 OutputPath string Audio bool Preset string } func (g Generator) previewVideoChunk(lockCtx *fsutil.LockContext, fn string, options previewChunkOptions, fallback bool, useVsync2 bool) error { var videoFilter ffmpeg.VideoFilter videoFilter = videoFilter.ScaleWidth(scenePreviewWidth) var videoArgs ffmpeg.Args videoArgs = videoArgs.VideoFilter(videoFilter) videoArgs = append(videoArgs, "-pix_fmt", "yuv420p", "-profile:v", "high", "-level", "4.2", "-preset", options.Preset, "-crf", "21", "-threads", "4", "-strict", "-2", ) if useVsync2 { videoArgs = append(videoArgs, "-vsync", "2") } trimOptions := transcoder.TranscodeOptions{ OutputPath: options.OutputPath, StartTime: options.StartTime, Duration: options.Duration, XError: !fallback, SlowSeek: fallback, VideoCodec: ffmpeg.VideoCodecLibX264, VideoArgs: videoArgs, ExtraInputArgs: g.FFMpegConfig.GetTranscodeInputArgs(), ExtraOutputArgs: g.FFMpegConfig.GetTranscodeOutputArgs(), } if options.Audio { var audioArgs ffmpeg.Args audioArgs = audioArgs.AudioBitrate(scenePreviewAudioBitrate) trimOptions.AudioCodec = ffmpeg.AudioCodecAAC trimOptions.AudioArgs = audioArgs } args := transcoder.Transcode(fn, trimOptions) return g.generate(lockCtx, args) } func (g Generator) generateConcatFile(chunkFiles []string) (fn string, err error) { concatFile, err := g.ScenePaths.TempFile(txtPattern) if err != nil { return "", fmt.Errorf("creating concat file: %w", err) } defer concatFile.Close() w := bufio.NewWriter(concatFile) for _, f := range chunkFiles { // files in concat file should be relative to concat relFile := filepath.Base(f) if _, err := w.WriteString(fmt.Sprintf("file '%s'\n", relFile)); err != nil { return concatFile.Name(), fmt.Errorf("writing concat file: %w", err) } } return concatFile.Name(), w.Flush() } func (g Generator) previewVideoChunkCombine(lockCtx *fsutil.LockContext, concatFilePath string, outputPath string) error { spliceOptions := transcoder.SpliceOptions{ OutputPath: outputPath, } args := transcoder.Splice(concatFilePath, spliceOptions) return g.generate(lockCtx, args) } func removeFiles(list []string) { for _, f := range list { if err := os.Remove(f); err != nil { logger.Warnf("[generator] Delete error: %s", err) } } } // PreviewWebp generates a webp file based on the preview video input. // TODO - this should really generate a new webp using chunks. func (g Generator) PreviewWebp(ctx context.Context, input string, hash string) error { lockCtx := g.LockManager.ReadLock(ctx, input) defer lockCtx.Cancel() output := g.ScenePaths.GetWebpPreviewPath(hash) if !g.Overwrite { if exists, _ := fsutil.FileExists(output); exists { return nil } } logger.Infof("[generator] generating webp preview for %s", input) src := g.ScenePaths.GetVideoPreviewPath(hash) if err := g.generateFile(lockCtx, g.ScenePaths, webpPattern, output, g.previewVideoToImage(src)); err != nil { return err } logger.Debug("created video preview: ", output) return nil } func (g Generator) previewVideoToImage(input string) generateFn { return func(lockCtx *fsutil.LockContext, tmpFn string) error { var videoFilter ffmpeg.VideoFilter videoFilter = videoFilter.ScaleWidth(scenePreviewWidth) videoFilter = videoFilter.Fps(scenePreviewImageFPS) var videoArgs ffmpeg.Args videoArgs = videoArgs.VideoFilter(videoFilter) videoArgs = append(videoArgs, "-lossless", "1", "-q:v", "70", "-compression_level", "6", "-preset", "default", "-loop", "0", "-threads", "4", ) encodeOptions := transcoder.TranscodeOptions{ OutputPath: tmpFn, VideoCodec: ffmpeg.VideoCodecLibWebP, VideoArgs: videoArgs, ExtraInputArgs: g.FFMpegConfig.GetTranscodeInputArgs(), ExtraOutputArgs: g.FFMpegConfig.GetTranscodeOutputArgs(), } args := transcoder.Transcode(input, encodeOptions) return g.generate(lockCtx, args) } } ================================================ FILE: pkg/scene/generate/screenshot.go ================================================ package generate import ( "context" "github.com/stashapp/stash/pkg/ffmpeg/transcoder" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" ) const ( // thumbnailWidth = 320 // thumbnailQuality = 5 screenshotQuality = 2 screenshotDurationProportion = 0.2 ) type ScreenshotOptions struct { At *float64 } func (g Generator) Screenshot(ctx context.Context, input string, videoWidth int, videoDuration float64, options ScreenshotOptions) ([]byte, error) { lockCtx := g.LockManager.ReadLock(ctx, input) defer lockCtx.Cancel() logger.Infof("Creating screenshot for %s", input) at := screenshotDurationProportion * videoDuration if options.At != nil { at = *options.At } ret, err := g.generateBytes(lockCtx, g.ScenePaths, jpgPattern, g.screenshot(input, screenshotOptions{ Time: at, Quality: screenshotQuality, // default Width is video width })) if err != nil { return nil, err } return ret, nil } type screenshotOptions struct { Time float64 Width int Quality int } func (g Generator) screenshot(input string, options screenshotOptions) generateFn { return func(lockCtx *fsutil.LockContext, tmpFn string) error { ssOptions := transcoder.ScreenshotOptions{ OutputPath: tmpFn, OutputType: transcoder.ScreenshotOutputTypeImage2, Quality: options.Quality, Width: options.Width, } args := transcoder.ScreenshotTime(input, options.Time, ssOptions) return g.generate(lockCtx, args) } } ================================================ FILE: pkg/scene/generate/sprite.go ================================================ package generate import ( "bytes" "context" "fmt" "image" "image/color" "math" "os" "path/filepath" "strings" "github.com/disintegration/imaging" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/ffmpeg/transcoder" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/utils" ) func (g Generator) SpriteScreenshot(ctx context.Context, input string, seconds float64, size int, isPortrait bool) (image.Image, error) { lockCtx := g.LockManager.ReadLock(ctx, input) defer lockCtx.Cancel() ssOptions := transcoder.ScreenshotOptions{ OutputPath: "-", OutputType: transcoder.ScreenshotOutputTypeBMP, } if !isPortrait { ssOptions.Width = size } else { ssOptions.Height = size } args := transcoder.ScreenshotTime(input, seconds, ssOptions) return g.generateImage(lockCtx, args) } func (g Generator) SpriteScreenshotSlow(ctx context.Context, input string, frame int, width int) (image.Image, error) { lockCtx := g.LockManager.ReadLock(ctx, input) defer lockCtx.Cancel() ssOptions := transcoder.ScreenshotOptions{ OutputPath: "-", OutputType: transcoder.ScreenshotOutputTypeBMP, Width: width, } args := transcoder.ScreenshotFrame(input, frame, ssOptions) return g.generateImage(lockCtx, args) } func (g Generator) generateImage(lockCtx *fsutil.LockContext, args ffmpeg.Args) (image.Image, error) { out, err := g.generateOutput(lockCtx, args) if err != nil { return nil, err } img, _, err := image.Decode(bytes.NewReader(out)) if err != nil { return nil, fmt.Errorf("decoding image from ffmpeg: %w", err) } return img, nil } func (g Generator) CombineSpriteImages(images []image.Image) image.Image { // Combine all of the thumbnails into a sprite image width := images[0].Bounds().Size().X height := images[0].Bounds().Size().Y gridSize := GetSpriteGridSize(len(images)) canvasWidth := width * gridSize canvasHeight := height * gridSize montage := imaging.New(canvasWidth, canvasHeight, color.NRGBA{}) for index := 0; index < len(images); index++ { x := width * (index % gridSize) y := height * int(math.Floor(float64(index)/float64(gridSize))) img := images[index] montage = imaging.Paste(montage, img, image.Pt(x, y)) } return montage } // GetSpriteGridSize return the required size of a grid, where the number of images in width // equals the number of images in height, to hold 'imageCount' images func GetSpriteGridSize(imageCount int) int { return int(math.Ceil(math.Sqrt(float64(imageCount)))) } func (g Generator) SpriteVTT(ctx context.Context, output string, spritePath string, stepSize float64, spriteChunks int) error { lockCtx := g.LockManager.ReadLock(ctx, spritePath) defer lockCtx.Cancel() return g.generateFile(lockCtx, g.ScenePaths, vttPattern, output, g.spriteVTT(spritePath, stepSize, spriteChunks)) } func (g Generator) spriteVTT(spritePath string, stepSize float64, spriteChunks int) generateFn { return func(lockCtx *fsutil.LockContext, tmpFn string) error { spriteImage, err := os.Open(spritePath) if err != nil { return err } defer spriteImage.Close() spriteImageName := filepath.Base(spritePath) image, _, err := image.DecodeConfig(spriteImage) if err != nil { return err } gridSize := GetSpriteGridSize(spriteChunks) width := image.Width / gridSize height := image.Height / gridSize vttLines := []string{"WEBVTT", ""} for index := 0; index < spriteChunks; index++ { x := width * (index % gridSize) y := height * int(math.Floor(float64(index)/float64(gridSize))) startTime := utils.GetVTTTime(float64(index) * stepSize) endTime := utils.GetVTTTime(float64(index+1) * stepSize) vttLines = append(vttLines, startTime+" --> "+endTime) vttLines = append(vttLines, fmt.Sprintf("%s#xywh=%d,%d,%d,%d", spriteImageName, x, y, width, height)) vttLines = append(vttLines, "") } vtt := strings.Join(vttLines, "\n") return os.WriteFile(tmpFn, []byte(vtt), 0644) } } // TODO - move all sprite generation code here // WIP // func (g Generator) Sprite(ctx context.Context, videoFile *ffmpeg.VideoFile, hash string) error { // input := videoFile.Path // if err := g.generateSpriteImage(ctx, videoFile, hash); err != nil { // return fmt.Errorf("generating sprite image for %s: %w", input, err) // } // output := g.ScenePaths.GetSpriteVttFilePath(hash) // if !g.Overwrite { // if exists, _ := fsutil.FileExists(output); exists { // return nil // } // } // if err := g.generateFile(ctx, g.ScenePaths, vttPattern, output, g.spriteVtt(input, screenshotOptions{ // Time: at, // Quality: screenshotQuality, // // default Width is video width // })); err != nil { // return err // } // logger.Debug("created screenshot: ", output) // return nil // } // func (g Generator) generateSpriteImage(ctx context.Context, videoFile *ffmpeg.VideoFile, hash string) error { // output := g.ScenePaths.GetSpriteImageFilePath(hash) // if !g.Overwrite { // if exists, _ := fsutil.FileExists(output); exists { // return nil // } // } // var images []image.Image // var err error // if options.VideoDuration > 0 { // images, err = g.generateSprites(ctx, input, options.VideoDuration) // } else { // images, err = g.generateSpritesSlow(ctx, input, options.FrameCount) // } // if len(images) == 0 { // return errors.New("images slice is empty") // } // montage, err := g.combineSpriteImages(images) // if err != nil { // return err // } // if err := imaging.Save(montage, output); err != nil { // return err // } // logger.Debug("created sprite image: ", output) // return nil // } // func useSlowSeek(videoFile *ffmpeg.VideoFile) (bool, error) { // // For files with small duration / low frame count try to seek using frame number intead of seconds // // some files can have FrameCount == 0, only use SlowSeek if duration < 5 // if videoFile.Duration < 5 || (videoFile.FrameCount > 0 && videoFile.FrameCount <= int64(spriteChunks)) { // if videoFile.Duration <= 0 { // return false, fmt.Errorf("duration(%.3f)/frame count(%d) invalid", videoFile.Duration, videoFile.FrameCount) // } // logger.Warnf("[generator] video %s too short (%.3fs, %d frames), using frame seeking", videoFile.Path, videoFile.Duration, videoFile.FrameCount) // return true, nil // } // } // func (g Generator) combineSpriteImages(images []image.Image) (image.Image, error) { // // Combine all of the thumbnails into a sprite image // width := images[0].Bounds().Size().X // height := images[0].Bounds().Size().Y // canvasWidth := width * spriteCols // canvasHeight := height * spriteRows // montage := imaging.New(canvasWidth, canvasHeight, color.NRGBA{}) // for index := 0; index < len(images); index++ { // x := width * (index % spriteCols) // y := height * int(math.Floor(float64(index)/float64(spriteRows))) // img := images[index] // montage = imaging.Paste(montage, img, image.Pt(x, y)) // } // return montage, nil // } // func (g Generator) generateSprites(ctx context.Context, input string, videoDuration float64) ([]image.Image, error) { // logger.Infof("[generator] generating sprite image for %s", input) // // generate `ChunkCount` thumbnails // stepSize := videoDuration / float64(spriteChunks) // var images []image.Image // for i := 0; i < spriteChunks; i++ { // time := float64(i) * stepSize // img, err := g.spriteScreenshot(ctx, input, time) // if err != nil { // return nil, err // } // images = append(images, img) // } // return images, nil // } // func (g Generator) generateSpritesSlow(ctx context.Context, input string, frameCount int) ([]image.Image, error) { // logger.Infof("[generator] generating sprite image for %s (%d frames)", input, frameCount) // stepFrame := float64(frameCount-1) / float64(spriteChunks) // var images []image.Image // for i := 0; i < spriteChunks; i++ { // // generate exactly `ChunkCount` thumbnails, using duplicate frames if needed // frame := math.Round(float64(i) * stepFrame) // if frame >= math.MaxInt || frame <= math.MinInt { // return nil, errors.New("invalid frame number conversion") // } // img, err := g.spriteScreenshotSlow(ctx, input, int(frame)) // if err != nil { // return nil, err // } // images = append(images, img) // } // return images, nil // } // func (g Generator) spriteScreenshot(ctx context.Context, input string, seconds float64) (image.Image, error) { // ssOptions := transcoder.ScreenshotOptions{ // OutputPath: "-", // OutputType: transcoder.ScreenshotOutputTypeBMP, // Width: spriteScreenshotWidth, // } // args := transcoder.ScreenshotTime(input, seconds, ssOptions) // return g.generateImage(ctx, args) // } // func (g Generator) spriteScreenshotSlow(ctx context.Context, input string, frame int) (image.Image, error) { // ssOptions := transcoder.ScreenshotOptions{ // OutputPath: "-", // OutputType: transcoder.ScreenshotOutputTypeBMP, // Width: spriteScreenshotWidth, // } // args := transcoder.ScreenshotFrame(input, frame, ssOptions) // return g.generateImage(ctx, args) // } // func (g Generator) spriteVTT(videoFile ffmpeg.VideoFile, spriteImagePath string, slowSeek bool) generateFn { // return func(ctx context.Context, tmpFn string) error { // logger.Infof("[generator] generating sprite vtt for %s", input) // spriteImage, err := os.Open(spriteImagePath) // if err != nil { // return err // } // defer spriteImage.Close() // spriteImageName := filepath.Base(spriteImagePath) // image, _, err := image.DecodeConfig(spriteImage) // if err != nil { // return err // } // width := image.Width / spriteCols // height := image.Height / spriteRows // var stepSize float64 // if !slowSeek { // nthFrame = g.NumberOfFrames / g.ChunkCount // stepSize = float64(g.Info.NthFrame) / g.Info.FrameRate // } else { // // for files with a low framecount ( "+endTime) // vttLines = append(vttLines, fmt.Sprintf("%s#xywh=%d,%d,%d,%d", spriteImageName, x, y, width, height)) // vttLines = append(vttLines, "") // } // vtt := strings.Join(vttLines, "\n") // return os.WriteFile(tmpFn, []byte(vtt), 0644) // } // } ================================================ FILE: pkg/scene/generate/transcode.go ================================================ package generate import ( "context" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/ffmpeg/transcoder" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" ) type TranscodeOptions struct { Width int Height int } func (g Generator) Transcode(ctx context.Context, input string, hash string, options TranscodeOptions) error { lockCtx := g.LockManager.ReadLock(ctx, input) defer lockCtx.Cancel() return g.makeTranscode(lockCtx, hash, g.transcode(input, options)) } // TranscodeVideo transcodes the video, and removes the audio. // In some videos where the audio codec is not supported by ffmpeg, // ffmpeg fails if you try to transcode the audio func (g Generator) TranscodeVideo(ctx context.Context, input string, hash string, options TranscodeOptions) error { lockCtx := g.LockManager.ReadLock(ctx, input) defer lockCtx.Cancel() return g.makeTranscode(lockCtx, hash, g.transcodeVideo(input, options)) } // TranscodeAudio will copy the video stream as is, and transcode audio. func (g Generator) TranscodeAudio(ctx context.Context, input string, hash string) error { lockCtx := g.LockManager.ReadLock(ctx, input) defer lockCtx.Cancel() return g.makeTranscode(lockCtx, hash, g.transcodeAudio(input)) } // TranscodeCopyVideo will copy the video stream as is, and drop the audio stream. func (g Generator) TranscodeCopyVideo(ctx context.Context, input string, hash string) error { lockCtx := g.LockManager.ReadLock(ctx, input) defer lockCtx.Cancel() return g.makeTranscode(lockCtx, hash, g.transcodeCopyVideo(input)) } func (g Generator) makeTranscode(lockCtx *fsutil.LockContext, hash string, generateFn generateFn) error { output := g.ScenePaths.GetTranscodePath(hash) if !g.Overwrite { if exists, _ := fsutil.FileExists(output); exists { return nil } } if err := g.generateFile(lockCtx, g.ScenePaths, mp4Pattern, output, generateFn); err != nil { return err } logger.Debug("created transcode: ", output) return nil } func (g Generator) transcode(input string, options TranscodeOptions) generateFn { return func(lockCtx *fsutil.LockContext, tmpFn string) error { var videoArgs ffmpeg.Args if options.Width != 0 && options.Height != 0 { var videoFilter ffmpeg.VideoFilter videoFilter = videoFilter.ScaleDimensions(options.Width, options.Height) videoArgs = videoArgs.VideoFilter(videoFilter) } videoArgs = append(videoArgs, "-pix_fmt", "yuv420p", "-profile:v", "high", "-level", "4.2", "-preset", "superfast", "-crf", "23", ) args := transcoder.Transcode(input, transcoder.TranscodeOptions{ OutputPath: tmpFn, VideoCodec: ffmpeg.VideoCodecLibX264, VideoArgs: videoArgs, AudioCodec: ffmpeg.AudioCodecAAC, ExtraInputArgs: g.FFMpegConfig.GetTranscodeInputArgs(), ExtraOutputArgs: g.FFMpegConfig.GetTranscodeOutputArgs(), }) return g.generate(lockCtx, args) } } func (g Generator) transcodeVideo(input string, options TranscodeOptions) generateFn { return func(lockCtx *fsutil.LockContext, tmpFn string) error { var videoArgs ffmpeg.Args if options.Width != 0 && options.Height != 0 { var videoFilter ffmpeg.VideoFilter videoFilter = videoFilter.ScaleDimensions(options.Width, options.Height) videoArgs = videoArgs.VideoFilter(videoFilter) } videoArgs = append(videoArgs, "-pix_fmt", "yuv420p", "-profile:v", "high", "-level", "4.2", "-preset", "superfast", "-crf", "23", ) var audioArgs ffmpeg.Args audioArgs = audioArgs.SkipAudio() args := transcoder.Transcode(input, transcoder.TranscodeOptions{ OutputPath: tmpFn, VideoCodec: ffmpeg.VideoCodecLibX264, VideoArgs: videoArgs, AudioArgs: audioArgs, ExtraInputArgs: g.FFMpegConfig.GetTranscodeInputArgs(), ExtraOutputArgs: g.FFMpegConfig.GetTranscodeOutputArgs(), }) return g.generate(lockCtx, args) } } func (g Generator) transcodeAudio(input string) generateFn { return func(lockCtx *fsutil.LockContext, tmpFn string) error { args := transcoder.Transcode(input, transcoder.TranscodeOptions{ OutputPath: tmpFn, VideoCodec: ffmpeg.VideoCodecCopy, AudioCodec: ffmpeg.AudioCodecAAC, }) return g.generate(lockCtx, args) } } func (g Generator) transcodeCopyVideo(input string) generateFn { return func(lockCtx *fsutil.LockContext, tmpFn string) error { var audioArgs ffmpeg.Args audioArgs = audioArgs.SkipAudio() args := transcoder.Transcode(input, transcoder.TranscodeOptions{ OutputPath: tmpFn, VideoCodec: ffmpeg.VideoCodecCopy, AudioArgs: audioArgs, }) return g.generate(lockCtx, args) } } ================================================ FILE: pkg/scene/hash.go ================================================ package scene import ( "github.com/stashapp/stash/pkg/models" ) // GetHash returns the hash of the file, based on the hash algorithm provided. If // hash algorithm is MD5, then Checksum is returned. Otherwise, OSHash is returned. func GetHash(f models.File, hashAlgorithm models.HashAlgorithm) string { switch hashAlgorithm { case models.HashAlgorithmMd5: return f.Base().Fingerprints.GetString(models.FingerprintTypeMD5) case models.HashAlgorithmOshash: return f.Base().Fingerprints.GetString(models.FingerprintTypeOshash) default: panic("unknown hash algorithm") } } ================================================ FILE: pkg/scene/import.go ================================================ package scene import ( "context" "fmt" "slices" "strings" "time" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/utils" ) type ImporterReaderWriter interface { models.SceneCreatorUpdater models.ViewHistoryWriter models.OHistoryWriter models.CustomFieldsWriter FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Scene, error) } type Importer struct { ReaderWriter ImporterReaderWriter FileFinder models.FileFinder StudioWriter models.StudioFinderCreator GalleryFinder models.GalleryFinder PerformerWriter models.PerformerFinderCreator GroupWriter models.GroupFinderCreator TagWriter models.TagFinderCreator Input jsonschema.Scene MissingRefBehaviour models.ImportMissingRefEnum FileNamingAlgorithm models.HashAlgorithm ID int scene models.Scene customFields map[string]interface{} coverImageData []byte viewHistory []time.Time oHistory []time.Time } func (i *Importer) PreImport(ctx context.Context) error { i.scene = i.sceneJSONToScene(i.Input) if err := i.populateFiles(ctx); err != nil { return err } if err := i.populateStudio(ctx); err != nil { return err } if err := i.populateGalleries(ctx); err != nil { return err } if err := i.populatePerformers(ctx); err != nil { return err } if err := i.populateTags(ctx); err != nil { return err } if err := i.populateGroups(ctx); err != nil { return err } var err error if len(i.Input.Cover) > 0 { i.coverImageData, err = utils.ProcessBase64Image(i.Input.Cover) if err != nil { return fmt.Errorf("invalid cover image: %v", err) } } i.customFields = i.Input.CustomFields i.populateViewHistory() i.populateOHistory() return nil } func (i *Importer) sceneJSONToScene(sceneJSON jsonschema.Scene) models.Scene { newScene := models.Scene{ Title: sceneJSON.Title, Code: sceneJSON.Code, Details: sceneJSON.Details, Director: sceneJSON.Director, PerformerIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), GalleryIDs: models.NewRelatedIDs([]int{}), Groups: models.NewRelatedGroups([]models.GroupsScenes{}), StashIDs: models.NewRelatedStashIDs(sceneJSON.StashIDs), } if len(sceneJSON.URLs) > 0 { newScene.URLs = models.NewRelatedStrings(sceneJSON.URLs) } else if sceneJSON.URL != "" { newScene.URLs = models.NewRelatedStrings([]string{sceneJSON.URL}) } if sceneJSON.Date != "" { d, err := models.ParseDate(sceneJSON.Date) if err == nil { newScene.Date = &d } } if sceneJSON.Rating != 0 { newScene.Rating = &sceneJSON.Rating } newScene.Organized = sceneJSON.Organized newScene.CreatedAt = sceneJSON.CreatedAt.GetTime() newScene.UpdatedAt = sceneJSON.UpdatedAt.GetTime() newScene.ResumeTime = sceneJSON.ResumeTime newScene.PlayDuration = sceneJSON.PlayDuration return newScene } func getHistory(historyJSON []json.JSONTime, count int, last json.JSONTime, createdAt json.JSONTime) []time.Time { var ret []time.Time if len(historyJSON) > 0 { for _, d := range historyJSON { ret = append(ret, d.GetTime()) } } else if count > 0 { createdAt := createdAt.GetTime() for j := 0; j < count; j++ { t := createdAt if j+1 == count && !last.IsZero() { // last one, use last play date t = last.GetTime() } ret = append(ret, t) } } return ret } func (i *Importer) populateViewHistory() { i.viewHistory = getHistory( i.Input.PlayHistory, i.Input.PlayCount, i.Input.LastPlayedAt, i.Input.CreatedAt, ) } func (i *Importer) populateOHistory() { i.oHistory = getHistory( i.Input.OHistory, i.Input.OCounter, i.Input.CreatedAt, // no last o count date i.Input.CreatedAt, ) } func (i *Importer) populateFiles(ctx context.Context) error { files := make([]*models.VideoFile, 0) for _, ref := range i.Input.Files { path := ref f, err := i.FileFinder.FindByPath(ctx, path, true) if err != nil { return fmt.Errorf("error finding file: %w", err) } if f == nil { return fmt.Errorf("scene file '%s' not found", path) } else { files = append(files, f.(*models.VideoFile)) } } i.scene.Files = models.NewRelatedVideoFiles(files) return nil } func (i *Importer) populateStudio(ctx context.Context) error { if i.Input.Studio != "" { studio, err := i.StudioWriter.FindByName(ctx, i.Input.Studio, false) if err != nil { return fmt.Errorf("error finding studio by name: %v", err) } if studio == nil { if i.MissingRefBehaviour == models.ImportMissingRefEnumFail { return fmt.Errorf("scene studio '%s' not found", i.Input.Studio) } if i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore { return nil } if i.MissingRefBehaviour == models.ImportMissingRefEnumCreate { studioID, err := i.createStudio(ctx, i.Input.Studio) if err != nil { return err } i.scene.StudioID = &studioID } } else { i.scene.StudioID = &studio.ID } } return nil } func (i *Importer) createStudio(ctx context.Context, name string) (int, error) { newStudio := models.NewCreateStudioInput() newStudio.Name = name err := i.StudioWriter.Create(ctx, &newStudio) if err != nil { return 0, err } return newStudio.ID, nil } func (i *Importer) locateGallery(ctx context.Context, ref jsonschema.GalleryRef) (*models.Gallery, error) { var galleries []*models.Gallery var err error switch { case ref.FolderPath != "": galleries, err = i.GalleryFinder.FindByPath(ctx, ref.FolderPath) case len(ref.ZipFiles) > 0: for _, p := range ref.ZipFiles { galleries, err = i.GalleryFinder.FindByPath(ctx, p) if err != nil { break } if len(galleries) > 0 { break } } case ref.Title != "": galleries, err = i.GalleryFinder.FindUserGalleryByTitle(ctx, ref.Title) } var ret *models.Gallery if len(galleries) > 0 { ret = galleries[0] } return ret, err } func (i *Importer) populateGalleries(ctx context.Context) error { for _, ref := range i.Input.Galleries { gallery, err := i.locateGallery(ctx, ref) if err != nil { return err } if gallery == nil { if i.MissingRefBehaviour == models.ImportMissingRefEnumFail { return fmt.Errorf("scene gallery '%s' not found", ref.String()) } // we don't create galleries - just ignore } else { i.scene.GalleryIDs.Add(gallery.ID) } } return nil } func (i *Importer) populatePerformers(ctx context.Context) error { if len(i.Input.Performers) > 0 { names := i.Input.Performers performers, err := i.PerformerWriter.FindByNames(ctx, names, false) if err != nil { return err } var pluckedNames []string for _, performer := range performers { if performer.Name == "" { continue } pluckedNames = append(pluckedNames, performer.Name) } missingPerformers := sliceutil.Filter(names, func(name string) bool { return !slices.Contains(pluckedNames, name) }) if len(missingPerformers) > 0 { if i.MissingRefBehaviour == models.ImportMissingRefEnumFail { return fmt.Errorf("scene performers [%s] not found", strings.Join(missingPerformers, ", ")) } if i.MissingRefBehaviour == models.ImportMissingRefEnumCreate { createdPerformers, err := i.createPerformers(ctx, missingPerformers) if err != nil { return fmt.Errorf("error creating scene performers: %v", err) } performers = append(performers, createdPerformers...) } // ignore if MissingRefBehaviour set to Ignore } for _, p := range performers { i.scene.PerformerIDs.Add(p.ID) } } return nil } func (i *Importer) createPerformers(ctx context.Context, names []string) ([]*models.Performer, error) { var ret []*models.Performer for _, name := range names { newPerformer := models.NewPerformer() newPerformer.Name = name err := i.PerformerWriter.Create(ctx, &models.CreatePerformerInput{ Performer: &newPerformer, }) if err != nil { return nil, err } ret = append(ret, &newPerformer) } return ret, nil } func (i *Importer) populateGroups(ctx context.Context) error { if len(i.Input.Groups) > 0 { for _, inputGroup := range i.Input.Groups { group, err := i.GroupWriter.FindByName(ctx, inputGroup.GroupName, false) if err != nil { return fmt.Errorf("error finding scene group: %v", err) } var groupID int if group == nil { if i.MissingRefBehaviour == models.ImportMissingRefEnumFail { return fmt.Errorf("scene group [%s] not found", inputGroup.GroupName) } if i.MissingRefBehaviour == models.ImportMissingRefEnumCreate { groupID, err = i.createGroup(ctx, inputGroup.GroupName) if err != nil { return fmt.Errorf("error creating scene group: %v", err) } } // ignore if MissingRefBehaviour set to Ignore if i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore { continue } } else { groupID = group.ID } toAdd := models.GroupsScenes{ GroupID: groupID, } if inputGroup.SceneIndex != 0 { index := inputGroup.SceneIndex toAdd.SceneIndex = &index } i.scene.Groups.Add(toAdd) } } return nil } func (i *Importer) createGroup(ctx context.Context, name string) (int, error) { newGroup := models.NewGroup() newGroup.Name = name err := i.GroupWriter.Create(ctx, &newGroup) if err != nil { return 0, err } return newGroup.ID, nil } func (i *Importer) populateTags(ctx context.Context) error { if len(i.Input.Tags) > 0 { tags, err := importTags(ctx, i.TagWriter, i.Input.Tags, i.MissingRefBehaviour) if err != nil { return err } for _, p := range tags { i.scene.TagIDs.Add(p.ID) } } return nil } func (i *Importer) addViewHistory(ctx context.Context) error { if len(i.viewHistory) > 0 { _, err := i.ReaderWriter.AddViews(ctx, i.ID, i.viewHistory) if err != nil { return fmt.Errorf("error adding view date: %v", err) } } return nil } func (i *Importer) addOHistory(ctx context.Context) error { if len(i.oHistory) > 0 { _, err := i.ReaderWriter.AddO(ctx, i.ID, i.oHistory) if err != nil { return fmt.Errorf("error adding o date: %v", err) } } return nil } func (i *Importer) PostImport(ctx context.Context, id int) error { if len(i.coverImageData) > 0 { if err := i.ReaderWriter.UpdateCover(ctx, id, i.coverImageData); err != nil { return fmt.Errorf("error setting scene images: %v", err) } } // add histories if err := i.addViewHistory(ctx); err != nil { return err } if err := i.addOHistory(ctx); err != nil { return err } if len(i.customFields) > 0 { if err := i.ReaderWriter.SetCustomFields(ctx, id, models.CustomFieldsInput{ Full: i.customFields, }); err != nil { return fmt.Errorf("error setting scene custom fields: %v", err) } } return nil } func (i *Importer) Name() string { if i.Input.Title != "" { return i.Input.Title } if len(i.Input.Files) > 0 { return i.Input.Files[0] } return "" } func (i *Importer) FindExistingID(ctx context.Context) (*int, error) { var existing []*models.Scene var err error for _, f := range i.scene.Files.List() { existing, err = i.ReaderWriter.FindByFileID(ctx, f.ID) if err != nil { return nil, err } if len(existing) > 0 { id := existing[0].ID return &id, nil } } return nil, nil } func (i *Importer) Create(ctx context.Context) (*int, error) { var fileIDs []models.FileID for _, f := range i.scene.Files.List() { fileIDs = append(fileIDs, f.Base().ID) } if err := i.ReaderWriter.Create(ctx, &i.scene, fileIDs); err != nil { return nil, fmt.Errorf("error creating scene: %v", err) } id := i.scene.ID i.ID = id return &id, nil } func (i *Importer) Update(ctx context.Context, id int) error { scene := i.scene scene.ID = id i.ID = id if err := i.ReaderWriter.Update(ctx, &scene); err != nil { return fmt.Errorf("error updating existing scene: %v", err) } return nil } func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string, missingRefBehaviour models.ImportMissingRefEnum) ([]*models.Tag, error) { tags, err := tagWriter.FindByNames(ctx, names, false) if err != nil { return nil, err } var pluckedNames []string for _, tag := range tags { pluckedNames = append(pluckedNames, tag.Name) } missingTags := sliceutil.Filter(names, func(name string) bool { return !slices.Contains(pluckedNames, name) }) if len(missingTags) > 0 { if missingRefBehaviour == models.ImportMissingRefEnumFail { return nil, fmt.Errorf("tags [%s] not found", strings.Join(missingTags, ", ")) } if missingRefBehaviour == models.ImportMissingRefEnumCreate { createdTags, err := createTags(ctx, tagWriter, missingTags) if err != nil { return nil, fmt.Errorf("error creating tags: %v", err) } tags = append(tags, createdTags...) } // ignore if MissingRefBehaviour set to Ignore } return tags, nil } func createTags(ctx context.Context, tagWriter models.TagCreator, names []string) ([]*models.Tag, error) { var ret []*models.Tag for _, name := range names { newTag := models.NewTag() newTag.Name = name err := tagWriter.Create(ctx, &models.CreateTagInput{ Tag: &newTag, }) if err != nil { return nil, err } ret = append(ret, &newTag) } return ret, nil } ================================================ FILE: pkg/scene/import_test.go ================================================ package scene import ( "context" "errors" "testing" "time" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) const invalidImage = "aW1hZ2VCeXRlcw&&" var ( existingStudioID = 101 existingPerformerID = 103 existingGroupID = 104 existingTagID = 105 existingStudioName = "existingStudioName" existingStudioErr = "existingStudioErr" missingStudioName = "missingStudioName" existingPerformerName = "existingPerformerName" existingPerformerErr = "existingPerformerErr" missingPerformerName = "missingPerformerName" existingGroupName = "existingGroupName" existingGroupErr = "existingGroupErr" missingGroupName = "missingGroupName" existingTagName = "existingTagName" existingTagErr = "existingTagErr" missingTagName = "missingTagName" ) var testCtx = context.Background() func TestImporterPreImport(t *testing.T) { var ( title = "title" code = "code" details = "details" director = "director" endpoint1 = "endpoint1" stashID1 = "stashID1" endpoint2 = "endpoint2" stashID2 = "stashID2" url1 = "url1" url2 = "url2" rating = 3 organized = true createdAt = time.Now().Add(-time.Hour) updatedAt = time.Now().Add(-time.Minute) resumeTime = 1.234 playDuration = 2.345 ) tests := []struct { name string input jsonschema.Scene output models.Scene }{ { "basic", jsonschema.Scene{ Title: title, Code: code, Details: details, Director: director, StashIDs: []models.StashID{ {Endpoint: endpoint1, StashID: stashID1}, {Endpoint: endpoint2, StashID: stashID2}, }, URLs: []string{url1, url2}, Rating: rating, Organized: organized, CreatedAt: json.JSONTime{Time: createdAt}, UpdatedAt: json.JSONTime{Time: updatedAt}, ResumeTime: resumeTime, PlayDuration: playDuration, }, models.Scene{ Title: title, Code: code, Details: details, Director: director, StashIDs: models.NewRelatedStashIDs([]models.StashID{ {Endpoint: endpoint1, StashID: stashID1}, {Endpoint: endpoint2, StashID: stashID2}, }), URLs: models.NewRelatedStrings([]string{url1, url2}), Rating: &rating, Organized: organized, CreatedAt: createdAt.Truncate(0), UpdatedAt: updatedAt.Truncate(0), ResumeTime: resumeTime, PlayDuration: playDuration, Files: models.NewRelatedVideoFiles([]*models.VideoFile{}), GalleryIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), PerformerIDs: models.NewRelatedIDs([]int{}), Groups: models.NewRelatedGroups([]models.GroupsScenes{}), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { i := Importer{ Input: tt.input, } if err := i.PreImport(testCtx); err != nil { t.Errorf("PreImport() error = %v", err) return } assert.Equal(t, tt.output, i.scene) }) } } func truncateTimes(t []time.Time) []time.Time { return sliceutil.Map(t, func(t time.Time) time.Time { return t.Truncate(0) }) } func TestImporterPreImportHistory(t *testing.T) { var ( playTime1 = time.Now().Add(-time.Hour * 2) playTime2 = time.Now().Add(-time.Minute * 2) oTime1 = time.Now().Add(-time.Hour * 3) oTime2 = time.Now().Add(-time.Minute * 3) ) tests := []struct { name string input jsonschema.Scene expectedPlayHistory []time.Time expectedOHistory []time.Time }{ { "basic", jsonschema.Scene{ PlayHistory: []json.JSONTime{ {Time: playTime1}, {Time: playTime2}, }, OHistory: []json.JSONTime{ {Time: oTime1}, {Time: oTime2}, }, }, []time.Time{playTime1, playTime2}, []time.Time{oTime1, oTime2}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { i := Importer{ Input: tt.input, } if err := i.PreImport(testCtx); err != nil { t.Errorf("PreImport() error = %v", err) return } // convert histories to unix timestamps for comparison eph := truncateTimes(tt.expectedPlayHistory) vh := truncateTimes(i.viewHistory) eoh := truncateTimes(tt.expectedOHistory) oh := truncateTimes(i.oHistory) assert.Equal(t, eph, vh, "view history mismatch") assert.Equal(t, eoh, oh, "o history mismatch") }) } } func TestImporterPreImportCoverImage(t *testing.T) { i := Importer{ Input: jsonschema.Scene{ Cover: invalidImage, }, } err := i.PreImport(testCtx) assert.NotNil(t, err) i.Input.Cover = imageBase64 err = i.PreImport(testCtx) assert.Nil(t, err) } func TestImporterPreImportWithStudio(t *testing.T) { db := mocks.NewDatabase() i := Importer{ StudioWriter: db.Studio, Input: jsonschema.Scene{ Studio: existingStudioName, }, } db.Studio.On("FindByName", testCtx, existingStudioName, false).Return(&models.Studio{ ID: existingStudioID, }, nil).Once() db.Studio.On("FindByName", testCtx, existingStudioErr, false).Return(nil, errors.New("FindByName error")).Once() err := i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, existingStudioID, *i.scene.StudioID) i.Input.Studio = existingStudioErr err = i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithMissingStudio(t *testing.T) { db := mocks.NewDatabase() i := Importer{ StudioWriter: db.Studio, Input: jsonschema.Scene{ Studio: missingStudioName, }, MissingRefBehaviour: models.ImportMissingRefEnumFail, } db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Times(3) db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Run(func(args mock.Arguments) { s := args.Get(1).(*models.CreateStudioInput) s.Studio.ID = existingStudioID }).Return(nil) err := i.PreImport(testCtx) assert.NotNil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore err = i.PreImport(testCtx) assert.Nil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumCreate err = i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, existingStudioID, *i.scene.StudioID) db.AssertExpectations(t) } func TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) { db := mocks.NewDatabase() i := Importer{ StudioWriter: db.Studio, Input: jsonschema.Scene{ Studio: missingStudioName, }, MissingRefBehaviour: models.ImportMissingRefEnumCreate, } db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Once() db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithPerformer(t *testing.T) { db := mocks.NewDatabase() i := Importer{ PerformerWriter: db.Performer, MissingRefBehaviour: models.ImportMissingRefEnumFail, Input: jsonschema.Scene{ Performers: []string{ existingPerformerName, }, }, } db.Performer.On("FindByNames", testCtx, []string{existingPerformerName}, false).Return([]*models.Performer{ { ID: existingPerformerID, Name: existingPerformerName, }, }, nil).Once() db.Performer.On("FindByNames", testCtx, []string{existingPerformerErr}, false).Return(nil, errors.New("FindByNames error")).Once() err := i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, []int{existingPerformerID}, i.scene.PerformerIDs.List()) i.Input.Performers = []string{existingPerformerErr} err = i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithMissingPerformer(t *testing.T) { db := mocks.NewDatabase() i := Importer{ PerformerWriter: db.Performer, Input: jsonschema.Scene{ Performers: []string{ missingPerformerName, }, }, MissingRefBehaviour: models.ImportMissingRefEnumFail, } db.Performer.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Times(3) db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.CreatePerformerInput")).Run(func(args mock.Arguments) { p := args.Get(1).(*models.CreatePerformerInput) p.ID = existingPerformerID }).Return(nil) err := i.PreImport(testCtx) assert.NotNil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore err = i.PreImport(testCtx) assert.Nil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumCreate err = i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, []int{existingPerformerID}, i.scene.PerformerIDs.List()) db.AssertExpectations(t) } func TestImporterPreImportWithMissingPerformerCreateErr(t *testing.T) { db := mocks.NewDatabase() i := Importer{ PerformerWriter: db.Performer, Input: jsonschema.Scene{ Performers: []string{ missingPerformerName, }, }, MissingRefBehaviour: models.ImportMissingRefEnumCreate, } db.Performer.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Once() db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.CreatePerformerInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithGroup(t *testing.T) { db := mocks.NewDatabase() i := Importer{ GroupWriter: db.Group, MissingRefBehaviour: models.ImportMissingRefEnumFail, Input: jsonschema.Scene{ Groups: []jsonschema.SceneGroup{ { GroupName: existingGroupName, SceneIndex: 1, }, }, }, } db.Group.On("FindByName", testCtx, existingGroupName, false).Return(&models.Group{ ID: existingGroupID, Name: existingGroupName, }, nil).Once() db.Group.On("FindByName", testCtx, existingGroupErr, false).Return(nil, errors.New("FindByName error")).Once() err := i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, existingGroupID, i.scene.Groups.List()[0].GroupID) i.Input.Groups[0].GroupName = existingGroupErr err = i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithMissingGroup(t *testing.T) { db := mocks.NewDatabase() i := Importer{ GroupWriter: db.Group, Input: jsonschema.Scene{ Groups: []jsonschema.SceneGroup{ { GroupName: missingGroupName, }, }, }, MissingRefBehaviour: models.ImportMissingRefEnumFail, } db.Group.On("FindByName", testCtx, missingGroupName, false).Return(nil, nil).Times(3) db.Group.On("Create", testCtx, mock.AnythingOfType("*models.Group")).Run(func(args mock.Arguments) { m := args.Get(1).(*models.Group) m.ID = existingGroupID }).Return(nil) err := i.PreImport(testCtx) assert.NotNil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore err = i.PreImport(testCtx) assert.Nil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumCreate err = i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, existingGroupID, i.scene.Groups.List()[0].GroupID) db.AssertExpectations(t) } func TestImporterPreImportWithMissingGroupCreateErr(t *testing.T) { db := mocks.NewDatabase() i := Importer{ GroupWriter: db.Group, Input: jsonschema.Scene{ Groups: []jsonschema.SceneGroup{ { GroupName: missingGroupName, }, }, }, MissingRefBehaviour: models.ImportMissingRefEnumCreate, } db.Group.On("FindByName", testCtx, missingGroupName, false).Return(nil, nil).Once() db.Group.On("Create", testCtx, mock.AnythingOfType("*models.Group")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithTag(t *testing.T) { db := mocks.NewDatabase() i := Importer{ TagWriter: db.Tag, MissingRefBehaviour: models.ImportMissingRefEnumFail, Input: jsonschema.Scene{ Tags: []string{ existingTagName, }, }, } db.Tag.On("FindByNames", testCtx, []string{existingTagName}, false).Return([]*models.Tag{ { ID: existingTagID, Name: existingTagName, }, }, nil).Once() db.Tag.On("FindByNames", testCtx, []string{existingTagErr}, false).Return(nil, errors.New("FindByNames error")).Once() err := i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, []int{existingTagID}, i.scene.TagIDs.List()) i.Input.Tags = []string{existingTagErr} err = i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithMissingTag(t *testing.T) { db := mocks.NewDatabase() i := Importer{ TagWriter: db.Tag, Input: jsonschema.Scene{ Tags: []string{ missingTagName, }, }, MissingRefBehaviour: models.ImportMissingRefEnumFail, } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3) db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Run(func(args mock.Arguments) { t := args.Get(1).(*models.CreateTagInput) t.Tag.ID = existingTagID }).Return(nil) err := i.PreImport(testCtx) assert.NotNil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore err = i.PreImport(testCtx) assert.Nil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumCreate err = i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, []int{existingTagID}, i.scene.TagIDs.List()) db.AssertExpectations(t) } func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) { db := mocks.NewDatabase() i := Importer{ TagWriter: db.Tag, Input: jsonschema.Scene{ Tags: []string{ missingTagName, }, }, MissingRefBehaviour: models.ImportMissingRefEnumCreate, } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once() db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPostImport(t *testing.T) { db := mocks.NewDatabase() vt := time.Now() ot := vt.Add(time.Minute) var ( okID = 1 errViewHistoryID = 2 errOHistoryID = 3 errImageID = 4 errCustomFieldsID = 5 ) var ( errImage = errors.New("error updating cover image") errViewHistory = errors.New("error updating view history") errOHistory = errors.New("error updating o history") errCustomFields = errors.New("error updating custom fields") ) table := []struct { name string importer Importer err bool }{ { name: "all set successfully", importer: Importer{ ID: okID, coverImageData: []byte(imageBase64), viewHistory: []time.Time{vt}, oHistory: []time.Time{ot}, customFields: customFields, }, err: false, }, { name: "cover image set with error", importer: Importer{ ID: errImageID, coverImageData: []byte(invalidImage), }, err: true, }, { name: "view history set with error", importer: Importer{ ID: errViewHistoryID, viewHistory: []time.Time{vt}, }, err: true, }, { name: "o history set with error", importer: Importer{ ID: errOHistoryID, oHistory: []time.Time{ot}, }, err: true, }, { name: "custom fields set with error", importer: Importer{ ID: errCustomFieldsID, customFields: customFields, }, err: true, }, } db.Scene.On("UpdateCover", testCtx, okID, []byte(imageBase64)).Return(nil).Once() db.Scene.On("UpdateCover", testCtx, errImageID, []byte(invalidImage)).Return(errImage).Once() db.Scene.On("AddViews", testCtx, okID, []time.Time{vt}).Return([]time.Time{vt}, nil).Once() db.Scene.On("AddViews", testCtx, errViewHistoryID, []time.Time{vt}).Return(nil, errViewHistory).Once() db.Scene.On("AddO", testCtx, okID, []time.Time{ot}).Return([]time.Time{ot}, nil).Once() db.Scene.On("AddO", testCtx, errOHistoryID, []time.Time{ot}).Return(nil, errOHistory).Once() db.Scene.On("SetCustomFields", testCtx, okID, models.CustomFieldsInput{ Full: customFields, }).Return(nil).Once() db.Scene.On("SetCustomFields", testCtx, errCustomFieldsID, models.CustomFieldsInput{ Full: customFields, }).Return(errCustomFields).Once() for _, tt := range table { t.Run(tt.name, func(t *testing.T) { i := tt.importer i.ReaderWriter = db.Scene err := i.PostImport(testCtx, i.ID) if tt.err { assert.NotNil(t, err, "expected error but got nil") } else { assert.Nil(t, err, "unexpected error: %v", err) } }) } } ================================================ FILE: pkg/scene/marker_import.go ================================================ package scene import ( "context" "fmt" "strconv" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" ) type MarkerCreatorUpdater interface { models.SceneMarkerCreatorUpdater FindBySceneID(ctx context.Context, sceneID int) ([]*models.SceneMarker, error) } type MarkerImporter struct { SceneID int ReaderWriter MarkerCreatorUpdater TagWriter models.TagFinderCreator Input jsonschema.SceneMarker MissingRefBehaviour models.ImportMissingRefEnum tags []*models.Tag marker models.SceneMarker } func (i *MarkerImporter) PreImport(ctx context.Context) error { seconds, _ := strconv.ParseFloat(i.Input.Seconds, 64) var endSeconds *float64 if i.Input.EndSeconds != "" { parsedEndSeconds, _ := strconv.ParseFloat(i.Input.EndSeconds, 64) endSeconds = &parsedEndSeconds } i.marker = models.SceneMarker{ Title: i.Input.Title, Seconds: seconds, EndSeconds: endSeconds, SceneID: i.SceneID, CreatedAt: i.Input.CreatedAt.GetTime(), UpdatedAt: i.Input.UpdatedAt.GetTime(), } if err := i.populateTags(ctx); err != nil { return err } return nil } func (i *MarkerImporter) populateTags(ctx context.Context) error { // primary tag cannot be ignored mrb := i.MissingRefBehaviour if mrb == models.ImportMissingRefEnumIgnore { mrb = models.ImportMissingRefEnumFail } primaryTag, err := importTags(ctx, i.TagWriter, []string{i.Input.PrimaryTag}, mrb) if err != nil { return err } i.marker.PrimaryTagID = primaryTag[0].ID if len(i.Input.Tags) > 0 { tags, err := importTags(ctx, i.TagWriter, i.Input.Tags, i.MissingRefBehaviour) if err != nil { return err } i.tags = tags } return nil } func (i *MarkerImporter) PostImport(ctx context.Context, id int) error { if len(i.tags) > 0 { var tagIDs []int for _, t := range i.tags { tagIDs = append(tagIDs, t.ID) } if err := i.ReaderWriter.UpdateTags(ctx, id, tagIDs); err != nil { return fmt.Errorf("failed to associate tags: %v", err) } } return nil } func (i *MarkerImporter) Name() string { return fmt.Sprintf("%s (%s)", i.Input.Title, i.Input.Seconds) } func (i *MarkerImporter) FindExistingID(ctx context.Context) (*int, error) { existingMarkers, err := i.ReaderWriter.FindBySceneID(ctx, i.SceneID) if err != nil { return nil, err } for _, m := range existingMarkers { if m.Seconds == i.marker.Seconds { id := m.ID return &id, nil } } return nil, nil } func (i *MarkerImporter) Create(ctx context.Context) (*int, error) { err := i.ReaderWriter.Create(ctx, &i.marker) if err != nil { return nil, fmt.Errorf("error creating marker: %v", err) } id := i.marker.ID return &id, nil } func (i *MarkerImporter) Update(ctx context.Context, id int) error { marker := i.marker marker.ID = id err := i.ReaderWriter.Update(ctx, &marker) if err != nil { return fmt.Errorf("error updating existing marker: %v", err) } return nil } ================================================ FILE: pkg/scene/marker_import_test.go ================================================ package scene // import ( // "context" // "errors" // "testing" // "github.com/stashapp/stash/pkg/models" // "github.com/stashapp/stash/pkg/models/jsonschema" // "github.com/stashapp/stash/pkg/models/mocks" // "github.com/stretchr/testify/assert" // "github.com/stretchr/testify/mock" // ) // const ( // seconds = "5" // secondsFloat = 5.0 // errSceneID = 999 // ) // func TestMarkerImporterName(t *testing.T) { // i := MarkerImporter{ // Input: jsonschema.SceneMarker{ // Title: title, // Seconds: seconds, // }, // } // assert.Equal(t, title+" (5)", i.Name()) // } // func TestMarkerImporterPreImportWithTag(t *testing.T) { // tagReaderWriter := &mocks.TagReaderWriter{} // ctx := context.Background() // i := MarkerImporter{ // TagWriter: tagReaderWriter, // MissingRefBehaviour: models.ImportMissingRefEnumFail, // Input: jsonschema.SceneMarker{ // PrimaryTag: existingTagName, // }, // } // tagReaderWriter.On("FindByNames", ctx, []string{existingTagName}, false).Return([]*models.Tag{ // { // ID: existingTagID, // Name: existingTagName, // }, // }, nil).Times(4) // tagReaderWriter.On("FindByNames", ctx, []string{existingTagErr}, false).Return(nil, errors.New("FindByNames error")).Times(2) // err := i.PreImport(ctx) // assert.Nil(t, err) // assert.Equal(t, existingTagID, i.marker.PrimaryTagID) // i.Input.PrimaryTag = existingTagErr // err = i.PreImport(ctx) // assert.NotNil(t, err) // i.Input.PrimaryTag = existingTagName // i.Input.Tags = []string{ // existingTagName, // } // err = i.PreImport(ctx) // assert.Nil(t, err) // assert.Equal(t, existingTagID, i.tags[0].ID) // i.Input.Tags[0] = existingTagErr // err = i.PreImport(ctx) // assert.NotNil(t, err) // tagReaderWriter.AssertExpectations(t) // } // func TestMarkerImporterPostImportUpdateTags(t *testing.T) { // sceneMarkerReaderWriter := &mocks.SceneMarkerReaderWriter{} // ctx := context.Background() // i := MarkerImporter{ // ReaderWriter: sceneMarkerReaderWriter, // tags: []*models.Tag{ // { // ID: existingTagID, // }, // }, // } // updateErr := errors.New("UpdateTags error") // sceneMarkerReaderWriter.On("UpdateTags", ctx, sceneID, []int{existingTagID}).Return(nil).Once() // sceneMarkerReaderWriter.On("UpdateTags", ctx, errTagsID, mock.AnythingOfType("[]int")).Return(updateErr).Once() // err := i.PostImport(ctx, sceneID) // assert.Nil(t, err) // err = i.PostImport(ctx, errTagsID) // assert.NotNil(t, err) // sceneMarkerReaderWriter.AssertExpectations(t) // } // func TestMarkerImporterFindExistingID(t *testing.T) { // readerWriter := &mocks.SceneMarkerReaderWriter{} // ctx := context.Background() // i := MarkerImporter{ // ReaderWriter: readerWriter, // SceneID: sceneID, // marker: models.SceneMarker{ // Seconds: secondsFloat, // }, // } // expectedErr := errors.New("FindBy* error") // readerWriter.On("FindBySceneID", ctx, sceneID).Return([]*models.SceneMarker{ // { // ID: existingSceneID, // Seconds: secondsFloat, // }, // }, nil).Times(2) // readerWriter.On("FindBySceneID", ctx, errSceneID).Return(nil, expectedErr).Once() // id, err := i.FindExistingID(ctx) // assert.Equal(t, existingSceneID, *id) // assert.Nil(t, err) // i.marker.Seconds++ // id, err = i.FindExistingID(ctx) // assert.Nil(t, id) // assert.Nil(t, err) // i.SceneID = errSceneID // id, err = i.FindExistingID(ctx) // assert.Nil(t, id) // assert.NotNil(t, err) // readerWriter.AssertExpectations(t) // } // func TestMarkerImporterCreate(t *testing.T) { // readerWriter := &mocks.SceneMarkerReaderWriter{} // ctx := context.Background() // scene := models.SceneMarker{ // Title: title, // } // sceneErr := models.SceneMarker{ // Title: sceneNameErr, // } // i := MarkerImporter{ // ReaderWriter: readerWriter, // marker: scene, // } // errCreate := errors.New("Create error") // readerWriter.On("Create", ctx, scene).Return(&models.SceneMarker{ // ID: sceneID, // }, nil).Once() // readerWriter.On("Create", ctx, sceneErr).Return(nil, errCreate).Once() // id, err := i.Create(ctx) // assert.Equal(t, sceneID, *id) // assert.Nil(t, err) // i.marker = sceneErr // id, err = i.Create(ctx) // assert.Nil(t, id) // assert.NotNil(t, err) // readerWriter.AssertExpectations(t) // } // func TestMarkerImporterUpdate(t *testing.T) { // readerWriter := &mocks.SceneMarkerReaderWriter{} // ctx := context.Background() // scene := models.SceneMarker{ // Title: title, // } // sceneErr := models.SceneMarker{ // Title: sceneNameErr, // } // i := MarkerImporter{ // ReaderWriter: readerWriter, // marker: scene, // } // errUpdate := errors.New("Update error") // // id needs to be set for the mock input // scene.ID = sceneID // readerWriter.On("Update", ctx, scene).Return(nil, nil).Once() // err := i.Update(ctx, sceneID) // assert.Nil(t, err) // i.marker = sceneErr // // need to set id separately // sceneErr.ID = errImageID // readerWriter.On("Update", ctx, sceneErr).Return(nil, errUpdate).Once() // err = i.Update(ctx, errImageID) // assert.NotNil(t, err) // readerWriter.AssertExpectations(t) // } ================================================ FILE: pkg/scene/marker_query.go ================================================ package scene import ( "context" "strconv" "github.com/stashapp/stash/pkg/models" ) func MarkerCountByTagID(ctx context.Context, r models.SceneMarkerQueryer, id int, depth *int) (int, error) { filter := &models.SceneMarkerFilterType{ Tags: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, Depth: depth, }, } return r.QueryCount(ctx, filter, nil) } ================================================ FILE: pkg/scene/merge.go ================================================ package scene import ( "context" "errors" "fmt" "os" "path/filepath" "slices" "time" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/txn" ) type MergeOptions struct { ScenePartial models.ScenePartial IncludePlayHistory bool IncludeOHistory bool } func (s *Service) Merge(ctx context.Context, sourceIDs []int, destinationID int, fileDeleter *FileDeleter, options MergeOptions) error { scenePartial := options.ScenePartial // ensure source ids are unique sourceIDs = sliceutil.AppendUniques(nil, sourceIDs) // ensure destination is not in source list if slices.Contains(sourceIDs, destinationID) { return errors.New("destination scene cannot be in source list") } dest, err := s.Repository.Find(ctx, destinationID) if err != nil { return fmt.Errorf("finding destination scene ID %d: %w", destinationID, err) } sources, err := s.Repository.FindMany(ctx, sourceIDs) if err != nil { return fmt.Errorf("finding source scenes: %w", err) } var fileIDs []models.FileID for _, src := range sources { if err := src.LoadRelationships(ctx, s.Repository); err != nil { return fmt.Errorf("loading scene relationships from %d: %w", src.ID, err) } for _, f := range src.Files.List() { fileIDs = append(fileIDs, f.Base().ID) } if err := s.mergeSceneMarkers(ctx, dest, src); err != nil { return err } } // move files to destination scene if len(fileIDs) > 0 { if err := s.Repository.AssignFiles(ctx, destinationID, fileIDs); err != nil { return fmt.Errorf("moving files to destination scene: %w", err) } // if scene didn't already have a primary file, then set it now if dest.PrimaryFileID == nil { scenePartial.PrimaryFileID = &fileIDs[0] } else { // don't allow changing primary file ID from the input values scenePartial.PrimaryFileID = nil } } if _, err := s.Repository.UpdatePartial(ctx, destinationID, scenePartial); err != nil { return fmt.Errorf("updating scene: %w", err) } // merge play history if options.IncludePlayHistory { var allDates []time.Time for _, src := range sources { thisDates, err := s.Repository.GetViewDates(ctx, src.ID) if err != nil { return fmt.Errorf("getting view dates for scene %d: %w", src.ID, err) } allDates = append(allDates, thisDates...) } if len(allDates) > 0 { if _, err := s.Repository.AddViews(ctx, destinationID, allDates); err != nil { return fmt.Errorf("adding view dates to scene %d: %w", destinationID, err) } } } // merge o history if options.IncludeOHistory { var allDates []time.Time for _, src := range sources { thisDates, err := s.Repository.GetODates(ctx, src.ID) if err != nil { return fmt.Errorf("getting o dates for scene %d: %w", src.ID, err) } allDates = append(allDates, thisDates...) } if len(allDates) > 0 { if _, err := s.Repository.AddO(ctx, destinationID, allDates); err != nil { return fmt.Errorf("adding o dates to scene %d: %w", destinationID, err) } } } // delete old scenes for _, src := range sources { const deleteGenerated = true const deleteFile = false const destroyFileEntry = false if err := s.Destroy(ctx, src, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil { return fmt.Errorf("deleting scene %d: %w", src.ID, err) } } return nil } func (s *Service) mergeSceneMarkers(ctx context.Context, dest *models.Scene, src *models.Scene) error { markers, err := s.MarkerRepository.FindBySceneID(ctx, src.ID) if err != nil { return fmt.Errorf("finding scene markers: %w", err) } type rename struct { src string dest string } var toRename []rename destHash := dest.GetHash(s.Config.GetVideoFileNamingAlgorithm()) for _, m := range markers { srcHash := src.GetHash(s.Config.GetVideoFileNamingAlgorithm()) // updated the scene id m.SceneID = dest.ID if err := s.MarkerRepository.Update(ctx, m); err != nil { return fmt.Errorf("updating scene marker %d: %w", m.ID, err) } // move generated files to new location toRename = append(toRename, []rename{ { src: s.Paths.SceneMarkers.GetScreenshotPath(srcHash, int(m.Seconds)), dest: s.Paths.SceneMarkers.GetScreenshotPath(destHash, int(m.Seconds)), }, { src: s.Paths.SceneMarkers.GetThumbnailPath(srcHash, int(m.Seconds)), dest: s.Paths.SceneMarkers.GetThumbnailPath(destHash, int(m.Seconds)), }, { src: s.Paths.SceneMarkers.GetWebpPreviewPath(srcHash, int(m.Seconds)), dest: s.Paths.SceneMarkers.GetWebpPreviewPath(destHash, int(m.Seconds)), }, }...) } if len(toRename) > 0 { txn.AddPostCommitHook(ctx, func(ctx context.Context) { // rename the files if they exist for _, e := range toRename { srcExists, _ := fsutil.FileExists(e.src) destExists, _ := fsutil.FileExists(e.dest) if srcExists && !destExists { destDir := filepath.Dir(e.dest) if err := fsutil.EnsureDir(destDir); err != nil { logger.Errorf("Error creating generated marker folder %s: %v", destDir, err) continue } if err := os.Rename(e.src, e.dest); err != nil { logger.Errorf("Error renaming generated marker file from %s to %s: %v", e.src, e.dest, err) } } } }) } return nil } ================================================ FILE: pkg/scene/migrate_hash.go ================================================ package scene import ( "bytes" "os" "path/filepath" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models/paths" ) func MigrateHash(p *paths.Paths, oldHash string, newHash string) { oldPath := filepath.Join(p.Generated.Markers, oldHash) newPath := filepath.Join(p.Generated.Markers, newHash) migrateSceneFiles(oldPath, newPath) scenePaths := p.Scene oldPath = scenePaths.GetVideoPreviewPath(oldHash) newPath = scenePaths.GetVideoPreviewPath(newHash) migrateSceneFiles(oldPath, newPath) oldPath = scenePaths.GetWebpPreviewPath(oldHash) newPath = scenePaths.GetWebpPreviewPath(newHash) migrateSceneFiles(oldPath, newPath) oldPath = scenePaths.GetTranscodePath(oldHash) newPath = scenePaths.GetTranscodePath(newHash) migrateSceneFiles(oldPath, newPath) oldVttPath := scenePaths.GetSpriteVttFilePath(oldHash) newVttPath := scenePaths.GetSpriteVttFilePath(newHash) migrateSceneFiles(oldVttPath, newVttPath) oldPath = scenePaths.GetSpriteImageFilePath(oldHash) newPath = scenePaths.GetSpriteImageFilePath(newHash) migrateSceneFiles(oldPath, newPath) migrateVttFile(newVttPath, oldPath, newPath) oldPath = scenePaths.GetInteractiveHeatmapPath(oldHash) newPath = scenePaths.GetInteractiveHeatmapPath(newHash) migrateSceneFiles(oldPath, newPath) // #3986 - migrate scene marker files markerPaths := p.SceneMarkers oldPath = markerPaths.GetFolderPath(oldHash) newPath = markerPaths.GetFolderPath(newHash) migrateSceneFolder(oldPath, newPath) } func migrateSceneFiles(oldName, newName string) { oldExists, err := fsutil.FileExists(oldName) if err != nil && !os.IsNotExist(err) { logger.Errorf("Error checking existence of %s: %s", oldName, err.Error()) return } if oldExists { logger.Infof("renaming %s to %s", oldName, newName) if err := os.Rename(oldName, newName); err != nil { logger.Errorf("error renaming %s to %s: %s", oldName, newName, err.Error()) } } } // #2481: migrate vtt file contents in addition to renaming func migrateVttFile(vttPath, oldSpritePath, newSpritePath string) { // #3356 - don't try to migrate if the file doesn't exist exists, err := fsutil.FileExists(vttPath) if err != nil && !os.IsNotExist(err) { logger.Errorf("Error checking existence of %s: %s", vttPath, err.Error()) return } if !exists { return } contents, err := os.ReadFile(vttPath) if err != nil { logger.Errorf("Error reading %s for vtt migration: %v", vttPath, err) return } oldSpriteBasename := filepath.Base(oldSpritePath) newSpriteBasename := filepath.Base(newSpritePath) contents = bytes.ReplaceAll(contents, []byte(oldSpriteBasename), []byte(newSpriteBasename)) if err := os.WriteFile(vttPath, contents, 0644); err != nil { logger.Errorf("Error writing %s for vtt migration: %v", vttPath, err) return } } func migrateSceneFolder(oldName, newName string) { oldExists, err := fsutil.DirExists(oldName) if err != nil && !os.IsNotExist(err) { logger.Errorf("Error checking existence of %s: %s", oldName, err.Error()) return } if oldExists { logger.Infof("renaming %s to %s", oldName, newName) if err := os.Rename(oldName, newName); err != nil { logger.Errorf("error renaming %s to %s: %s", oldName, newName, err.Error()) } } } ================================================ FILE: pkg/scene/migrate_screenshots.go ================================================ package scene import ( "context" "fmt" "os" "path/filepath" "strings" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/txn" ) type MigrateSceneScreenshotsInput struct { DeleteFiles bool `json:"deleteFiles"` OverwriteExisting bool `json:"overwriteExisting"` } type HashFinderCoverUpdater interface { FindByChecksum(ctx context.Context, checksum string) ([]*models.Scene, error) FindByOSHash(ctx context.Context, oshash string) ([]*models.Scene, error) HasCover(ctx context.Context, sceneID int) (bool, error) UpdateCover(ctx context.Context, sceneID int, cover []byte) error } type ScreenshotMigrator struct { Options MigrateSceneScreenshotsInput SceneUpdater HashFinderCoverUpdater TxnManager txn.Manager } func (m *ScreenshotMigrator) MigrateScreenshots(ctx context.Context, screenshotPath string) error { // find the scene based on the screenshot path s, err := m.findScenes(ctx, screenshotPath) if err != nil { return fmt.Errorf("finding scenes for screenshot: %w", err) } for _, scene := range s { // migrate each scene in its own transaction if err := txn.WithTxn(ctx, m.TxnManager, func(ctx context.Context) error { return m.migrateSceneScreenshot(ctx, scene, screenshotPath) }); err != nil { return fmt.Errorf("migrating screenshot for scene %s: %w", scene.DisplayName(), err) } } // if deleteFiles is true, delete the file if m.Options.DeleteFiles { if err := os.Remove(screenshotPath); err != nil { // log and continue logger.Errorf("Error deleting screenshot file %s: %v", screenshotPath, err) } else { logger.Debugf("Deleted screenshot file %s", screenshotPath) } // also delete the thumb file thumbPath := strings.TrimSuffix(screenshotPath, ".jpg") + ".thumb.jpg" // ignore errors for thumb files if err := os.Remove(thumbPath); err == nil { logger.Debugf("Deleted thumb file %s", thumbPath) } } return nil } func (m *ScreenshotMigrator) findScenes(ctx context.Context, screenshotPath string) ([]*models.Scene, error) { basename := filepath.Base(screenshotPath) ext := filepath.Ext(basename) basename = basename[:len(basename)-len(ext)] // use the basename to determine the hash type algo := m.getHashType(basename) if algo == "" { // log and return return nil, fmt.Errorf("could not determine hash type") } // use the hash type to get the scene var ret []*models.Scene err := txn.WithReadTxn(ctx, m.TxnManager, func(ctx context.Context) error { var err error if algo == models.HashAlgorithmOshash { // use oshash ret, err = m.SceneUpdater.FindByOSHash(ctx, basename) } else { // use md5 ret, err = m.SceneUpdater.FindByChecksum(ctx, basename) } return err }) return ret, err } func (m *ScreenshotMigrator) getHashType(basename string) models.HashAlgorithm { // if the basename is 16 characters long, must be oshash if len(basename) == 16 { return models.HashAlgorithmOshash } // if its 32 characters long, must be md5 if len(basename) == 32 { return models.HashAlgorithmMd5 } // otherwise, it's undefined return "" } func (m *ScreenshotMigrator) migrateSceneScreenshot(ctx context.Context, scene *models.Scene, screenshotPath string) error { if !m.Options.OverwriteExisting { // check if the scene has a cover already hasCover, err := m.SceneUpdater.HasCover(ctx, scene.ID) if err != nil { return fmt.Errorf("checking for existing cover: %w", err) } if hasCover { // already has cover, just silently return logger.Debugf("Scene %s already has a screenshot, skipping", scene.DisplayName()) return nil } } // get the data from the file data, err := os.ReadFile(screenshotPath) if err != nil { return fmt.Errorf("reading screenshot file: %w", err) } if err := m.SceneUpdater.UpdateCover(ctx, scene.ID, data); err != nil { return fmt.Errorf("updating scene screenshot: %w", err) } logger.Infof("Updated screenshot for scene %s from %s", scene.DisplayName(), filepath.Base(screenshotPath)) return nil } ================================================ FILE: pkg/scene/query.go ================================================ package scene import ( "context" "fmt" "path/filepath" "strconv" "strings" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/models" ) // QueryOptions returns a SceneQueryOptions populated with the provided filters. func QueryOptions(sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType, count bool) models.SceneQueryOptions { return models.SceneQueryOptions{ QueryOptions: models.QueryOptions{ FindFilter: findFilter, Count: count, }, SceneFilter: sceneFilter, } } // QueryWithCount queries for scenes, returning the scene objects and the total count. func QueryWithCount(ctx context.Context, qb models.SceneQueryer, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) ([]*models.Scene, int, error) { // this was moved from the queryBuilder code // left here so that calling functions can reference this instead result, err := qb.Query(ctx, QueryOptions(sceneFilter, findFilter, true)) if err != nil { return nil, 0, err } scenes, err := result.Resolve(ctx) if err != nil { return nil, 0, err } return scenes, result.Count, nil } // Query queries for scenes using the provided filters. func Query(ctx context.Context, qb models.SceneQueryer, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) ([]*models.Scene, error) { result, err := qb.Query(ctx, QueryOptions(sceneFilter, findFilter, false)) if err != nil { return nil, err } scenes, err := result.Resolve(ctx) if err != nil { return nil, err } return scenes, nil } func BatchProcess(ctx context.Context, reader models.SceneQueryer, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType, fn func(scene *models.Scene) error) error { const batchSize = 1000 if findFilter == nil { findFilter = &models.FindFilterType{} } page := 1 perPage := batchSize findFilter.Page = &page findFilter.PerPage = &perPage for more := true; more; { if job.IsCancelled(ctx) { return nil } scenes, err := Query(ctx, reader, sceneFilter, findFilter) if err != nil { return fmt.Errorf("error querying for scenes: %w", err) } for _, scene := range scenes { if err := fn(scene); err != nil { return err } } if len(scenes) != batchSize { more = false } else { *findFilter.Page++ } } return nil } // FilterFromPaths creates a SceneFilterType that filters using the provided // paths. func FilterFromPaths(paths []string) *models.SceneFilterType { ret := &models.SceneFilterType{} or := ret sep := string(filepath.Separator) for _, p := range paths { if !strings.HasSuffix(p, sep) { p += sep } if ret.Path == nil { or = ret } else { newOr := &models.SceneFilterType{} or.Or = newOr or = newOr } or.Path = &models.StringCriterionInput{ Modifier: models.CriterionModifierEquals, Value: p + "%", } } return ret } func CountByStudioID(ctx context.Context, r models.SceneQueryer, id int, depth *int) (int, error) { filter := &models.SceneFilterType{ Studios: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, Depth: depth, }, } return r.QueryCount(ctx, filter, nil) } func CountByTagID(ctx context.Context, r models.SceneQueryer, id int, depth *int) (int, error) { filter := &models.SceneFilterType{ Tags: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, Depth: depth, }, } return r.QueryCount(ctx, filter, nil) } func CountByGroupID(ctx context.Context, r models.SceneQueryer, id int, depth *int) (int, error) { filter := &models.SceneFilterType{ Groups: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, Depth: depth, }, } return r.QueryCount(ctx, filter, nil) } ================================================ FILE: pkg/scene/scan.go ================================================ package scene import ( "context" "errors" "fmt" "path/filepath" "strings" "github.com/stashapp/stash/pkg/file/video" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/paths" "github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/txn" ) var ( ErrNotVideoFile = errors.New("not a video file") // fingerprint types to match with // only try to match by data fingerprints, _not_ perceptual fingerprints matchableFingerprintTypes = []string{models.FingerprintTypeOshash, models.FingerprintTypeMD5} ) type ScanCreatorUpdater interface { FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Scene, error) FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Scene, error) GetFiles(ctx context.Context, relatedID int) ([]*models.VideoFile, error) Create(ctx context.Context, newScene *models.Scene, fileIDs []models.FileID) error UpdatePartial(ctx context.Context, id int, updatedScene models.ScenePartial) (*models.Scene, error) AddFileID(ctx context.Context, id int, fileID models.FileID) error } type ScanGalleryFinderUpdater interface { FindByPath(ctx context.Context, p string) ([]*models.Gallery, error) AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error } type ScanGenerator interface { Generate(ctx context.Context, s *models.Scene, f *models.VideoFile) error } type ScanHandler struct { CreatorUpdater ScanCreatorUpdater GalleryFinderUpdater ScanGalleryFinderUpdater ScanGenerator ScanGenerator CaptionUpdater video.CaptionUpdater PluginCache *plugin.Cache FileNamingAlgorithm models.HashAlgorithm Paths *paths.Paths } func (h *ScanHandler) validate() error { if h.CreatorUpdater == nil { return errors.New("CreatorUpdater is required") } if h.ScanGenerator == nil { return errors.New("ScanGenerator is required") } if h.CaptionUpdater == nil { return errors.New("CaptionUpdater is required") } if !h.FileNamingAlgorithm.IsValid() { return errors.New("FileNamingAlgorithm is required") } if h.Paths == nil { return errors.New("Paths is required") } return nil } func (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models.File) error { if err := h.validate(); err != nil { return err } videoFile, ok := f.(*models.VideoFile) if !ok { return ErrNotVideoFile } if oldFile != nil { if err := video.CleanCaptions(ctx, videoFile, nil, h.CaptionUpdater); err != nil { return fmt.Errorf("cleaning captions: %w", err) } } // try to match the file to a scene existing, err := h.CreatorUpdater.FindByFileID(ctx, f.Base().ID) if err != nil { return fmt.Errorf("finding existing scene: %w", err) } if len(existing) == 0 { // try also to match file by fingerprints existing, err = h.CreatorUpdater.FindByFingerprints(ctx, videoFile.Fingerprints.Filter(matchableFingerprintTypes...)) if err != nil { return fmt.Errorf("finding existing scene by fingerprints: %w", err) } } if len(existing) > 0 { updateExisting := oldFile != nil if err := h.associateExisting(ctx, existing, videoFile, updateExisting); err != nil { return err } } else { // create a new scene newScene := models.NewScene() logger.Infof("%s doesn't exist. Creating new scene...", f.Base().Path) if err := h.CreatorUpdater.Create(ctx, &newScene, []models.FileID{videoFile.ID}); err != nil { return fmt.Errorf("creating new scene: %w", err) } h.PluginCache.RegisterPostHooks(ctx, newScene.ID, hook.SceneCreatePost, nil, nil) existing = []*models.Scene{&newScene} } if oldFile != nil { // migrate hashes from the old file to the new oldHash := GetHash(oldFile, h.FileNamingAlgorithm) newHash := GetHash(f, h.FileNamingAlgorithm) if oldHash != "" && newHash != "" && oldHash != newHash { MigrateHash(h.Paths, oldHash, newHash) } } if err := h.associateGallery(ctx, existing, f); err != nil { return err } // do this after the commit so that cover generation doesn't hold up the transaction txn.AddPostCommitHook(ctx, func(ctx context.Context) { for _, s := range existing { if err := h.ScanGenerator.Generate(ctx, s, videoFile); err != nil { // just log if cover generation fails. We can try again on rescan logger.Errorf("Error generating content for %s: %v", videoFile.Path, err) } } }) return nil } func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.Scene, f *models.VideoFile, updateExisting bool) error { for _, s := range existing { if err := s.LoadFiles(ctx, h.CreatorUpdater); err != nil { return err } found := false for _, sf := range s.Files.List() { if sf.ID == f.ID { found = true break } } if !found { logger.Infof("Adding %s to scene %s", f.Path, s.DisplayName()) if err := h.CreatorUpdater.AddFileID(ctx, s.ID, f.ID); err != nil { return fmt.Errorf("adding file to scene: %w", err) } } if !found || updateExisting { // update updated_at time when file association or content changes scenePartial := models.NewScenePartial() if _, err := h.CreatorUpdater.UpdatePartial(ctx, s.ID, scenePartial); err != nil { return fmt.Errorf("updating scene: %w", err) } h.PluginCache.RegisterPostHooks(ctx, s.ID, hook.SceneUpdatePost, nil, nil) } } return nil } func (h *ScanHandler) associateGallery(ctx context.Context, existing []*models.Scene, f models.File) error { sceneIDs := make([]int, len(existing)) for i, s := range existing { sceneIDs[i] = s.ID } path := f.Base().Path zipPath := strings.TrimSuffix(path, filepath.Ext(path)) + ".zip" // find galleries with a file that matches galleries, err := h.GalleryFinderUpdater.FindByPath(ctx, zipPath) if err != nil { return err } for _, gallery := range galleries { // found related Scene logger.Infof("associate: Scene %s is related to gallery: %d", path, gallery.ID) if err := h.GalleryFinderUpdater.AddSceneIDs(ctx, gallery.ID, sceneIDs); err != nil { return err } } return nil } ================================================ FILE: pkg/scene/scan_test.go ================================================ package scene import ( "context" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stashapp/stash/pkg/plugin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) { const ( testSceneID = 1 testFileID = 100 ) existingFile := &models.VideoFile{ BaseFile: &models.BaseFile{ID: models.FileID(testFileID), Path: "test.mp4"}, } makeScene := func() *models.Scene { return &models.Scene{ ID: testSceneID, Files: models.NewRelatedVideoFiles([]*models.VideoFile{existingFile}), } } tests := []struct { name string updateExisting bool expectUpdate bool }{ { name: "calls UpdatePartial when file content changed", updateExisting: true, expectUpdate: true, }, { name: "skips UpdatePartial when file unchanged and already associated", updateExisting: false, expectUpdate: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { db := mocks.NewDatabase() db.Scene.On("GetFiles", mock.Anything, testSceneID).Return([]*models.VideoFile{existingFile}, nil) if tt.expectUpdate { db.Scene.On("UpdatePartial", mock.Anything, testSceneID, mock.Anything). Return(&models.Scene{ID: testSceneID}, nil) } h := &ScanHandler{ CreatorUpdater: db.Scene, PluginCache: &plugin.Cache{}, } db.WithTxnCtx(func(ctx context.Context) { err := h.associateExisting(ctx, []*models.Scene{makeScene()}, existingFile, tt.updateExisting) assert.NoError(t, err) }) if tt.expectUpdate { db.Scene.AssertCalled(t, "UpdatePartial", mock.Anything, testSceneID, mock.Anything) } else { db.Scene.AssertNotCalled(t, "UpdatePartial", mock.Anything, mock.Anything, mock.Anything) } }) } } func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) { const ( testSceneID = 1 existFileID = 100 newFileID = 200 ) existingFile := &models.VideoFile{ BaseFile: &models.BaseFile{ID: models.FileID(existFileID), Path: "existing.mp4"}, } newFile := &models.VideoFile{ BaseFile: &models.BaseFile{ID: models.FileID(newFileID), Path: "new.mp4"}, } scene := &models.Scene{ ID: testSceneID, Files: models.NewRelatedVideoFiles([]*models.VideoFile{existingFile}), } db := mocks.NewDatabase() db.Scene.On("GetFiles", mock.Anything, testSceneID).Return([]*models.VideoFile{existingFile}, nil) db.Scene.On("AddFileID", mock.Anything, testSceneID, models.FileID(newFileID)).Return(nil) db.Scene.On("UpdatePartial", mock.Anything, testSceneID, mock.Anything). Return(&models.Scene{ID: testSceneID}, nil) h := &ScanHandler{ CreatorUpdater: db.Scene, PluginCache: &plugin.Cache{}, } db.WithTxnCtx(func(ctx context.Context) { err := h.associateExisting(ctx, []*models.Scene{scene}, newFile, false) assert.NoError(t, err) }) db.Scene.AssertCalled(t, "AddFileID", mock.Anything, testSceneID, models.FileID(newFileID)) db.Scene.AssertCalled(t, "UpdatePartial", mock.Anything, testSceneID, mock.Anything) } ================================================ FILE: pkg/scene/service.go ================================================ // Package scene provides the application logic for scene functionality. // Most functionality is provided by [Service]. package scene import ( "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/paths" "github.com/stashapp/stash/pkg/plugin" ) type Config interface { GetVideoFileNamingAlgorithm() models.HashAlgorithm } type Service struct { File models.FileReaderWriter Repository models.SceneReaderWriter MarkerRepository models.SceneMarkerReaderWriter PluginCache *plugin.Cache Paths *paths.Paths Config Config } ================================================ FILE: pkg/scene/update.go ================================================ package scene import ( "context" "errors" "fmt" "time" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) var ErrEmptyUpdater = errors.New("no fields have been set") // UpdateSet is used to update a scene and its relationships. type UpdateSet struct { ID int Partial models.ScenePartial // in future these could be moved into a separate struct and reused // for a Creator struct // Not set if nil. Set to []byte{} to clear existing CoverImage []byte } // IsEmpty returns true if there is nothing to update. func (u *UpdateSet) IsEmpty() bool { withoutID := u.Partial return withoutID == models.ScenePartial{} && u.CoverImage == nil } // Update updates a scene by updating the fields in the Partial field, then // updates non-nil relationships. Returns an error if there is no work to // be done. func (u *UpdateSet) Update(ctx context.Context, qb models.SceneUpdater) (*models.Scene, error) { if u.IsEmpty() { return nil, ErrEmptyUpdater } partial := u.Partial updatedAt := time.Now() partial.UpdatedAt = models.NewOptionalTime(updatedAt) ret, err := qb.UpdatePartial(ctx, u.ID, partial) if err != nil { return nil, fmt.Errorf("error updating scene: %w", err) } if u.CoverImage != nil { if err := qb.UpdateCover(ctx, u.ID, u.CoverImage); err != nil { return nil, fmt.Errorf("error updating scene cover: %w", err) } } return ret, nil } // UpdateInput converts the UpdateSet into SceneUpdateInput for hook firing purposes. func (u UpdateSet) UpdateInput() models.SceneUpdateInput { // ensure the partial ID is set ret := u.Partial.UpdateInput(u.ID) if u.CoverImage != nil { // convert back to base64 data := utils.GetBase64StringFromData(u.CoverImage) ret.CoverImage = &data } return ret } func AddPerformer(ctx context.Context, qb models.SceneUpdater, o *models.Scene, performerID int) error { scenePartial := models.NewScenePartial() scenePartial.PerformerIDs = &models.UpdateIDs{ IDs: []int{performerID}, Mode: models.RelationshipUpdateModeAdd, } _, err := qb.UpdatePartial(ctx, o.ID, scenePartial) return err } func AddTag(ctx context.Context, qb models.SceneUpdater, o *models.Scene, tagID int) error { scenePartial := models.NewScenePartial() scenePartial.TagIDs = &models.UpdateIDs{ IDs: []int{tagID}, Mode: models.RelationshipUpdateModeAdd, } _, err := qb.UpdatePartial(ctx, o.ID, scenePartial) return err } func AddGallery(ctx context.Context, qb models.SceneUpdater, o *models.Scene, galleryID int) error { scenePartial := models.NewScenePartial() scenePartial.TagIDs = &models.UpdateIDs{ IDs: []int{galleryID}, Mode: models.RelationshipUpdateModeAdd, } _, err := qb.UpdatePartial(ctx, o.ID, scenePartial) return err } func (s *Service) AssignFile(ctx context.Context, sceneID int, fileID models.FileID) error { // ensure file isn't a primary file and that it is a video file f, err := s.File.Find(ctx, fileID) if err != nil { return err } ff := f[0] if _, ok := ff.(*models.VideoFile); !ok { return fmt.Errorf("%s is not a video file", ff.Base().Path) } isPrimary, err := s.File.IsPrimary(ctx, fileID) if err != nil { return err } if isPrimary { return errors.New("cannot reassign primary file") } return s.Repository.AssignFiles(ctx, sceneID, []models.FileID{fileID}) } ================================================ FILE: pkg/scene/update_test.go ================================================ package scene import ( "errors" "strconv" "testing" "time" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stashapp/stash/pkg/sliceutil/intslice" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) func TestUpdater_IsEmpty(t *testing.T) { organized := true ids := []int{1} stashIDs := []models.StashID{ {}, } cover := []byte{1} tests := []struct { name string u *UpdateSet want bool }{ { "empty", &UpdateSet{}, true, }, { "partial set", &UpdateSet{ Partial: models.ScenePartial{ Organized: models.NewOptionalBool(organized), }, }, false, }, { "performer set", &UpdateSet{ Partial: models.ScenePartial{ PerformerIDs: &models.UpdateIDs{ IDs: ids, Mode: models.RelationshipUpdateModeSet, }, }, }, false, }, { "tags set", &UpdateSet{ Partial: models.ScenePartial{ TagIDs: &models.UpdateIDs{ IDs: ids, Mode: models.RelationshipUpdateModeSet, }, }, }, false, }, { "performer set", &UpdateSet{ Partial: models.ScenePartial{ StashIDs: &models.UpdateStashIDs{ StashIDs: stashIDs, Mode: models.RelationshipUpdateModeSet, }, }, }, false, }, { "cover set", &UpdateSet{ CoverImage: cover, }, false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.u.IsEmpty(); got != tt.want { t.Errorf("Updater.IsEmpty() = %v, want %v", got, tt.want) } }) } } func TestUpdater_Update(t *testing.T) { const ( sceneID = iota + 1 badUpdateID badPerformersID badTagsID badStashIDsID badCoverID performerID tagID ) performerIDs := []int{performerID} tagIDs := []int{tagID} stashID := "stashID" endpoint := "endpoint" title := "title" cover := []byte("cover") validScene := &models.Scene{} updateErr := errors.New("error updating") db := mocks.NewDatabase() db.Scene.On("UpdatePartial", testCtx, mock.MatchedBy(func(id int) bool { return id != badUpdateID }), mock.Anything).Return(validScene, nil) db.Scene.On("UpdatePartial", testCtx, badUpdateID, mock.Anything).Return(nil, updateErr) db.Scene.On("UpdateCover", testCtx, sceneID, cover).Return(nil).Once() db.Scene.On("UpdateCover", testCtx, badCoverID, cover).Return(updateErr).Once() tests := []struct { name string u *UpdateSet wantNil bool wantErr bool }{ { "empty", &UpdateSet{ ID: sceneID, }, true, true, }, { "update all", &UpdateSet{ ID: sceneID, Partial: models.ScenePartial{ PerformerIDs: &models.UpdateIDs{ IDs: performerIDs, Mode: models.RelationshipUpdateModeSet, }, TagIDs: &models.UpdateIDs{ IDs: tagIDs, Mode: models.RelationshipUpdateModeSet, }, StashIDs: &models.UpdateStashIDs{ StashIDs: []models.StashID{ { StashID: stashID, Endpoint: endpoint, }, }, Mode: models.RelationshipUpdateModeSet, }, }, CoverImage: cover, }, false, false, }, { "update fields only", &UpdateSet{ ID: sceneID, Partial: models.ScenePartial{ Title: models.NewOptionalString(title), }, }, false, false, }, { "error updating scene", &UpdateSet{ ID: badUpdateID, Partial: models.ScenePartial{ Title: models.NewOptionalString(title), }, }, true, true, }, { "error updating cover", &UpdateSet{ ID: badCoverID, CoverImage: cover, }, true, true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := tt.u.Update(testCtx, db.Scene) if (err != nil) != tt.wantErr { t.Errorf("Updater.Update() error = %v, wantErr %v", err, tt.wantErr) return } if (got == nil) != tt.wantNil { t.Errorf("Updater.Update() = %v, want %v", got, tt.wantNil) } }) } db.AssertExpectations(t) } func TestUpdateSet_UpdateInput(t *testing.T) { const ( sceneID = iota + 1 badUpdateID badPerformersID badTagsID badStashIDsID badCoverID performerID tagID ) sceneIDStr := strconv.Itoa(sceneID) performerIDs := []int{performerID} performerIDStrs := intslice.IntSliceToStringSlice(performerIDs) tagIDs := []int{tagID} tagIDStrs := intslice.IntSliceToStringSlice(tagIDs) stashID := "stashID" endpoint := "endpoint" updatedAt := time.Now() stashIDs := []models.StashID{ { StashID: stashID, Endpoint: endpoint, UpdatedAt: updatedAt, }, } stashIDInputs := []models.StashIDInput{ { StashID: stashID, Endpoint: endpoint, UpdatedAt: &updatedAt, }, } title := "title" cover := []byte("cover") coverB64 := "Y292ZXI=" tests := []struct { name string u UpdateSet want models.SceneUpdateInput }{ { "empty", UpdateSet{ ID: sceneID, }, models.SceneUpdateInput{ ID: sceneIDStr, }, }, { "update all", UpdateSet{ ID: sceneID, Partial: models.ScenePartial{ PerformerIDs: &models.UpdateIDs{ IDs: performerIDs, Mode: models.RelationshipUpdateModeSet, }, TagIDs: &models.UpdateIDs{ IDs: tagIDs, Mode: models.RelationshipUpdateModeSet, }, StashIDs: &models.UpdateStashIDs{ StashIDs: stashIDs, Mode: models.RelationshipUpdateModeSet, }, }, CoverImage: cover, }, models.SceneUpdateInput{ ID: sceneIDStr, PerformerIds: performerIDStrs, TagIds: tagIDStrs, StashIds: stashIDInputs, CoverImage: &coverB64, }, }, { "update fields only", UpdateSet{ ID: sceneID, Partial: models.ScenePartial{ Title: models.NewOptionalString(title), }, }, models.SceneUpdateInput{ ID: sceneIDStr, Title: &title, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.u.UpdateInput() assert.Equal(t, tt.want, got) }) } } ================================================ FILE: pkg/scraper/action.go ================================================ package scraper import ( "context" "net/http" "github.com/stashapp/stash/pkg/models" ) type scraperAction string const ( scraperActionScript scraperAction = "script" scraperActionStash scraperAction = "stash" scraperActionXPath scraperAction = "scrapeXPath" scraperActionJson scraperAction = "scrapeJson" ) func (e scraperAction) IsValid() bool { switch e { case scraperActionScript, scraperActionStash, scraperActionXPath, scraperActionJson: return true } return false } type urlScraperActionImpl interface { scrapeByURL(ctx context.Context, url string, ty ScrapeContentType) (ScrapedContent, error) } func (c Definition) getURLScraper(def ByURLDefinition, client *http.Client, globalConfig GlobalConfig) urlScraperActionImpl { switch def.Action { case scraperActionScript: return &scriptURLScraper{ scriptScraper: scriptScraper{ definition: c, globalConfig: globalConfig, }, definition: def, } case scraperActionStash: return newStashScraper(client, c, globalConfig) case scraperActionXPath: return &xpathURLScraper{ xpathScraper: xpathScraper{ definition: c, globalConfig: globalConfig, client: client, }, definition: def, } case scraperActionJson: return &jsonURLScraper{ jsonScraper: jsonScraper{ definition: c, globalConfig: globalConfig, client: client, }, definition: def, } } panic("unknown scraper action: " + def.Action) } type nameScraperActionImpl interface { scrapeByName(ctx context.Context, name string, ty ScrapeContentType) ([]ScrapedContent, error) } func (c Definition) getNameScraper(def ByNameDefinition, client *http.Client, globalConfig GlobalConfig) nameScraperActionImpl { switch def.Action { case scraperActionScript: return &scriptNameScraper{ scriptScraper: scriptScraper{ definition: c, globalConfig: globalConfig, }, definition: def, } case scraperActionStash: return newStashScraper(client, c, globalConfig) case scraperActionXPath: return &xpathNameScraper{ xpathScraper: xpathScraper{ definition: c, globalConfig: globalConfig, client: client, }, definition: def, } case scraperActionJson: return &jsonNameScraper{ jsonScraper: jsonScraper{ definition: c, globalConfig: globalConfig, client: client, }, definition: def, } } panic("unknown scraper action: " + def.Action) } type fragmentScraperActionImpl interface { scrapeByFragment(ctx context.Context, input Input) (ScrapedContent, error) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*models.ScrapedGallery, error) scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) } func (c Definition) getFragmentScraper(actionDef ByFragmentDefinition, client *http.Client, globalConfig GlobalConfig) fragmentScraperActionImpl { switch actionDef.Action { case scraperActionScript: return &scriptFragmentScraper{ scriptScraper: scriptScraper{ definition: c, globalConfig: globalConfig, }, definition: actionDef, } case scraperActionStash: return newStashScraper(client, c, globalConfig) case scraperActionXPath: return &xpathFragmentScraper{ xpathScraper: xpathScraper{ definition: c, globalConfig: globalConfig, client: client, }, definition: actionDef, } case scraperActionJson: return &jsonFragmentScraper{ jsonScraper: jsonScraper{ definition: c, globalConfig: globalConfig, client: client, }, definition: actionDef, } } panic("unknown scraper action: " + actionDef.Action) } ================================================ FILE: pkg/scraper/autotag.go ================================================ package scraper import ( "context" "fmt" "net/http" "strconv" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/txn" ) // autoTagScraperID is the scraper ID for the built-in AutoTag scraper const ( autoTagScraperID = "builtin_autotag" autoTagScraperName = "Auto Tag" ) type autotagScraper struct { txnManager txn.Manager performerReader models.PerformerAutoTagQueryer studioReader models.StudioAutoTagQueryer tagReader models.TagAutoTagQueryer globalConfig GlobalConfig } func autotagMatchPerformers(ctx context.Context, path string, performerReader models.PerformerAutoTagQueryer, trimExt bool) ([]*models.ScrapedPerformer, error) { p, err := match.PathToPerformers(ctx, path, performerReader, nil, trimExt) if err != nil { return nil, fmt.Errorf("error matching performers: %w", err) } var ret []*models.ScrapedPerformer for _, pp := range p { id := strconv.Itoa(pp.ID) sp := &models.ScrapedPerformer{ Name: &pp.Name, StoredID: &id, } if pp.Gender != nil && pp.Gender.IsValid() { v := pp.Gender.String() sp.Gender = &v } ret = append(ret, sp) } return ret, nil } func autotagMatchStudio(ctx context.Context, path string, studioReader models.StudioAutoTagQueryer, trimExt bool) (*models.ScrapedStudio, error) { studio, err := match.PathToStudio(ctx, path, studioReader, nil, trimExt) if err != nil { return nil, fmt.Errorf("error matching studios: %w", err) } if studio != nil { id := strconv.Itoa(studio.ID) return &models.ScrapedStudio{ Name: studio.Name, StoredID: &id, }, nil } return nil, nil } func autotagMatchTags(ctx context.Context, path string, tagReader models.TagAutoTagQueryer, trimExt bool) ([]*models.ScrapedTag, error) { t, err := match.PathToTags(ctx, path, tagReader, nil, trimExt) if err != nil { return nil, fmt.Errorf("error matching tags: %w", err) } var ret []*models.ScrapedTag for _, tt := range t { id := strconv.Itoa(tt.ID) st := &models.ScrapedTag{ Name: tt.Name, StoredID: &id, } ret = append(ret, st) } return ret, nil } func (s autotagScraper) viaScene(ctx context.Context, _client *http.Client, scene *models.Scene) (*models.ScrapedScene, error) { var ret *models.ScrapedScene const trimExt = false // populate performers, studio and tags based on scene path if err := txn.WithReadTxn(ctx, s.txnManager, func(ctx context.Context) error { path := scene.Path if path == "" { return nil } performers, err := autotagMatchPerformers(ctx, path, s.performerReader, trimExt) if err != nil { return fmt.Errorf("autotag scraper viaScene: %w", err) } studio, err := autotagMatchStudio(ctx, path, s.studioReader, trimExt) if err != nil { return fmt.Errorf("autotag scraper viaScene: %w", err) } tags, err := autotagMatchTags(ctx, path, s.tagReader, trimExt) if err != nil { return fmt.Errorf("autotag scraper viaScene: %w", err) } if len(performers) > 0 || studio != nil || len(tags) > 0 { ret = &models.ScrapedScene{ Performers: performers, Studio: studio, Tags: tags, } } return nil }); err != nil { return nil, err } return ret, nil } func (s autotagScraper) viaGallery(ctx context.Context, _client *http.Client, gallery *models.Gallery) (*models.ScrapedGallery, error) { path := gallery.Path if path == "" { // not valid for non-path-based galleries return nil, nil } // only trim extension if gallery is file-based trimExt := gallery.PrimaryFileID != nil var ret *models.ScrapedGallery // populate performers, studio and tags based on scene path if err := txn.WithReadTxn(ctx, s.txnManager, func(ctx context.Context) error { path := gallery.Path performers, err := autotagMatchPerformers(ctx, path, s.performerReader, trimExt) if err != nil { return fmt.Errorf("autotag scraper viaGallery: %w", err) } studio, err := autotagMatchStudio(ctx, path, s.studioReader, trimExt) if err != nil { return fmt.Errorf("autotag scraper viaGallery: %w", err) } tags, err := autotagMatchTags(ctx, path, s.tagReader, trimExt) if err != nil { return fmt.Errorf("autotag scraper viaGallery: %w", err) } if len(performers) > 0 || studio != nil || len(tags) > 0 { ret = &models.ScrapedGallery{ Performers: performers, Studio: studio, Tags: tags, } } return nil }); err != nil { return nil, err } return ret, nil } func (s autotagScraper) supports(ty ScrapeContentType) bool { switch ty { case ScrapeContentTypeScene: return true case ScrapeContentTypeGallery: return true } return false } func (s autotagScraper) supportsURL(url string, ty ScrapeContentType) bool { return false } func (s autotagScraper) spec() Scraper { supportedScrapes := []ScrapeType{ ScrapeTypeFragment, } return Scraper{ ID: autoTagScraperID, Name: autoTagScraperName, Scene: &ScraperSpec{ SupportedScrapes: supportedScrapes, }, Gallery: &ScraperSpec{ SupportedScrapes: supportedScrapes, }, } } func getAutoTagScraper(repo Repository, globalConfig GlobalConfig) scraper { base := autotagScraper{ txnManager: repo.TxnManager, performerReader: repo.PerformerFinder, studioReader: repo.StudioFinder, tagReader: repo.TagFinder, globalConfig: globalConfig, } return base } ================================================ FILE: pkg/scraper/cache.go ================================================ package scraper import ( "context" "crypto/tls" "fmt" "net/http" "os" "path/filepath" "regexp" "sort" "strings" "time" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/txn" ) const ( // scrapeGetTimeout is the timeout for scraper HTTP requests. Includes transfer time. // We may want to bump this at some point and use local context-timeouts if more granularity // is needed. scrapeGetTimeout = time.Second * 60 // maxIdleConnsPerHost is the maximum number of idle connections the HTTP client will // keep on a per-host basis. maxIdleConnsPerHost = 8 // maxRedirects defines the maximum number of redirects the HTTP client will follow maxRedirects = 20 ) // GlobalConfig contains the global scraper options. type GlobalConfig interface { GetScraperUserAgent() string GetScrapersPath() string GetScraperCDPPath() string GetScraperCertCheck() bool GetPythonPath() string GetProxy() string GetScraperExcludeTagPatterns() []string } func isCDPPathHTTP(c GlobalConfig) bool { return strings.HasPrefix(c.GetScraperCDPPath(), "http://") || strings.HasPrefix(c.GetScraperCDPPath(), "https://") } func isCDPPathWS(c GlobalConfig) bool { return strings.HasPrefix(c.GetScraperCDPPath(), "ws://") } type SceneFinder interface { models.SceneGetter models.URLLoader models.VideoFileLoader } type PerformerFinder interface { models.PerformerAutoTagQueryer match.PerformerFinder } type StudioFinder interface { models.StudioAutoTagQueryer FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Studio, error) } type TagFinder interface { models.TagGetter models.TagAutoTagQueryer } type GalleryFinder interface { models.GalleryGetter models.FileLoader models.URLLoader } type ImageFinder interface { models.ImageGetter models.FileLoader models.URLLoader } type Repository struct { TxnManager models.TxnManager SceneFinder SceneFinder GalleryFinder GalleryFinder ImageFinder ImageFinder TagFinder TagFinder PerformerFinder PerformerFinder GroupFinder match.GroupNamesFinder StudioFinder StudioFinder } func NewRepository(repo models.Repository) Repository { return Repository{ TxnManager: repo.TxnManager, SceneFinder: repo.Scene, GalleryFinder: repo.Gallery, ImageFinder: repo.Image, TagFinder: repo.Tag, PerformerFinder: repo.Performer, GroupFinder: repo.Group, StudioFinder: repo.Studio, } } func (r *Repository) WithReadTxn(ctx context.Context, fn txn.TxnFunc) error { return txn.WithReadTxn(ctx, r.TxnManager, fn) } // Cache stores the database of scrapers type Cache struct { client *http.Client scrapers map[string]scraper // Scraper ID -> Scraper globalConfig GlobalConfig repository Repository } // newClient creates a scraper-local http client we use throughout the scraper subsystem. func newClient(gc GlobalConfig) *http.Client { client := &http.Client{ Transport: &http.Transport{ // ignore insecure certificates TLSClientConfig: &tls.Config{InsecureSkipVerify: !gc.GetScraperCertCheck()}, MaxIdleConnsPerHost: maxIdleConnsPerHost, Proxy: http.ProxyFromEnvironment, }, Timeout: scrapeGetTimeout, // defaultCheckRedirect code with max changed from 10 to maxRedirects CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= maxRedirects { return fmt.Errorf("%w: gave up after %d redirects", ErrMaxRedirects, maxRedirects) } return nil }, } return client } // NewCache returns a new Cache. // // Scraper configurations are loaded from yml files in the scrapers // directory in the config and any subdirectories. // // Does not load scrapers. Scrapers will need to be // loaded explicitly using ReloadScrapers. func NewCache(globalConfig GlobalConfig, repo Repository) *Cache { // HTTP Client setup client := newClient(globalConfig) return &Cache{ client: client, globalConfig: globalConfig, repository: repo, } } // ReloadScrapers clears the scraper cache and reloads from the scraper path. // If a scraper cannot be loaded, an error is logged and the scraper is skipped. func (c *Cache) ReloadScrapers() { path := c.globalConfig.GetScrapersPath() scrapers := make(map[string]scraper) // Add built-in scrapers freeOnes := getFreeonesScraper(c.globalConfig) autoTag := getAutoTagScraper(c.repository, c.globalConfig) scrapers[freeOnes.spec().ID] = freeOnes scrapers[autoTag.spec().ID] = autoTag logger.Debugf("Reading scraper configs from %s", path) err := fsutil.SymWalk(path, func(fp string, f os.FileInfo, err error) error { if filepath.Ext(fp) == ".yml" { conf, err := loadConfigFromYAMLFile(fp) if err != nil { logger.Errorf("Error loading scraper %s: %v", fp, err) } else { scraper := scraperFromDefinition(*conf, c.globalConfig) scrapers[scraper.spec().ID] = scraper } } return nil }) if err != nil { logger.Errorf("Error reading scraper configs: %v", err) } c.scrapers = scrapers } // ListScrapers lists scrapers matching one of the given types. // Returns a list of scrapers, sorted by their name. func (c Cache) ListScrapers(tys []ScrapeContentType) []*Scraper { var ret []*Scraper for _, s := range c.scrapers { for _, t := range tys { if s.supports(t) { spec := s.spec() ret = append(ret, &spec) break } } } sort.Slice(ret, func(i, j int) bool { return strings.ToLower(ret[i].Name) < strings.ToLower(ret[j].Name) }) return ret } // GetScraper returns the scraper matching the provided id. func (c Cache) GetScraper(scraperID string) *Scraper { s := c.findScraper(scraperID) if s != nil { spec := s.spec() return &spec } return nil } func (c Cache) findScraper(scraperID string) scraper { s, ok := c.scrapers[scraperID] if ok { return s } return nil } func (c Cache) compileExcludeTagPatterns() []*regexp.Regexp { return CompileExclusionRegexps(c.globalConfig.GetScraperExcludeTagPatterns()) } func (c Cache) ScrapeName(ctx context.Context, id, query string, ty ScrapeContentType) ([]ScrapedContent, error) { // find scraper with the provided id s := c.findScraper(id) if s == nil { return nil, fmt.Errorf("%w: id %s", ErrNotFound, id) } if !s.supports(ty) { return nil, fmt.Errorf("%w: cannot use scraper %s as a %v scraper", ErrNotSupported, id, ty) } ns, ok := s.(nameScraper) if !ok { return nil, fmt.Errorf("%w: cannot use scraper %s to scrape by name", ErrNotSupported, id) } content, err := ns.viaName(ctx, c.client, query, ty) if err != nil { return nil, fmt.Errorf("error while name scraping with scraper %s: %w", id, err) } pp := postScraper{ Cache: c, excludeTagRE: c.compileExcludeTagPatterns(), } if err := c.repository.WithReadTxn(ctx, func(ctx context.Context) error { for i, cc := range content { content[i], err = pp.postScrape(ctx, cc) if err != nil { return fmt.Errorf("error while post-scraping with scraper %s: %w", id, err) } } return nil }); err != nil { return nil, err } LogIgnoredTags(pp.ignoredTags) return content, nil } // ScrapeFragment uses the given fragment input to scrape func (c Cache) ScrapeFragment(ctx context.Context, id string, input Input) (ScrapedContent, error) { // set the deprecated URL field if it's not set input.populateURL() s := c.findScraper(id) if s == nil { return nil, fmt.Errorf("%w: id %s", ErrNotFound, id) } fs, ok := s.(fragmentScraper) if !ok { return nil, fmt.Errorf("%w: cannot use scraper %s as a fragment scraper", ErrNotSupported, id) } content, err := fs.viaFragment(ctx, c.client, input) if err != nil { return nil, fmt.Errorf("error while fragment scraping with scraper %s: %w", id, err) } return c.postScrapeSingle(ctx, content) } // ScrapeURL scrapes a given url for the given content. Searches the scraper cache // and picks the first scraper capable of scraping the given url into the desired // content. Returns the scraped content or an error if the scrape fails. func (c Cache) ScrapeURL(ctx context.Context, url string, ty ScrapeContentType) (ScrapedContent, error) { for _, s := range c.scrapers { if s.supportsURL(url, ty) { ul, ok := s.(urlScraper) if !ok { return nil, fmt.Errorf("%w: cannot use scraper %s as an url scraper", ErrNotSupported, s.spec().ID) } ret, err := ul.viaURL(ctx, c.client, url, ty) if err != nil { return nil, err } if ret == nil { return ret, nil } return c.postScrapeSingle(ctx, ret) } } return nil, nil } func (c Cache) ScrapeID(ctx context.Context, scraperID string, id int, ty ScrapeContentType) (ScrapedContent, error) { s := c.findScraper(scraperID) if s == nil { return nil, fmt.Errorf("%w: id %s", ErrNotFound, scraperID) } if !s.supports(ty) { return nil, fmt.Errorf("%w: cannot use scraper %s to scrape %v content", ErrNotSupported, scraperID, ty) } var ret ScrapedContent switch ty { case ScrapeContentTypeScene: ss, ok := s.(sceneScraper) if !ok { return nil, fmt.Errorf("%w: cannot use scraper %s as a scene scraper", ErrNotSupported, scraperID) } scene, err := c.getScene(ctx, id) if err != nil { return nil, fmt.Errorf("scraper %s: unable to load scene id %v: %w", scraperID, id, err) } // don't assign nil concrete pointer to ret interface, otherwise nil // detection is harder scraped, err := ss.viaScene(ctx, c.client, scene) if err != nil { return nil, fmt.Errorf("scraper %s: %w", scraperID, err) } if scraped != nil { ret = scraped } case ScrapeContentTypeGallery: gs, ok := s.(galleryScraper) if !ok { return nil, fmt.Errorf("%w: cannot use scraper %s as a gallery scraper", ErrNotSupported, scraperID) } gallery, err := c.getGallery(ctx, id) if err != nil { return nil, fmt.Errorf("scraper %s: unable to load gallery id %v: %w", scraperID, id, err) } // don't assign nil concrete pointer to ret interface, otherwise nil // detection is harder scraped, err := gs.viaGallery(ctx, c.client, gallery) if err != nil { return nil, fmt.Errorf("scraper %s: %w", scraperID, err) } if scraped != nil { ret = scraped } case ScrapeContentTypeImage: is, ok := s.(imageScraper) if !ok { return nil, fmt.Errorf("%w: cannot use scraper %s as a image scraper", ErrNotSupported, scraperID) } scene, err := c.getImage(ctx, id) if err != nil { return nil, fmt.Errorf("scraper %s: unable to load image id %v: %w", scraperID, id, err) } // don't assign nil concrete pointer to ret interface, otherwise nil // detection is harder scraped, err := is.viaImage(ctx, c.client, scene) if err != nil { return nil, fmt.Errorf("scraper %s: %w", scraperID, err) } if scraped != nil { ret = scraped } } return c.postScrapeSingle(ctx, ret) } func (c Cache) getScene(ctx context.Context, sceneID int) (*models.Scene, error) { var ret *models.Scene r := c.repository if err := r.WithReadTxn(ctx, func(ctx context.Context) error { qb := r.SceneFinder var err error ret, err = qb.Find(ctx, sceneID) if err != nil { return err } if ret == nil { return fmt.Errorf("scene with id %d not found", sceneID) } if err := ret.LoadURLs(ctx, qb); err != nil { return err } if err := ret.LoadFiles(ctx, qb); err != nil { return err } return nil }); err != nil { return nil, err } return ret, nil } func (c Cache) getGallery(ctx context.Context, galleryID int) (*models.Gallery, error) { var ret *models.Gallery r := c.repository if err := r.WithReadTxn(ctx, func(ctx context.Context) error { qb := r.GalleryFinder var err error ret, err = qb.Find(ctx, galleryID) if err != nil { return err } if ret == nil { return fmt.Errorf("gallery with id %d not found", galleryID) } if err := ret.LoadURLs(ctx, qb); err != nil { return err } if err := ret.LoadFiles(ctx, qb); err != nil { return err } return nil }); err != nil { return nil, err } return ret, nil } func (c Cache) getImage(ctx context.Context, imageID int) (*models.Image, error) { var ret *models.Image r := c.repository if err := r.WithReadTxn(ctx, func(ctx context.Context) error { qb := r.ImageFinder var err error ret, err = qb.Find(ctx, imageID) if err != nil { return err } if ret == nil { return fmt.Errorf("image with id %d not found", imageID) } err = ret.LoadFiles(ctx, qb) if err != nil { return err } return ret.LoadURLs(ctx, qb) }); err != nil { return nil, err } return ret, nil } ================================================ FILE: pkg/scraper/cookies.go ================================================ package scraper import ( "context" "fmt" "math/rand" "net/http" "net/http/cookiejar" "net/url" "time" "github.com/chromedp/cdproto/cdp" "github.com/chromedp/cdproto/network" "github.com/chromedp/chromedp" "golang.org/x/net/publicsuffix" "github.com/stashapp/stash/pkg/logger" ) // jar constructs a cookie jar from a configuration func (c Definition) jar() (*cookiejar.Jar, error) { opts := c.DriverOptions jar, err := cookiejar.New(&cookiejar.Options{ PublicSuffixList: publicsuffix.List, }) if err != nil { return nil, err } if opts == nil || opts.UseCDP { return jar, nil } for i, ckURL := range opts.Cookies { url, err := url.Parse(ckURL.CookieURL) // CookieURL must be valid, include schema if err != nil { logger.Warnf("skipping cookie [%d] for cookieURL %s: %v", i, ckURL.CookieURL, err) continue } var httpCookies []*http.Cookie for _, cookie := range ckURL.Cookies { c := &http.Cookie{ Name: cookie.Name, Value: getCookieValue(cookie), Path: cookie.Path, Domain: cookie.Domain, } httpCookies = append(httpCookies, c) } jar.SetCookies(url, httpCookies) if jar.Cookies(url) == nil { logger.Warnf("setting jar cookies for %s failed", url.String()) } } return jar, nil } func getCookieValue(cookie *scraperCookies) string { if cookie.ValueRandom > 0 { return randomSequence(cookie.ValueRandom) } return cookie.Value } var characters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890") func randomSequence(n int) string { b := make([]rune, n) rand := rand.New(rand.NewSource(time.Now().UnixNano())) for i := range b { b[i] = characters[rand.Intn(len(characters))] } return string(b) } // printCookies prints all cookies from the given cookie jar func printCookies(jar *cookiejar.Jar, scraperConfig Definition, msg string) { driverOptions := scraperConfig.DriverOptions if driverOptions != nil && !driverOptions.UseCDP { var foundURLs []*url.URL for _, ckURL := range driverOptions.Cookies { // go through all cookies url, err := url.Parse(ckURL.CookieURL) // CookieURL must be valid, include schema if err == nil { foundURLs = append(foundURLs, url) } } if len(foundURLs) > 0 { logger.Debugf("%s\n", msg) printJarCookies(jar, foundURLs) } } } // print all cookies from the jar of the native http client for given urls func printJarCookies(jar *cookiejar.Jar, urls []*url.URL) { for _, url := range urls { logger.Debugf("Jar cookies for %s", url.String()) for i, cookie := range jar.Cookies(url) { logger.Debugf("[%d]: Name: \"%s\" Value: \"%s\"", i, cookie.Name, cookie.Value) } } } // set all cookies listed in the scraper config func setCDPCookies(driverOptions scraperDriverOptions) chromedp.Tasks { return chromedp.Tasks{ chromedp.ActionFunc(func(ctx context.Context) error { // create cookie expiration expr := cdp.TimeSinceEpoch(time.Now().Add(180 * 24 * time.Hour)) for _, ckURL := range driverOptions.Cookies { for _, cookie := range ckURL.Cookies { err := network.SetCookie(cookie.Name, getCookieValue(cookie)). WithExpires(&expr). WithDomain(cookie.Domain). WithPath(cookie.Path). WithHTTPOnly(false). WithSecure(false). Do(ctx) if err != nil { return fmt.Errorf("could not set chrome cookie %s: %s", cookie.Name, err) } } } return nil }), } } // print cookies whose domain is included in the scraper config func printCDPCookies(driverOptions scraperDriverOptions, msg string) chromedp.Action { return chromedp.ActionFunc(func(ctx context.Context) error { chromeCookies, err := network.GetCookies().Do(ctx) if err != nil { return err } scraperDomains := make(map[string]struct{}) for _, ckURL := range driverOptions.Cookies { for _, cookie := range ckURL.Cookies { scraperDomains[cookie.Domain] = struct{}{} } } if len(scraperDomains) > 0 { // only print the cookies if they are listed in the scraper logger.Debugf("%s\n", msg) for i, cookie := range chromeCookies { _, ok := scraperDomains[cookie.Domain] if ok { logger.Debugf("[%d]: Name: \"%s\" Value: \"%s\" Domain: \"%s\"", i, cookie.Name, cookie.Value, cookie.Domain) } } } return nil }) } ================================================ FILE: pkg/scraper/country.go ================================================ package scraper import ( "strings" "github.com/stashapp/stash/pkg/logger" ) var countryNameMapping = map[string]string{ "afghanistan": "AF", "albania": "AL", "algeria": "DZ", "america": "US", "american": "US", "american samoa": "AS", "andorra": "AD", "angola": "AO", "anguilla": "AI", "antarctica": "AQ", "antigua and barbuda": "AG", "argentina": "AR", "armenia": "AM", "aruba": "AW", "australia": "AU", "austria": "AT", "azerbaijan": "AZ", "bahamas": "BS", "bahrain": "BH", "bangladesh": "BD", "barbados": "BB", "belarus": "BY", "belgium": "BE", "belize": "BZ", "benin": "BJ", "bermuda": "BM", "bhutan": "BT", "bolivia": "BO", "bosnia and herzegovina": "BA", "botswana": "BW", "bouvet island": "BV", "brazil": "BR", "british indian ocean territory": "IO", "brunei darussalam": "BN", "bulgaria": "BG", "burkina faso": "BF", "burundi": "BI", "cambodia": "KH", "cameroon": "CM", "canada": "CA", "cape verde": "CV", "cayman islands": "KY", "central african republic": "CF", "chad": "TD", "chile": "CL", "china": "CN", "christmas island": "CX", "cocos (keeling) islands": "CC", "colombia": "CO", "comoros": "KM", "congo": "CG", "congo the democratic republic of the": "CD", "cook islands": "CK", "costa rica": "CR", "cote d'ivoire": "CI", "croatia": "HR", "cuba": "CU", "cyprus": "CY", "czech republic": "CZ", "czechia": "CZ", "denmark": "DK", "djibouti": "DJ", "dominica": "DM", "dominican republic": "DO", "ecuador": "EC", "egypt": "EG", "el salvador": "SV", "equatorial guinea": "GQ", "eritrea": "ER", "estonia": "EE", "ethiopia": "ET", "falkland islands (malvinas)": "FK", "faroe islands": "FO", "fiji": "FJ", "finland": "FI", "france": "FR", "french guiana": "GF", "french polynesia": "PF", "french southern territories": "TF", "gabon": "GA", "gambia": "GM", "georgia": "GE", "germany": "DE", "ghana": "GH", "gibraltar": "GI", "greece": "GR", "greenland": "GL", "grenada": "GD", "guadeloupe": "GP", "guam": "GU", "guatemala": "GT", "guinea": "GN", "guinea-bissau": "GW", "guyana": "GY", "haiti": "HT", "heard island and mcdonald islands": "HM", "holy see (vatican city state)": "VA", "honduras": "HN", "hong kong": "HK", "hungary": "HU", "iceland": "IS", "india": "IN", "indonesia": "ID", "iran": "IR", "iran islamic republic of": "IR", "iraq": "IQ", "ireland": "IE", "israel": "IL", "italy": "IT", "jamaica": "JM", "japan": "JP", "jordan": "JO", "kazakhstan": "KZ", "kenya": "KE", "kiribati": "KI", "north korea": "KP", "south korea": "KR", "kuwait": "KW", "kyrgyzstan": "KG", "lao people's democratic republic": "LA", "latvia": "LV", "lebanon": "LB", "lesotho": "LS", "liberia": "LR", "libya": "LY", "liechtenstein": "LI", "lithuania": "LT", "luxembourg": "LU", "macao": "MO", "madagascar": "MG", "malawi": "MW", "malaysia": "MY", "maldives": "MV", "mali": "ML", "malta": "MT", "marshall islands": "MH", "martinique": "MQ", "mauritania": "MR", "mauritius": "MU", "mayotte": "YT", "mexico": "MX", "micronesia federated states of": "FM", "moldova": "MD", "moldova republic of": "MD", "moldova, republic of": "MD", "monaco": "MC", "mongolia": "MN", "montserrat": "MS", "morocco": "MA", "mozambique": "MZ", "myanmar": "MM", "namibia": "NA", "nauru": "NR", "nepal": "NP", "netherlands": "NL", "new caledonia": "NC", "new zealand": "NZ", "nicaragua": "NI", "niger": "NE", "nigeria": "NG", "niue": "NU", "norfolk island": "NF", "north macedonia republic of": "MK", "northern mariana islands": "MP", "norway": "NO", "oman": "OM", "pakistan": "PK", "palau": "PW", "palestinian territory occupied": "PS", "panama": "PA", "papua new guinea": "PG", "paraguay": "PY", "peru": "PE", "philippines": "PH", "pitcairn": "PN", "poland": "PL", "portugal": "PT", "puerto rico": "PR", "qatar": "QA", "reunion": "RE", "romania": "RO", "russia": "RU", "russian federation": "RU", "rwanda": "RW", "saint helena": "SH", "saint kitts and nevis": "KN", "saint lucia": "LC", "saint pierre and miquelon": "PM", "saint vincent and the grenadines": "VC", "samoa": "WS", "san marino": "SM", "sao tome and principe": "ST", "saudi arabia": "SA", "senegal": "SN", "seychelles": "SC", "sierra leone": "SL", "singapore": "SG", "slovakia": "SK", "slovak republic": "SK", "slovenia": "SI", "solomon islands": "SB", "somalia": "SO", "south africa": "ZA", "south georgia and the south sandwich islands": "GS", "spain": "ES", "sri lanka": "LK", "sudan": "SD", "suriname": "SR", "svalbard and jan mayen": "SJ", "eswatini": "SZ", "sweden": "SE", "switzerland": "CH", "syrian arab republic": "SY", "taiwan": "TW", "tajikistan": "TJ", "tanzania united republic of": "TZ", "thailand": "TH", "timor-leste": "TL", "togo": "TG", "tokelau": "TK", "tonga": "TO", "trinidad and tobago": "TT", "tunisia": "TN", "turkey": "TR", "turkmenistan": "TM", "turks and caicos islands": "TC", "tuvalu": "TV", "uganda": "UG", "ukraine": "UA", "united arab emirates": "AE", "england": "GB", "great britain": "GB", "united kingdom": "GB", "usa": "US", "united states": "US", "united states of america": "US", "united states minor outlying islands": "UM", "uruguay": "UY", "uzbekistan": "UZ", "vanuatu": "VU", "venezuela": "VE", "vietnam": "VN", "virgin islands british": "VG", "virgin islands u.s.": "VI", "wallis and futuna": "WF", "western sahara": "EH", "yemen": "YE", "zambia": "ZM", "zimbabwe": "ZW", "åland islands": "AX", "bonaire sint eustatius and saba": "BQ", "curaçao": "CW", "guernsey": "GG", "isle of man": "IM", "jersey": "JE", "montenegro": "ME", "saint barthélemy": "BL", "saint martin (french part)": "MF", "serbia": "RS", "sint maarten (dutch part)": "SX", "south sudan": "SS", "kosovo": "XK", } func resolveCountryName(name *string) *string { if name == nil { return nil } trimmedName := strings.TrimSpace(*name) if len(trimmedName) == 2 { // If name is two characters it's likely already an ISO value return &trimmedName } else if len(trimmedName) == 0 { return nil } v, exists := countryNameMapping[strings.ToLower(trimmedName)] if exists { return &v } logger.Debugf("Scraped country was not recognized: %s", trimmedName) // return original name return &trimmedName } ================================================ FILE: pkg/scraper/defined_scraper.go ================================================ package scraper import ( "context" "fmt" "net/http" "github.com/stashapp/stash/pkg/models" ) // definedScraper implements the scraper interface using a Definition object. type definedScraper struct { config Definition globalConf GlobalConfig } func scraperFromDefinition(c Definition, globalConfig GlobalConfig) definedScraper { return definedScraper{ config: c, globalConf: globalConfig, } } func (g definedScraper) spec() Scraper { return g.config.spec() } // fragmentScraper finds an appropriate fragment scraper based on input. func (g definedScraper) fragmentScraper(input Input) *ByFragmentDefinition { switch { case input.Performer != nil: return g.config.PerformerByFragment case input.Gallery != nil: // TODO - this should be galleryByQueryFragment return g.config.GalleryByFragment case input.Image != nil: // TODO - this should be imageByImageFragment return g.config.ImageByFragment case input.Scene != nil: return g.config.SceneByQueryFragment } return nil } func (g definedScraper) viaFragment(ctx context.Context, client *http.Client, input Input) (ScrapedContent, error) { stc := g.fragmentScraper(input) if stc == nil { // If there's no performer fragment scraper in the group, we try to use // the URL scraper. Check if there's an URL in the input, and then shift // to an URL scrape if it's present. if input.Performer != nil && input.Performer.URL != nil && *input.Performer.URL != "" { return g.viaURL(ctx, client, *input.Performer.URL, ScrapeContentTypePerformer) } return nil, ErrNotSupported } s := g.config.getFragmentScraper(*stc, client, g.globalConf) return s.scrapeByFragment(ctx, input) } func (g definedScraper) viaScene(ctx context.Context, client *http.Client, scene *models.Scene) (*models.ScrapedScene, error) { if g.config.SceneByFragment == nil { return nil, ErrNotSupported } s := g.config.getFragmentScraper(*g.config.SceneByFragment, client, g.globalConf) return s.scrapeSceneByScene(ctx, scene) } func (g definedScraper) viaGallery(ctx context.Context, client *http.Client, gallery *models.Gallery) (*models.ScrapedGallery, error) { if g.config.GalleryByFragment == nil { return nil, ErrNotSupported } s := g.config.getFragmentScraper(*g.config.GalleryByFragment, client, g.globalConf) return s.scrapeGalleryByGallery(ctx, gallery) } func (g definedScraper) viaImage(ctx context.Context, client *http.Client, gallery *models.Image) (*models.ScrapedImage, error) { if g.config.ImageByFragment == nil { return nil, ErrNotSupported } s := g.config.getFragmentScraper(*g.config.ImageByFragment, client, g.globalConf) return s.scrapeImageByImage(ctx, gallery) } func loadUrlCandidates(c Definition, ty ScrapeContentType) []*ByURLDefinition { switch ty { case ScrapeContentTypePerformer: return c.PerformerByURL case ScrapeContentTypeScene: return c.SceneByURL case ScrapeContentTypeMovie, ScrapeContentTypeGroup: return append(c.MovieByURL, c.GroupByURL...) case ScrapeContentTypeGallery: return c.GalleryByURL case ScrapeContentTypeImage: return c.ImageByURL } panic("loadUrlCandidates: unreachable") } func (g definedScraper) viaURL(ctx context.Context, client *http.Client, url string, ty ScrapeContentType) (ScrapedContent, error) { candidates := loadUrlCandidates(g.config, ty) for _, scraper := range candidates { if scraper.matchesURL(url) { u := replaceURL(url, *scraper) // allow a URL Replace for url-queries s := g.config.getURLScraper(*scraper, client, g.globalConf) ret, err := s.scrapeByURL(ctx, u, ty) if err != nil { return nil, err } if ret != nil { return ret, nil } } } return nil, nil } func (g definedScraper) viaName(ctx context.Context, client *http.Client, name string, ty ScrapeContentType) ([]ScrapedContent, error) { switch ty { case ScrapeContentTypePerformer: if g.config.PerformerByName == nil { break } s := g.config.getNameScraper(*g.config.PerformerByName, client, g.globalConf) return s.scrapeByName(ctx, name, ty) case ScrapeContentTypeScene: if g.config.SceneByName == nil { break } s := g.config.getNameScraper(*g.config.SceneByName, client, g.globalConf) return s.scrapeByName(ctx, name, ty) } return nil, fmt.Errorf("%w: cannot load %v by name", ErrNotSupported, ty) } func (g definedScraper) supports(ty ScrapeContentType) bool { return g.config.supports(ty) } func (g definedScraper) supportsURL(url string, ty ScrapeContentType) bool { return g.config.matchesURL(url, ty) } ================================================ FILE: pkg/scraper/definition.go ================================================ package scraper import ( "errors" "fmt" "io" "os" "path/filepath" "strings" "gopkg.in/yaml.v2" ) // Definition represents a scraper definition (typically) loaded from a YAML configuration file. type Definition struct { ID string path string // The name of the scraper. This is displayed in the UI. Name string `yaml:"name"` // Configuration for querying performers by name PerformerByName *ByNameDefinition `yaml:"performerByName"` // Configuration for querying performers by a Performer fragment PerformerByFragment *ByFragmentDefinition `yaml:"performerByFragment"` // Configuration for querying a performer by a URL PerformerByURL []*ByURLDefinition `yaml:"performerByURL"` // Configuration for querying scenes by a Scene fragment SceneByFragment *ByFragmentDefinition `yaml:"sceneByFragment"` // Configuration for querying gallery by a Gallery fragment GalleryByFragment *ByFragmentDefinition `yaml:"galleryByFragment"` // Configuration for querying scenes by name SceneByName *ByNameDefinition `yaml:"sceneByName"` // Configuration for querying scenes by query fragment SceneByQueryFragment *ByFragmentDefinition `yaml:"sceneByQueryFragment"` // Configuration for querying a scene by a URL SceneByURL []*ByURLDefinition `yaml:"sceneByURL"` // Configuration for querying a gallery by a URL GalleryByURL []*ByURLDefinition `yaml:"galleryByURL"` // Configuration for querying an image by a URL ImageByURL []*ByURLDefinition `yaml:"imageByURL"` // Configuration for querying image by an Image fragment ImageByFragment *ByFragmentDefinition `yaml:"imageByFragment"` // Configuration for querying a movie by a URL - deprecated, use GroupByURL MovieByURL []*ByURLDefinition `yaml:"movieByURL"` // Configuration for querying a group by a URL GroupByURL []*ByURLDefinition `yaml:"groupByURL"` // Scraper debugging options DebugOptions *scraperDebugOptions `yaml:"debug"` // Stash server configuration StashServer *stashServer `yaml:"stashServer"` // Xpath scraping configurations XPathScrapers mappedScrapers `yaml:"xPathScrapers"` // Json scraping configurations JsonScrapers mappedScrapers `yaml:"jsonScrapers"` // Scraping driver options DriverOptions *scraperDriverOptions `yaml:"driver"` } func (c Definition) validate() error { if strings.TrimSpace(c.Name) == "" { return errors.New("name must not be empty") } if c.PerformerByName != nil { if err := c.PerformerByName.validate(); err != nil { return err } } if c.PerformerByFragment != nil { if err := c.PerformerByFragment.validate(); err != nil { return err } } if c.SceneByFragment != nil { if err := c.SceneByFragment.validate(); err != nil { return err } } for _, s := range c.PerformerByURL { if err := s.validate(); err != nil { return err } } for _, s := range c.SceneByURL { if err := s.validate(); err != nil { return err } } if len(c.MovieByURL) > 0 && len(c.GroupByURL) > 0 { return errors.New("movieByURL disallowed if groupByURL is present") } for _, s := range append(c.MovieByURL, c.GroupByURL...) { if err := s.validate(); err != nil { return err } } return nil } type stashServer struct { URL string `yaml:"url"` ApiKey string `yaml:"apiKey"` } type ActionDefinition struct { Action scraperAction `yaml:"action"` Script []string `yaml:"script,flow"` Scraper string `yaml:"scraper"` } func (c ActionDefinition) validate() error { if !c.Action.IsValid() { return fmt.Errorf("%s is not a valid scraper action", c.Action) } if c.Action == scraperActionScript && len(c.Script) == 0 { return errors.New("script is mandatory for script scraper action") } return nil } type ByURLDefinition struct { ActionDefinition `yaml:",inline"` URL []string `yaml:"url,flow"` QueryURL string `yaml:"queryURL"` QueryURLReplacements queryURLReplacements `yaml:"queryURLReplace"` } func (c ByURLDefinition) validate() error { if len(c.URL) == 0 { return errors.New("url is mandatory for scrape by url scrapers") } return c.ActionDefinition.validate() } func (c ByURLDefinition) matchesURL(url string) bool { for _, thisURL := range c.URL { if strings.Contains(url, thisURL) { return true } } return false } type ByFragmentDefinition struct { ActionDefinition `yaml:",inline"` QueryURL string `yaml:"queryURL"` QueryURLReplacements queryURLReplacements `yaml:"queryURLReplace"` } type ByNameDefinition struct { ActionDefinition `yaml:",inline"` QueryURL string `yaml:"queryURL"` } type scraperDebugOptions struct { PrintHTML bool `yaml:"printHTML"` } type scraperCookies struct { Name string `yaml:"Name"` Value string `yaml:"Value"` ValueRandom int `yaml:"ValueRandom"` Domain string `yaml:"Domain"` Path string `yaml:"Path"` } type cookieOptions struct { CookieURL string `yaml:"CookieURL"` Cookies []*scraperCookies `yaml:"Cookies"` } type clickOptions struct { XPath string `yaml:"xpath"` Sleep int `yaml:"sleep"` } type header struct { Key string `yaml:"Key"` Value string `yaml:"Value"` } type scraperDriverOptions struct { UseCDP bool `yaml:"useCDP"` Sleep int `yaml:"sleep"` Clicks []*clickOptions `yaml:"clicks"` Cookies []*cookieOptions `yaml:"cookies"` Headers []*header `yaml:"headers"` } func loadConfigFromYAML(id string, reader io.Reader) (*Definition, error) { ret := &Definition{} parser := yaml.NewDecoder(reader) parser.SetStrict(true) err := parser.Decode(&ret) if err != nil { return nil, err } ret.ID = id if err := ret.validate(); err != nil { return nil, err } return ret, nil } func loadConfigFromYAMLFile(path string) (*Definition, error) { file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() // set id to the filename id := filepath.Base(path) id = id[:strings.LastIndex(id, ".")] ret, err := loadConfigFromYAML(id, file) if err != nil { return nil, err } ret.path = path return ret, nil } func (c Definition) spec() Scraper { ret := Scraper{ ID: c.ID, Name: c.Name, } performer := ScraperSpec{} if c.PerformerByName != nil { performer.SupportedScrapes = append(performer.SupportedScrapes, ScrapeTypeName) } if c.PerformerByFragment != nil { performer.SupportedScrapes = append(performer.SupportedScrapes, ScrapeTypeFragment) } if len(c.PerformerByURL) > 0 { performer.SupportedScrapes = append(performer.SupportedScrapes, ScrapeTypeURL) for _, v := range c.PerformerByURL { performer.Urls = append(performer.Urls, v.URL...) } } if len(performer.SupportedScrapes) > 0 { ret.Performer = &performer } scene := ScraperSpec{} if c.SceneByFragment != nil { scene.SupportedScrapes = append(scene.SupportedScrapes, ScrapeTypeFragment) } if c.SceneByName != nil && c.SceneByQueryFragment != nil { scene.SupportedScrapes = append(scene.SupportedScrapes, ScrapeTypeName) } if len(c.SceneByURL) > 0 { scene.SupportedScrapes = append(scene.SupportedScrapes, ScrapeTypeURL) for _, v := range c.SceneByURL { scene.Urls = append(scene.Urls, v.URL...) } } if len(scene.SupportedScrapes) > 0 { ret.Scene = &scene } gallery := ScraperSpec{} if c.GalleryByFragment != nil { gallery.SupportedScrapes = append(gallery.SupportedScrapes, ScrapeTypeFragment) } if len(c.GalleryByURL) > 0 { gallery.SupportedScrapes = append(gallery.SupportedScrapes, ScrapeTypeURL) for _, v := range c.GalleryByURL { gallery.Urls = append(gallery.Urls, v.URL...) } } if len(gallery.SupportedScrapes) > 0 { ret.Gallery = &gallery } image := ScraperSpec{} if c.ImageByFragment != nil { image.SupportedScrapes = append(image.SupportedScrapes, ScrapeTypeFragment) } if len(c.ImageByURL) > 0 { image.SupportedScrapes = append(image.SupportedScrapes, ScrapeTypeURL) for _, v := range c.ImageByURL { image.Urls = append(image.Urls, v.URL...) } } if len(image.SupportedScrapes) > 0 { ret.Image = &image } group := ScraperSpec{} if len(c.MovieByURL) > 0 || len(c.GroupByURL) > 0 { group.SupportedScrapes = append(group.SupportedScrapes, ScrapeTypeURL) for _, v := range append(c.MovieByURL, c.GroupByURL...) { group.Urls = append(group.Urls, v.URL...) } } if len(group.SupportedScrapes) > 0 { ret.Movie = &group ret.Group = &group } return ret } func (c Definition) supports(ty ScrapeContentType) bool { switch ty { case ScrapeContentTypePerformer: return c.PerformerByName != nil || c.PerformerByFragment != nil || len(c.PerformerByURL) > 0 case ScrapeContentTypeScene: return (c.SceneByName != nil && c.SceneByQueryFragment != nil) || c.SceneByFragment != nil || len(c.SceneByURL) > 0 case ScrapeContentTypeGallery: return c.GalleryByFragment != nil || len(c.GalleryByURL) > 0 case ScrapeContentTypeImage: return c.ImageByFragment != nil || len(c.ImageByURL) > 0 case ScrapeContentTypeMovie, ScrapeContentTypeGroup: return len(c.MovieByURL) > 0 || len(c.GroupByURL) > 0 } panic("Unhandled ScrapeContentType") } func (c Definition) matchesURL(url string, ty ScrapeContentType) bool { switch ty { case ScrapeContentTypePerformer: for _, scraper := range c.PerformerByURL { if scraper.matchesURL(url) { return true } } case ScrapeContentTypeScene: for _, scraper := range c.SceneByURL { if scraper.matchesURL(url) { return true } } case ScrapeContentTypeGallery: for _, scraper := range c.GalleryByURL { if scraper.matchesURL(url) { return true } } case ScrapeContentTypeImage: for _, scraper := range c.ImageByURL { if scraper.matchesURL(url) { return true } } case ScrapeContentTypeMovie, ScrapeContentTypeGroup: for _, scraper := range c.GroupByURL { if scraper.matchesURL(url) { return true } } for _, scraper := range c.MovieByURL { if scraper.matchesURL(url) { return true } } } return false } ================================================ FILE: pkg/scraper/freeones.go ================================================ package scraper import ( "strings" "github.com/stashapp/stash/pkg/logger" ) // FreeonesScraperID is the scraper ID for the built-in Freeones scraper const FreeonesScraperID = "builtin_freeones" // 537: stolen from: https://github.com/stashapp/CommunityScrapers/blob/master/scrapers/FreeonesCommunity.yml const freeonesScraperConfig = ` name: Freeones performerByName: action: scrapeXPath queryURL: https://www.freeones.com/babes?q={}&v=teasers&s=relevance&l=96&m%5BcanPreviewFeatures%5D=0 scraper: performerSearch performerByURL: - action: scrapeXPath url: - freeones.xxx - freeones.com scraper: performerScraper xPathScrapers: performerSearch: performer: Name: //div[@id="search-result"]//p[@data-test="subject-name"]/text() URL: selector: //div[@id="search-result"]//div[@data-test="teaser-subject"]/a/@href postProcess: - replace: - regex: ^ with: https://www.freeones.com - regex: /feed$ with: /bio performerScraper: performer: Name: selector: //h1 postProcess: - replace: - regex: (.+)\sidentifies.+ with: $1 URL: //link[@rel="alternate" and @hreflang="x-default"]/@href Twitter: //form//a[contains(@href,'twitter.com/')]/@href Instagram: //form//a[contains(@href,'instagram.com/')]/@href Birthdate: selector: //span[@data-test="link_span_dateOfBirth"]/text() postProcess: - parseDate: January 2, 2006 Ethnicity: selector: //span[@data-test="link_span_ethnicity"] postProcess: - map: Asian: Asian Caucasian: White Black: Black Latin: Hispanic Country: selector: //a[@data-test="link_placeOfBirth"][contains(@href, 'country')]/span/text() postProcess: - map: United States: "USA" EyeColor: //span[text()='Eye Color:']/following-sibling::span/a/span/text() Height: selector: //span[text()='Height:']/following-sibling::span/a postProcess: - replace: - regex: \scm with: "" - map: Unknown: "" Measurements: selector: //span[(@data-test='link_span_bra') or (@data-test='link_span_waist') or (@data-test='link_span_hip')] concat: " - " postProcess: - replace: - regex: \sIn with: "" - map: Unknown: "" FakeTits: selector: //span[text()='Boobs:']/following-sibling::span/a postProcess: - map: Unknown: "" Fake: "Yes" Natural: "No" CareerLength: selector: //div[contains(@class,'timeline-horizontal')]//p[@class='m-0'] concat: "-" Aliases: selector: //span[@data-test='link_span_aliases']/text() concat: ", " Tattoos: selector: //span[text()='Tattoo locations:']/following-sibling::span postProcess: - map: Unknown: "" Piercings: selector: //span[text()='Piercing locations:']/following-sibling::span postProcess: - map: Unknown: "" Image: selector: //div[contains(@class,'image-container')]//a/img/@src Gender: selector: //h1/*[1]/*[1]/text()Add commentMore actions postProcess: - replace: - regex: .+ identifies as (.+) with: $1 DeathDate: selector: //div[contains(text(),'Passed away on')] postProcess: - replace: - regex: Passed away on (.+) at the age of \d+ with: $1 - parseDate: January 2, 2006 HairColor: //span[@data-test="link_span_hair_color"] Weight: selector: //span[@data-test="link_span_weight"] postProcess: - replace: - regex: \skg with: "" # Last Updated June 22, 2025 ` func getFreeonesScraper(globalConfig GlobalConfig) scraper { yml := freeonesScraperConfig c, err := loadConfigFromYAML(FreeonesScraperID, strings.NewReader(yml)) if err != nil { logger.Fatalf("Error loading builtin freeones scraper: %s", err.Error()) } return scraperFromDefinition(*c, globalConfig) } ================================================ FILE: pkg/scraper/graphql.go ================================================ package scraper import ( "errors" "strings" "github.com/hasura/go-graphql-client" ) type graphqlErrors []error func (e graphqlErrors) Error() string { b := strings.Builder{} for _, err := range e { _, _ = b.WriteString(err.Error()) } return b.String() } type graphqlError struct { err graphql.Error } func (e graphqlError) Error() string { unwrapped := e.err.Unwrap() if unwrapped != nil { var networkErr graphql.NetworkError if errors.As(unwrapped, &networkErr) { if networkErr.StatusCode() == 422 { return networkErr.Body() } } } return e.err.Error() } // convertGraphqlError converts a graphql.Error or graphql.Errors into an error with a useful message. // graphql.Error swallows important information, so we need to convert it to a more useful error type. func convertGraphqlError(err error) error { var gqlErrs graphql.Errors if errors.As(err, &gqlErrs) { ret := make(graphqlErrors, len(gqlErrs)) for i, e := range gqlErrs { ret[i] = convertGraphqlError(e) } return ret } var gqlErr graphql.Error if errors.As(err, &gqlErr) { return graphqlError{gqlErr} } return err } ================================================ FILE: pkg/scraper/image.go ================================================ package scraper import ( "context" "fmt" "io" "net/http" "strings" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) func setPerformerImage(ctx context.Context, client *http.Client, p *models.ScrapedPerformer, globalConfig GlobalConfig) error { // backwards compatibility: we fetch the image if it's a URL and set it to the first image // Image is deprecated, so only do this if Images is unset if p.Image == nil || len(p.Images) > 0 { // nothing to do return nil } // don't try to get the image if it doesn't appear to be a URL if !strings.HasPrefix(*p.Image, "http") { p.Images = []string{*p.Image} return nil } img, err := getImage(ctx, *p.Image, client, globalConfig) if err != nil { return err } p.Image = img // Image is deprecated. Use images instead p.Images = []string{*img} return nil } func setStudioImage(ctx context.Context, client *http.Client, p *models.ScrapedStudio, globalConfig GlobalConfig) error { // backwards compatibility: we fetch the image if it's a URL and set it to the first image // Image is deprecated, so only do this if Images is unset if p.Image == nil || len(p.Images) > 0 { // nothing to do return nil } // don't try to get the image if it doesn't appear to be a URL if !strings.HasPrefix(*p.Image, "http") { p.Images = []string{*p.Image} return nil } img, err := getImage(ctx, *p.Image, client, globalConfig) if err != nil { return err } p.Image = img // Image is deprecated. Use images instead p.Images = []string{*img} return nil } func processImageField(ctx context.Context, imageField *string, client *http.Client, globalConfig GlobalConfig) error { if imageField == nil { return nil } // don't try to get the image if it doesn't appear to be a URL // this allows scrapers to return base64 data URIs directly if !strings.HasPrefix(*imageField, "http") { return nil } img, err := getImage(ctx, *imageField, client, globalConfig) if err != nil { return err } *imageField = *img return nil } type imageGetter struct { client *http.Client globalConfig GlobalConfig requestModifier func(req *http.Request) } func (i *imageGetter) getImage(ctx context.Context, url string) (*string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } userAgent := i.globalConfig.GetScraperUserAgent() if userAgent != "" { req.Header.Set("User-Agent", userAgent) } // assume is a URL for now // set the host of the URL as the referer if req.URL.Scheme != "" { req.Header.Set("Referer", req.URL.Scheme+"://"+req.Host+"/") } if i.requestModifier != nil { i.requestModifier(req) } resp, err := i.client.Do(req) if err != nil { return nil, err } if resp.StatusCode >= 400 { return nil, fmt.Errorf("http error %d", resp.StatusCode) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } // determine the image type and set the base64 type contentType := resp.Header.Get("Content-Type") if contentType == "" { contentType = http.DetectContentType(body) } img := "data:" + contentType + ";base64," + utils.GetBase64StringFromData(body) return &img, nil } func getImage(ctx context.Context, url string, client *http.Client, globalConfig GlobalConfig) (*string, error) { g := imageGetter{ client: client, globalConfig: globalConfig, } return g.getImage(ctx, url) } func getStashPerformerImage(ctx context.Context, stashURL string, performerID string, imageGetter imageGetter) (*string, error) { return imageGetter.getImage(ctx, stashURL+"/performer/"+performerID+"/image") } func getStashSceneImage(ctx context.Context, stashURL string, sceneID string, imageGetter imageGetter) (*string, error) { return imageGetter.getImage(ctx, stashURL+"/scene/"+sceneID+"/screenshot") } ================================================ FILE: pkg/scraper/json.go ================================================ package scraper import ( "context" "errors" "fmt" "io" "net/http" "net/url" "strings" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/tidwall/gjson" ) type jsonScraper struct { definition Definition globalConfig GlobalConfig client *http.Client } func (s *jsonScraper) getJsonScraper(name string) (*mappedScraper, error) { ret, ok := s.definition.JsonScrapers[name] if !ok { return nil, fmt.Errorf("json scraper with name %s not found in config", name) } return &ret, nil } func (s *jsonScraper) loadURL(ctx context.Context, url string) (string, error) { r, err := loadURL(ctx, url, s.client, s.definition, s.globalConfig) if err != nil { return "", err } logger.Infof("loadURL (%s)\n", url) doc, err := io.ReadAll(r) if err != nil { return "", err } docStr := string(doc) if !gjson.Valid(docStr) { return "", errors.New("not valid json") } if s.definition.DebugOptions != nil && s.definition.DebugOptions.PrintHTML { logger.Infof("loadURL (%s) response: \n%s", url, docStr) } return docStr, err } type jsonURLScraper struct { jsonScraper definition ByURLDefinition } func (s *jsonURLScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeContentType) (ScrapedContent, error) { scraper, err := s.getJsonScraper(s.definition.Scraper) if err != nil { return nil, err } doc, err := s.loadURL(ctx, url) if err != nil { return nil, err } q := s.getJsonQuery(doc, url) // if these just return the return values from scraper.scrape* functions then // it ends up returning ScrapedContent(nil) rather than nil switch ty { case ScrapeContentTypePerformer: ret, err := scraper.scrapePerformer(ctx, q) if err != nil || ret == nil { return nil, err } return ret, nil case ScrapeContentTypeScene: ret, err := scraper.scrapeScene(ctx, q) if err != nil || ret == nil { return nil, err } return ret, nil case ScrapeContentTypeGallery: ret, err := scraper.scrapeGallery(ctx, q) if err != nil || ret == nil { return nil, err } return ret, nil case ScrapeContentTypeImage: ret, err := scraper.scrapeImage(ctx, q) if err != nil || ret == nil { return nil, err } return ret, nil case ScrapeContentTypeMovie, ScrapeContentTypeGroup: ret, err := scraper.scrapeGroup(ctx, q) if err != nil || ret == nil { return nil, err } return ret, nil } return nil, ErrNotSupported } type jsonNameScraper struct { jsonScraper definition ByNameDefinition } func (s *jsonNameScraper) scrapeByName(ctx context.Context, name string, ty ScrapeContentType) ([]ScrapedContent, error) { scraper, err := s.getJsonScraper(s.definition.Scraper) if err != nil { return nil, err } const placeholder = "{}" // replace the placeholder string with the URL-escaped name escapedName := url.QueryEscape(name) url := s.definition.QueryURL url = strings.ReplaceAll(url, placeholder, escapedName) doc, err := s.loadURL(ctx, url) if err != nil { return nil, err } q := s.getJsonQuery(doc, url) q.setType(SearchQuery) var content []ScrapedContent switch ty { case ScrapeContentTypePerformer: performers, err := scraper.scrapePerformers(ctx, q) if err != nil { return nil, err } for _, p := range performers { content = append(content, p) } return content, nil case ScrapeContentTypeScene: scenes, err := scraper.scrapeScenes(ctx, q) if err != nil { return nil, err } for _, s := range scenes { content = append(content, s) } return content, nil } return nil, ErrNotSupported } type jsonFragmentScraper struct { jsonScraper definition ByFragmentDefinition } func (s *jsonFragmentScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error) { // construct the URL queryURL := queryURLParametersFromScene(scene) if s.definition.QueryURLReplacements != nil { queryURL.applyReplacements(s.definition.QueryURLReplacements) } url := queryURL.constructURL(s.definition.QueryURL) scraper, err := s.getJsonScraper(s.definition.Scraper) if err != nil { return nil, err } doc, err := s.loadURL(ctx, url) if err != nil { return nil, err } q := s.getJsonQuery(doc, url) return scraper.scrapeScene(ctx, q) } func (s *jsonFragmentScraper) scrapeByFragment(ctx context.Context, input Input) (ScrapedContent, error) { switch { case input.Gallery != nil: return nil, fmt.Errorf("%w: cannot use a json scraper as a gallery fragment scraper", ErrNotSupported) case input.Performer != nil: return nil, fmt.Errorf("%w: cannot use a json scraper as a performer fragment scraper", ErrNotSupported) case input.Scene == nil: return nil, fmt.Errorf("%w: scene input is nil", ErrNotSupported) } scene := *input.Scene // construct the URL queryURL := queryURLParametersFromScrapedScene(scene) if s.definition.QueryURLReplacements != nil { queryURL.applyReplacements(s.definition.QueryURLReplacements) } url := queryURL.constructURL(s.definition.QueryURL) scraper, err := s.getJsonScraper(s.definition.Scraper) if err != nil { return nil, err } doc, err := s.loadURL(ctx, url) if err != nil { return nil, err } q := s.getJsonQuery(doc, url) return scraper.scrapeScene(ctx, q) } func (s *jsonFragmentScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) { // construct the URL queryURL := queryURLParametersFromImage(image) if s.definition.QueryURLReplacements != nil { queryURL.applyReplacements(s.definition.QueryURLReplacements) } url := queryURL.constructURL(s.definition.QueryURL) scraper, err := s.getJsonScraper(s.definition.Scraper) if err != nil { return nil, err } doc, err := s.loadURL(ctx, url) if err != nil { return nil, err } q := s.getJsonQuery(doc, url) return scraper.scrapeImage(ctx, q) } func (s *jsonFragmentScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*models.ScrapedGallery, error) { // construct the URL queryURL := queryURLParametersFromGallery(gallery) if s.definition.QueryURLReplacements != nil { queryURL.applyReplacements(s.definition.QueryURLReplacements) } url := queryURL.constructURL(s.definition.QueryURL) scraper, err := s.getJsonScraper(s.definition.Scraper) if err != nil { return nil, err } doc, err := s.loadURL(ctx, url) if err != nil { return nil, err } q := s.getJsonQuery(doc, url) return scraper.scrapeGallery(ctx, q) } func (s *jsonScraper) getJsonQuery(doc string, url string) *jsonQuery { return &jsonQuery{ doc: doc, scraper: s, url: url, } } type jsonQuery struct { doc string scraper *jsonScraper queryType QueryType url string } func (q *jsonQuery) getType() QueryType { return q.queryType } func (q *jsonQuery) setType(t QueryType) { q.queryType = t } func (q *jsonQuery) getURL() string { return q.url } func (q *jsonQuery) runQuery(selector string) ([]string, error) { value := gjson.Get(q.doc, selector) if !value.Exists() { // many possible reasons why the selector may not be in the json object // and not all are errors. // Just return nil return nil, nil } var ret []string if value.IsArray() { value.ForEach(func(k, v gjson.Result) bool { ret = append(ret, v.String()) return true }) } else { ret = append(ret, value.String()) } return ret, nil } func (q *jsonQuery) subScrape(ctx context.Context, value string) mappedQuery { doc, err := q.scraper.loadURL(ctx, value) if err != nil { logger.Warnf("Error getting URL '%s' for sub-scraper: %s", value, err.Error()) return nil } return q.scraper.getJsonQuery(doc, value) } ================================================ FILE: pkg/scraper/json_test.go ================================================ package scraper import ( "context" "testing" "gopkg.in/yaml.v2" ) func TestJsonPerformerScraper(t *testing.T) { const yamlStr = `name: Test jsonScrapers: performerScraper: common: $extras: data.extras performer: Name: data.name Gender: $extras.gender Birthdate: $extras.birthday Ethnicity: $extras.ethnicity Height: $extras.height Measurements: $extras.measurements Tattoos: $extras.tattoos Piercings: $extras.piercings Aliases: data.aliases Image: data.image Details: data.bio HairColor: $extras.hair_colour Weight: $extras.weight ` const json = ` { "data": { "id": "2cd4146b-637d-49b1-8ff9-19d4a06947bb", "name": "Mia Malkova", "bio": "Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the ... arrow_drop_down Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the VR Porn movies – trust us. Ankles behind her neck and feet over her back so she can kiss her toes, turned, twisted and gyrating, she can fuck any which way she wants (and that ass!), will surely make you fall in love with this hot Virtual Reality Porn slut, as she is one of the finest of them all. Talking about perfection, maybe it’s all the acrobatic work that keeps it in such gorgeous shape? Who cares really, because you just want to take a big bite out of it and never let go. But it’s not all about the body. Mia’s also got a great smile, which might not sound kinky, but believe us, it is a smile that will heat up your innards and drop your pants. Is it her golden skin, her innocent pink lips or that heart-shaped face? There is just too much good stuff going on with Mia Malkova, which is maybe why these past few years have heaped awards upon awards on this Southern California native. Mia came to VR Bangers for her first VR Porn video, so you know she’s only going for top-notch scenes with top-game performers, men, and women. Better hit up that yoga studio if you ever dream of being able to bang a flexible and talented chick like lady Malkova. arrow_drop_up", "extras": { "gender": "Female", "birthday": "1992-07-01", "birthday_timestamp": 709948800, "birthplace": "Palm Springs, California, United States", "active": 1, "astrology": "Cancer (Jun 21 - Jul 22)", "ethnicity": "Caucasian", "nationality": "United States", "hair_colour": "Blonde", "weight": 57, "height": "5'6\" (or 167 cm)", "measurements": "34-26-36", "cupsize": "34C (75C)", "tattoos": "None", "piercings": "Navel", "first_seen": null }, "aliases": [ "Mia Bliss", "Madison Clover", "Madison Swan", "Mia Mountain", "Mia M.", "Mia Malvoka", "Mia Molkova", "Mia Thomas" ], "image": "https:\/\/thumb.metadataapi.net\/unsafe\/1000x1500\/smart\/filters:sharpen():upscale()\/https%3A%2F%2Fcdn.metadataapi.net%2Fperformer%2F49%2F05%2F30%2Fade2255dc065032a89ebb23f0e038fa%2Fposter%2Fmia-malkova.jpg%3Fid1582610531" } } ` c := &Definition{} err := yaml.Unmarshal([]byte(yamlStr), &c) if err != nil { t.Fatalf("Error loading yaml: %s", err.Error()) } // perform scrape using json string performerScraper := c.JsonScrapers["performerScraper"] q := &jsonQuery{ doc: json, } scrapedPerformer, err := performerScraper.scrapePerformer(context.Background(), q) if err != nil { t.Fatalf("Error scraping performer: %s", err.Error()) } verifyField(t, "Mia Malkova", scrapedPerformer.Name, "Name") verifyField(t, "Female", scrapedPerformer.Gender, "Gender") verifyField(t, "1992-07-01", scrapedPerformer.Birthdate, "Birthdate") verifyField(t, "Caucasian", scrapedPerformer.Ethnicity, "Ethnicity") verifyField(t, "5'6\" (or 167 cm)", scrapedPerformer.Height, "Height") verifyField(t, "None", scrapedPerformer.Tattoos, "Tattoos") verifyField(t, "Navel", scrapedPerformer.Piercings, "Piercings") verifyField(t, "Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the ... arrow_drop_down Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the VR Porn movies – trust us. Ankles behind her neck and feet over her back so she can kiss her toes, turned, twisted and gyrating, she can fuck any which way she wants (and that ass!), will surely make you fall in love with this hot Virtual Reality Porn slut, as she is one of the finest of them all. Talking about perfection, maybe it’s all the acrobatic work that keeps it in such gorgeous shape? Who cares really, because you just want to take a big bite out of it and never let go. But it’s not all about the body. Mia’s also got a great smile, which might not sound kinky, but believe us, it is a smile that will heat up your innards and drop your pants. Is it her golden skin, her innocent pink lips or that heart-shaped face? There is just too much good stuff going on with Mia Malkova, which is maybe why these past few years have heaped awards upon awards on this Southern California native. Mia came to VR Bangers for her first VR Porn video, so you know she’s only going for top-notch scenes with top-game performers, men, and women. Better hit up that yoga studio if you ever dream of being able to bang a flexible and talented chick like lady Malkova. arrow_drop_up", scrapedPerformer.Details, "Details") verifyField(t, "Blonde", scrapedPerformer.HairColor, "HairColor") verifyField(t, "57", scrapedPerformer.Weight, "Weight") notFoundJson := ` { "data": null }` q = &jsonQuery{ doc: notFoundJson, } scrapedPerformer, err = performerScraper.scrapePerformer(context.Background(), q) if err != nil { t.Fatalf("Error scraping performer: %s", err.Error()) } if scrapedPerformer != nil { t.Errorf("expected nil scraped performer when not found, got %v", scrapedPerformer) } } ================================================ FILE: pkg/scraper/mapped.go ================================================ package scraper import ( "context" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) type mappedQuery interface { runQuery(selector string) ([]string, error) getType() QueryType setType(QueryType) subScrape(ctx context.Context, value string) mappedQuery getURL() string } type mappedScrapers map[string]mappedScraper type mappedScraper struct { Common commonMappedConfig `yaml:"common"` Scene *mappedSceneScraperConfig `yaml:"scene"` Gallery *mappedGalleryScraperConfig `yaml:"gallery"` Image *mappedImageScraperConfig `yaml:"image"` Performer *mappedPerformerScraperConfig `yaml:"performer"` Group *mappedMovieScraperConfig `yaml:"group"` // deprecated Movie *mappedMovieScraperConfig `yaml:"movie"` } func urlsIsMulti(key string) bool { return key == "URLs" } func (s mappedScraper) scrapePerformer(ctx context.Context, q mappedQuery) (*models.ScrapedPerformer, error) { var ret *models.ScrapedPerformer performerMap := s.Performer if performerMap == nil { return nil, nil } performerTagsMap := performerMap.Tags results := performerMap.process(ctx, q, s.Common, urlsIsMulti) // now apply the tags var tagResults mappedResults if performerTagsMap != nil { logger.Debug(`Processing performer tags:`) tagResults = performerTagsMap.process(ctx, q, s.Common, nil) } if len(results) == 0 { return nil, nil } if len(results) > 0 { ret = results[0].scrapedPerformer() ret.Tags = tagResults.scrapedTags() } return ret, nil } func (s mappedScraper) scrapePerformers(ctx context.Context, q mappedQuery) ([]*models.ScrapedPerformer, error) { performerMap := s.Performer if performerMap == nil { return nil, nil } // isMulti is nil because it will behave incorrect when scraping multiple performers results := performerMap.process(ctx, q, s.Common, nil) return results.scrapedPerformers(), nil } // processSceneRelationships sets the relationships on the models.ScrapedScene. It returns true if any relationships were set. func (s mappedScraper) processSceneRelationships(ctx context.Context, q mappedQuery, resultIndex int, ret *models.ScrapedScene) bool { sceneScraperConfig := s.Scene scenePerformersMap := sceneScraperConfig.Performers sceneTagsMap := sceneScraperConfig.Tags sceneStudioMap := sceneScraperConfig.Studio sceneMoviesMap := sceneScraperConfig.Movies sceneGroupsMap := sceneScraperConfig.Groups ret.Performers = s.processPerformers(ctx, scenePerformersMap, q) if sceneTagsMap != nil { logger.Debug(`Processing scene tags:`) ret.Tags = sceneTagsMap.process(ctx, q, s.Common, nil).scrapedTags() } if sceneStudioMap != nil { logger.Debug(`Processing scene studio:`) studioResults := sceneStudioMap.process(ctx, q, s.Common, nil) if len(studioResults) > 0 && resultIndex < len(studioResults) { // when doing a `search` scrape get the related studio studio := studioResults[resultIndex].scrapedStudio() ret.Studio = studio } } if sceneMoviesMap != nil { logger.Debug(`Processing scene movies:`) ret.Movies = sceneMoviesMap.process(ctx, q, s.Common, nil).scrapedMovies() } if sceneGroupsMap != nil { logger.Debug(`Processing scene groups:`) ret.Groups = sceneGroupsMap.process(ctx, q, s.Common, nil).scrapedGroups() } return len(ret.Performers) > 0 || len(ret.Tags) > 0 || ret.Studio != nil || len(ret.Movies) > 0 || len(ret.Groups) > 0 } func (s mappedScraper) processPerformers(ctx context.Context, performersMap mappedPerformerScraperConfig, q mappedQuery) []*models.ScrapedPerformer { var ret []*models.ScrapedPerformer // now apply the performers and tags if performersMap.mappedConfig != nil { logger.Debug(`Processing performers:`) // isMulti is nil because it will behave incorrect when scraping multiple performers performerResults := performersMap.process(ctx, q, s.Common, nil) scenePerformerTagsMap := performersMap.Tags // process performer tags once var performerTagResults mappedResults if scenePerformerTagsMap != nil { performerTagResults = scenePerformerTagsMap.process(ctx, q, s.Common, nil) } for _, p := range performerResults { performer := p.scrapedPerformer() for _, p := range performerTagResults { tag := p.scrapedTag() performer.Tags = append(performer.Tags, tag) } ret = append(ret, performer) } } return ret } func (s mappedScraper) scrapeScenes(ctx context.Context, q mappedQuery) ([]*models.ScrapedScene, error) { var ret []*models.ScrapedScene sceneScraperConfig := s.Scene sceneMap := sceneScraperConfig.mappedConfig if sceneMap == nil { return nil, nil } logger.Debug(`Processing scenes:`) // urlsIsMulti is nil because it will behave incorrect when scraping multiple scenes results := sceneMap.process(ctx, q, s.Common, nil) for i, r := range results { logger.Debug(`Processing scene:`) thisScene := r.scrapedScene() s.processSceneRelationships(ctx, q, i, thisScene) ret = append(ret, thisScene) } return ret, nil } func (s mappedScraper) scrapeScene(ctx context.Context, q mappedQuery) (*models.ScrapedScene, error) { sceneScraperConfig := s.Scene if sceneScraperConfig == nil { return nil, nil } sceneMap := sceneScraperConfig.mappedConfig logger.Debug(`Processing scene:`) results := sceneMap.process(ctx, q, s.Common, urlsIsMulti) var ret *models.ScrapedScene if len(results) > 0 { ret = results[0].scrapedScene() } hasRelationships := s.processSceneRelationships(ctx, q, 0, ret) // #3953 - process only returns results if the non-relationship fields are // populated // only return if we have results or relationships if len(results) > 0 || hasRelationships { return ret, nil } return nil, nil } func (s mappedScraper) scrapeImage(ctx context.Context, q mappedQuery) (*models.ScrapedImage, error) { var ret models.ScrapedImage imageScraperConfig := s.Image if imageScraperConfig == nil { return nil, nil } imageMap := imageScraperConfig.mappedConfig imagePerformersMap := imageScraperConfig.Performers imageTagsMap := imageScraperConfig.Tags imageStudioMap := imageScraperConfig.Studio logger.Debug(`Processing image:`) results := imageMap.process(ctx, q, s.Common, urlsIsMulti) if len(results) > 0 { ret = *results[0].scrapedImage() } // now apply the performers and tags if imagePerformersMap != nil { logger.Debug(`Processing image performers:`) ret.Performers = imagePerformersMap.process(ctx, q, s.Common, nil).scrapedPerformers() } if imageTagsMap != nil { logger.Debug(`Processing image tags:`) ret.Tags = imageTagsMap.process(ctx, q, s.Common, nil).scrapedTags() } if imageStudioMap != nil { logger.Debug(`Processing image studio:`) studioResults := imageStudioMap.process(ctx, q, s.Common, nil) if len(studioResults) > 0 { ret.Studio = studioResults[0].scrapedStudio() } } // if no basic fields are populated, and no relationships, then return nil if len(results) == 0 && len(ret.Performers) == 0 && len(ret.Tags) == 0 && ret.Studio == nil { return nil, nil } return &ret, nil } func (s mappedScraper) scrapeGallery(ctx context.Context, q mappedQuery) (*models.ScrapedGallery, error) { var ret models.ScrapedGallery galleryScraperConfig := s.Gallery if galleryScraperConfig == nil { return nil, nil } galleryMap := galleryScraperConfig.mappedConfig galleryPerformersMap := galleryScraperConfig.Performers galleryTagsMap := galleryScraperConfig.Tags galleryStudioMap := galleryScraperConfig.Studio logger.Debug(`Processing gallery:`) results := galleryMap.process(ctx, q, s.Common, urlsIsMulti) if len(results) > 0 { ret = *results[0].scrapedGallery() } // now apply the performers and tags if galleryPerformersMap != nil { logger.Debug(`Processing gallery performers:`) performerResults := galleryPerformersMap.process(ctx, q, s.Common, urlsIsMulti) ret.Performers = performerResults.scrapedPerformers() } if galleryTagsMap != nil { logger.Debug(`Processing gallery tags:`) tagResults := galleryTagsMap.process(ctx, q, s.Common, nil) ret.Tags = tagResults.scrapedTags() } if galleryStudioMap != nil { logger.Debug(`Processing gallery studio:`) studioResults := galleryStudioMap.process(ctx, q, s.Common, nil) if len(studioResults) > 0 { ret.Studio = studioResults[0].scrapedStudio() } } // if no basic fields are populated, and no relationships, then return nil if len(results) == 0 && len(ret.Performers) == 0 && len(ret.Tags) == 0 && ret.Studio == nil { return nil, nil } return &ret, nil } func (s mappedScraper) scrapeGroup(ctx context.Context, q mappedQuery) (*models.ScrapedGroup, error) { var ret models.ScrapedGroup // try group scraper first, falling back to movie groupScraperConfig := s.Group if groupScraperConfig == nil { groupScraperConfig = s.Movie } if groupScraperConfig == nil { return nil, nil } groupMap := groupScraperConfig.mappedConfig groupStudioMap := groupScraperConfig.Studio groupTagsMap := groupScraperConfig.Tags results := groupMap.process(ctx, q, s.Common, urlsIsMulti) if len(results) > 0 { ret = *results[0].scrapedGroup() } if groupStudioMap != nil { logger.Debug(`Processing group studio:`) studioResults := groupStudioMap.process(ctx, q, s.Common, nil) if len(studioResults) > 0 { ret.Studio = studioResults[0].scrapedStudio() } } // now apply the tags if groupTagsMap != nil { logger.Debug(`Processing group tags:`) tagResults := groupTagsMap.process(ctx, q, s.Common, nil) ret.Tags = tagResults.scrapedTags() } if len(results) == 0 && ret.Studio == nil && len(ret.Tags) == 0 { return nil, nil } return &ret, nil } ================================================ FILE: pkg/scraper/mapped_config.go ================================================ package scraper import ( "context" "errors" "net/url" "strings" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/sliceutil" "gopkg.in/yaml.v2" ) type commonMappedConfig map[string]string type mappedConfig map[string]mappedScraperAttrConfig func (s mappedConfig) applyCommon(c commonMappedConfig, src string) string { if c == nil { return src } ret := src for commonKey, commonVal := range c { ret = strings.ReplaceAll(ret, commonKey, commonVal) } return ret } // extractHostname parses a URL string and returns the hostname. // Returns empty string if the URL cannot be parsed. func extractHostname(urlStr string) string { if urlStr == "" { return "" } u, err := url.Parse(urlStr) if err != nil { logger.Warnf("Error parsing URL '%s': %s", urlStr, err.Error()) return "" } return u.Hostname() } type isMultiFunc func(key string) bool func (s mappedConfig) process(ctx context.Context, q mappedQuery, common commonMappedConfig, isMulti isMultiFunc) mappedResults { var ret mappedResults for k, attrConfig := range s { if attrConfig.Fixed != "" { // TODO - not sure if this needs to set _all_ indexes for the key const i = 0 // Support {inputURL} and {inputHostname} placeholders in fixed values value := strings.ReplaceAll(attrConfig.Fixed, "{inputURL}", q.getURL()) value = strings.ReplaceAll(value, "{inputHostname}", extractHostname(q.getURL())) ret = ret.setSingleValue(i, k, value) } else { selector := attrConfig.Selector selector = s.applyCommon(common, selector) // Support {inputURL} and {inputHostname} placeholders in selectors selector = strings.ReplaceAll(selector, "{inputURL}", q.getURL()) selector = strings.ReplaceAll(selector, "{inputHostname}", extractHostname(q.getURL())) found, err := q.runQuery(selector) if err != nil { logger.Warnf("key '%v': %v", k, err) } if len(found) > 0 { result := s.postProcess(ctx, q, attrConfig, found) // HACK - if the key is URLs, then we need to set the value as a multi-value isMulti := isMulti != nil && isMulti(k) if isMulti { ret = ret.setMultiValue(0, k, result) } else { for i, text := range result { ret = ret.setSingleValue(i, k, text) } } } } } return ret } func (s mappedConfig) postProcess(ctx context.Context, q mappedQuery, attrConfig mappedScraperAttrConfig, found []string) []string { // check if we're concatenating the results into a single result var ret []string if attrConfig.hasConcat() { result := attrConfig.concatenateResults(found) result = attrConfig.postProcess(ctx, result, q) if attrConfig.hasSplit() { results := attrConfig.splitString(result) // skip cleaning when the query is used for searching if q.getType() == SearchQuery { return results } results = attrConfig.cleanResults(results) return results } ret = []string{result} } else { for _, text := range found { text = attrConfig.postProcess(ctx, text, q) if attrConfig.hasSplit() { return attrConfig.splitString(text) } ret = append(ret, text) } // skip cleaning when the query is used for searching if q.getType() == SearchQuery { return ret } ret = attrConfig.cleanResults(ret) } return ret } type mappedSceneScraperConfig struct { mappedConfig Tags mappedConfig `yaml:"Tags"` Performers mappedPerformerScraperConfig `yaml:"Performers"` Studio mappedConfig `yaml:"Studio"` Movies mappedConfig `yaml:"Movies"` Groups mappedConfig `yaml:"Groups"` } type _mappedSceneScraperConfig mappedSceneScraperConfig const ( mappedScraperConfigSceneTags = "Tags" mappedScraperConfigScenePerformers = "Performers" mappedScraperConfigSceneStudio = "Studio" mappedScraperConfigSceneMovies = "Movies" mappedScraperConfigSceneGroups = "Groups" ) func (s *mappedSceneScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { // HACK - unmarshal to map first, then remove known scene sub-fields, then // remarshal to yaml and pass that down to the base map parentMap := make(map[string]interface{}) if err := unmarshal(parentMap); err != nil { return err } // move the known sub-fields to a separate map thisMap := make(map[string]interface{}) thisMap[mappedScraperConfigSceneTags] = parentMap[mappedScraperConfigSceneTags] thisMap[mappedScraperConfigScenePerformers] = parentMap[mappedScraperConfigScenePerformers] thisMap[mappedScraperConfigSceneStudio] = parentMap[mappedScraperConfigSceneStudio] thisMap[mappedScraperConfigSceneMovies] = parentMap[mappedScraperConfigSceneMovies] thisMap[mappedScraperConfigSceneGroups] = parentMap[mappedScraperConfigSceneGroups] delete(parentMap, mappedScraperConfigSceneTags) delete(parentMap, mappedScraperConfigScenePerformers) delete(parentMap, mappedScraperConfigSceneStudio) delete(parentMap, mappedScraperConfigSceneMovies) delete(parentMap, mappedScraperConfigSceneGroups) // re-unmarshal the sub-fields yml, err := yaml.Marshal(thisMap) if err != nil { return err } // needs to be a different type to prevent infinite recursion c := _mappedSceneScraperConfig{} if err := yaml.Unmarshal(yml, &c); err != nil { return err } *s = mappedSceneScraperConfig(c) yml, err = yaml.Marshal(parentMap) if err != nil { return err } if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil { return err } return nil } type mappedGalleryScraperConfig struct { mappedConfig Tags mappedConfig `yaml:"Tags"` Performers mappedConfig `yaml:"Performers"` Studio mappedConfig `yaml:"Studio"` } type _mappedGalleryScraperConfig mappedGalleryScraperConfig func (s *mappedGalleryScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { // HACK - unmarshal to map first, then remove known scene sub-fields, then // remarshal to yaml and pass that down to the base map parentMap := make(map[string]interface{}) if err := unmarshal(parentMap); err != nil { return err } // move the known sub-fields to a separate map thisMap := make(map[string]interface{}) thisMap[mappedScraperConfigSceneTags] = parentMap[mappedScraperConfigSceneTags] thisMap[mappedScraperConfigScenePerformers] = parentMap[mappedScraperConfigScenePerformers] thisMap[mappedScraperConfigSceneStudio] = parentMap[mappedScraperConfigSceneStudio] delete(parentMap, mappedScraperConfigSceneTags) delete(parentMap, mappedScraperConfigScenePerformers) delete(parentMap, mappedScraperConfigSceneStudio) // re-unmarshal the sub-fields yml, err := yaml.Marshal(thisMap) if err != nil { return err } // needs to be a different type to prevent infinite recursion c := _mappedGalleryScraperConfig{} if err := yaml.Unmarshal(yml, &c); err != nil { return err } *s = mappedGalleryScraperConfig(c) yml, err = yaml.Marshal(parentMap) if err != nil { return err } if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil { return err } return nil } type mappedImageScraperConfig struct { mappedConfig Tags mappedConfig `yaml:"Tags"` Performers mappedConfig `yaml:"Performers"` Studio mappedConfig `yaml:"Studio"` } type _mappedImageScraperConfig mappedImageScraperConfig func (s *mappedImageScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { // HACK - unmarshal to map first, then remove known scene sub-fields, then // remarshal to yaml and pass that down to the base map parentMap := make(map[string]interface{}) if err := unmarshal(parentMap); err != nil { return err } // move the known sub-fields to a separate map thisMap := make(map[string]interface{}) thisMap[mappedScraperConfigSceneTags] = parentMap[mappedScraperConfigSceneTags] thisMap[mappedScraperConfigScenePerformers] = parentMap[mappedScraperConfigScenePerformers] thisMap[mappedScraperConfigSceneStudio] = parentMap[mappedScraperConfigSceneStudio] delete(parentMap, mappedScraperConfigSceneTags) delete(parentMap, mappedScraperConfigScenePerformers) delete(parentMap, mappedScraperConfigSceneStudio) // re-unmarshal the sub-fields yml, err := yaml.Marshal(thisMap) if err != nil { return err } // needs to be a different type to prevent infinite recursion c := _mappedImageScraperConfig{} if err := yaml.Unmarshal(yml, &c); err != nil { return err } *s = mappedImageScraperConfig(c) yml, err = yaml.Marshal(parentMap) if err != nil { return err } if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil { return err } return nil } type mappedPerformerScraperConfig struct { mappedConfig Tags mappedConfig `yaml:"Tags"` } type _mappedPerformerScraperConfig mappedPerformerScraperConfig const ( mappedScraperConfigPerformerTags = "Tags" ) func (s *mappedPerformerScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { // HACK - unmarshal to map first, then remove known scene sub-fields, then // remarshal to yaml and pass that down to the base map parentMap := make(map[string]interface{}) if err := unmarshal(parentMap); err != nil { return err } // move the known sub-fields to a separate map thisMap := make(map[string]interface{}) thisMap[mappedScraperConfigPerformerTags] = parentMap[mappedScraperConfigPerformerTags] delete(parentMap, mappedScraperConfigPerformerTags) // re-unmarshal the sub-fields yml, err := yaml.Marshal(thisMap) if err != nil { return err } // needs to be a different type to prevent infinite recursion c := _mappedPerformerScraperConfig{} if err := yaml.Unmarshal(yml, &c); err != nil { return err } *s = mappedPerformerScraperConfig(c) yml, err = yaml.Marshal(parentMap) if err != nil { return err } if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil { return err } return nil } type mappedMovieScraperConfig struct { mappedConfig Studio mappedConfig `yaml:"Studio"` Tags mappedConfig `yaml:"Tags"` } type _mappedMovieScraperConfig mappedMovieScraperConfig const ( mappedScraperConfigMovieStudio = "Studio" mappedScraperConfigMovieTags = "Tags" ) func (s *mappedMovieScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { // HACK - unmarshal to map first, then remove known movie sub-fields, then // remarshal to yaml and pass that down to the base map parentMap := make(map[string]interface{}) if err := unmarshal(parentMap); err != nil { return err } // move the known sub-fields to a separate map thisMap := make(map[string]interface{}) thisMap[mappedScraperConfigMovieStudio] = parentMap[mappedScraperConfigMovieStudio] delete(parentMap, mappedScraperConfigMovieStudio) thisMap[mappedScraperConfigMovieTags] = parentMap[mappedScraperConfigMovieTags] delete(parentMap, mappedScraperConfigMovieTags) // re-unmarshal the sub-fields yml, err := yaml.Marshal(thisMap) if err != nil { return err } // needs to be a different type to prevent infinite recursion c := _mappedMovieScraperConfig{} if err := yaml.Unmarshal(yml, &c); err != nil { return err } *s = mappedMovieScraperConfig(c) yml, err = yaml.Marshal(parentMap) if err != nil { return err } if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil { return err } return nil } type mappedScraperAttrConfig struct { Selector string `yaml:"selector"` Fixed string `yaml:"fixed"` PostProcess []mappedPostProcessAction `yaml:"postProcess"` Concat string `yaml:"concat"` Split string `yaml:"split"` postProcessActions []postProcessAction // Deprecated: use PostProcess instead ParseDate string `yaml:"parseDate"` Replace mappedRegexConfigs `yaml:"replace"` SubScraper *mappedScraperAttrConfig `yaml:"subScraper"` } type _mappedScraperAttrConfig mappedScraperAttrConfig func (c *mappedScraperAttrConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { // try unmarshalling into a string first if err := unmarshal(&c.Selector); err != nil { // if it's a type error then we try to unmarshall to the full object var typeErr *yaml.TypeError if !errors.As(err, &typeErr) { return err } // unmarshall to full object // need it as a separate object t := _mappedScraperAttrConfig{} if err = unmarshal(&t); err != nil { return err } *c = mappedScraperAttrConfig(t) } return c.convertPostProcessActions() } func (c *mappedScraperAttrConfig) convertPostProcessActions() error { // ensure we don't have the old deprecated fields and the new post process field if len(c.PostProcess) > 0 { if c.ParseDate != "" || len(c.Replace) > 0 || c.SubScraper != nil { return errors.New("cannot include postProcess and (parseDate, replace, subScraper) deprecated fields") } // convert xpathPostProcessAction actions to postProcessActions for _, a := range c.PostProcess { action, err := a.ToPostProcessAction() if err != nil { return err } c.postProcessActions = append(c.postProcessActions, action) } c.PostProcess = nil } else { // convert old deprecated fields if present // in same order as they used to be executed if len(c.Replace) > 0 { action := postProcessReplace(c.Replace) c.postProcessActions = append(c.postProcessActions, &action) c.Replace = nil } if c.SubScraper != nil { action := postProcessSubScraper(*c.SubScraper) c.postProcessActions = append(c.postProcessActions, &action) c.SubScraper = nil } if c.ParseDate != "" { action := postProcessParseDate(c.ParseDate) c.postProcessActions = append(c.postProcessActions, &action) c.ParseDate = "" } } return nil } func (c mappedScraperAttrConfig) hasConcat() bool { return c.Concat != "" } func (c mappedScraperAttrConfig) hasSplit() bool { return c.Split != "" } func (c mappedScraperAttrConfig) concatenateResults(nodes []string) string { separator := c.Concat return strings.Join(nodes, separator) } func (c mappedScraperAttrConfig) cleanResults(nodes []string) []string { cleaned := sliceutil.Unique(nodes) // remove duplicate values cleaned = sliceutil.Delete(cleaned, "") // remove empty values return cleaned } func (c mappedScraperAttrConfig) splitString(value string) []string { separator := c.Split var res []string if separator == "" { return []string{value} } for _, str := range strings.Split(value, separator) { if str != "" { res = append(res, str) } } return res } func (c mappedScraperAttrConfig) postProcess(ctx context.Context, value string, q mappedQuery) string { for _, action := range c.postProcessActions { value = action.Apply(ctx, value, q) } return value } ================================================ FILE: pkg/scraper/mapped_postprocessing.go ================================================ package scraper import ( "context" "errors" "fmt" "math" "regexp" "strconv" "strings" "time" "github.com/stashapp/stash/pkg/javascript" "github.com/stashapp/stash/pkg/logger" ) type mappedRegexConfig struct { Regex string `yaml:"regex"` With string `yaml:"with"` } type mappedRegexConfigs []mappedRegexConfig func (c mappedRegexConfig) apply(value string) string { if c.Regex != "" { re, err := regexp.Compile(c.Regex) if err != nil { logger.Warnf("Error compiling regex '%s': %s", c.Regex, err.Error()) return value } ret := re.ReplaceAllString(value, c.With) // trim leading and trailing whitespace // this is done to maintain backwards compatibility with existing // scrapers ret = strings.TrimSpace(ret) logger.Debugf(`Replace: '%s' with '%s'`, c.Regex, c.With) logger.Debugf("Before: %s", value) logger.Debugf("After: %s", ret) return ret } return value } func (c mappedRegexConfigs) apply(value string) string { // apply regex in order for _, config := range c { value = config.apply(value) } return value } type postProcessAction interface { Apply(ctx context.Context, value string, q mappedQuery) string } type postProcessParseDate string func (p *postProcessParseDate) Apply(ctx context.Context, value string, q mappedQuery) string { parseDate := string(*p) const internalDateFormat = "2006-01-02" valueLower := strings.ToLower(value) if valueLower == "today" || valueLower == "yesterday" { // handle today, yesterday dt := time.Now() if valueLower == "yesterday" { // subtract 1 day from now dt = dt.AddDate(0, 0, -1) } return dt.Format(internalDateFormat) } if parseDate == "" { return value } if parseDate == "unix" { // try to parse the date using unix timestamp format // if it fails, then just fall back to the original value timeAsInt, err := strconv.ParseInt(value, 10, 64) if err != nil { logger.Warnf("Error parsing date string '%s' using unix timestamp format : %s", value, err.Error()) return value } parsedValue := time.Unix(timeAsInt, 0) return parsedValue.Format(internalDateFormat) } // try to parse the date using the pattern // if it fails, then just fall back to the original value parsedValue, err := time.Parse(parseDate, value) if err != nil { logger.Warnf("Error parsing date string '%s' using format '%s': %s", value, parseDate, err.Error()) return value } // convert it into our date format return parsedValue.Format(internalDateFormat) } type postProcessSubtractDays bool func (p *postProcessSubtractDays) Apply(ctx context.Context, value string, q mappedQuery) string { const internalDateFormat = "2006-01-02" i, err := strconv.Atoi(value) if err != nil { logger.Warnf("Error parsing day string %s: %s", value, err) return value } dt := time.Now() dt = dt.AddDate(0, 0, -i) return dt.Format(internalDateFormat) } type postProcessReplace mappedRegexConfigs func (c *postProcessReplace) Apply(ctx context.Context, value string, q mappedQuery) string { replace := mappedRegexConfigs(*c) return replace.apply(value) } type postProcessSubScraper mappedScraperAttrConfig func (p *postProcessSubScraper) Apply(ctx context.Context, value string, q mappedQuery) string { subScrapeConfig := mappedScraperAttrConfig(*p) logger.Debugf("Sub-scraping for: %s", value) ss := q.subScrape(ctx, value) if ss != nil { found, err := ss.runQuery(subScrapeConfig.Selector) if err != nil { logger.Warnf("subscrape for '%v': %v", value, err) } if len(found) > 0 { // check if we're concatenating the results into a single result var result string if subScrapeConfig.hasConcat() { result = subScrapeConfig.concatenateResults(found) } else { result = found[0] } result = subScrapeConfig.postProcess(ctx, result, ss) return result } } return "" } type postProcessMap map[string]string func (p *postProcessMap) Apply(ctx context.Context, value string, q mappedQuery) string { // return the mapped value if present m := *p mapped, ok := m[value] if ok { return mapped } return value } type postProcessFeetToCm bool func (p *postProcessFeetToCm) Apply(ctx context.Context, value string, q mappedQuery) string { const foot_in_cm = 30.48 const inch_in_cm = 2.54 reg := regexp.MustCompile("[0-9]+") filtered := reg.FindAllString(value, -1) var feet float64 var inches float64 if len(filtered) > 0 { feet, _ = strconv.ParseFloat(filtered[0], 64) } if len(filtered) > 1 { inches, _ = strconv.ParseFloat(filtered[1], 64) } var centimeters = feet*foot_in_cm + inches*inch_in_cm // Return rounded integer string return strconv.Itoa(int(math.Round(centimeters))) } type postProcessLbToKg bool func (p *postProcessLbToKg) Apply(ctx context.Context, value string, q mappedQuery) string { const lb_in_kg = 0.45359237 w, err := strconv.ParseFloat(value, 64) if err == nil { w *= lb_in_kg value = strconv.Itoa(int(math.Round(w))) } return value } type postProcessJavascript string func (p *postProcessJavascript) Apply(ctx context.Context, value string, q mappedQuery) string { vm := javascript.NewVM() if err := vm.Set("value", value); err != nil { logger.Warnf("javascript failed to set value: %v", err) return value } log := &javascript.Log{ Logger: logger.Logger, Prefix: "", ProgressChan: make(chan float64), } if err := log.AddToVM("log", vm); err != nil { logger.Logger.Errorf("error adding log API: %w", err) } util := &javascript.Util{} if err := util.AddToVM("util", vm); err != nil { logger.Logger.Errorf("error adding util API: %w", err) } script, err := javascript.CompileScript("", "(function() { "+string(*p)+"})()") if err != nil { logger.Warnf("javascript failed to compile: %v", err) return value } output, err := vm.RunProgram(script) if err != nil { logger.Warnf("javascript failed to run: %v", err) return value } // assume output is string return output.String() } type mappedPostProcessAction struct { ParseDate string `yaml:"parseDate"` SubtractDays bool `yaml:"subtractDays"` Replace mappedRegexConfigs `yaml:"replace"` SubScraper *mappedScraperAttrConfig `yaml:"subScraper"` Map map[string]string `yaml:"map"` FeetToCm bool `yaml:"feetToCm"` LbToKg bool `yaml:"lbToKg"` Javascript string `yaml:"javascript"` } func (a mappedPostProcessAction) ToPostProcessAction() (postProcessAction, error) { var found string var ret postProcessAction ensureOnly := func(field string) error { if found != "" { return fmt.Errorf("post-process actions must have a single field, found %s and %s", found, field) } found = field return nil } if a.ParseDate != "" { found = "parseDate" action := postProcessParseDate(a.ParseDate) ret = &action } if len(a.Replace) > 0 { if err := ensureOnly("replace"); err != nil { return nil, err } action := postProcessReplace(a.Replace) ret = &action } if a.SubScraper != nil { if err := ensureOnly("subScraper"); err != nil { return nil, err } action := postProcessSubScraper(*a.SubScraper) ret = &action } if a.Map != nil { if err := ensureOnly("map"); err != nil { return nil, err } action := postProcessMap(a.Map) ret = &action } if a.FeetToCm { if err := ensureOnly("feetToCm"); err != nil { return nil, err } action := postProcessFeetToCm(a.FeetToCm) ret = &action } if a.LbToKg { if err := ensureOnly("lbToKg"); err != nil { return nil, err } action := postProcessLbToKg(a.LbToKg) ret = &action } if a.SubtractDays { if err := ensureOnly("subtractDays"); err != nil { return nil, err } action := postProcessSubtractDays(a.SubtractDays) ret = &action } if a.Javascript != "" { if err := ensureOnly("javascript"); err != nil { return nil, err } action := postProcessJavascript(a.Javascript) ret = &action } if ret == nil { return nil, errors.New("invalid post-process action") } return ret, nil } ================================================ FILE: pkg/scraper/mapped_result.go ================================================ package scraper import ( "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) type mappedResult map[string]interface{} type mappedResults []mappedResult func (r mappedResult) string(key string) (string, bool) { v, ok := r[key] if !ok { return "", false } val, ok := v.(string) if !ok { logger.Errorf("String field %s is %T in mappedResult", key, r[key]) } return val, true } func (r mappedResult) mustString(key string) string { v, ok := r[key] if !ok { logger.Errorf("Missing required string field %s in mappedResult", key) return "" } val, ok := v.(string) if !ok { logger.Errorf("String field %s is %T in mappedResult", key, r[key]) } return val } func (r mappedResult) stringPtr(key string) *string { val, ok := r.string(key) if !ok { return nil } return &val } func (r mappedResult) stringSlice(key string) []string { v, ok := r[key] if !ok { return nil } // need to try both []string and string val, ok := v.([]string) if ok { return val } // try single string singleVal, ok := v.(string) if !ok { logger.Errorf("String slice field %s is %T in mappedResult", key, r[key]) return nil } return []string{singleVal} } func (r mappedResult) IntPtr(key string) *int { v, ok := r[key] if !ok { return nil } val, ok := v.(int) if !ok { logger.Errorf("Int field %s is %T in mappedResult", key, r[key]) return nil } return &val } func (r mappedResults) setSingleValue(index int, key string, value string) mappedResults { if index >= len(r) { r = append(r, make(mappedResult)) } logger.Debugf(`[%d][%s] = %s`, index, key, value) r[index][key] = value return r } func (r mappedResults) setMultiValue(index int, key string, value []string) mappedResults { if index >= len(r) { r = append(r, make(mappedResult)) } logger.Debugf(`[%d][%s] = %s`, index, key, value) r[index][key] = value return r } func (r mappedResults) scrapedTags() []*models.ScrapedTag { if len(r) == 0 { return nil } ret := make([]*models.ScrapedTag, len(r)) for i, result := range r { ret[i] = result.scrapedTag() } return ret } func (r mappedResult) scrapedTag() *models.ScrapedTag { return &models.ScrapedTag{ Name: r.mustString("Name"), } } func (r mappedResult) scrapedPerformer() *models.ScrapedPerformer { ret := &models.ScrapedPerformer{ Name: r.stringPtr("Name"), Disambiguation: r.stringPtr("Disambiguation"), Gender: r.stringPtr("Gender"), URL: r.stringPtr("URL"), URLs: r.stringSlice("URLs"), Twitter: r.stringPtr("Twitter"), Birthdate: r.stringPtr("Birthdate"), Ethnicity: r.stringPtr("Ethnicity"), Country: r.stringPtr("Country"), EyeColor: r.stringPtr("EyeColor"), Height: r.stringPtr("Height"), Measurements: r.stringPtr("Measurements"), FakeTits: r.stringPtr("FakeTits"), PenisLength: r.stringPtr("PenisLength"), Circumcised: r.stringPtr("Circumcised"), CareerLength: r.stringPtr("CareerLength"), CareerStart: r.stringPtr("CareerStart"), CareerEnd: r.stringPtr("CareerEnd"), Tattoos: r.stringPtr("Tattoos"), Piercings: r.stringPtr("Piercings"), Aliases: r.stringPtr("Aliases"), Image: r.stringPtr("Image"), Images: r.stringSlice("Images"), Details: r.stringPtr("Details"), DeathDate: r.stringPtr("DeathDate"), HairColor: r.stringPtr("HairColor"), Weight: r.stringPtr("Weight"), } return ret } func (r mappedResults) scrapedPerformers() []*models.ScrapedPerformer { if len(r) == 0 { return nil } ret := make([]*models.ScrapedPerformer, len(r)) for i, result := range r { ret[i] = result.scrapedPerformer() } return ret } func (r mappedResult) scrapedScene() *models.ScrapedScene { ret := &models.ScrapedScene{ Title: r.stringPtr("Title"), Code: r.stringPtr("Code"), Details: r.stringPtr("Details"), Director: r.stringPtr("Director"), URL: r.stringPtr("URL"), URLs: r.stringSlice("URLs"), Date: r.stringPtr("Date"), Image: r.stringPtr("Image"), Duration: r.IntPtr("Duration"), } return ret } func (r mappedResult) scrapedImage() *models.ScrapedImage { ret := &models.ScrapedImage{ Title: r.stringPtr("Title"), Code: r.stringPtr("Code"), Details: r.stringPtr("Details"), Photographer: r.stringPtr("Photographer"), URLs: r.stringSlice("URLs"), Date: r.stringPtr("Date"), } return ret } func (r mappedResult) scrapedGallery() *models.ScrapedGallery { ret := &models.ScrapedGallery{ Title: r.stringPtr("Title"), Code: r.stringPtr("Code"), Details: r.stringPtr("Details"), Photographer: r.stringPtr("Photographer"), URL: r.stringPtr("URL"), URLs: r.stringSlice("URLs"), Date: r.stringPtr("Date"), } return ret } func (r mappedResult) scrapedStudio() *models.ScrapedStudio { ret := &models.ScrapedStudio{ Name: r.mustString("Name"), URL: r.stringPtr("URL"), URLs: r.stringSlice("URLs"), Image: r.stringPtr("Image"), Details: r.stringPtr("Details"), Aliases: r.stringPtr("Aliases"), } return ret } func (r mappedResult) scrapedMovie() *models.ScrapedMovie { ret := &models.ScrapedMovie{ Name: r.stringPtr("Name"), Aliases: r.stringPtr("Aliases"), URLs: r.stringSlice("URLs"), Duration: r.stringPtr("Duration"), Date: r.stringPtr("Date"), Director: r.stringPtr("Director"), Synopsis: r.stringPtr("Synopsis"), FrontImage: r.stringPtr("FrontImage"), BackImage: r.stringPtr("BackImage"), } return ret } func (r mappedResult) scrapedGroup() *models.ScrapedGroup { ret := &models.ScrapedGroup{ Name: r.stringPtr("Name"), Aliases: r.stringPtr("Aliases"), URL: r.stringPtr("URL"), URLs: r.stringSlice("URLs"), Duration: r.stringPtr("Duration"), Date: r.stringPtr("Date"), Director: r.stringPtr("Director"), Synopsis: r.stringPtr("Synopsis"), FrontImage: r.stringPtr("FrontImage"), BackImage: r.stringPtr("BackImage"), } return ret } func (r mappedResults) scrapedMovies() []*models.ScrapedMovie { if len(r) == 0 { return nil } ret := make([]*models.ScrapedMovie, len(r)) for i, result := range r { ret[i] = result.scrapedMovie() } return ret } func (r mappedResults) scrapedGroups() []*models.ScrapedGroup { if len(r) == 0 { return nil } ret := make([]*models.ScrapedGroup, len(r)) for i, result := range r { ret[i] = result.scrapedGroup() } return ret } ================================================ FILE: pkg/scraper/mapped_result_test.go ================================================ package scraper import ( "testing" "github.com/stashapp/stash/pkg/models" "github.com/stretchr/testify/assert" ) // Test string method func TestMappedResultString(t *testing.T) { tests := []struct { name string data mappedResult key string expectedValue string expectedOk bool }{ { name: "valid string", data: mappedResult{"name": "test"}, key: "name", expectedValue: "test", expectedOk: true, }, { name: "missing key", data: mappedResult{}, key: "missing", expectedValue: "", expectedOk: false, }, { name: "wrong type still returns ok true but empty value", data: mappedResult{"num": 123}, key: "num", expectedValue: "", expectedOk: true, // logs error but returns ok=true }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { val, ok := test.data.string(test.key) assert.Equal(t, test.expectedValue, val) assert.Equal(t, test.expectedOk, ok) }) } } // Test mustString method func TestMappedResultMustString(t *testing.T) { tests := []struct { name string data mappedResult key string expectedValue string }{ { name: "valid string", data: mappedResult{"name": "test"}, key: "name", expectedValue: "test", }, { name: "missing key returns empty string", data: mappedResult{}, key: "missing", expectedValue: "", }, { name: "wrong type returns empty string", data: mappedResult{"num": 123}, key: "num", expectedValue: "", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { val := test.data.mustString(test.key) assert.Equal(t, test.expectedValue, val) }) } } // Test stringPtr method func TestMappedResultStringPtr(t *testing.T) { tests := []struct { name string data mappedResult key string expectedValue *string }{ { name: "valid string", data: mappedResult{"name": "test"}, key: "name", expectedValue: strPtr("test"), }, { name: "missing key returns nil", data: mappedResult{}, key: "missing", expectedValue: nil, }, { name: "wrong type returns non-nil pointer to empty string", data: mappedResult{"num": 123}, key: "num", expectedValue: strPtr(""), // string() returns empty string but ok=true }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { val := test.data.stringPtr(test.key) if test.expectedValue == nil { assert.Nil(t, val) } else { assert.NotNil(t, val) assert.Equal(t, *test.expectedValue, *val) } }) } } // Test stringSlice method func TestMappedResultStringSlice(t *testing.T) { tests := []struct { name string data mappedResult key string expectedValue []string }{ { name: "valid slice", data: mappedResult{"tags": []string{"a", "b", "c"}}, key: "tags", expectedValue: []string{"a", "b", "c"}, }, { name: "missing key returns nil", data: mappedResult{}, key: "missing", expectedValue: nil, }, { name: "single value converted to slice", data: mappedResult{"tags": "not a slice"}, key: "tags", expectedValue: []string{"not a slice"}, }, { name: "wrong type returns nil", data: mappedResult{"tags": 123}, key: "tags", expectedValue: nil, }, { name: "empty slice", data: mappedResult{"tags": []string{}}, key: "tags", expectedValue: []string{}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { val := test.data.stringSlice(test.key) assert.Equal(t, test.expectedValue, val) }) } } // Test IntPtr method func TestMappedResultIntPtr(t *testing.T) { tests := []struct { name string data mappedResult key string expectedValue *int }{ { name: "valid int", data: mappedResult{"duration": 120}, key: "duration", expectedValue: intPtr(120), }, { name: "missing key returns nil", data: mappedResult{}, key: "missing", expectedValue: nil, }, { name: "wrong type returns nil", data: mappedResult{"duration": "120"}, key: "duration", expectedValue: nil, }, { name: "zero value", data: mappedResult{"duration": 0}, key: "duration", expectedValue: intPtr(0), }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { val := test.data.IntPtr(test.key) assert.Equal(t, test.expectedValue, val) }) } } // Test setSingleValue method func TestMappedResultsSetSingleValue(t *testing.T) { tests := []struct { name string initialResults mappedResults index int key string value string expectedLen int shouldPanic bool }{ { name: "append to empty", initialResults: mappedResults{}, index: 0, key: "name", value: "test", expectedLen: 1, shouldPanic: false, }, { name: "set in existing", initialResults: mappedResults{mappedResult{}}, index: 0, key: "name", value: "test", expectedLen: 1, shouldPanic: false, }, { name: "append to existing", initialResults: mappedResults{mappedResult{}}, index: 1, key: "name", value: "test", expectedLen: 2, shouldPanic: false, }, { name: "sparse index causes panic", initialResults: mappedResults{mappedResult{}}, index: 5, key: "name", value: "test", expectedLen: 6, shouldPanic: true, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if test.shouldPanic { assert.Panics(t, func() { test.initialResults.setSingleValue(test.index, test.key, test.value) }) } else { results := test.initialResults.setSingleValue(test.index, test.key, test.value) assert.Equal(t, test.expectedLen, len(results)) assert.Equal(t, test.value, results[test.index][test.key]) } }) } } // Test setMultiValue method func TestMappedResultsSetMultiValue(t *testing.T) { tests := []struct { name string initialResults mappedResults index int key string value []string expectedLen int }{ { name: "append to empty", initialResults: mappedResults{}, index: 0, key: "tags", value: []string{"a", "b"}, expectedLen: 1, }, { name: "set in existing", initialResults: mappedResults{mappedResult{}}, index: 0, key: "tags", value: []string{"a", "b"}, expectedLen: 1, }, { name: "append to existing", initialResults: mappedResults{mappedResult{}}, index: 1, key: "tags", value: []string{"x", "y"}, expectedLen: 2, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { results := test.initialResults.setMultiValue(test.index, test.key, test.value) assert.Equal(t, test.expectedLen, len(results)) assert.Equal(t, test.value, results[test.index][test.key]) }) } } // Test scrapedTag method func TestMappedResultScrapedTag(t *testing.T) { tests := []struct { name string data mappedResult expectedName string }{ { name: "valid tag", data: mappedResult{"Name": "Action"}, expectedName: "Action", }, { name: "missing name", data: mappedResult{}, expectedName: "", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { tag := test.data.scrapedTag() assert.NotNil(t, tag) assert.Equal(t, test.expectedName, tag.Name) }) } } // Test scrapedTags method func TestMappedResultsScrapedTags(t *testing.T) { tests := []struct { name string data mappedResults expectedCount int expectedNames []string }{ { name: "empty results", data: mappedResults{}, expectedCount: 0, }, { name: "single tag", data: mappedResults{ mappedResult{"Name": "Action"}, }, expectedCount: 1, expectedNames: []string{"Action"}, }, { name: "multiple tags", data: mappedResults{ mappedResult{"Name": "Action"}, mappedResult{"Name": "Drama"}, mappedResult{"Name": "Comedy"}, }, expectedCount: 3, expectedNames: []string{"Action", "Drama", "Comedy"}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { tags := test.data.scrapedTags() if test.expectedCount == 0 { assert.Nil(t, tags) } else { assert.NotNil(t, tags) assert.Equal(t, test.expectedCount, len(tags)) for i, expectedName := range test.expectedNames { assert.Equal(t, expectedName, tags[i].Name) } } }) } } // Test scrapedPerformer method func TestMappedResultScrapedPerformer(t *testing.T) { tests := []struct { name string data mappedResult validate func(t *testing.T, p *models.ScrapedPerformer) }{ { name: "full performer", data: mappedResult{ "Name": "Jane Doe", "Disambiguation": "Actress", "Gender": "Female", "URL": "https://example.com/jane", "URLs": []string{"url1", "url2"}, "Twitter": "@jane", "Birthdate": "1990-01-01", "Ethnicity": "Caucasian", "Country": "USA", "EyeColor": "Blue", "Height": "5'6\"", "Measurements": "36-24-36", "FakeTits": "No", "PenisLength": "N/A", "Circumcised": "N/A", "CareerLength": "10 years", "Tattoos": "Yes", "Piercings": "Yes", "Aliases": "Jane Smith", "Image": "image.jpg", "Images": []string{"img1", "img2"}, "Details": "Some details", "DeathDate": "N/A", "HairColor": "Blonde", "Weight": "130 lbs", }, validate: func(t *testing.T, p *models.ScrapedPerformer) { assert.NotNil(t, p) assert.Equal(t, "Jane Doe", *p.Name) assert.Equal(t, "Actress", *p.Disambiguation) assert.Equal(t, "Female", *p.Gender) assert.Equal(t, "https://example.com/jane", *p.URL) assert.Equal(t, []string{"url1", "url2"}, p.URLs) assert.Equal(t, "@jane", *p.Twitter) assert.Equal(t, "Blonde", *p.HairColor) assert.Equal(t, "130 lbs", *p.Weight) }, }, { name: "minimal performer", data: mappedResult{}, validate: func(t *testing.T, p *models.ScrapedPerformer) { assert.NotNil(t, p) assert.Nil(t, p.Name) assert.Nil(t, p.Gender) assert.Empty(t, p.URLs) }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { performer := test.data.scrapedPerformer() test.validate(t, performer) }) } } // Test scrapedPerformers method func TestMappedResultsScrapedPerformers(t *testing.T) { tests := []struct { name string data mappedResults expectedCount int }{ { name: "empty results", data: mappedResults{}, expectedCount: 0, }, { name: "single performer", data: mappedResults{ mappedResult{"Name": "Jane Doe"}, }, expectedCount: 1, }, { name: "multiple performers", data: mappedResults{ mappedResult{"Name": "Jane Doe"}, mappedResult{"Name": "John Doe"}, mappedResult{"Name": "Alice"}, }, expectedCount: 3, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { performers := test.data.scrapedPerformers() if test.expectedCount == 0 { assert.Nil(t, performers) } else { assert.NotNil(t, performers) assert.Equal(t, test.expectedCount, len(performers)) } }) } } // Test scrapedScene method func TestMappedResultScrapedScene(t *testing.T) { tests := []struct { name string data mappedResult validate func(t *testing.T, s *models.ScrapedScene) }{ { name: "full scene", data: mappedResult{ "Title": "Scene Title", "Code": "CODE123", "Details": "Scene details", "Director": "John Smith", "URL": "https://example.com/scene", "URLs": []string{"url1", "url2"}, "Date": "2020-01-01", "Image": "scene.jpg", "Duration": 3600, }, validate: func(t *testing.T, s *models.ScrapedScene) { assert.NotNil(t, s) assert.Equal(t, "Scene Title", *s.Title) assert.Equal(t, "CODE123", *s.Code) assert.Equal(t, "Scene details", *s.Details) assert.Equal(t, "John Smith", *s.Director) assert.Equal(t, "https://example.com/scene", *s.URL) assert.Equal(t, []string{"url1", "url2"}, s.URLs) assert.Equal(t, "2020-01-01", *s.Date) assert.Equal(t, "scene.jpg", *s.Image) assert.Equal(t, 3600, *s.Duration) }, }, { name: "minimal scene", data: mappedResult{}, validate: func(t *testing.T, s *models.ScrapedScene) { assert.NotNil(t, s) assert.Nil(t, s.Title) assert.Nil(t, s.Duration) assert.Empty(t, s.URLs) }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { scene := test.data.scrapedScene() test.validate(t, scene) }) } } // Test scrapedImage method func TestMappedResultScrapedImage(t *testing.T) { tests := []struct { name string data mappedResult validate func(t *testing.T, i *models.ScrapedImage) }{ { name: "full image", data: mappedResult{ "Title": "Image Title", "Code": "IMG123", "Details": "Image details", "Photographer": "Jane Photographer", "URLs": []string{"url1", "url2"}, "Date": "2020-06-15", }, validate: func(t *testing.T, i *models.ScrapedImage) { assert.NotNil(t, i) assert.Equal(t, "Image Title", *i.Title) assert.Equal(t, "IMG123", *i.Code) assert.Equal(t, "Image details", *i.Details) assert.Equal(t, "Jane Photographer", *i.Photographer) assert.Equal(t, []string{"url1", "url2"}, i.URLs) assert.Equal(t, "2020-06-15", *i.Date) }, }, { name: "minimal image", data: mappedResult{}, validate: func(t *testing.T, i *models.ScrapedImage) { assert.NotNil(t, i) assert.Nil(t, i.Title) assert.Empty(t, i.URLs) }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { image := test.data.scrapedImage() test.validate(t, image) }) } } // Test scrapedGallery method func TestMappedResultScrapedGallery(t *testing.T) { tests := []struct { name string data mappedResult validate func(t *testing.T, g *models.ScrapedGallery) }{ { name: "full gallery", data: mappedResult{ "Title": "Gallery Title", "Code": "GAL123", "Details": "Gallery details", "Photographer": "Jane Photographer", "URL": "https://example.com/gallery", "URLs": []string{"url1", "url2"}, "Date": "2020-07-20", }, validate: func(t *testing.T, g *models.ScrapedGallery) { assert.NotNil(t, g) assert.Equal(t, "Gallery Title", *g.Title) assert.Equal(t, "GAL123", *g.Code) assert.Equal(t, "Gallery details", *g.Details) assert.Equal(t, "Jane Photographer", *g.Photographer) assert.Equal(t, "https://example.com/gallery", *g.URL) assert.Equal(t, []string{"url1", "url2"}, g.URLs) assert.Equal(t, "2020-07-20", *g.Date) }, }, { name: "minimal gallery", data: mappedResult{}, validate: func(t *testing.T, g *models.ScrapedGallery) { assert.NotNil(t, g) assert.Nil(t, g.Title) assert.Empty(t, g.URLs) }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { gallery := test.data.scrapedGallery() test.validate(t, gallery) }) } } // Test scrapedStudio method func TestMappedResultScrapedStudio(t *testing.T) { tests := []struct { name string data mappedResult validate func(t *testing.T, st *models.ScrapedStudio) }{ { name: "full studio", data: mappedResult{ "Name": "Studio Name", "URL": "https://example.com/studio", "URLs": []string{"url1", "url2"}, "Image": "studio.jpg", "Details": "Studio details", "Aliases": "Studio Alias", }, validate: func(t *testing.T, st *models.ScrapedStudio) { assert.NotNil(t, st) assert.Equal(t, "Studio Name", st.Name) assert.Equal(t, "https://example.com/studio", *st.URL) assert.Equal(t, []string{"url1", "url2"}, st.URLs) assert.Equal(t, "studio.jpg", *st.Image) assert.Equal(t, "Studio details", *st.Details) assert.Equal(t, "Studio Alias", *st.Aliases) }, }, { name: "minimal studio", data: mappedResult{}, validate: func(t *testing.T, st *models.ScrapedStudio) { assert.NotNil(t, st) assert.Equal(t, "", st.Name) // mustString returns empty string assert.Nil(t, st.URL) assert.Empty(t, st.URLs) }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { studio := test.data.scrapedStudio() test.validate(t, studio) }) } } // Test scrapedMovie method func TestMappedResultScrapedMovie(t *testing.T) { tests := []struct { name string data mappedResult validate func(t *testing.T, m *models.ScrapedMovie) }{ { name: "full movie", data: mappedResult{ "Name": "Movie Title", "Aliases": "Movie Alias", "URLs": []string{"url1", "url2"}, "Duration": "120 minutes", "Date": "2020-05-10", "Director": "John Director", "Synopsis": "Movie synopsis", "FrontImage": "front.jpg", "BackImage": "back.jpg", }, validate: func(t *testing.T, m *models.ScrapedMovie) { assert.NotNil(t, m) assert.Equal(t, "Movie Title", *m.Name) assert.Equal(t, "Movie Alias", *m.Aliases) assert.Equal(t, []string{"url1", "url2"}, m.URLs) assert.Equal(t, "120 minutes", *m.Duration) assert.Equal(t, "2020-05-10", *m.Date) assert.Equal(t, "John Director", *m.Director) assert.Equal(t, "Movie synopsis", *m.Synopsis) assert.Equal(t, "front.jpg", *m.FrontImage) assert.Equal(t, "back.jpg", *m.BackImage) }, }, { name: "minimal movie", data: mappedResult{}, validate: func(t *testing.T, m *models.ScrapedMovie) { assert.NotNil(t, m) assert.Nil(t, m.Name) assert.Empty(t, m.URLs) }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { movie := test.data.scrapedMovie() test.validate(t, movie) }) } } // Test scrapedMovies method func TestMappedResultsScrapedMovies(t *testing.T) { tests := []struct { name string data mappedResults expectedCount int }{ { name: "empty results", data: mappedResults{}, expectedCount: 0, }, { name: "single movie", data: mappedResults{ mappedResult{"Name": "Movie 1"}, }, expectedCount: 1, }, { name: "multiple movies", data: mappedResults{ mappedResult{"Name": "Movie 1"}, mappedResult{"Name": "Movie 2"}, mappedResult{"Name": "Movie 3"}, }, expectedCount: 3, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { movies := test.data.scrapedMovies() if test.expectedCount == 0 { assert.Nil(t, movies) } else { assert.NotNil(t, movies) assert.Equal(t, test.expectedCount, len(movies)) } }) } } // Test scrapedGroup method func TestMappedResultScrapedGroup(t *testing.T) { tests := []struct { name string data mappedResult validate func(t *testing.T, g *models.ScrapedGroup) }{ { name: "full group", data: mappedResult{ "Name": "Group Title", "Aliases": "Group Alias", "URL": "https://example.com/group", "URLs": []string{"url1", "url2"}, "Duration": "240 minutes", "Date": "2020-08-15", "Director": "Jane Director", "Synopsis": "Group synopsis", "FrontImage": "front.jpg", "BackImage": "back.jpg", }, validate: func(t *testing.T, g *models.ScrapedGroup) { assert.NotNil(t, g) assert.Equal(t, "Group Title", *g.Name) assert.Equal(t, "Group Alias", *g.Aliases) assert.Equal(t, "https://example.com/group", *g.URL) assert.Equal(t, []string{"url1", "url2"}, g.URLs) assert.Equal(t, "240 minutes", *g.Duration) assert.Equal(t, "2020-08-15", *g.Date) assert.Equal(t, "Jane Director", *g.Director) assert.Equal(t, "Group synopsis", *g.Synopsis) assert.Equal(t, "front.jpg", *g.FrontImage) assert.Equal(t, "back.jpg", *g.BackImage) }, }, { name: "minimal group", data: mappedResult{}, validate: func(t *testing.T, g *models.ScrapedGroup) { assert.NotNil(t, g) assert.Nil(t, g.Name) assert.Empty(t, g.URLs) }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { group := test.data.scrapedGroup() test.validate(t, group) }) } } // Test scrapedGroups method func TestMappedResultsScrapedGroups(t *testing.T) { tests := []struct { name string data mappedResults expectedCount int }{ { name: "empty results", data: mappedResults{}, expectedCount: 0, }, { name: "single group", data: mappedResults{ mappedResult{"Name": "Group 1"}, }, expectedCount: 1, }, { name: "multiple groups", data: mappedResults{ mappedResult{"Name": "Group 1"}, mappedResult{"Name": "Group 2"}, mappedResult{"Name": "Group 3"}, }, expectedCount: 3, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { groups := test.data.scrapedGroups() if test.expectedCount == 0 { assert.Nil(t, groups) } else { assert.NotNil(t, groups) assert.Equal(t, test.expectedCount, len(groups)) } }) } } // Helper functions func strPtr(s string) *string { return &s } func intPtr(i int) *int { return &i } ================================================ FILE: pkg/scraper/mapped_test.go ================================================ package scraper import ( "context" "strconv" "testing" "time" "github.com/stretchr/testify/assert" "gopkg.in/yaml.v2" ) func TestInvalidPostProcessAction(t *testing.T) { yamlStr := `name: Test performerByURL: - action: scrapeXPath scraper: performerScraper xPathScrapers: performerScraper: performer: Name: selector: //div/a/@href postProcess: - parseDate: Jan 2, 2006 - anything ` c := &Definition{} err := yaml.Unmarshal([]byte(yamlStr), &c) if err == nil { t.Error("expected error unmarshalling with invalid post-process action") return } } type feetToCMTest struct { in string out string } var feetToCMTests = []feetToCMTest{ {"", "0"}, {"a", "0"}, {"6", "183"}, {"6 feet", "183"}, {"6ft0", "183"}, {"6ft2", "188"}, {"6'2\"", "188"}, {"6.2", "188"}, {"6ft2.99", "188"}, {"text6other2", "188"}, } func TestFeetToCM(t *testing.T) { pp := postProcessFeetToCm(true) q := &xpathQuery{} for _, test := range feetToCMTests { assert.Equal(t, test.out, pp.Apply(context.Background(), test.in, q)) } } func Test_postProcessParseDate_Apply(t *testing.T) { const internalDateFormat = "2006-01-02" unixDate := time.Date(2021, 9, 4, 1, 2, 3, 4, time.Local) tests := []struct { name string arg postProcessParseDate value string want string }{ { "simple", "2006=01=02", "2001=03=23", "2001-03-23", }, { "today", "", "today", time.Now().Format(internalDateFormat), }, { "yesterday", "", "yesterday", time.Now().Add(-24 * time.Hour).Format(internalDateFormat), }, { "unix", "unix", strconv.FormatInt(unixDate.Unix(), 10), unixDate.Format(internalDateFormat), }, { "invalid", "invalid", "2001=03=23", "2001=03=23", }, } ctx := context.Background() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.arg.Apply(ctx, tt.value, nil); got != tt.want { t.Errorf("postProcessParseDate.Apply() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: pkg/scraper/movie.go ================================================ package scraper type ScrapedMovieInput struct { Name *string `json:"name"` Aliases *string `json:"aliases"` Duration *string `json:"duration"` Date *string `json:"date"` Rating *string `json:"rating"` Director *string `json:"director"` URLs []string `json:"urls"` Synopsis *string `json:"synopsis"` // deprecated URL *string `json:"url"` } ================================================ FILE: pkg/scraper/performer.go ================================================ package scraper type ScrapedPerformerInput struct { // Set if performer matched StoredID *string `json:"stored_id"` Name *string `json:"name"` Disambiguation *string `json:"disambiguation"` Gender *string `json:"gender"` URLs []string `json:"urls"` URL *string `json:"url"` // deprecated Twitter *string `json:"twitter"` // deprecated Instagram *string `json:"instagram"` // deprecated Birthdate *string `json:"birthdate"` Ethnicity *string `json:"ethnicity"` Country *string `json:"country"` EyeColor *string `json:"eye_color"` Height *string `json:"height"` Measurements *string `json:"measurements"` FakeTits *string `json:"fake_tits"` PenisLength *string `json:"penis_length"` Circumcised *string `json:"circumcised"` CareerLength *string `json:"career_length"` CareerStart *string `json:"career_start"` CareerEnd *string `json:"career_end"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` Aliases *string `json:"aliases"` Details *string `json:"details"` DeathDate *string `json:"death_date"` HairColor *string `json:"hair_color"` Weight *string `json:"weight"` RemoteSiteID *string `json:"remote_site_id"` } ================================================ FILE: pkg/scraper/post_processing_test.go ================================================ package scraper import ( "context" "testing" "github.com/stashapp/stash/pkg/models" ) func TestPostScrapePerformerCareerLength(t *testing.T) { ctx := context.Background() const related = false strPtr := func(s string) *string { return &s } tests := []struct { name string input models.ScrapedPerformer want models.ScrapedPerformer }{ { "start = 2000", models.ScrapedPerformer{ CareerStart: strPtr("2000"), }, models.ScrapedPerformer{ CareerStart: strPtr("2000"), CareerLength: strPtr("2000 -"), }, }, { "end = 2000", models.ScrapedPerformer{ CareerEnd: strPtr("2000"), }, models.ScrapedPerformer{ CareerEnd: strPtr("2000"), CareerLength: strPtr("- 2000"), }, }, { "start = 2000, end = 2020", models.ScrapedPerformer{ CareerStart: strPtr("2000"), CareerEnd: strPtr("2020"), }, models.ScrapedPerformer{ CareerStart: strPtr("2000"), CareerEnd: strPtr("2020"), CareerLength: strPtr("2000 - 2020"), }, }, { "length = 2000 -", models.ScrapedPerformer{ CareerLength: strPtr("2000 -"), }, models.ScrapedPerformer{ CareerStart: strPtr("2000"), CareerLength: strPtr("2000 -"), }, }, { "length = - 2010", models.ScrapedPerformer{ CareerLength: strPtr("- 2010"), }, models.ScrapedPerformer{ CareerEnd: strPtr("2010"), CareerLength: strPtr("- 2010"), }, }, { "length = 2000 - 2010", models.ScrapedPerformer{ CareerLength: strPtr("2000 - 2010"), }, models.ScrapedPerformer{ CareerStart: strPtr("2000"), CareerEnd: strPtr("2010"), CareerLength: strPtr("2000 - 2010"), }, }, { "invalid start", models.ScrapedPerformer{ CareerStart: strPtr("two thousand"), }, models.ScrapedPerformer{ CareerStart: strPtr("two thousand"), }, }, { "invalid end", models.ScrapedPerformer{ CareerEnd: strPtr("two thousand"), }, models.ScrapedPerformer{ CareerEnd: strPtr("two thousand"), }, }, { "invalid career length", models.ScrapedPerformer{ CareerLength: strPtr("1234 - 4567 - 9224"), }, models.ScrapedPerformer{ CareerLength: strPtr("1234 - 4567 - 9224"), }, }, } compareStrPtr := func(a, b *string) bool { if a == b { return true } if a == nil || b == nil { return false } return *a == *b } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &postScraper{} got, err := c.postScrapePerformer(ctx, tt.input, related) if err != nil { t.Fatalf("postScrapePerformer returned error: %v", err) } postScraped := got.(models.ScrapedPerformer) if !compareStrPtr(postScraped.CareerStart, tt.want.CareerStart) { t.Errorf("CareerStart = %v, want %v", postScraped.CareerStart, tt.want.CareerStart) } if !compareStrPtr(postScraped.CareerEnd, tt.want.CareerEnd) { t.Errorf("CareerEnd = %v, want %v", postScraped.CareerEnd, tt.want.CareerEnd) } if !compareStrPtr(postScraped.CareerLength, tt.want.CareerLength) { t.Errorf("CareerLength = %v, want %v", postScraped.CareerLength, tt.want.CareerLength) } }) } } ================================================ FILE: pkg/scraper/postprocessing.go ================================================ package scraper import ( "context" "regexp" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/utils" ) type postScraper struct { Cache excludeTagRE []*regexp.Regexp // ignoredTags is a list of tags that were ignored during post-processing ignoredTags []string } // postScrape handles post-processing of scraped content. If the content // requires post-processing, this function fans out to the given content // type and post-processes it. // Assumes called within a read transaction. func (c *postScraper) postScrape(ctx context.Context, content ScrapedContent) (_ ScrapedContent, err error) { const related = false // Analyze the concrete type, call the right post-processing function switch v := content.(type) { case *models.ScrapedPerformer: if v != nil { return c.postScrapePerformer(ctx, *v, related) } case models.ScrapedPerformer: return c.postScrapePerformer(ctx, v, related) case *models.ScrapedScene: if v != nil { return c.postScrapeScene(ctx, *v) } case models.ScrapedScene: return c.postScrapeScene(ctx, v) case *models.ScrapedGallery: if v != nil { return c.postScrapeGallery(ctx, *v) } case models.ScrapedGallery: return c.postScrapeGallery(ctx, v) case *models.ScrapedImage: if v != nil { return c.postScrapeImage(ctx, *v) } case models.ScrapedImage: return c.postScrapeImage(ctx, v) case *models.ScrapedMovie: if v != nil { return c.postScrapeMovie(ctx, *v, related) } case models.ScrapedMovie: return c.postScrapeMovie(ctx, v, related) case *models.ScrapedGroup: if v != nil { return c.postScrapeGroup(ctx, *v, related) } case models.ScrapedGroup: return c.postScrapeGroup(ctx, v, related) } // If nothing matches, pass the content through return content, nil } func (c *postScraper) filterTags(tags []*models.ScrapedTag) []*models.ScrapedTag { var ret []*models.ScrapedTag var thisIgnoredTags []string ret, thisIgnoredTags = FilterTags(c.excludeTagRE, tags) c.ignoredTags = sliceutil.AppendUniques(c.ignoredTags, thisIgnoredTags) return ret } func (c *postScraper) postScrapePerformer(ctx context.Context, p models.ScrapedPerformer, related bool) (_ ScrapedContent, err error) { r := c.repository tqb := r.TagFinder tags, err := postProcessTags(ctx, tqb, p.Tags) if err != nil { return nil, err } p.Tags = c.filterTags(tags) // post-process - set the image if applicable // don't set image for related performers to avoid excessive network calls if !related { if err := setPerformerImage(ctx, c.client, &p, c.globalConfig); err != nil { logger.Warnf("Could not set image using URL %s: %s", *p.Image, err.Error()) } } p.Country = resolveCountryName(p.Country) // populate URL/URLs // if URLs are provided, only use those if len(p.URLs) > 0 { p.URL = &p.URLs[0] } else { urls := []string{} if p.URL != nil { urls = append(urls, *p.URL) } if p.Twitter != nil && *p.Twitter != "" { // handle twitter profile names u := utils.URLFromHandle(*p.Twitter, "https://twitter.com") urls = append(urls, u) } if p.Instagram != nil && *p.Instagram != "" { // handle instagram profile names u := utils.URLFromHandle(*p.Instagram, "https://instagram.com") urls = append(urls, u) } if len(urls) > 0 { p.URLs = urls } } c.postProcessCareerLength(&p) return p, nil } func (c *postScraper) postProcessCareerLength(p *models.ScrapedPerformer) { isEmptyStr := func(s *string) bool { return s == nil || *s == "" } // populate career start/end from career length and vice versa if !isEmptyStr(p.CareerLength) && isEmptyStr(p.CareerStart) && isEmptyStr(p.CareerEnd) { start, end, err := models.ParseYearRangeString(*p.CareerLength) if err != nil { logger.Warnf("Could not parse career length %s: %v", *p.CareerLength, err) return } if start != nil { startStr := start.String() p.CareerStart = &startStr } if end != nil { endStr := end.String() p.CareerEnd = &endStr } return } // populate career length from career start/end if career length is missing if isEmptyStr(p.CareerLength) { var ( start *models.Date end *models.Date ) if !isEmptyStr(p.CareerStart) { date, err := models.ParseDate(*p.CareerStart) if err != nil { logger.Warnf("Could not parse career start %s: %v", *p.CareerStart, err) return } start = &date } if !isEmptyStr(p.CareerEnd) { date, err := models.ParseDate(*p.CareerEnd) if err != nil { logger.Warnf("Could not parse career end %s: %v", *p.CareerEnd, err) return } end = &date } v := models.FormatYearRange(start, end) p.CareerLength = &v } } func (c *postScraper) postScrapeMovie(ctx context.Context, m models.ScrapedMovie, related bool) (_ ScrapedContent, err error) { r := c.repository tqb := r.TagFinder tags, err := postProcessTags(ctx, tqb, m.Tags) if err != nil { return nil, err } m.Tags = c.filterTags(tags) if m.Studio != nil { if err := match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, ""); err != nil { return nil, err } } // populate URL/URLs // if URLs are provided, only use those if len(m.URLs) > 0 { m.URL = &m.URLs[0] } else { urls := []string{} if m.URL != nil { urls = append(urls, *m.URL) } if len(urls) > 0 { m.URLs = urls } } // post-process - set the image if applicable // don't set images for related movies to avoid excessive network calls if !related { if err := processImageField(ctx, m.FrontImage, c.client, c.globalConfig); err != nil { logger.Warnf("could not set front image using URL %s: %v", *m.FrontImage, err) } if err := processImageField(ctx, m.BackImage, c.client, c.globalConfig); err != nil { logger.Warnf("could not set back image using URL %s: %v", *m.BackImage, err) } } return m, nil } func (c *postScraper) postScrapeGroup(ctx context.Context, m models.ScrapedGroup, related bool) (_ ScrapedContent, err error) { r := c.repository tqb := r.TagFinder tags, err := postProcessTags(ctx, tqb, m.Tags) if err != nil { return nil, err } m.Tags = c.filterTags(tags) if m.Studio != nil { if err := match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, ""); err != nil { return nil, err } } // populate URL/URLs // if URLs are provided, only use those if len(m.URLs) > 0 { m.URL = &m.URLs[0] } else { urls := []string{} if m.URL != nil { urls = append(urls, *m.URL) } if len(urls) > 0 { m.URLs = urls } } // post-process - set the image if applicable // don't set images for related groups to avoid excessive network calls if !related { if err := processImageField(ctx, m.FrontImage, c.client, c.globalConfig); err != nil { logger.Warnf("could not set front image using URL %s: %v", *m.FrontImage, err) } if err := processImageField(ctx, m.BackImage, c.client, c.globalConfig); err != nil { logger.Warnf("could not set back image using URL %s: %v", *m.BackImage, err) } } return m, nil } // postScrapeRelatedPerformers post-processes a list of performers. // It modifies the performers in place. func (c *postScraper) postScrapeRelatedPerformers(ctx context.Context, items []*models.ScrapedPerformer) error { for _, p := range items { if p == nil { continue } const related = true sc, err := c.postScrapePerformer(ctx, *p, related) if err != nil { return err } newP := sc.(models.ScrapedPerformer) *p = newP if err := match.ScrapedPerformer(ctx, c.repository.PerformerFinder, p, ""); err != nil { return err } } return nil } func (c *postScraper) postScrapeRelatedMovies(ctx context.Context, items []*models.ScrapedMovie) error { for _, p := range items { const related = true sc, err := c.postScrapeMovie(ctx, *p, related) if err != nil { return err } newP := sc.(models.ScrapedMovie) *p = newP matchedID, err := match.ScrapedGroup(ctx, c.repository.GroupFinder, p.StoredID, p.Name) if err != nil { return err } if matchedID != nil { p.StoredID = matchedID } } return nil } func (c *postScraper) postScrapeRelatedGroups(ctx context.Context, items []*models.ScrapedGroup) error { for _, p := range items { const related = true sc, err := c.postScrapeGroup(ctx, *p, related) if err != nil { return err } newP := sc.(models.ScrapedGroup) *p = newP matchedID, err := match.ScrapedGroup(ctx, c.repository.GroupFinder, p.StoredID, p.Name) if err != nil { return err } if matchedID != nil { p.StoredID = matchedID } } return nil } func (c *postScraper) postScrapeStudio(ctx context.Context, s models.ScrapedStudio, related bool) (_ ScrapedContent, err error) { r := c.repository tqb := r.TagFinder tags, err := postProcessTags(ctx, tqb, s.Tags) if err != nil { return nil, err } s.Tags = c.filterTags(tags) // post-process - set the image if applicable // don't set image for related studios to avoid excessive network calls if !related { if err := setStudioImage(ctx, c.client, &s, c.globalConfig); err != nil { logger.Warnf("Could not set image using URL %s: %s", *s.Image, err.Error()) } } // populate URL/URLs // if URLs are provided, only use those if len(s.URLs) > 0 { s.URL = &s.URLs[0] } else { urls := []string{} if s.URL != nil { urls = append(urls, *s.URL) } if len(urls) > 0 { s.URLs = urls } } return s, nil } func (c *postScraper) postScrapeRelatedStudio(ctx context.Context, s *models.ScrapedStudio) error { if s == nil { return nil } const related = true sc, err := c.postScrapeStudio(ctx, *s, related) if err != nil { return err } newS := sc.(models.ScrapedStudio) *s = newS if err = match.ScrapedStudio(ctx, c.repository.StudioFinder, s, ""); err != nil { return err } return nil } func (c *postScraper) postScrapeScene(ctx context.Context, scene models.ScrapedScene) (_ ScrapedContent, err error) { // set the URL/URLs field if scene.URL == nil && len(scene.URLs) > 0 { scene.URL = &scene.URLs[0] } if scene.URL != nil && len(scene.URLs) == 0 { scene.URLs = []string{*scene.URL} } r := c.repository tqb := r.TagFinder if err = c.postScrapeRelatedPerformers(ctx, scene.Performers); err != nil { return nil, err } if err = c.postScrapeRelatedMovies(ctx, scene.Movies); err != nil { return nil, err } if err = c.postScrapeRelatedGroups(ctx, scene.Groups); err != nil { return nil, err } // HACK - if movies was returned but not groups, add the groups from the movies // if groups was returned but not movies, add the movies from the groups for backward compatibility if len(scene.Movies) > 0 && len(scene.Groups) == 0 { for _, m := range scene.Movies { g := m.ScrapedGroup() scene.Groups = append(scene.Groups, &g) } } else if len(scene.Groups) > 0 && len(scene.Movies) == 0 { for _, g := range scene.Groups { m := g.ScrapedMovie() scene.Movies = append(scene.Movies, &m) } } tags, err := postProcessTags(ctx, tqb, scene.Tags) if err != nil { return nil, err } scene.Tags = c.filterTags(tags) if err := c.postScrapeRelatedStudio(ctx, scene.Studio); err != nil { return nil, err } // post-process - set the image if applicable if err := processImageField(ctx, scene.Image, c.client, c.globalConfig); err != nil { logger.Warnf("Could not set image using URL %s: %v", *scene.Image, err) } return scene, nil } func (c *postScraper) postScrapeGallery(ctx context.Context, g models.ScrapedGallery) (_ ScrapedContent, err error) { // set the URL/URLs field if g.URL == nil && len(g.URLs) > 0 { g.URL = &g.URLs[0] } if g.URL != nil && len(g.URLs) == 0 { g.URLs = []string{*g.URL} } r := c.repository tqb := r.TagFinder if err = c.postScrapeRelatedPerformers(ctx, g.Performers); err != nil { return nil, err } tags, err := postProcessTags(ctx, tqb, g.Tags) if err != nil { return nil, err } g.Tags = c.filterTags(tags) if err := c.postScrapeRelatedStudio(ctx, g.Studio); err != nil { return nil, err } return g, nil } func (c *postScraper) postScrapeImage(ctx context.Context, image models.ScrapedImage) (_ ScrapedContent, err error) { r := c.repository tqb := r.TagFinder if err = c.postScrapeRelatedPerformers(ctx, image.Performers); err != nil { return nil, err } tags, err := postProcessTags(ctx, tqb, image.Tags) if err != nil { return nil, err } image.Tags = c.filterTags(tags) if err := c.postScrapeRelatedStudio(ctx, image.Studio); err != nil { return nil, err } return image, nil } // postScrapeSingle handles post-processing of a single scraped content item. // This is a convenience function that includes logging the ignored tags, as opposed to logging them in the caller. func (c Cache) postScrapeSingle(ctx context.Context, content ScrapedContent) (ret ScrapedContent, err error) { pp := postScraper{ Cache: c, excludeTagRE: c.compileExcludeTagPatterns(), } if err := c.repository.WithReadTxn(ctx, func(ctx context.Context) error { ret, err = pp.postScrape(ctx, content) if err != nil { return err } return nil }); err != nil { return nil, err } LogIgnoredTags(pp.ignoredTags) return ret, nil } ================================================ FILE: pkg/scraper/query_url.go ================================================ package scraper import ( "path/filepath" "strings" "github.com/stashapp/stash/pkg/models" ) type queryURLReplacements map[string]mappedRegexConfigs type queryURLParameters map[string]string func queryURLParametersFromScene(scene *models.Scene) queryURLParameters { ret := make(queryURLParameters) ret["checksum"] = scene.Checksum ret["oshash"] = scene.OSHash ret["filename"] = filepath.Base(scene.Path) // pull phash from primary file phashFingerprints := scene.Files.Primary().Base().Fingerprints.Filter(models.FingerprintTypePhash) if len(phashFingerprints) > 0 { ret["phash"] = phashFingerprints[0].Value() } if scene.Title != "" { ret["title"] = scene.Title } if len(scene.URLs.List()) > 0 { ret["url"] = scene.URLs.List()[0] } return ret } func queryURLParametersFromScrapedScene(scene models.ScrapedSceneInput) queryURLParameters { ret := make(queryURLParameters) setField := func(field string, value *string) { if value != nil { ret[field] = *value } } setField("title", scene.Title) setField("code", scene.Code) if len(scene.URLs) > 0 { setField("url", &scene.URLs[0]) } else { setField("url", scene.URL) } setField("date", scene.Date) setField("details", scene.Details) setField("director", scene.Director) setField("remote_site_id", scene.RemoteSiteID) return ret } func queryURLParameterFromURL(url string) queryURLParameters { ret := make(queryURLParameters) ret["url"] = url return ret } func queryURLParametersFromGallery(gallery *models.Gallery) queryURLParameters { ret := make(queryURLParameters) ret["checksum"] = gallery.PrimaryChecksum() if gallery.Path != "" { ret["filename"] = filepath.Base(gallery.Path) } if gallery.Title != "" { ret["title"] = gallery.Title } if len(gallery.URLs.List()) > 0 { ret["url"] = gallery.URLs.List()[0] } return ret } func queryURLParametersFromImage(image *models.Image) queryURLParameters { ret := make(queryURLParameters) ret["checksum"] = image.Checksum if image.Path != "" { ret["filename"] = filepath.Base(image.Path) } if image.Title != "" { ret["title"] = image.Title } if len(image.URLs.List()) > 0 { ret["url"] = image.URLs.List()[0] } return ret } func (p queryURLParameters) applyReplacements(r queryURLReplacements) { for k, v := range p { rpl, found := r[k] if found { p[k] = rpl.apply(v) } } } func (p queryURLParameters) constructURL(url string) string { ret := url for k, v := range p { ret = strings.ReplaceAll(ret, "{"+k+"}", v) } return ret } // replaceURL does a partial URL Replace ( only url parameter is used) func replaceURL(url string, scraperConfig ByURLDefinition) string { u := url queryURL := queryURLParameterFromURL(u) if scraperConfig.QueryURLReplacements != nil { queryURL.applyReplacements(scraperConfig.QueryURLReplacements) u = queryURL.constructURL(scraperConfig.QueryURL) } return u } ================================================ FILE: pkg/scraper/scraper.go ================================================ // Package scraper provides interfaces to interact with the scraper subsystem. // The [Cache] type is the main entry point to the scraper subsystem. package scraper import ( "context" "errors" "fmt" "io" "net/http" "strconv" "github.com/stashapp/stash/pkg/models" ) type Source struct { // Index of the configured stash-box instance to use. Should be unset if scraper_id is set StashBoxIndex *int `json:"stash_box_index"` // Stash-box endpoint StashBoxEndpoint *string `json:"stash_box_endpoint"` // Scraper ID to scrape with. Should be unset if stash_box_index is set ScraperID *string `json:"scraper_id"` } // Scraped Content is the forming union over the different scrapers type ScrapedContent interface { IsScrapedContent() } // Type of the content a scraper generates type ScrapeContentType string const ( ScrapeContentTypeGallery ScrapeContentType = "GALLERY" ScrapeContentTypeMovie ScrapeContentType = "MOVIE" ScrapeContentTypeGroup ScrapeContentType = "GROUP" ScrapeContentTypePerformer ScrapeContentType = "PERFORMER" ScrapeContentTypeScene ScrapeContentType = "SCENE" ScrapeContentTypeImage ScrapeContentType = "IMAGE" ) var AllScrapeContentType = []ScrapeContentType{ ScrapeContentTypeGallery, ScrapeContentTypeMovie, ScrapeContentTypeGroup, ScrapeContentTypePerformer, ScrapeContentTypeScene, ScrapeContentTypeImage, } func (e ScrapeContentType) IsValid() bool { switch e { case ScrapeContentTypeGallery, ScrapeContentTypeMovie, ScrapeContentTypeGroup, ScrapeContentTypePerformer, ScrapeContentTypeScene, ScrapeContentTypeImage: return true } return false } func (e ScrapeContentType) String() string { return string(e) } func (e *ScrapeContentType) UnmarshalGQL(v interface{}) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = ScrapeContentType(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid ScrapeContentType", str) } return nil } func (e ScrapeContentType) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } type Scraper struct { ID string `json:"id"` Name string `json:"name"` // Details for performer scraper Performer *ScraperSpec `json:"performer"` // Details for scene scraper Scene *ScraperSpec `json:"scene"` // Details for gallery scraper Gallery *ScraperSpec `json:"gallery"` // Details for image scraper Image *ScraperSpec `json:"image"` // Details for movie scraper Group *ScraperSpec `json:"group"` // Details for movie scraper Movie *ScraperSpec `json:"movie"` } type ScraperSpec struct { // URLs matching these can be scraped with Urls []string `json:"urls"` SupportedScrapes []ScrapeType `json:"supported_scrapes"` } type ScrapeType string const ( // From text query ScrapeTypeName ScrapeType = "NAME" // From existing object ScrapeTypeFragment ScrapeType = "FRAGMENT" // From URL ScrapeTypeURL ScrapeType = "URL" ) var AllScrapeType = []ScrapeType{ ScrapeTypeName, ScrapeTypeFragment, ScrapeTypeURL, } func (e ScrapeType) IsValid() bool { switch e { case ScrapeTypeName, ScrapeTypeFragment, ScrapeTypeURL: return true } return false } func (e ScrapeType) String() string { return string(e) } func (e *ScrapeType) UnmarshalGQL(v interface{}) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = ScrapeType(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid ScrapeType", str) } return nil } func (e ScrapeType) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } var ( // ErrMaxRedirects is returned if the max number of HTTP redirects are reached. ErrMaxRedirects = errors.New("maximum number of HTTP redirects reached") // ErrNotFound is returned when an entity isn't found ErrNotFound = errors.New("scraper not found") // ErrNotSupported is returned when a given invocation isn't supported, and there // is a guard function which should be able to guard against it. ErrNotSupported = errors.New("scraper operation not supported") ) // Input coalesces inputs of different types into a single structure. // The system expects one of these to be set, and the remaining to be // set to nil. type Input struct { Performer *ScrapedPerformerInput Scene *models.ScrapedSceneInput Gallery *models.ScrapedGalleryInput Image *models.ScrapedImageInput } // populateURL populates the URL field of the input based on the // URLs field of the input. Does nothing if the URL field is already set. func (i *Input) populateURL() { if i.Scene != nil && i.Scene.URL == nil && len(i.Scene.URLs) > 0 { i.Scene.URL = &i.Scene.URLs[0] } if i.Gallery != nil && i.Gallery.URL == nil && len(i.Gallery.URLs) > 0 { i.Gallery.URL = &i.Gallery.URLs[0] } if i.Performer != nil && i.Performer.URL == nil && len(i.Performer.URLs) > 0 { i.Performer.URL = &i.Performer.URLs[0] } } // simple type definitions that can help customize // actions per query type QueryType int const ( // for now only SearchQuery is needed SearchQuery QueryType = iota + 1 ) // scraper is the generic interface to the scraper subsystems type scraper interface { // spec returns the scraper specification, suitable for graphql spec() Scraper // supports tests if the scraper supports a given content type supports(ScrapeContentType) bool // supportsURL tests if the scraper supports scrapes of a given url, producing a given content type supportsURL(url string, ty ScrapeContentType) bool } // urlScraper is the interface of scrapers supporting url loads type urlScraper interface { scraper viaURL(ctx context.Context, client *http.Client, url string, ty ScrapeContentType) (ScrapedContent, error) } // nameScraper is the interface of scrapers supporting name loads type nameScraper interface { scraper viaName(ctx context.Context, client *http.Client, name string, ty ScrapeContentType) ([]ScrapedContent, error) } // fragmentScraper is the interface of scrapers supporting fragment loads type fragmentScraper interface { scraper viaFragment(ctx context.Context, client *http.Client, input Input) (ScrapedContent, error) } // sceneScraper is a scraper which supports scene scrapes with // scene data as the input. type sceneScraper interface { scraper viaScene(ctx context.Context, client *http.Client, scene *models.Scene) (*models.ScrapedScene, error) } // imageScraper is a scraper which supports image scrapes with // image data as the input. type imageScraper interface { scraper viaImage(ctx context.Context, client *http.Client, image *models.Image) (*models.ScrapedImage, error) } // galleryScraper is a scraper which supports gallery scrapes with // gallery data as the input. type galleryScraper interface { scraper viaGallery(ctx context.Context, client *http.Client, gallery *models.Gallery) (*models.ScrapedGallery, error) } ================================================ FILE: pkg/scraper/script.go ================================================ package scraper import ( "context" "encoding/json" "errors" "fmt" "io" "os/exec" "path/filepath" "strconv" "strings" stashExec "github.com/stashapp/stash/pkg/exec" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" stashJson "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/python" ) // inputs for scrapers type fingerprintInput struct { Type string `json:"type,omitempty"` Fingerprint string `json:"fingerprint,omitempty"` } type fileInput struct { ID string `json:"id"` ZipFile *fileInput `json:"zip_file,omitempty"` ModTime stashJson.JSONTime `json:"mod_time"` Path string `json:"path,omitempty"` Fingerprints []fingerprintInput `json:"fingerprints,omitempty"` Size int64 `json:"size,omitempty"` } type videoFileInput struct { fileInput Format string `json:"format,omitempty"` Width int `json:"width,omitempty"` Height int `json:"height,omitempty"` Duration float64 `json:"duration,omitempty"` VideoCodec string `json:"video_codec,omitempty"` AudioCodec string `json:"audio_codec,omitempty"` FrameRate float64 `json:"frame_rate,omitempty"` BitRate int64 `json:"bitrate,omitempty"` Interactive bool `json:"interactive,omitempty"` InteractiveSpeed *int `json:"interactive_speed,omitempty"` } // sceneInput is the input passed to the scraper for an existing scene type sceneInput struct { ID string `json:"id"` Title string `json:"title"` Code string `json:"code,omitempty"` // deprecated - use urls instead URL *string `json:"url"` URLs []string `json:"urls"` // don't use omitempty for these to maintain backwards compatibility Date *string `json:"date"` Details string `json:"details"` Director string `json:"director,omitempty"` Files []videoFileInput `json:"files,omitempty"` } func fileInputFromFile(f models.BaseFile) fileInput { b := f.Base() var z *fileInput if b.ZipFile != nil { zz := fileInputFromFile(*b.ZipFile.Base()) z = &zz } ret := fileInput{ ID: f.ID.String(), ZipFile: z, ModTime: stashJson.JSONTime{Time: f.ModTime}, Path: f.Path, Size: f.Size, } for _, fp := range f.Fingerprints { ret.Fingerprints = append(ret.Fingerprints, fingerprintInput{ Type: fp.Type, Fingerprint: fp.Value(), }) } return ret } func videoFileInputFromVideoFile(vf *models.VideoFile) videoFileInput { return videoFileInput{ fileInput: fileInputFromFile(*vf.Base()), Format: vf.Format, Width: vf.Width, Height: vf.Height, Duration: vf.Duration, VideoCodec: vf.VideoCodec, AudioCodec: vf.AudioCodec, FrameRate: vf.FrameRate, BitRate: vf.BitRate, Interactive: vf.Interactive, InteractiveSpeed: vf.InteractiveSpeed, } } func sceneInputFromScene(scene *models.Scene) sceneInput { dateToStringPtr := func(s *models.Date) *string { if s != nil { v := s.String() return &v } return nil } // fallback to file basename if title is empty title := scene.GetTitle() var url *string urls := scene.URLs.List() if len(urls) > 0 { url = &urls[0] } ret := sceneInput{ ID: strconv.Itoa(scene.ID), Title: title, Details: scene.Details, // include deprecated URL for now URL: url, URLs: urls, Date: dateToStringPtr(scene.Date), Code: scene.Code, Director: scene.Director, } for _, f := range scene.Files.List() { vf := videoFileInputFromVideoFile(f) ret.Files = append(ret.Files, vf) } return ret } type galleryInput struct { ID string `json:"id"` Title string `json:"title"` Urls []string `json:"urls"` Date *string `json:"date"` Details string `json:"details"` Code string `json:"code,omitempty"` Photographer string `json:"photographer,omitempty"` Files []fileInput `json:"files,omitempty"` // deprecated URL *string `json:"url"` } func galleryInputFromGallery(gallery *models.Gallery) galleryInput { dateToStringPtr := func(s *models.Date) *string { if s != nil { v := s.String() return &v } return nil } // fallback to file basename if title is empty title := gallery.GetTitle() var url *string urls := gallery.URLs.List() if len(urls) > 0 { url = &urls[0] } ret := galleryInput{ ID: strconv.Itoa(gallery.ID), Title: title, Details: gallery.Details, URL: url, Urls: urls, Date: dateToStringPtr(gallery.Date), Code: gallery.Code, Photographer: gallery.Photographer, } for _, f := range gallery.Files.List() { fi := fileInputFromFile(*f.Base()) ret.Files = append(ret.Files, fi) } return ret } var ErrScraperScript = errors.New("scraper script error") type scriptScraper struct { definition Definition globalConfig GlobalConfig } func (s *scriptScraper) runScraperScript(ctx context.Context, command []string, inString string, out interface{}) error { var cmd *exec.Cmd if python.IsPythonCommand(command[0]) { pythonPath := s.globalConfig.GetPythonPath() p, err := python.Resolve(pythonPath) if err != nil { logger.Warnf("%s", err) } else { cmd = p.Command(ctx, command[1:]) envVariable, _ := filepath.Abs(filepath.Dir(filepath.Dir(s.definition.path))) python.AppendPythonPath(cmd, envVariable) } } if cmd == nil { // if could not find python, just use the command args as-is cmd = stashExec.CommandContext(ctx, command[0], command[1:]...) } cmd.Dir = filepath.Dir(s.definition.path) stdin, err := cmd.StdinPipe() if err != nil { return err } go func() { defer stdin.Close() if n, err := io.WriteString(stdin, inString); err != nil { logger.Warnf("failure to write full input to script (wrote %v bytes out of %v): %v", n, len(inString), err) } }() stderr, err := cmd.StderrPipe() if err != nil { logger.Error("Scraper stderr not available: " + err.Error()) } stdout, err := cmd.StdoutPipe() if nil != err { logger.Error("Scraper stdout not available: " + err.Error()) } if err = cmd.Start(); err != nil { logger.Error("Error running scraper script: " + err.Error()) return errors.New("error running scraper script") } go handleScraperStderr(s.definition.Name, stderr) logger.Debugf("Scraper script <%s> started", strings.Join(cmd.Args, " ")) // TODO - add a timeout here // Make a copy of stdout here. This allows us to decode it twice. var sb strings.Builder tr := io.TeeReader(stdout, &sb) // First, perform a decode where unknown fields are disallowed. d := json.NewDecoder(tr) d.DisallowUnknownFields() strictErr := d.Decode(out) if strictErr != nil { // The decode failed for some reason, use the built string // and allow unknown fields in the decode. s := sb.String() lenientErr := json.NewDecoder(strings.NewReader(s)).Decode(out) if lenientErr != nil { // The error is genuine, so return it logger.Errorf("could not unmarshal json from script output: %v", lenientErr) return fmt.Errorf("could not unmarshal json from script output: %w", lenientErr) } // Lenient decode succeeded, print a warning, but use the decode logger.Warnf("reading script result: %v", strictErr) } err = cmd.Wait() logger.Debugf("Scraper script finished") if err != nil { return fmt.Errorf("%w: %v", ErrScraperScript, err) } return nil } func (s *scriptScraper) scrape(ctx context.Context, command []string, input string, ty ScrapeContentType) (ScrapedContent, error) { switch ty { case ScrapeContentTypePerformer: var performer *models.ScrapedPerformer err := s.runScraperScript(ctx, command, input, &performer) return performer, err case ScrapeContentTypeGallery: var gallery *models.ScrapedGallery err := s.runScraperScript(ctx, command, input, &gallery) return gallery, err case ScrapeContentTypeScene: var scene *models.ScrapedScene err := s.runScraperScript(ctx, command, input, &scene) return scene, err case ScrapeContentTypeMovie, ScrapeContentTypeGroup: var movie *models.ScrapedMovie err := s.runScraperScript(ctx, command, input, &movie) return movie, err case ScrapeContentTypeImage: var image *models.ScrapedImage err := s.runScraperScript(ctx, command, input, &image) return image, err } return nil, ErrNotSupported } type scriptNameScraper struct { scriptScraper definition ByNameDefinition } func (s *scriptNameScraper) scrapeByName(ctx context.Context, name string, ty ScrapeContentType) ([]ScrapedContent, error) { input := `{"name": "` + name + `"}` var ret []ScrapedContent var err error switch ty { case ScrapeContentTypePerformer: var performers []models.ScrapedPerformer err = s.runScraperScript(ctx, s.definition.Script, input, &performers) if err == nil { for _, p := range performers { v := p ret = append(ret, &v) } } case ScrapeContentTypeScene: var scenes []models.ScrapedScene err = s.runScraperScript(ctx, s.definition.Script, input, &scenes) if err == nil { for _, s := range scenes { v := s ret = append(ret, &v) } } default: return nil, ErrNotSupported } return ret, err } type scriptURLScraper struct { scriptScraper definition ByURLDefinition } func (s *scriptURLScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeContentType) (ScrapedContent, error) { return s.scrape(ctx, s.definition.Script, `{"url": "`+url+`"}`, ty) } type scriptFragmentScraper struct { scriptScraper definition ByFragmentDefinition } func (s *scriptFragmentScraper) scrapeByFragment(ctx context.Context, input Input) (ScrapedContent, error) { var inString []byte var err error var ty ScrapeContentType switch { case input.Performer != nil: inString, err = json.Marshal(*input.Performer) ty = ScrapeContentTypePerformer case input.Gallery != nil: inString, err = json.Marshal(*input.Gallery) ty = ScrapeContentTypeGallery case input.Scene != nil: inString, err = json.Marshal(*input.Scene) ty = ScrapeContentTypeScene } if err != nil { return nil, err } return s.scrape(ctx, s.definition.Script, string(inString), ty) } func (s *scriptFragmentScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error) { inString, err := json.Marshal(sceneInputFromScene(scene)) if err != nil { return nil, err } var ret *models.ScrapedScene err = s.runScraperScript(ctx, s.definition.Script, string(inString), &ret) return ret, err } func (s *scriptFragmentScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*models.ScrapedGallery, error) { inString, err := json.Marshal(galleryInputFromGallery(gallery)) if err != nil { return nil, err } var ret *models.ScrapedGallery err = s.runScraperScript(ctx, s.definition.Script, string(inString), &ret) return ret, err } func (s *scriptFragmentScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) { inString, err := json.Marshal(imageToUpdateInput(image)) if err != nil { return nil, err } var ret *models.ScrapedImage err = s.runScraperScript(ctx, s.definition.Script, string(inString), &ret) return ret, err } func handleScraperStderr(name string, scraperOutputReader io.ReadCloser) { const scraperPrefix = "[Scrape / %s] " lgr := logger.PluginLogger{ Logger: logger.Logger, Prefix: fmt.Sprintf(scraperPrefix, name), DefaultLogLevel: &logger.ErrorLevel, } lgr.ReadLogMessages(scraperOutputReader) } ================================================ FILE: pkg/scraper/stash.go ================================================ package scraper import ( "context" "fmt" "net/http" "strconv" "strings" graphql "github.com/hasura/go-graphql-client" "github.com/jinzhu/copier" "github.com/stashapp/stash/pkg/models" ) type stashScraper struct { config Definition globalConfig GlobalConfig client *http.Client } func newStashScraper(client *http.Client, config Definition, globalConfig GlobalConfig) *stashScraper { return &stashScraper{ config: config, client: client, globalConfig: globalConfig, } } func setApiKeyHeader(apiKey string) func(req *http.Request) { return func(req *http.Request) { req.Header.Set("ApiKey", apiKey) } } func (s *stashScraper) getStashClient() *graphql.Client { url := s.config.StashServer.URL + "/graphql" ret := graphql.NewClient(url, s.client) if s.config.StashServer.ApiKey != "" { ret = ret.WithRequestModifier(setApiKeyHeader(s.config.StashServer.ApiKey)) } return ret } type stashFindPerformerNamePerformer struct { ID string `json:"id" graphql:"id"` Name string `json:"name" graphql:"name"` } func (p stashFindPerformerNamePerformer) toPerformer() *models.ScrapedPerformer { return &models.ScrapedPerformer{ Name: &p.Name, // HACK - put id into the URL field URL: &p.ID, } } type stashFindPerformerNamesResultType struct { Count int `graphql:"count"` Performers []*stashFindPerformerNamePerformer `graphql:"performers"` } // need a separate for scraped stash performers - does not include remote_site_id or image type scrapedTagStash struct { Name string `graphql:"name" json:"name"` } type scrapedPerformerStash struct { Name *string `graphql:"name" json:"name"` Gender *string `graphql:"gender" json:"gender"` URLs []string `graphql:"urls" json:"urls"` Birthdate *string `graphql:"birthdate" json:"birthdate"` Ethnicity *string `graphql:"ethnicity" json:"ethnicity"` Country *string `graphql:"country" json:"country"` EyeColor *string `graphql:"eye_color" json:"eye_color"` Height *int `graphql:"height_cm" json:"height_cm"` Measurements *string `graphql:"measurements" json:"measurements"` FakeTits *string `graphql:"fake_tits" json:"fake_tits"` PenisLength *string `graphql:"penis_length" json:"penis_length"` Circumcised *string `graphql:"circumcised" json:"circumcised"` CareerLength *string `graphql:"career_length" json:"career_length"` Tattoos *string `graphql:"tattoos" json:"tattoos"` Piercings *string `graphql:"piercings" json:"piercings"` Aliases []string `graphql:"alias_list" json:"alias_list"` Tags []*scrapedTagStash `graphql:"tags" json:"tags"` Details *string `graphql:"details" json:"details"` DeathDate *string `graphql:"death_date" json:"death_date"` HairColor *string `graphql:"hair_color" json:"hair_color"` Weight *int `graphql:"weight" json:"weight"` } func (s *stashScraper) imageGetter() imageGetter { ret := imageGetter{ client: s.client, globalConfig: s.globalConfig, } if s.config.StashServer.ApiKey != "" { ret.requestModifier = setApiKeyHeader(s.config.StashServer.ApiKey) } return ret } func (s *stashScraper) scrapeByFragment(ctx context.Context, input Input) (ScrapedContent, error) { if input.Performer != nil { return s.scrapeByPerformerFragment(ctx, *input.Performer) } if input.Scene != nil { return s.scrapeBySceneFragment(ctx, *input.Scene) } return nil, fmt.Errorf("%w: using stash scraper as a fragment scraper", ErrNotSupported) } func (s *stashScraper) scrapeByPerformerFragment(ctx context.Context, scrapedPerformer ScrapedPerformerInput) (ScrapedContent, error) { client := s.getStashClient() var q struct { FindPerformer *scrapedPerformerStash `graphql:"findPerformer(id: $f)"` } performerID := *scrapedPerformer.URL // get the id from the URL field vars := map[string]interface{}{ "f": graphql.ID(performerID), } err := client.Query(ctx, &q, vars) if err != nil { return nil, convertGraphqlError(err) } // need to copy back to a scraped performer ret := models.ScrapedPerformer{} err = copier.Copy(&ret, q.FindPerformer) if err != nil { return nil, err } // convert alias list to aliases aliasStr := strings.Join(q.FindPerformer.Aliases, ", ") ret.Aliases = &aliasStr // convert numeric to string if q.FindPerformer.Height != nil { heightStr := strconv.Itoa(*q.FindPerformer.Height) ret.Height = &heightStr } if q.FindPerformer.Weight != nil { weightStr := strconv.Itoa(*q.FindPerformer.Weight) ret.Weight = &weightStr } // get the performer image directly ig := s.imageGetter() img, err := getStashPerformerImage(ctx, s.config.StashServer.URL, performerID, ig) if err != nil { return nil, err } ret.Images = []string{*img} ret.Image = img return &ret, nil } func (s *stashScraper) scrapeBySceneFragment(ctx context.Context, scrapedScene models.ScrapedSceneInput) (ScrapedContent, error) { client := s.getStashClient() var q struct { FindScene *scrapedSceneStash `graphql:"findScene(id: $f)"` } sceneID := scrapedScene.URLs[0] // get the id from the URL field vars := map[string]interface{}{ "f": graphql.ID(sceneID), } err := client.Query(ctx, &q, vars) if err != nil { return nil, convertGraphqlError(err) } if q.FindScene == nil { return nil, nil } // need to copy back to a scraped scene ret, err := s.scrapedStashSceneToScrapedScene(ctx, q.FindScene) if err != nil { return nil, err } // get the scene image directly ig := s.imageGetter() ret.Image, err = getStashSceneImage(ctx, s.config.StashServer.URL, q.FindScene.ID, ig) if err != nil { return nil, err } return ret, nil } type scrapedStudioStash struct { Name string `graphql:"name" json:"name"` URL *string `graphql:"url" json:"url"` } type stashFindSceneNamesResultType struct { Count int `graphql:"count"` Scenes []*scrapedSceneStash `graphql:"scenes"` } func (s *stashScraper) scrapedStashSceneToScrapedScene(ctx context.Context, scene *scrapedSceneStash) (*models.ScrapedScene, error) { ret := models.ScrapedScene{} err := copier.Copy(&ret, scene) if err != nil { return nil, err } // convert first in files to file if len(scene.Files) > 0 { f := scene.Files[0].SceneFileType() ret.File = &f } // get the scene image directly ig := s.imageGetter() ret.Image, err = getStashSceneImage(ctx, s.config.StashServer.URL, scene.ID, ig) if err != nil { return nil, err } return &ret, nil } func (s *stashScraper) scrapeByName(ctx context.Context, name string, ty ScrapeContentType) ([]ScrapedContent, error) { client := s.getStashClient() page := 1 perPage := 10 vars := map[string]interface{}{ "f": models.FindFilterType{ Q: &name, Page: &page, PerPage: &perPage, }, } var ret []ScrapedContent switch ty { case ScrapeContentTypeScene: var q struct { FindScenes stashFindSceneNamesResultType `graphql:"findScenes(filter: $f)"` } err := client.Query(ctx, &q, vars) if err != nil { return nil, convertGraphqlError(err) } for _, scene := range q.FindScenes.Scenes { converted, err := s.scrapedStashSceneToScrapedScene(ctx, scene) if err != nil { return nil, err } // HACK - put id into the URL field // put id into the URL field converted.URLs = []string{scene.ID} ret = append(ret, converted) } return ret, nil case ScrapeContentTypePerformer: var q struct { FindPerformers stashFindPerformerNamesResultType `graphql:"findPerformers(filter: $f)"` } err := client.Query(ctx, &q, vars) if err != nil { return nil, err } for _, p := range q.FindPerformers.Performers { ret = append(ret, p.toPerformer()) } return ret, nil } return nil, ErrNotSupported } type stashVideoFile struct { Size int64 `graphql:"size" json:"size"` Duration float64 `graphql:"duration" json:"duration"` VideoCodec string `graphql:"video_codec" json:"video_codec"` AudioCodec string `graphql:"audio_codec" json:"audio_codec"` Width int `graphql:"width" json:"width"` Height int `graphql:"height" json:"height"` Framerate float64 `graphql:"frame_rate" json:"frame_rate"` Bitrate int `graphql:"bit_rate" json:"bit_rate"` } func (f stashVideoFile) SceneFileType() models.SceneFileType { ret := models.SceneFileType{ Duration: &f.Duration, VideoCodec: &f.VideoCodec, AudioCodec: &f.AudioCodec, Width: &f.Width, Height: &f.Height, Framerate: &f.Framerate, Bitrate: &f.Bitrate, } size := strconv.FormatInt(f.Size, 10) ret.Size = &size return ret } type scrapedSceneStash struct { ID string `graphql:"id" json:"id"` Title *string `graphql:"title" json:"title"` Details *string `graphql:"details" json:"details"` URLs []string `graphql:"urls" json:"urls"` Date *string `graphql:"date" json:"date"` Files []stashVideoFile `graphql:"files" json:"files"` Studio *scrapedStudioStash `graphql:"studio" json:"studio"` Tags []*scrapedTagStash `graphql:"tags" json:"tags"` Performers []*scrapedPerformerStash `graphql:"performers" json:"performers"` } func (s *stashScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error) { // query by MD5 var q struct { FindScene *scrapedSceneStash `graphql:"findSceneByHash(input: $c)"` } type SceneHashInput struct { Checksum *string `graphql:"checksum" json:"checksum"` Oshash *string `graphql:"oshash" json:"oshash"` } checksum := scene.Checksum oshash := scene.OSHash input := SceneHashInput{ Checksum: &checksum, Oshash: &oshash, } vars := map[string]interface{}{ "c": input, } client := s.getStashClient() if err := client.Query(ctx, &q, vars); err != nil { return nil, convertGraphqlError(err) } if q.FindScene == nil { return nil, nil } // need to copy back to a scraped scene ret, err := s.scrapedStashSceneToScrapedScene(ctx, q.FindScene) if err != nil { return nil, err } // get the scene image directly ig := s.imageGetter() ret.Image, err = getStashSceneImage(ctx, s.config.StashServer.URL, q.FindScene.ID, ig) if err != nil { return nil, err } return ret, nil } type scrapedGalleryStash struct { ID string `graphql:"id" json:"id"` Title *string `graphql:"title" json:"title"` Details *string `graphql:"details" json:"details"` URL *string `graphql:"url" json:"url"` Date *string `graphql:"date" json:"date"` File *models.SceneFileType `graphql:"file" json:"file"` Studio *scrapedStudioStash `graphql:"studio" json:"studio"` Tags []*scrapedTagStash `graphql:"tags" json:"tags"` Performers []*scrapedPerformerStash `graphql:"performers" json:"performers"` } func (s *stashScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*models.ScrapedGallery, error) { var q struct { FindGallery *scrapedGalleryStash `graphql:"findGalleryByHash(input: $c)"` } type GalleryHashInput struct { Checksum *string `graphql:"checksum" json:"checksum"` } checksum := gallery.PrimaryChecksum() input := GalleryHashInput{ Checksum: &checksum, } vars := map[string]interface{}{ "c": &input, } client := s.getStashClient() if err := client.Query(ctx, &q, vars); err != nil { return nil, err } // need to copy back to a scraped scene ret := models.ScrapedGallery{} if err := copier.Copy(&ret, q.FindGallery); err != nil { return nil, err } return &ret, nil } func (s *stashScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) { return nil, ErrNotSupported } func (s *stashScraper) scrapeByURL(_ context.Context, _ string, _ ScrapeContentType) (ScrapedContent, error) { return nil, ErrNotSupported } func imageToUpdateInput(gallery *models.Image) models.ImageUpdateInput { dateToStringPtr := func(s *models.Date) *string { if s != nil { v := s.String() return &v } return nil } // fallback to file basename if title is empty title := gallery.GetTitle() urls := gallery.URLs.List() return models.ImageUpdateInput{ ID: strconv.Itoa(gallery.ID), Title: &title, Details: &gallery.Details, Urls: urls, Date: dateToStringPtr(gallery.Date), } } ================================================ FILE: pkg/scraper/tag.go ================================================ package scraper import ( "context" "regexp" "strings" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil" ) func postProcessTags(ctx context.Context, tqb models.TagQueryer, scrapedTags []*models.ScrapedTag) (ret []*models.ScrapedTag, err error) { ret = make([]*models.ScrapedTag, 0, len(scrapedTags)) for _, t := range scrapedTags { // Pass empty string for endpoint since this is used by general scrapers, not just stash-box err := match.ScrapedTag(ctx, tqb, t, "") if err != nil { return nil, err } ret = append(ret, t) } return ret, err } // FilterTags removes tags matching excluded tag patterns from the list of scraped tags // It returns the filtered list of tags and a list of the excluded tags func FilterTags(excludeRegexps []*regexp.Regexp, tags []*models.ScrapedTag) (newTags []*models.ScrapedTag, ignoredTags []string) { if len(excludeRegexps) == 0 { return tags, nil } newTags = make([]*models.ScrapedTag, 0, len(tags)) for _, t := range tags { ignore := false for _, reg := range excludeRegexps { if reg.MatchString(strings.ToLower(t.Name)) { ignore = true ignoredTags = sliceutil.AppendUnique(ignoredTags, t.Name) break } } if !ignore { newTags = append(newTags, t) } } return newTags, ignoredTags } // CompileExclusionRegexps compiles a list of tag exclusion patterns into a list of regular expressions func CompileExclusionRegexps(patterns []string) []*regexp.Regexp { excludePatterns := patterns var excludeRegexps []*regexp.Regexp for _, excludePattern := range excludePatterns { reg, err := regexp.Compile(strings.ToLower(excludePattern)) if err != nil { logger.Errorf("Invalid tag exclusion pattern: %v", err) } else { excludeRegexps = append(excludeRegexps, reg) } } return excludeRegexps } // LogIgnoredTags logs the list of ignored tags func LogIgnoredTags(ignoredTags []string) { if len(ignoredTags) > 0 { logger.Debugf("Tags ignored for matching exclusion patterns: %s", strings.Join(ignoredTags, ", ")) } } ================================================ FILE: pkg/scraper/url.go ================================================ package scraper import ( "bytes" "context" "fmt" "io" "net" "net/http" "net/url" "os" "regexp" "strings" "time" "github.com/chromedp/cdproto/cdp" "github.com/chromedp/cdproto/fetch" "github.com/chromedp/cdproto/network" "github.com/chromedp/chromedp" jsoniter "github.com/json-iterator/go" "golang.org/x/net/html/charset" "github.com/stashapp/stash/pkg/logger" ) const scrapeDefaultSleep = time.Second * 2 func loadURL(ctx context.Context, loadURL string, client *http.Client, def Definition, globalConfig GlobalConfig) (io.Reader, error) { driverOptions := def.DriverOptions if driverOptions != nil && driverOptions.UseCDP { // get the page using chrome dp return urlFromCDP(ctx, loadURL, *driverOptions, globalConfig) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, loadURL, nil) if err != nil { return nil, err } jar, err := def.jar() if err != nil { return nil, fmt.Errorf("error creating cookie jar: %w", err) } u, err := url.Parse(loadURL) if err != nil { return nil, fmt.Errorf("error parsing url %s: %w", loadURL, err) } // Fetch relevant cookies from the jar for url u and add them to the request cookies := jar.Cookies(u) for _, cookie := range cookies { req.AddCookie(cookie) } userAgent := globalConfig.GetScraperUserAgent() if userAgent != "" { req.Header.Set("User-Agent", userAgent) } if driverOptions != nil { // setting the Headers after the UA allows us to override it from inside the scraper for _, h := range driverOptions.Headers { if h.Key != "" { req.Header.Set(h.Key, h.Value) logger.Debugf("[scraper] adding header <%s:%s>", h.Key, h.Value) } } } resp, err := client.Do(req) if err != nil { return nil, err } if resp.StatusCode >= 400 { return nil, fmt.Errorf("http error %d:%s", resp.StatusCode, http.StatusText(resp.StatusCode)) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } bodyReader := bytes.NewReader(body) printCookies(jar, def, "Jar cookies found for scraper urls") return charset.NewReader(bodyReader, resp.Header.Get("Content-Type")) } // func urlFromCDP uses chrome cdp and DOM to load and process the url // if remote is set as true in the scraperConfig it will try to use localhost:9222 // else it will look for google-chrome in path func urlFromCDP(ctx context.Context, urlCDP string, driverOptions scraperDriverOptions, globalConfig GlobalConfig) (io.Reader, error) { if !driverOptions.UseCDP { return nil, fmt.Errorf("url shouldn't be fetched through CDP") } sleepDuration := scrapeDefaultSleep if driverOptions.Sleep > 0 { sleepDuration = time.Duration(driverOptions.Sleep) * time.Second } // if scraperCDPPath is a remote address, then allocate accordingly cdpPath := globalConfig.GetScraperCDPPath() if cdpPath != "" { var cancelAct context.CancelFunc if isCDPPathHTTP(globalConfig) || isCDPPathWS(globalConfig) { remote := cdpPath // ------------------------------------------------------------------- // #1023 // when chromium is listening over RDP it only accepts requests // with host headers that are either IPs or `localhost` cdpURL, err := url.Parse(remote) if err != nil { return nil, fmt.Errorf("failed to parse CDP Path: %v", err) } hostname := cdpURL.Hostname() if hostname != "localhost" { if net.ParseIP(hostname) == nil { // not an IP addr, err := net.LookupIP(hostname) if err != nil || len(addr) == 0 { // can not resolve to IP return nil, fmt.Errorf("CDP: hostname <%s> can not be resolved", hostname) } if len(addr[0]) == 0 { // nil IP return nil, fmt.Errorf("CDP: hostname <%s> resolved to nil", hostname) } // addr is a valid IP // replace the host part of the cdpURL with the IP cdpURL.Host = strings.Replace(cdpURL.Host, hostname, addr[0].String(), 1) // use that for remote remote = cdpURL.String() } } // -------------------------------------------------------------------- // if CDPPath is http(s) then we need to get the websocket URL if isCDPPathHTTP(globalConfig) { var err error remote, err = getRemoteCDPWSAddress(ctx, remote) if err != nil { return nil, err } } ctx, cancelAct = chromedp.NewRemoteAllocator(ctx, remote) } else { // use a temporary user directory for chrome dir, err := os.MkdirTemp("", "stash-chromedp") if err != nil { return nil, err } defer os.RemoveAll(dir) opts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.UserDataDir(dir), chromedp.ExecPath(cdpPath), ) if globalConfig.GetProxy() != "" { url, _, _ := splitProxyAuth(globalConfig.GetProxy()) opts = append(opts, chromedp.ProxyServer(url)) } ctx, cancelAct = chromedp.NewExecAllocator(ctx, opts...) } defer cancelAct() } ctx, cancel := chromedp.NewContext(ctx) defer cancel() // add a fixed timeout for the http request ctx, cancel = context.WithTimeout(ctx, scrapeGetTimeout) defer cancel() var res string headers := cdpHeaders(driverOptions) if proxyUsesAuth(globalConfig.GetProxy()) { _, user, pass := splitProxyAuth(globalConfig.GetProxy()) // Based on https://github.com/chromedp/examples/blob/master/proxy/main.go lctx, lcancel := context.WithCancel(ctx) chromedp.ListenTarget(lctx, func(ev interface{}) { switch ev := ev.(type) { case *fetch.EventRequestPaused: go func() { _ = chromedp.Run(ctx, fetch.ContinueRequest(ev.RequestID)) }() case *fetch.EventAuthRequired: if ev.AuthChallenge.Source == fetch.AuthChallengeSourceProxy { go func() { _ = chromedp.Run(ctx, fetch.ContinueWithAuth(ev.RequestID, &fetch.AuthChallengeResponse{ Response: fetch.AuthChallengeResponseResponseProvideCredentials, Username: user, Password: pass, }), // Chrome will remember the credential for the current instance, // so we can disable the fetch domain once credential is provided. // Please file an issue if Chrome does not work in this way. fetch.Disable(), ) // and cancel the event handler too. lcancel() }() } } }) } err := chromedp.Run(ctx, network.Enable(), setCDPCookies(driverOptions), printCDPCookies(driverOptions, "Cookies found"), network.SetExtraHTTPHeaders(network.Headers(headers)), chromedp.Navigate(urlCDP), chromedp.Sleep(sleepDuration), setCDPClicks(driverOptions), chromedp.OuterHTML("html", &res, chromedp.ByQuery), printCDPCookies(driverOptions, "Cookies set"), ) if err != nil { return nil, err } return strings.NewReader(res), nil } // click all xpaths listed in the scraper config func setCDPClicks(driverOptions scraperDriverOptions) chromedp.Tasks { var tasks chromedp.Tasks for _, click := range driverOptions.Clicks { // for each click element find the node from the xpath and add a click action if click.XPath != "" { xpath := click.XPath waitDuration := scrapeDefaultSleep if click.Sleep > 0 { waitDuration = time.Duration(click.Sleep) * time.Second } action := chromedp.ActionFunc(func(ctx context.Context) error { var nodes []*cdp.Node if err := chromedp.Nodes(xpath, &nodes, chromedp.AtLeast(0)).Do(ctx); err != nil { logger.Debugf("Error %s looking for click xpath %s.\n", err, xpath) return err } if len(nodes) == 0 { logger.Debugf("Click xpath %s not found in page.\n", xpath) return nil } logger.Debugf("Clicking %s\n", xpath) return chromedp.MouseClickNode(nodes[0]).Do(ctx) }) tasks = append(tasks, action) tasks = append(tasks, chromedp.Sleep(waitDuration)) } } return tasks } // getRemoteCDPWSAddress returns the complete remote address that is required to access the cdp instance func getRemoteCDPWSAddress(ctx context.Context, url string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return "", err } resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() var result map[string]interface{} var json = jsoniter.ConfigCompatibleWithStandardLibrary if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", err } remote := result["webSocketDebuggerUrl"].(string) logger.Debugf("Remote cdp instance found %s", remote) return remote, err } func cdpHeaders(driverOptions scraperDriverOptions) map[string]interface{} { headers := map[string]interface{}{} if driverOptions.Headers != nil { for _, h := range driverOptions.Headers { if h.Key != "" { headers[h.Key] = h.Value logger.Debugf("[scraper] adding header <%s:%s>", h.Key, h.Value) } } } return headers } func proxyUsesAuth(proxyUrl string) bool { if proxyUrl == "" { return false } reg := regexp.MustCompile(`^(https?:\/\/)(([\P{Cc}]+):([\P{Cc}]+)@)?(([a-zA-Z0-9][a-zA-Z0-9.-]*)(:[0-9]{1,5})?)`) matches := reg.FindAllStringSubmatch(proxyUrl, -1) if matches != nil { split := matches[0] return len(split) == 0 || (len(split) > 5 && split[3] != "") } return false } func splitProxyAuth(proxyUrl string) (string, string, string) { if proxyUrl == "" { return "", "", "" } reg := regexp.MustCompile(`^(https?:\/\/)(([\P{Cc}]+):([\P{Cc}]+)@)?(([a-zA-Z0-9][a-zA-Z0-9.-]*)(:[0-9]{1,5})?)`) matches := reg.FindAllStringSubmatch(proxyUrl, -1) if matches != nil && len(matches[0]) > 5 { split := matches[0] return split[1] + split[5], split[3], split[4] } return proxyUrl, "", "" } ================================================ FILE: pkg/scraper/xpath.go ================================================ package scraper import ( "bytes" "context" "fmt" "net/http" "net/url" "regexp" "strings" "github.com/antchfx/htmlquery" "golang.org/x/net/html" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) type xpathScraper struct { definition Definition globalConfig GlobalConfig client *http.Client } func (s *xpathScraper) getXpathScraper(name string) (*mappedScraper, error) { ret, ok := s.definition.XPathScrapers[name] if !ok { return nil, fmt.Errorf("xpath scraper with name %s not found in config", name) } return &ret, nil } type xpathURLScraper struct { xpathScraper definition ByURLDefinition } func (s *xpathURLScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeContentType) (ScrapedContent, error) { scraper, err := s.getXpathScraper(s.definition.Scraper) if err != nil { return nil, err } doc, err := s.loadURL(ctx, url) if err != nil { return nil, err } q := s.getXPathQuery(doc, url) // if these just return the return values from scraper.scrape* functions then // it ends up returning ScrapedContent(nil) rather than nil switch ty { case ScrapeContentTypePerformer: ret, err := scraper.scrapePerformer(ctx, q) if err != nil || ret == nil { return nil, err } return ret, nil case ScrapeContentTypeScene: ret, err := scraper.scrapeScene(ctx, q) if err != nil || ret == nil { return nil, err } return ret, nil case ScrapeContentTypeGallery: ret, err := scraper.scrapeGallery(ctx, q) if err != nil || ret == nil { return nil, err } return ret, nil case ScrapeContentTypeImage: ret, err := scraper.scrapeImage(ctx, q) if err != nil || ret == nil { return nil, err } return ret, nil case ScrapeContentTypeMovie, ScrapeContentTypeGroup: ret, err := scraper.scrapeGroup(ctx, q) if err != nil || ret == nil { return nil, err } return ret, nil } return nil, ErrNotSupported } type xpathNameScraper struct { xpathScraper definition ByNameDefinition } func (s *xpathNameScraper) scrapeByName(ctx context.Context, name string, ty ScrapeContentType) ([]ScrapedContent, error) { scraper, err := s.getXpathScraper(s.definition.Scraper) if err != nil { return nil, err } const placeholder = "{}" // replace the placeholder string with the URL-escaped name escapedName := url.QueryEscape(name) url := s.definition.QueryURL url = strings.ReplaceAll(url, placeholder, escapedName) doc, err := s.loadURL(ctx, url) if err != nil { return nil, err } q := s.getXPathQuery(doc, url) q.setType(SearchQuery) var content []ScrapedContent switch ty { case ScrapeContentTypePerformer: performers, err := scraper.scrapePerformers(ctx, q) if err != nil { return nil, err } for _, p := range performers { content = append(content, p) } return content, nil case ScrapeContentTypeScene: scenes, err := scraper.scrapeScenes(ctx, q) if err != nil { return nil, err } for _, s := range scenes { content = append(content, s) } return content, nil } return nil, ErrNotSupported } type xpathFragmentScraper struct { xpathScraper definition ByFragmentDefinition } func (s *xpathFragmentScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error) { // construct the URL queryURL := queryURLParametersFromScene(scene) if s.definition.QueryURLReplacements != nil { queryURL.applyReplacements(s.definition.QueryURLReplacements) } url := queryURL.constructURL(s.definition.QueryURL) scraper, err := s.getXpathScraper(s.definition.Scraper) if err != nil { return nil, err } doc, err := s.loadURL(ctx, url) if err != nil { return nil, err } q := s.getXPathQuery(doc, url) return scraper.scrapeScene(ctx, q) } func (s *xpathFragmentScraper) scrapeByFragment(ctx context.Context, input Input) (ScrapedContent, error) { switch { case input.Gallery != nil: return nil, fmt.Errorf("%w: cannot use an xpath scraper as a gallery fragment scraper", ErrNotSupported) case input.Performer != nil: return nil, fmt.Errorf("%w: cannot use an xpath scraper as a performer fragment scraper", ErrNotSupported) case input.Scene == nil: return nil, fmt.Errorf("%w: scene input is nil", ErrNotSupported) } scene := *input.Scene // construct the URL queryURL := queryURLParametersFromScrapedScene(scene) if s.definition.QueryURLReplacements != nil { queryURL.applyReplacements(s.definition.QueryURLReplacements) } url := queryURL.constructURL(s.definition.QueryURL) scraper, err := s.getXpathScraper(s.definition.Scraper) if err != nil { return nil, err } doc, err := s.loadURL(ctx, url) if err != nil { return nil, err } q := s.getXPathQuery(doc, url) return scraper.scrapeScene(ctx, q) } func (s *xpathFragmentScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*models.ScrapedGallery, error) { // construct the URL queryURL := queryURLParametersFromGallery(gallery) if s.definition.QueryURLReplacements != nil { queryURL.applyReplacements(s.definition.QueryURLReplacements) } url := queryURL.constructURL(s.definition.QueryURL) scraper, err := s.getXpathScraper(s.definition.Scraper) if err != nil { return nil, err } doc, err := s.loadURL(ctx, url) if err != nil { return nil, err } q := s.getXPathQuery(doc, url) return scraper.scrapeGallery(ctx, q) } func (s *xpathFragmentScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) { // construct the URL queryURL := queryURLParametersFromImage(image) if s.definition.QueryURLReplacements != nil { queryURL.applyReplacements(s.definition.QueryURLReplacements) } url := queryURL.constructURL(s.definition.QueryURL) scraper, err := s.getXpathScraper(s.definition.Scraper) if err != nil { return nil, err } doc, err := s.loadURL(ctx, url) if err != nil { return nil, err } q := s.getXPathQuery(doc, url) return scraper.scrapeImage(ctx, q) } func (s *xpathScraper) loadURL(ctx context.Context, url string) (*html.Node, error) { r, err := loadURL(ctx, url, s.client, s.definition, s.globalConfig) if err != nil { return nil, fmt.Errorf("failed to load URL %q: %w", url, err) } ret, err := html.Parse(r) if err == nil && s.definition.DebugOptions != nil && s.definition.DebugOptions.PrintHTML { var b bytes.Buffer if err := html.Render(&b, ret); err != nil { logger.Warnf("could not render HTML: %v", err) } logger.Infof("loadURL (%s) response: \n%s", url, b.String()) } return ret, err } func (s *xpathScraper) getXPathQuery(doc *html.Node, url string) *xpathQuery { return &xpathQuery{ doc: doc, scraper: s, url: url, } } type xpathQuery struct { doc *html.Node scraper *xpathScraper queryType QueryType url string } func (q *xpathQuery) getType() QueryType { return q.queryType } func (q *xpathQuery) setType(t QueryType) { q.queryType = t } func (q *xpathQuery) getURL() string { return q.url } func (q *xpathQuery) runQuery(selector string) ([]string, error) { found, err := htmlquery.QueryAll(q.doc, selector) if err != nil { return nil, fmt.Errorf("selector '%s': parse error: %v", selector, err) } var ret []string for _, n := range found { // don't add empty strings nodeText := q.nodeText(n) if nodeText != "" { ret = append(ret, q.nodeText(n)) } } return ret, nil } func (q *xpathQuery) nodeText(n *html.Node) string { var ret string if n != nil && n.Type == html.CommentNode { ret = htmlquery.OutputHTML(n, true) } else { ret = htmlquery.InnerText(n) } // trim all leading and trailing whitespace ret = strings.TrimSpace(ret) // remove multiple whitespace re := regexp.MustCompile(" +") ret = re.ReplaceAllString(ret, " ") // TODO - make this optional re = regexp.MustCompile("\n") ret = re.ReplaceAllString(ret, "") return ret } func (q *xpathQuery) subScrape(ctx context.Context, value string) mappedQuery { doc, err := q.scraper.loadURL(ctx, value) if err != nil { logger.Warnf("Error getting URL '%s' for sub-scraper: %s", value, err.Error()) return nil } return q.scraper.getXPathQuery(doc, value) } ================================================ FILE: pkg/scraper/xpath_test.go ================================================ package scraper import ( "context" "fmt" "net/http" "net/http/httptest" "strings" "testing" "github.com/antchfx/htmlquery" "github.com/stashapp/stash/pkg/models" "github.com/stretchr/testify/assert" "gopkg.in/yaml.v2" ) // adapted from https://www.freeones.com/html/m_links/bio_Mia_Malkova.php const htmlDoc1 = ` Freeones: Mia Malkova Biography
Babe Name:
Mia Malkova  Mia Malkova 
Profession:
Porn Star
Ethnicity: Caucasian 
Country of Origin: United States
Date of Birth: July 1, 1992 (27 years old) 
Aliases: Mia Bliss, Madison Clover, Madison Swan, Mia Mountain, Jessica 
Eye Color: Hazel 
Hair Color: Blonde 
Height: 5ft7
Weight: 126
Measurements: 34C-26-36
Fake boobs: No 
Career Start And End 2012 - 2019 (7 Years In The Business)
Tattoos: None 
Piercings: ;
Details: Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the ... arrow_drop_down Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the VR Porn movies – trust us. Ankles behind her neck and feet over her back so she can kiss her toes, turned, twisted and gyrating, she can fuck any which way she wants (and that ass!), will surely make you fall in love with this hot Virtual Reality Porn slut, as she is one of the finest of them all. Talking about perfection, maybe it’s all the acrobatic work that keeps it in such gorgeous shape? Who cares really, because you just want to take a big bite out of it and never let go. But it’s not all about the body. Mia’s also got a great smile, which might not sound kinky, but believe us, it is a smile that will heat up your innards and drop your pants. Is it her golden skin, her innocent pink lips or that heart-shaped face? There is just too much good stuff going on with Mia Malkova, which is maybe why these past few years have heaped awards upon awards on this Southern California native. Mia came to VR Bangers for her first VR Porn video, so you know she’s only going for top-notch scenes with top-game performers, men, and women. Better hit up that yoga studio if you ever dream of being able to bang a flexible and talented chick like lady Malkova.
Social Network Links:
` func makeCommonXPath(attr string) string { return `//table[@id="biographyTable"]//tr/td[@class="paramname"]//b[text() = '` + attr + `']/ancestor::tr/td[@class="paramvalue"]` } func makeSimpleAttrConfig(str string) mappedScraperAttrConfig { return mappedScraperAttrConfig{ Selector: str, } } func makeReplaceRegex(regex string, with string) mappedRegexConfig { ret := mappedRegexConfig{ Regex: regex, With: with, } return ret } func makeXPathConfig() mappedPerformerScraperConfig { config := mappedPerformerScraperConfig{ mappedConfig: make(mappedConfig), } config.mappedConfig["Name"] = makeSimpleAttrConfig(makeCommonXPath("Babe Name:") + `/a`) config.mappedConfig["URL"] = makeSimpleAttrConfig(makeCommonXPath("Babe Name:") + `/a/@href`) config.mappedConfig["URLs"] = makeSimpleAttrConfig(makeCommonXPath("Babe Name:") + `/a/@href`) config.mappedConfig["Ethnicity"] = makeSimpleAttrConfig(makeCommonXPath("Ethnicity:")) config.mappedConfig["Aliases"] = makeSimpleAttrConfig(makeCommonXPath("Aliases:")) config.mappedConfig["EyeColor"] = makeSimpleAttrConfig(makeCommonXPath("Eye Color:")) config.mappedConfig["Measurements"] = makeSimpleAttrConfig(makeCommonXPath("Measurements:")) config.mappedConfig["FakeTits"] = makeSimpleAttrConfig(makeCommonXPath("Fake boobs:")) config.mappedConfig["Tattoos"] = makeSimpleAttrConfig(makeCommonXPath("Tattoos:")) config.mappedConfig["Piercings"] = makeSimpleAttrConfig(makeCommonXPath("Piercings:") + "/comment()") config.mappedConfig["Details"] = makeSimpleAttrConfig(makeCommonXPath("Details:")) config.mappedConfig["HairColor"] = makeSimpleAttrConfig(makeCommonXPath("Hair Color:")) // special handling for birthdate birthdateAttrConfig := makeSimpleAttrConfig(makeCommonXPath("Date of Birth:")) var birthdateReplace mappedRegexConfigs // make this leave the trailing space to test existing scrapers that do so birthdateReplace = append(birthdateReplace, makeReplaceRegex(`\(.* years old\)`, "")) birthdateReplaceAction := postProcessReplace(birthdateReplace) birthdateParseDate := postProcessParseDate("January 2, 2006") // "July 1, 1992 (27 years old) " birthdateAttrConfig.postProcessActions = []postProcessAction{ &birthdateReplaceAction, &birthdateParseDate, } config.mappedConfig["Birthdate"] = birthdateAttrConfig // special handling for career length // no colon in attribute header careerLengthAttrConfig := makeSimpleAttrConfig(makeCommonXPath("Career Start And End")) var careerLengthReplace mappedRegexConfigs careerLengthReplace = append(careerLengthReplace, makeReplaceRegex(`\s+\(.*\)`, "")) careerLengthReplaceAction := postProcessReplace(careerLengthReplace) careerLengthAttrConfig.postProcessActions = []postProcessAction{ &careerLengthReplaceAction, } config.mappedConfig["CareerLength"] = careerLengthAttrConfig // use map post-process action for gender genderConfig := makeSimpleAttrConfig(makeCommonXPath("Profession:")) genderMapAction := make(postProcessMap) genderMapAction["Porn Star"] = "Female" genderConfig.postProcessActions = []postProcessAction{ &genderMapAction, } config.mappedConfig["Gender"] = genderConfig // use fixed for Country config.mappedConfig["Country"] = mappedScraperAttrConfig{ Fixed: "United States", } heightConfig := makeSimpleAttrConfig(makeCommonXPath("Height:")) heightConvAction := postProcessFeetToCm(true) heightConfig.postProcessActions = []postProcessAction{ &heightConvAction, } config.mappedConfig["Height"] = heightConfig weightConfig := makeSimpleAttrConfig(makeCommonXPath("Weight:")) weightConvAction := postProcessLbToKg(true) weightConfig.postProcessActions = []postProcessAction{ &weightConvAction, } config.mappedConfig["Weight"] = weightConfig tagConfig := mappedScraperAttrConfig{ Selector: `//ul[@id="socialmedia"]//a`, } config.Tags = make(mappedConfig) config.Tags["Name"] = tagConfig return config } func verifyField(t *testing.T, expected string, actual *string, field string) { t.Helper() if actual == nil || *actual != expected { if actual == nil { t.Errorf("Expected %s to be set to %s, instead got nil", field, expected) } else { t.Errorf("Expected %s to be set to %s, instead got %s", field, expected, *actual) } } } func TestScrapePerformerXPath(t *testing.T) { reader := strings.NewReader(htmlDoc1) doc, err := htmlquery.Parse(reader) if err != nil { t.Errorf("Error loading document: %s", err.Error()) return } xpathConfig := makeXPathConfig() scraper := mappedScraper{ Performer: &xpathConfig, } q := &xpathQuery{ doc: doc, } performer, err := scraper.scrapePerformer(context.Background(), q) if err != nil { t.Errorf("Error scraping performer: %s", err.Error()) return } const performerName = "Mia Malkova" const url = "/html/m_links/Mia_Malkova/" const secondURL = "/html/m_links/Mia_Malkova/second_url" const ethnicity = "Caucasian" const country = "United States" const birthdate = "1992-07-01" const aliases = "Mia Bliss, Madison Clover, Madison Swan, Mia Mountain, Jessica" const eyeColor = "Hazel" const measurements = "34C-26-36" const fakeTits = "No" const careerLength = "2012 - 2019" const tattoos = "None" const piercings = "" const gender = "Female" const height = "170" // 5ft7 const details = "Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the ... arrow_drop_down Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the VR Porn movies – trust us. Ankles behind her neck and feet over her back so she can kiss her toes, turned, twisted and gyrating, she can fuck any which way she wants (and that ass!), will surely make you fall in love with this hot Virtual Reality Porn slut, as she is one of the finest of them all. Talking about perfection, maybe it’s all the acrobatic work that keeps it in such gorgeous shape? Who cares really, because you just want to take a big bite out of it and never let go. But it’s not all about the body. Mia’s also got a great smile, which might not sound kinky, but believe us, it is a smile that will heat up your innards and drop your pants. Is it her golden skin, her innocent pink lips or that heart-shaped face? There is just too much good stuff going on with Mia Malkova, which is maybe why these past few years have heaped awards upon awards on this Southern California native. Mia came to VR Bangers for her first VR Porn video, so you know she’s only going for top-notch scenes with top-game performers, men, and women. Better hit up that yoga studio if you ever dream of being able to bang a flexible and talented chick like lady Malkova." const hairColor = "Blonde" const weight = "57" // 126 lb verifyField(t, performerName, performer.Name, "Name") verifyField(t, url, performer.URL, "URL") // #5294 - test multiple URLs if len(performer.URLs) != 2 { t.Errorf("Expected 2 URLs, got %d", len(performer.URLs)) } else { verifyField(t, url, &performer.URLs[0], "URLs[0]") verifyField(t, secondURL, &performer.URLs[1], "URLs[1]") } verifyField(t, gender, performer.Gender, "Gender") verifyField(t, ethnicity, performer.Ethnicity, "Ethnicity") verifyField(t, country, performer.Country, "Country") verifyField(t, birthdate, performer.Birthdate, "Birthdate") verifyField(t, aliases, performer.Aliases, "Aliases") verifyField(t, eyeColor, performer.EyeColor, "EyeColor") verifyField(t, measurements, performer.Measurements, "Measurements") verifyField(t, fakeTits, performer.FakeTits, "FakeTits") verifyField(t, careerLength, performer.CareerLength, "CareerLength") verifyField(t, tattoos, performer.Tattoos, "Tattoos") verifyField(t, piercings, performer.Piercings, "Piercings") verifyField(t, height, performer.Height, "Height") verifyField(t, details, performer.Details, "Details") verifyField(t, hairColor, performer.HairColor, "HairColor") verifyField(t, weight, performer.Weight, "Weight") expectedTagNames := []string{ "Twitter", "Facebook", "YouTube", "Instagram", } for i, expected := range expectedTagNames { verifyField(t, expected, &performer.Tags[i].Name, "TagName") } } func TestConcatXPath(t *testing.T) { const firstName = "FirstName" const lastName = "LastName" const eyeColor = "EyeColor" const separator = " " const testDoc = `
` + firstName + `
` + lastName + `
` + eyeColor + ` ` reader := strings.NewReader(testDoc) doc, err := htmlquery.Parse(reader) if err != nil { t.Errorf("Error loading document: %s", err.Error()) return } xpathConfig := make(mappedConfig) nameAttrConfig := mappedScraperAttrConfig{ Selector: "//div", Concat: separator, } xpathConfig["Name"] = nameAttrConfig xpathConfig["EyeColor"] = makeSimpleAttrConfig("//span") scraper := mappedScraper{ Performer: &mappedPerformerScraperConfig{ mappedConfig: xpathConfig, }, } q := &xpathQuery{ doc: doc, } performer, err := scraper.scrapePerformer(context.Background(), q) if err != nil { t.Errorf("Error scraping performer: %s", err.Error()) return } const performerName = firstName + separator + lastName verifyField(t, performerName, performer.Name, "Name") verifyField(t, eyeColor, performer.EyeColor, "EyeColor") } const sceneHTML = ` Test Video - Pornhub.com ` func makeSceneXPathConfig() mappedScraper { common := make(commonMappedConfig) common["$performerElem"] = `//div[@class="pornstarsWrapper"]/a[@data-mxptype="Pornstar"]` common["$studioElem"] = `//div[@data-type="channel"]/a` config := mappedSceneScraperConfig{ mappedConfig: make(mappedConfig), } config.mappedConfig["Title"] = makeSimpleAttrConfig(`//meta[@property="og:title"]/@content`) // this needs post-processing config.mappedConfig["Date"] = makeSimpleAttrConfig(`//script[@type="application/ld+json"]`) tagConfig := make(mappedConfig) tagConfig["Name"] = makeSimpleAttrConfig(`//div[@class="categoriesWrapper"]//a[not(@class="add-btn-small ")]`) config.Tags = tagConfig performerConfig := make(mappedConfig) performerConfig["Name"] = makeSimpleAttrConfig(`$performerElem/@data-mxptext`) performerConfig["URLs"] = makeSimpleAttrConfig(`$performerElem/@href`) config.Performers.mappedConfig = performerConfig studioConfig := make(mappedConfig) studioConfig["Name"] = makeSimpleAttrConfig(`$studioElem`) studioConfig["URL"] = makeSimpleAttrConfig(`$studioElem/@href`) config.Studio = studioConfig const sep = " " moviesNameConfig := mappedScraperAttrConfig{ Selector: `//i[@class="isMe tooltipTrig"]/@data-title`, Split: sep, } moviesConfig := make(mappedConfig) moviesConfig["Name"] = moviesNameConfig config.Movies = moviesConfig scraper := mappedScraper{ Scene: &config, Common: common, } return scraper } func verifyTags(t *testing.T, expectedTagNames []string, actualTags []*models.ScrapedTag) { t.Helper() i := 0 for i < len(expectedTagNames) || i < len(actualTags) { expectedTag := "" actualTag := "" if i < len(expectedTagNames) { expectedTag = expectedTagNames[i] } if i < len(actualTags) { actualTag = actualTags[i].Name } if expectedTag != actualTag { t.Errorf("Expected tag %s, got %s", expectedTag, actualTag) } i++ } } func verifyMovies(t *testing.T, expectedMovieNames []string, actualMovies []*models.ScrapedMovie) { t.Helper() i := 0 for i < len(expectedMovieNames) || i < len(actualMovies) { expectedMovie := "" actualMovie := "" if i < len(expectedMovieNames) { expectedMovie = expectedMovieNames[i] } if i < len(actualMovies) { actualMovie = *actualMovies[i].Name } if expectedMovie != actualMovie { t.Errorf("Expected movie %s, got %s", expectedMovie, actualMovie) } i++ } } func verifyPerformers(t *testing.T, expectedNames []string, expectedURLs []string, actualPerformers []*models.ScrapedPerformer) { t.Helper() i := 0 for i < len(expectedNames) || i < len(actualPerformers) { expectedName := "" actualName := "" expectedURL := "" actualURL := "" if i < len(expectedNames) { expectedName = expectedNames[i] } if i < len(expectedURLs) { expectedURL = expectedURLs[i] } if i < len(actualPerformers) { actualName = *actualPerformers[i].Name if len(actualPerformers[i].URLs) == 1 { actualURL = actualPerformers[i].URLs[0] } } if expectedName != actualName { t.Errorf("Expected performer name %q, got %q", expectedName, actualName) } if expectedURL != actualURL { t.Errorf("Expected performer URL %q, got %q", expectedURL, actualURL) } i++ } } func TestApplySceneXPathConfig(t *testing.T) { reader := strings.NewReader(sceneHTML) doc, err := htmlquery.Parse(reader) if err != nil { t.Errorf("Error loading document: %s", err.Error()) return } scraper := makeSceneXPathConfig() q := &xpathQuery{ doc: doc, } scene, err := scraper.scrapeScene(context.Background(), q) if err != nil { t.Errorf("Error scraping scene: %s", err.Error()) return } const title = "Test Video" verifyField(t, title, scene.Title, "Title") // verify tags expectedTags := []string{ "Amateur", "Babe", "Blowjob", "Exclusive", "HD Porn", "Pornstar", "Public", "Pussy Licking", "Threesome", "Verified Models", } verifyTags(t, expectedTags, scene.Tags) // verify movies expectedMovies := []string{ "Video", "of", "verified", "member", } verifyMovies(t, expectedMovies, scene.Movies) expectedPerformerNames := []string{ "Alex D", "Mia Malkova", "Riley Reid", } expectedPerformerURLs := []string{ "/pornstar/alex-d", "/pornstar/mia-malkova", "/pornstar/riley-reid", } verifyPerformers(t, expectedPerformerNames, expectedPerformerURLs, scene.Performers) const expectedStudioName = "Sis Loves Me" const expectedStudioURL = "/channels/sis-loves-me" verifyField(t, expectedStudioName, &scene.Studio.Name, "Studio.Name") verifyField(t, expectedStudioURL, scene.Studio.URL, "Studio.URL") } func TestLoadXPathScraperFromYAML(t *testing.T) { const yamlStr = `name: Test performerByURL: - action: scrapeXPath url: - test.com scraper: performerScraper xPathScrapers: performerScraper: performer: name: //h1[@itemprop="name"] sceneScraper: scene: Title: selector: //title postProcess: - parseDate: January 2, 2006 Tags: Name: //tags Movies: Name: //movies Performers: Name: //performers Studio: Name: //studio ` c := &Definition{} err := yaml.Unmarshal([]byte(yamlStr), &c) if err != nil { t.Errorf("Error loading yaml: %s", err.Error()) return } // ensure fields are filled in correctly sceneScraper := c.XPathScrapers["sceneScraper"] sceneConfig := sceneScraper.Scene assert.Equal(t, "//title", sceneConfig.mappedConfig["Title"].Selector) assert.Equal(t, "//tags", sceneConfig.Tags["Name"].Selector) assert.Equal(t, "//movies", sceneConfig.Movies["Name"].Selector) assert.Equal(t, "//performers", sceneConfig.Performers.mappedConfig["Name"].Selector) assert.Equal(t, "//studio", sceneConfig.Studio["Name"].Selector) postProcess := sceneConfig.mappedConfig["Title"].postProcessActions parseDate := postProcess[0].(*postProcessParseDate) assert.Equal(t, "January 2, 2006", string(*parseDate)) } func TestLoadInvalidXPath(t *testing.T) { config := make(mappedConfig) config["Name"] = makeSimpleAttrConfig(`//a[id=']/span`) reader := strings.NewReader(htmlDoc1) doc, err := htmlquery.Parse(reader) if err != nil { t.Errorf("Error loading document: %s", err.Error()) return } q := &xpathQuery{ doc: doc, } config.process(context.Background(), q, nil, nil) } type mockGlobalConfig struct{} func (mockGlobalConfig) GetScraperUserAgent() string { return "" } func (mockGlobalConfig) GetScrapersPath() string { return "" } func (mockGlobalConfig) GetScraperCDPPath() string { return "" } func (mockGlobalConfig) GetScraperCertCheck() bool { return false } func (mockGlobalConfig) GetScraperExcludeTagPatterns() []string { return nil } func (mockGlobalConfig) GetPythonPath() string { return "" } func (mockGlobalConfig) GetProxy() string { return "" } func TestSubScrape(t *testing.T) { retHTML := ` ` ssHTML := ` The name ` ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/getName" { fmt.Fprint(w, ssHTML) } else { fmt.Fprint(w, retHTML) } })) defer ts.Close() yamlStr := `name: Test performerByURL: - action: scrapeXPath url: - ` + ts.URL + ` scraper: performerScraper xPathScrapers: performerScraper: performer: Name: selector: //div/a/@href postProcess: - replace: - regex: ^ with: ` + ts.URL + ` - subScraper: selector: //span ` c := &Definition{} err := yaml.Unmarshal([]byte(yamlStr), &c) if err != nil { t.Errorf("Error loading yaml: %s", err.Error()) return } globalConfig := mockGlobalConfig{} client := &http.Client{} ctx := context.Background() s := scraperFromDefinition(*c, globalConfig) content, err := s.viaURL(ctx, client, ts.URL, ScrapeContentTypePerformer) if err != nil { t.Errorf("Error scraping performer: %s", err.Error()) return } performer, ok := content.(*models.ScrapedPerformer) if !ok { t.Error("couldn't convert scraped content into a performer") } verifyField(t, "The name", performer.Name, "Name") } ================================================ FILE: pkg/session/authentication.go ================================================ package session import ( "fmt" "net" "net/http" "strings" "github.com/stashapp/stash/pkg/logger" ) type ExternalAccessError net.IP func (e ExternalAccessError) Error() string { return fmt.Sprintf("stash accessed from external IP %s", net.IP(e).String()) } func CheckAllowPublicWithoutAuth(c ExternalAccessConfig, r *http.Request) error { if !c.HasCredentials() && !c.GetDangerousAllowPublicWithoutAuth() && !c.IsNewSystem() { requestIPString, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return fmt.Errorf("error parsing remote host (%s): %w", r.RemoteAddr, err) } // presence of scope ID in IPv6 addresses prevents parsing. Remove if present scopeIDIndex := strings.Index(requestIPString, "%") if scopeIDIndex != -1 { requestIPString = requestIPString[0:scopeIDIndex] } requestIP := net.ParseIP(requestIPString) if requestIP == nil { return fmt.Errorf("unable to parse remote host (%s)", requestIPString) } if r.Header.Get("X-FORWARDED-FOR") != "" { // Request was proxied proxyChain := strings.Split(r.Header.Get("X-FORWARDED-FOR"), ", ") // validate proxies against local network only if !isLocalIP(requestIP) { return ExternalAccessError(requestIP) } else { // Safe to validate X-Forwarded-For for i := range proxyChain { ip := net.ParseIP(proxyChain[i]) if !isLocalIP(ip) { return ExternalAccessError(ip) } } } } else if !isLocalIP(requestIP) { // request was not proxied return ExternalAccessError(requestIP) } } return nil } func CheckExternalAccessTripwire(c ExternalAccessConfig) *ExternalAccessError { if !c.HasCredentials() && !c.GetDangerousAllowPublicWithoutAuth() { if remoteIP := c.GetSecurityTripwireAccessedFromPublicInternet(); remoteIP != "" { err := ExternalAccessError(net.ParseIP(remoteIP)) return &err } } return nil } func isLocalIP(requestIP net.IP) bool { _, cgNatAddrSpace, _ := net.ParseCIDR("100.64.0.0/10") return requestIP.IsPrivate() || requestIP.IsLoopback() || requestIP.IsLinkLocalUnicast() || cgNatAddrSpace.Contains(requestIP) } func LogExternalAccessError(err ExternalAccessError) { logger.Errorf("Stash has been accessed from the internet (public IP %s), without authentication. \n"+ "This is extremely dangerous! The whole world can see your stash page and browse your files! \n"+ "You probably forwarded a port from your router. At the very least, add a password to stash in the settings. \n"+ "Stash will not serve requests until you edit config.yml, remove the security_tripwire_accessed_from_public_internet key and restart stash. \n"+ "This behaviour can be overridden (but not recommended) by setting dangerous_allow_public_without_auth to true in config.yml. \n"+ "More information is available at https://discourse.stashapp.cc/t/-/1658 \n"+ "Stash is not answering any other requests to protect your privacy.", net.IP(err).String()) } ================================================ FILE: pkg/session/authentication_test.go ================================================ package session import ( "errors" "net/http" "testing" ) type config struct { username string password string dangerousAllowPublicWithoutAuth bool securityTripwireAccessedFromPublicInternet string } func (c *config) HasCredentials() bool { return c.username != "" && c.password != "" } func (c *config) GetDangerousAllowPublicWithoutAuth() bool { return c.dangerousAllowPublicWithoutAuth } func (c *config) GetSecurityTripwireAccessedFromPublicInternet() string { return c.securityTripwireAccessedFromPublicInternet } func (c *config) IsNewSystem() bool { return false } func TestCheckAllowPublicWithoutAuth(t *testing.T) { c := &config{} doTest := func(caseIndex int, r *http.Request, expectedErr interface{}) { t.Helper() err := CheckAllowPublicWithoutAuth(c, r) if expectedErr == nil && err == nil { return } if expectedErr == nil { t.Errorf("[%d]: unexpected error: %v", caseIndex, err) return } if !errors.As(err, expectedErr) { t.Errorf("[%d]: expected %T, got %v (%T)", caseIndex, expectedErr, err, err) return } } { // direct connection tests testCases := []struct { address string err error }{ {"192.168.1.1:8080", nil}, {"192.168.1.1:8080", nil}, {"100.64.0.1:8080", nil}, {"127.0.0.1:8080", nil}, {"[::1]:8080", nil}, {"[fe80::c081:1c1a:ae39:d3cd%Ethernet 5]:9999", nil}, {"193.168.1.1:8080", &ExternalAccessError{}}, {"[2002:9fc4:ed97:e472:5170:5766:520c:c901]:9999", &ExternalAccessError{}}, } // try with no X-FORWARDED-FOR and valid one xFwdVals := []string{"", "192.168.1.1"} for i, xFwdVal := range xFwdVals { header := make(http.Header) header.Set("X-FORWARDED-FOR", xFwdVal) for ii, tc := range testCases { r := &http.Request{ RemoteAddr: tc.address, Header: header, } doTest((i*len(testCases) + ii), r, tc.err) } } } { // X-FORWARDED-FOR testCases := []struct { proxyChain string err error }{ {"192.168.1.1, 192.168.1.2, 100.64.0.1, 127.0.0.1", nil}, {"192.168.1.1, 193.168.1.1", &ExternalAccessError{}}, {"193.168.1.1, 192.168.1.1", &ExternalAccessError{}}, } const remoteAddr = "192.168.1.1:8080" header := make(http.Header) for i, tc := range testCases { header.Set("X-FORWARDED-FOR", tc.proxyChain) r := &http.Request{ RemoteAddr: remoteAddr, Header: header, } doTest(i, r, tc.err) } } { // test invalid request IPs invalidIPs := []string{"192.168.1.a:9999", "192.168.1.1"} for _, remoteAddr := range invalidIPs { r := &http.Request{ RemoteAddr: remoteAddr, } err := CheckAllowPublicWithoutAuth(c, r) if err == nil { t.Errorf("[%s]: expected error", remoteAddr) continue } } } { // test overrides r := &http.Request{ RemoteAddr: "193.168.1.1:8080", } c.username = "admin" c.password = "admin" if err := CheckAllowPublicWithoutAuth(c, r); err != nil { t.Errorf("unexpected error: %v", err) } c.username = "" c.password = "" c.dangerousAllowPublicWithoutAuth = true if err := CheckAllowPublicWithoutAuth(c, r); err != nil { t.Errorf("unexpected error: %v", err) } } } func TestCheckExternalAccessTripwire(t *testing.T) { c := &config{} c.securityTripwireAccessedFromPublicInternet = "4.4.4.4" // always return nil if authentication configured or dangerous key set c.username = "admin" c.password = "admin" if err := CheckExternalAccessTripwire(c); err != nil { t.Errorf("unexpected error %v", err) } c.username = "" c.password = "" // HACK - this key isn't publically exposed c.dangerousAllowPublicWithoutAuth = true if err := CheckExternalAccessTripwire(c); err != nil { t.Errorf("unexpected error %v", err) } c.dangerousAllowPublicWithoutAuth = false if err := CheckExternalAccessTripwire(c); err == nil { t.Errorf("expected error %v", ExternalAccessError("4.4.4.4")) } c.securityTripwireAccessedFromPublicInternet = "" if err := CheckExternalAccessTripwire(c); err != nil { t.Errorf("unexpected error %v", err) } } ================================================ FILE: pkg/session/config.go ================================================ package session type ExternalAccessConfig interface { HasCredentials() bool GetDangerousAllowPublicWithoutAuth() bool GetSecurityTripwireAccessedFromPublicInternet() string IsNewSystem() bool } type SessionConfig interface { GetUsername() string GetAPIKey() string GetSessionStoreKey() []byte GetMaxSessionAge() int ValidateCredentials(username string, password string) bool } ================================================ FILE: pkg/session/local.go ================================================ package session import ( "context" "net" "net/http" "github.com/stashapp/stash/pkg/logger" ) // SetLocalRequest checks if the request is from localhost and sets the context value accordingly. // It returns the modified request with the updated context, or the original request if it did // not come from localhost or if there was an error parsing the remote address. func SetLocalRequest(r *http.Request) *http.Request { // determine if request is from localhost host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { logger.Errorf("Error parsing remote address: %v", err) return r } ip := net.ParseIP(host) if ip == nil { logger.Errorf("Error parsing IP address: %s", host) return r } if ip.IsLoopback() { ctx := context.WithValue(r.Context(), contextLocalRequest, true) r = r.WithContext(ctx) } return r } // IsLocalRequest returns true if the request is from localhost, as determined by the context value set by SetLocalRequest. // If the context value is not set, it returns false. func IsLocalRequest(ctx context.Context) bool { val := ctx.Value(contextLocalRequest) if val == nil { return false } return val.(bool) } ================================================ FILE: pkg/session/plugin.go ================================================ package session import ( "context" "encoding/gob" "net/http" "github.com/gorilla/securecookie" "github.com/gorilla/sessions" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/plugin/hook" ) type VisitedPluginHook struct { PluginID string HookType hook.TriggerEnum } func init() { gob.Register([]VisitedPluginHook{}) } func (s *Store) VisitedPluginHandler() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // get the visited plugins from the cookie and set in the context session, err := s.sessionStore.Get(r, cookieName) // ignore errors if err == nil { val := session.Values[visitedPluginHooksKey] visitedPlugins, _ := val.([]VisitedPluginHook) ctx := setVisitedPluginHooks(r.Context(), visitedPlugins) r = r.WithContext(ctx) } next.ServeHTTP(w, r) }) } } func GetVisitedPluginHooks(ctx context.Context) []VisitedPluginHook { ctxVal := ctx.Value(contextVisitedPlugins) if ctxVal != nil { return ctxVal.([]VisitedPluginHook) } return nil } func AddVisitedPluginHook(ctx context.Context, pluginID string, hookType hook.TriggerEnum) context.Context { curVal := GetVisitedPluginHooks(ctx) curVal = append(curVal, VisitedPluginHook{PluginID: pluginID, HookType: hookType}) return setVisitedPluginHooks(ctx, curVal) } func setVisitedPluginHooks(ctx context.Context, visitedPlugins []VisitedPluginHook) context.Context { return context.WithValue(ctx, contextVisitedPlugins, visitedPlugins) } func (s *Store) MakePluginCookie(ctx context.Context) *http.Cookie { currentUser := GetCurrentUserID(ctx) visitedPlugins := GetVisitedPluginHooks(ctx) session := sessions.NewSession(s.sessionStore, cookieName) if currentUser != nil { session.Values[userIDKey] = *currentUser } session.Values[visitedPluginHooksKey] = visitedPlugins encoded, err := securecookie.EncodeMulti(session.Name(), session.Values, s.sessionStore.Codecs...) if err != nil { logger.Errorf("error creating session cookie: %s", err.Error()) return nil } return sessions.NewCookie(session.Name(), encoded, session.Options) } ================================================ FILE: pkg/session/session.go ================================================ // Package session provides session authentication and management for the application. package session import ( "context" "errors" "net/http" "github.com/gorilla/sessions" "github.com/stashapp/stash/pkg/logger" ) type key int const ( contextUser key = iota contextVisitedPlugins contextLocalRequest ) const ( userIDKey = "userID" visitedPluginHooksKey = "visitedPluginsHooks" ) const ( ApiKeyHeader = "ApiKey" ApiKeyParameter = "apikey" ) const ( cookieName = "session" usernameFormKey = "username" passwordFormKey = "password" ) type InvalidCredentialsError struct { Username string } func (e InvalidCredentialsError) Error() string { // don't leak the username return "invalid credentials" } var ErrUnauthorized = errors.New("unauthorized") type Store struct { sessionStore *sessions.CookieStore config SessionConfig } func NewStore(c SessionConfig) *Store { ret := &Store{ sessionStore: sessions.NewCookieStore(c.GetSessionStoreKey()), config: c, } ret.sessionStore.MaxAge(c.GetMaxSessionAge()) ret.sessionStore.Options.SameSite = http.SameSiteLaxMode return ret } func (s *Store) Login(w http.ResponseWriter, r *http.Request) error { // ignore error - we want a new session regardless newSession, _ := s.sessionStore.Get(r, cookieName) username := r.FormValue(usernameFormKey) password := r.FormValue(passwordFormKey) // authenticate the user if !s.config.ValidateCredentials(username, password) { return &InvalidCredentialsError{Username: username} } // since we only have one user, don't leak the name logger.Info("User logged in") newSession.Values[userIDKey] = username err := newSession.Save(r, w) if err != nil { return err } return nil } func (s *Store) Logout(w http.ResponseWriter, r *http.Request) error { session, err := s.sessionStore.Get(r, cookieName) if err != nil { return err } delete(session.Values, userIDKey) session.Options.MaxAge = -1 err = session.Save(r, w) if err != nil { return err } // since we only have one user, don't leak the name logger.Infof("User logged out") return nil } func (s *Store) GetSessionUserID(w http.ResponseWriter, r *http.Request) (string, error) { session, err := s.sessionStore.Get(r, cookieName) // ignore errors and treat as an empty user id, so that we handle expired // cookie if err != nil { return "", nil } if !session.IsNew { val := session.Values[userIDKey] // refresh the cookie err = session.Save(r, w) if err != nil { return "", err } ret, _ := val.(string) return ret, nil } return "", nil } func SetCurrentUserID(ctx context.Context, userID string) context.Context { return context.WithValue(ctx, contextUser, userID) } // GetCurrentUserID gets the current user id from the provided context func GetCurrentUserID(ctx context.Context) *string { userCtxVal := ctx.Value(contextUser) if userCtxVal != nil { currentUser := userCtxVal.(string) return ¤tUser } return nil } func (s *Store) Authenticate(w http.ResponseWriter, r *http.Request) (userID string, err error) { c := s.config // translate api key into current user, if present apiKey := r.Header.Get(ApiKeyHeader) // try getting the api key as a query parameter if apiKey == "" { apiKey = r.URL.Query().Get(ApiKeyParameter) } if apiKey != "" { // match against configured API and set userID to the // configured username. In future, we'll want to // get the username from the key. if c.GetAPIKey() != apiKey { return "", ErrUnauthorized } userID = c.GetUsername() } else { // handle session userID, err = s.GetSessionUserID(w, r) } if err != nil { return "", err } return } ================================================ FILE: pkg/sliceutil/collections.go ================================================ // Package sliceutil provides utilities for working with slices. package sliceutil import ( "slices" ) // AppendUnique appends toAdd to the vs slice if toAdd does not already // exist in the slice. It returns the new or unchanged slice. func AppendUnique[T comparable](vs []T, toAdd T) []T { if slices.Contains(vs, toAdd) { return vs } return append(vs, toAdd) } // AppendUniques appends a slice of values to the vs slice. It only // appends values that do not already exist in the slice. // It returns the new or unchanged slice. func AppendUniques[T comparable](vs []T, toAdd []T) []T { if len(toAdd) == 0 { return vs } // Extend the slice's capacity to avoid multiple re-allocations even in the worst case vs = slices.Grow(vs, len(toAdd)) for _, v := range toAdd { vs = AppendUnique(vs, v) } return vs } // Exclude returns a copy of the vs slice, excluding all values // that are also present in the toExclude slice. func Exclude[T comparable](vs []T, toExclude []T) []T { ret := make([]T, 0, len(vs)) for _, v := range vs { if !slices.Contains(toExclude, v) { ret = append(ret, v) } } return ret } // Unique returns a copy of the vs slice, with non-unique values removed. func Unique[T comparable](vs []T) []T { distinctValues := make(map[T]struct{}, len(vs)) ret := make([]T, 0, len(vs)) for _, v := range vs { if _, exists := distinctValues[v]; !exists { distinctValues[v] = struct{}{} ret = append(ret, v) } } return ret } // Delete returns a copy of the vs slice with toDel values removed. func Delete[T comparable](vs []T, toDel T) []T { ret := make([]T, 0, len(vs)) for _, v := range vs { if v != toDel { ret = append(ret, v) } } return ret } // Intersect returns a slice containing values that exist in both provided slices. func Intersect[T comparable](a []T, b []T) []T { var ret []T for _, v := range a { if slices.Contains(b, v) { ret = append(ret, v) } } return ret } // NotIntersect returns a slice containing values that do not exist in both provided slices. func NotIntersect[T comparable](a []T, b []T) []T { var ret []T for _, v := range a { if !slices.Contains(b, v) { ret = append(ret, v) } } for _, v := range b { if !slices.Contains(a, v) { ret = append(ret, v) } } return ret } // SliceSame returns true if the two provided slices have equal elements, // regardless of order. func SliceSame[T comparable](a []T, b []T) bool { if len(a) != len(b) { return false } visited := make(map[int]struct{}) for i := range a { found := false for j := range b { if _, exists := visited[j]; exists { continue } if a[i] == b[j] { found = true visited[j] = struct{}{} break } } if !found { return false } } return true } // Filter returns a slice containing the elements of the vs slice // that meet the condition specified by f. func Filter[T any](vs []T, f func(T) bool) []T { var ret []T for _, v := range vs { if f(v) { ret = append(ret, v) } } return ret } // Map returns the result of applying f to each element of the vs slice. func Map[T any, V any](vs []T, f func(T) V) []V { ret := make([]V, len(vs)) for i, v := range vs { ret[i] = f(v) } return ret } func PtrsToValues[T any](vs []*T) []T { ret := make([]T, len(vs)) for i, v := range vs { ret[i] = *v } return ret } func ValuesToPtrs[T any](vs []T) []*T { ret := make([]*T, len(vs)) for i, v := range vs { // We can do this safely because go.mod indicates Go 1.22 // See: https://go.dev/blog/loopvar-preview ret[i] = &v } return ret } // Flatten returns a single slice containing all elements of the provided // slice of slices. func Flatten[T any](vs [][]T) []T { var ret []T for _, v := range vs { ret = append(ret, v...) } return ret } ================================================ FILE: pkg/sliceutil/collections_test.go ================================================ package sliceutil import ( "reflect" "testing" "github.com/stretchr/testify/assert" ) func TestSliceSame(t *testing.T) { tests := []struct { name string a []int b []int want bool }{ {"nil values", nil, nil, true}, {"empty", []int{}, []int{}, true}, {"nil and empty", nil, []int{}, true}, { "different length", []int{1, 2, 3}, []int{1, 2}, false, }, { "equal", []int{1, 2, 3, 4, 5}, []int{1, 2, 3, 4, 5}, true, }, { "different order", []int{5, 4, 3, 2, 1}, []int{1, 2, 3, 4, 5}, true, }, { "different", []int{5, 4, 3, 2, 6}, []int{1, 2, 3, 4, 5}, false, }, { "same with duplicates", []int{1, 1, 2, 3, 4}, []int{1, 2, 3, 4, 1}, true, }, { "subset", []int{1, 1, 2, 2, 3}, []int{1, 2, 3, 4, 5}, false, }, { "superset", []int{1, 2, 3, 4, 5}, []int{1, 1, 2, 2, 3}, false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := SliceSame(tt.a, tt.b) assert.Equal(t, tt.want, got) }) } } func TestAppendUniques(t *testing.T) { type args struct { vs []int toAdd []int } tests := []struct { name string args args want []int }{ { name: "append to empty slice", args: args{ vs: []int{}, toAdd: []int{1, 2, 3}, }, want: []int{1, 2, 3}, }, { name: "append all unique values", args: args{ vs: []int{1, 2, 3}, toAdd: []int{4, 5, 6}, }, want: []int{1, 2, 3, 4, 5, 6}, }, { name: "append with some duplicates", args: args{ vs: []int{1, 2, 3}, toAdd: []int{3, 4, 5}, }, want: []int{1, 2, 3, 4, 5}, }, { name: "append all duplicates", args: args{ vs: []int{1, 2, 3}, toAdd: []int{1, 2, 3}, }, want: []int{1, 2, 3}, }, { name: "append to nil slice", args: args{ vs: nil, toAdd: []int{1, 2, 3}, }, want: []int{1, 2, 3}, }, { name: "append empty slice", args: args{ vs: []int{1, 2, 3}, toAdd: []int{}, }, want: []int{1, 2, 3}, }, { name: "append nil to slice", args: args{ vs: []int{1, 2, 3}, toAdd: nil, }, want: []int{1, 2, 3}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := AppendUniques(tt.args.vs, tt.args.toAdd); !reflect.DeepEqual(got, tt.want) { t.Errorf("AppendUniques() = %v, want %v", got, tt.want) } }) } } func BenchmarkAppendUniques(b *testing.B) { for i := 0; i < b.N; i++ { AppendUniques([]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, []int{3, 4, 4, 11, 12, 13, 14, 15, 16, 17, 18}) } } ================================================ FILE: pkg/sliceutil/intslice/int_collections.go ================================================ package intslice import "strconv" // IntSliceToStringSlice converts a slice of ints to a slice of strings. func IntSliceToStringSlice(ss []int) []string { ret := make([]string, len(ss)) for i, v := range ss { ret[i] = strconv.Itoa(v) } return ret } ================================================ FILE: pkg/sliceutil/stringslice/string_collections.go ================================================ package stringslice import ( "strconv" "strings" ) // StringSliceToIntSlice converts a slice of strings to a slice of ints. // Returns an error if any values cannot be parsed. func StringSliceToIntSlice(ss []string) ([]int, error) { ret := make([]int, len(ss)) for i, v := range ss { var err error ret[i], err = strconv.Atoi(v) if err != nil { return nil, err } } return ret, nil } // FromString converts a string to a slice of strings, splitting on the sep character. // Unlike strings.Split, this function will also trim whitespace from the resulting strings. func FromString(s string, sep string) []string { v := strings.Split(s, ",") for i, vv := range v { v[i] = strings.TrimSpace(vv) } return v } // Unique returns a slice containing only unique values from the provided slice. // The comparison is case-insensitive. func UniqueFold(s []string) []string { seen := make(map[string]struct{}, len(s)) ret := make([]string, 0, len(s)) for _, v := range s { if _, exists := seen[strings.ToLower(v)]; exists { continue } seen[strings.ToLower(v)] = struct{}{} ret = append(ret, v) } return ret } // UniqueExcludeFold returns a deduplicated slice of strings with the excluded string removed. // The comparison is case-insensitive. func UniqueExcludeFold(values []string, exclude string) []string { seen := make(map[string]struct{}, len(values)) seen[strings.ToLower(exclude)] = struct{}{} ret := make([]string, 0, len(values)) for _, v := range values { vLower := strings.ToLower(v) if _, exists := seen[vLower]; exists { continue } seen[vLower] = struct{}{} ret = append(ret, v) } return ret } // TrimSpace trims whitespace from each string in a slice. func TrimSpace(s []string) []string { for i, v := range s { s[i] = strings.TrimSpace(v) } return s } ================================================ FILE: pkg/sqlite/anonymise.go ================================================ package sqlite import ( "context" "crypto/rand" "database/sql" "fmt" "math/big" "path/filepath" "strings" "unicode" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/txn" "github.com/stashapp/stash/pkg/utils" ) const ( letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" hex = "0123456789abcdef" ) type Anonymiser struct { *Database } func NewAnonymiser(db *Database, outPath string) (*Anonymiser, error) { if _, err := db.writeDB.Exec(fmt.Sprintf(`VACUUM INTO "%s"`, outPath)); err != nil { return nil, fmt.Errorf("vacuuming into %s: %w", outPath, err) } newDB := NewDatabase() if err := newDB.Open(outPath); err != nil { return nil, fmt.Errorf("opening %s: %w", outPath, err) } return &Anonymiser{Database: newDB}, nil } func (db *Anonymiser) Anonymise(ctx context.Context) error { if err := func() error { defer db.Close() return utils.Do([]func() error{ func() error { return db.deleteBlobs() }, func() error { return db.deleteStashIDs() }, func() error { return db.clearOHistory() }, func() error { return db.clearWatchHistory() }, func() error { return db.anonymiseFolders(ctx) }, func() error { return db.anonymiseFiles(ctx) }, func() error { return db.anonymiseCaptions(ctx) }, func() error { return db.anonymiseFingerprints(ctx) }, func() error { return db.anonymiseScenes(ctx) }, func() error { return db.anonymiseMarkers(ctx) }, func() error { return db.anonymiseImages(ctx) }, func() error { return db.anonymiseGalleries(ctx) }, func() error { return db.anonymisePerformers(ctx) }, func() error { return db.anonymiseStudios(ctx) }, func() error { return db.anonymiseTags(ctx) }, func() error { return db.anonymiseGroups(ctx) }, func() error { return db.anonymiseSavedFilters(ctx) }, func() error { return db.Optimise(ctx) }, }) }(); err != nil { // delete the database _ = db.Remove() return err } return nil } func (db *Anonymiser) truncateColumn(tableName string, column string) error { _, err := db.writeDB.Exec("UPDATE " + tableName + " SET " + column + " = NULL") return err } func (db *Anonymiser) truncateTable(tableName string) error { _, err := db.writeDB.Exec("DELETE FROM " + tableName) return err } func (db *Anonymiser) deleteBlobs() error { return utils.Do([]func() error{ func() error { return db.truncateColumn(tagTable, tagImageBlobColumn) }, func() error { return db.truncateColumn(studioTable, studioImageBlobColumn) }, func() error { return db.truncateColumn(performerTable, performerImageBlobColumn) }, func() error { return db.truncateColumn(sceneTable, sceneCoverBlobColumn) }, func() error { return db.truncateColumn(groupTable, groupFrontImageBlobColumn) }, func() error { return db.truncateColumn(groupTable, groupBackImageBlobColumn) }, func() error { return db.truncateTable(blobTable) }, }) } func (db *Anonymiser) deleteStashIDs() error { return utils.Do([]func() error{ func() error { return db.truncateTable("scene_stash_ids") }, func() error { return db.truncateTable("studio_stash_ids") }, func() error { return db.truncateTable("performer_stash_ids") }, func() error { return db.truncateTable("tag_stash_ids") }, }) } func (db *Anonymiser) clearOHistory() error { return utils.Do([]func() error{ func() error { return db.truncateTable(scenesODatesTable) }, }) } func (db *Anonymiser) clearWatchHistory() error { return utils.Do([]func() error{ func() error { return db.truncateTable(scenesViewDatesTable) }, }) } func (db *Anonymiser) anonymiseFolders(ctx context.Context) error { logger.Infof("Anonymising folders") return txn.WithTxn(ctx, db, func(ctx context.Context) error { return db.anonymiseFoldersRecurse(ctx, 0, "") }) } func (db *Anonymiser) anonymiseFoldersRecurse(ctx context.Context, parentFolderID int, parentPath string) error { table := folderTableMgr.table stmt := dialect.Update(table) if parentFolderID == 0 { stmt = stmt.Set(goqu.Record{"path": goqu.Cast(table.Col(idColumn), "VARCHAR")}).Where(table.Col("parent_folder_id").IsNull()) } else { stmt = stmt.Prepared(true).Set(goqu.Record{ "path": goqu.L("? || ? || id", parentPath, string(filepath.Separator)), }).Where(table.Col("parent_folder_id").Eq(parentFolderID)) } if _, err := exec(ctx, stmt); err != nil { return fmt.Errorf("anonymising %s: %w", table.GetTable(), err) } // now recurse to sub-folders query := dialect.From(table).Select(table.Col(idColumn), table.Col("path")) if parentFolderID == 0 { query = query.Where(table.Col("parent_folder_id").IsNull()) } else { query = query.Where(table.Col("parent_folder_id").Eq(parentFolderID)) } const single = false return queryFunc(ctx, query, single, func(rows *sqlx.Rows) error { var id int var path string if err := rows.Scan(&id, &path); err != nil { return err } return db.anonymiseFoldersRecurse(ctx, id, path) }) } func (db *Anonymiser) anonymiseFiles(ctx context.Context) error { logger.Infof("Anonymising files") return txn.WithTxn(ctx, db, func(ctx context.Context) error { table := fileTableMgr.table stmt := dialect.Update(table).Set(goqu.Record{"basename": goqu.Cast(table.Col(idColumn), "VARCHAR")}) if _, err := exec(ctx, stmt); err != nil { return fmt.Errorf("anonymising %s: %w", table.GetTable(), err) } return nil }) } func (db *Anonymiser) anonymiseCaptions(ctx context.Context) error { logger.Infof("Anonymising captions") return txn.WithTxn(ctx, db, func(ctx context.Context) error { table := goqu.T(videoCaptionsTable) stmt := dialect.Update(table).Set(goqu.Record{"filename": goqu.Cast(table.Col("file_id"), "VARCHAR")}) if _, err := exec(ctx, stmt); err != nil { return fmt.Errorf("anonymising %s: %w", table.GetTable(), err) } return nil }) } func (db *Anonymiser) anonymiseFingerprints(ctx context.Context) error { logger.Infof("Anonymising fingerprints") table := fingerprintTableMgr.table lastID := 0 lastType := "" total := 0 const logEvery = 10000 for gotSome := true; gotSome; { if err := txn.WithTxn(ctx, db, func(ctx context.Context) error { query := dialect.From(table).Select( table.Col(fileIDColumn), table.Col("type"), table.Col("fingerprint"), ).Where(goqu.L("(file_id, type)").Gt(goqu.L("(?, ?)", lastID, lastType))).Limit(1000) gotSome = false const single = false return queryFunc(ctx, query, single, func(rows *sqlx.Rows) error { var ( id int typ string fingerprint string ) if err := rows.Scan( &id, &typ, &fingerprint, ); err != nil { return err } if err := db.anonymiseFingerprint(ctx, table, "fingerprint", fingerprint); err != nil { return err } lastID = id lastType = typ gotSome = true total++ if total%logEvery == 0 { logger.Infof("Anonymised %d fingerprints", total) } return nil }) }); err != nil { return err } } return nil } func (db *Anonymiser) anonymiseScenes(ctx context.Context) error { logger.Infof("Anonymising scenes") table := sceneTableMgr.table lastID := 0 total := 0 const logEvery = 10000 for gotSome := true; gotSome; { if err := txn.WithTxn(ctx, db, func(ctx context.Context) error { query := dialect.From(table).Select( table.Col(idColumn), table.Col("title"), table.Col("details"), table.Col("code"), table.Col("director"), ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) gotSome = false const single = false return queryFunc(ctx, query, single, func(rows *sqlx.Rows) error { var ( id int title sql.NullString details sql.NullString code sql.NullString director sql.NullString ) if err := rows.Scan( &id, &title, &details, &code, &director, ); err != nil { return err } set := goqu.Record{} // if title set set new title db.obfuscateNullString(set, "title", title) db.obfuscateNullString(set, "details", details) if len(set) > 0 { stmt := dialect.Update(table).Set(set).Where(table.Col(idColumn).Eq(id)) if _, err := exec(ctx, stmt); err != nil { return fmt.Errorf("anonymising %s: %w", table.GetTable(), err) } } if code.Valid { if err := db.anonymiseText(ctx, table, "code", code.String); err != nil { return err } } if director.Valid { if err := db.anonymiseText(ctx, table, "director", director.String); err != nil { return err } } lastID = id gotSome = true total++ if total%logEvery == 0 { logger.Infof("Anonymised %d scenes", total) } return nil }) }); err != nil { return err } } if err := db.anonymiseURLs(ctx, goqu.T(scenesURLsTable), "scene_id"); err != nil { return err } if err := db.anonymiseCustomFields(ctx, goqu.T(scenesCustomFieldsTable.GetTable()), "scene_id"); err != nil { return err } return nil } func (db *Anonymiser) anonymiseMarkers(ctx context.Context) error { logger.Infof("Anonymising scene markers") table := sceneMarkerTableMgr.table lastID := 0 total := 0 const logEvery = 10000 for gotSome := true; gotSome; { if err := txn.WithTxn(ctx, db, func(ctx context.Context) error { query := dialect.From(table).Select( table.Col(idColumn), table.Col("title"), ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) gotSome = false const single = false return queryFunc(ctx, query, single, func(rows *sqlx.Rows) error { var ( id int title string ) if err := rows.Scan( &id, &title, ); err != nil { return err } if err := db.anonymiseText(ctx, table, "title", title); err != nil { return err } lastID = id gotSome = true total++ if total%logEvery == 0 { logger.Infof("Anonymised %d scene markers", total) } return nil }) }); err != nil { return err } } return nil } func (db *Anonymiser) anonymiseImages(ctx context.Context) error { logger.Infof("Anonymising images") table := imageTableMgr.table lastID := 0 total := 0 const logEvery = 10000 for gotSome := true; gotSome; { if err := txn.WithTxn(ctx, db, func(ctx context.Context) error { query := dialect.From(table).Select( table.Col(idColumn), table.Col("title"), ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) gotSome = false const single = false return queryFunc(ctx, query, single, func(rows *sqlx.Rows) error { var ( id int title sql.NullString ) if err := rows.Scan( &id, &title, ); err != nil { return err } set := goqu.Record{} db.obfuscateNullString(set, "title", title) if len(set) > 0 { stmt := dialect.Update(table).Set(set).Where(table.Col(idColumn).Eq(id)) if _, err := exec(ctx, stmt); err != nil { return fmt.Errorf("anonymising %s: %w", table.GetTable(), err) } } lastID = id gotSome = true total++ if total%logEvery == 0 { logger.Infof("Anonymised %d images", total) } return nil }) }); err != nil { return err } } if err := db.anonymiseURLs(ctx, goqu.T(imagesURLsTable), "image_id"); err != nil { return err } return nil } func (db *Anonymiser) anonymiseGalleries(ctx context.Context) error { logger.Infof("Anonymising galleries") table := galleryTableMgr.table lastID := 0 total := 0 const logEvery = 10000 for gotSome := true; gotSome; { if err := txn.WithTxn(ctx, db, func(ctx context.Context) error { query := dialect.From(table).Select( table.Col(idColumn), table.Col("title"), table.Col("details"), table.Col("photographer"), ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) gotSome = false const single = false return queryFunc(ctx, query, single, func(rows *sqlx.Rows) error { var ( id int title sql.NullString details sql.NullString photographer sql.NullString ) if err := rows.Scan( &id, &title, &details, &photographer, ); err != nil { return err } set := goqu.Record{} db.obfuscateNullString(set, "title", title) db.obfuscateNullString(set, "details", details) db.obfuscateNullString(set, "photographer", photographer) if len(set) > 0 { stmt := dialect.Update(table).Set(set).Where(table.Col(idColumn).Eq(id)) if _, err := exec(ctx, stmt); err != nil { return fmt.Errorf("anonymising %s: %w", table.GetTable(), err) } } lastID = id gotSome = true total++ if total%logEvery == 0 { logger.Infof("Anonymised %d galleries", total) } return nil }) }); err != nil { return err } } if err := db.anonymiseURLs(ctx, goqu.T(galleriesURLsTable), "gallery_id"); err != nil { return err } if err := db.anonymiseCustomFields(ctx, goqu.T(galleriesCustomFieldsTable.GetTable()), "gallery_id"); err != nil { return err } return nil } func (db *Anonymiser) anonymisePerformers(ctx context.Context) error { logger.Infof("Anonymising performers") table := performerTableMgr.table lastID := 0 total := 0 const logEvery = 10000 for gotSome := true; gotSome; { if err := txn.WithTxn(ctx, db, func(ctx context.Context) error { query := dialect.From(table).Select( table.Col(idColumn), table.Col("name"), table.Col("disambiguation"), table.Col("details"), table.Col("tattoos"), table.Col("piercings"), ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) gotSome = false const single = false return queryFunc(ctx, query, single, func(rows *sqlx.Rows) error { var ( id int name sql.NullString disambiguation sql.NullString details sql.NullString tattoos sql.NullString piercings sql.NullString ) if err := rows.Scan( &id, &name, &disambiguation, &details, &tattoos, &piercings, ); err != nil { return err } set := goqu.Record{} db.obfuscateNullString(set, "name", name) db.obfuscateNullString(set, "disambiguation", disambiguation) db.obfuscateNullString(set, "details", details) db.obfuscateNullString(set, "tattoos", tattoos) db.obfuscateNullString(set, "piercings", piercings) if len(set) > 0 { stmt := dialect.Update(table).Set(set).Where(table.Col(idColumn).Eq(id)) if _, err := exec(ctx, stmt); err != nil { return fmt.Errorf("anonymising %s: %w", table.GetTable(), err) } } lastID = id gotSome = true total++ if total%logEvery == 0 { logger.Infof("Anonymised %d performers", total) } return nil }) }); err != nil { return err } } if err := db.anonymiseAliases(ctx, goqu.T(performersAliasesTable), "performer_id"); err != nil { return err } if err := db.anonymiseURLs(ctx, goqu.T(performerURLsTable), "performer_id"); err != nil { return err } if err := db.anonymiseCustomFields(ctx, goqu.T(performersCustomFieldsTable.GetTable()), "performer_id"); err != nil { return err } return nil } func (db *Anonymiser) anonymiseStudios(ctx context.Context) error { logger.Infof("Anonymising studios") table := studioTableMgr.table lastID := 0 total := 0 const logEvery = 10000 for gotSome := true; gotSome; { if err := txn.WithTxn(ctx, db, func(ctx context.Context) error { query := dialect.From(table).Select( table.Col(idColumn), table.Col("name"), table.Col("details"), ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) gotSome = false const single = false return queryFunc(ctx, query, single, func(rows *sqlx.Rows) error { var ( id int name sql.NullString details sql.NullString ) if err := rows.Scan( &id, &name, &details, ); err != nil { return err } set := goqu.Record{} db.obfuscateNullString(set, "name", name) db.obfuscateNullString(set, "details", details) if len(set) > 0 { stmt := dialect.Update(table).Set(set).Where(table.Col(idColumn).Eq(id)) if _, err := exec(ctx, stmt); err != nil { return fmt.Errorf("anonymising %s: %w", table.GetTable(), err) } } lastID = id gotSome = true total++ // TODO - anonymise studio aliases if total%logEvery == 0 { logger.Infof("Anonymised %d studios", total) } return nil }) }); err != nil { return err } } if err := db.anonymiseAliases(ctx, goqu.T(studioAliasesTable), "studio_id"); err != nil { return err } if err := db.anonymiseURLs(ctx, goqu.T(studioURLsTable), "studio_id"); err != nil { return err } if err := db.anonymiseCustomFields(ctx, goqu.T(studiosCustomFieldsTable.GetTable()), "studio_id"); err != nil { return err } return nil } func (db *Anonymiser) anonymiseAliases(ctx context.Context, table exp.IdentifierExpression, idColumn string) error { lastID := 0 lastAlias := "" total := 0 const logEvery = 10000 for gotSome := true; gotSome; { if err := txn.WithTxn(ctx, db, func(ctx context.Context) error { query := dialect.From(table).Select( table.Col(idColumn), table.Col("alias"), ).Where(goqu.L("(" + idColumn + ", alias)").Gt(goqu.L("(?, ?)", lastID, lastAlias))).Limit(1000) gotSome = false const single = false return queryFunc(ctx, query, single, func(rows *sqlx.Rows) error { var ( id int alias sql.NullString ) if err := rows.Scan( &id, &alias, ); err != nil { return err } set := goqu.Record{} db.obfuscateNullString(set, "alias", alias) if len(set) > 0 { stmt := dialect.Update(table).Set(set).Where( table.Col(idColumn).Eq(id), table.Col("alias").Eq(alias), ) if _, err := exec(ctx, stmt); err != nil { return fmt.Errorf("anonymising %s: %w", table.GetTable(), err) } } lastID = id lastAlias = alias.String gotSome = true total++ if total%logEvery == 0 { logger.Infof("Anonymised %d %s aliases", total, table.GetTable()) } return nil }) }); err != nil { return err } } return nil } func (db *Anonymiser) anonymiseURLs(ctx context.Context, table exp.IdentifierExpression, idColumn string) error { lastID := 0 lastURL := "" total := 0 const logEvery = 10000 for gotSome := true; gotSome; { if err := txn.WithTxn(ctx, db, func(ctx context.Context) error { query := dialect.From(table).Select( table.Col(idColumn), table.Col("url"), ).Where(goqu.L("(" + idColumn + ", url)").Gt(goqu.L("(?, ?)", lastID, lastURL))).Limit(1000) gotSome = false const single = false return queryFunc(ctx, query, single, func(rows *sqlx.Rows) error { var ( id int url sql.NullString ) if err := rows.Scan( &id, &url, ); err != nil { return err } set := goqu.Record{} db.obfuscateNullString(set, "url", url) if len(set) > 0 { stmt := dialect.Update(table).Set(set).Where( table.Col(idColumn).Eq(id), table.Col("url").Eq(url), ) if _, err := exec(ctx, stmt); err != nil { return fmt.Errorf("anonymising %s: %w", table.GetTable(), err) } } lastID = id lastURL = url.String gotSome = true total++ if total%logEvery == 0 { logger.Infof("Anonymised %d %s URLs", total, table.GetTable()) } return nil }) }); err != nil { return err } } return nil } func (db *Anonymiser) anonymiseTags(ctx context.Context) error { logger.Infof("Anonymising tags") table := tagTableMgr.table lastID := 0 total := 0 const logEvery = 10000 for gotSome := true; gotSome; { if err := txn.WithTxn(ctx, db, func(ctx context.Context) error { query := dialect.From(table).Select( table.Col(idColumn), table.Col("name"), table.Col("sort_name"), table.Col("description"), ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) gotSome = false const single = false return queryFunc(ctx, query, single, func(rows *sqlx.Rows) error { var ( id int name sql.NullString sortName sql.NullString description sql.NullString ) if err := rows.Scan( &id, &name, &sortName, &description, ); err != nil { return err } set := goqu.Record{} db.obfuscateNullString(set, "name", name) db.obfuscateNullString(set, "sort_name", sortName) db.obfuscateNullString(set, "description", description) if len(set) > 0 { stmt := dialect.Update(table).Set(set).Where(table.Col(idColumn).Eq(id)) if _, err := exec(ctx, stmt); err != nil { return fmt.Errorf("anonymising %s: %w", table.GetTable(), err) } } lastID = id gotSome = true total++ if total%logEvery == 0 { logger.Infof("Anonymised %d tags", total) } return nil }) }); err != nil { return err } } if err := db.anonymiseAliases(ctx, goqu.T(tagAliasesTable), "tag_id"); err != nil { return err } if err := db.anonymiseCustomFields(ctx, goqu.T(tagsCustomFieldsTable.GetTable()), "tag_id"); err != nil { return err } return nil } func (db *Anonymiser) anonymiseGroups(ctx context.Context) error { logger.Infof("Anonymising groups") table := groupTableMgr.table lastID := 0 total := 0 const logEvery = 10000 for gotSome := true; gotSome; { if err := txn.WithTxn(ctx, db, func(ctx context.Context) error { query := dialect.From(table).Select( table.Col(idColumn), table.Col("name"), table.Col("aliases"), table.Col("description"), table.Col("director"), ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) gotSome = false const single = false return queryFunc(ctx, query, single, func(rows *sqlx.Rows) error { var ( id int name sql.NullString aliases sql.NullString description sql.NullString director sql.NullString ) if err := rows.Scan( &id, &name, &aliases, &description, &director, ); err != nil { return err } set := goqu.Record{} db.obfuscateNullString(set, "name", name) db.obfuscateNullString(set, "aliases", aliases) db.obfuscateNullString(set, "description", description) db.obfuscateNullString(set, "director", director) if len(set) > 0 { stmt := dialect.Update(table).Set(set).Where(table.Col(idColumn).Eq(id)) if _, err := exec(ctx, stmt); err != nil { return fmt.Errorf("anonymising %s: %w", table.GetTable(), err) } } lastID = id gotSome = true total++ if total%logEvery == 0 { logger.Infof("Anonymised %d groups", total) } return nil }) }); err != nil { return err } } if err := db.anonymiseURLs(ctx, goqu.T(groupURLsTable), "group_id"); err != nil { return err } if err := db.anonymiseCustomFields(ctx, goqu.T(groupsCustomFieldsTable.GetTable()), "group_id"); err != nil { return err } return nil } func (db *Anonymiser) anonymiseSavedFilters(ctx context.Context) error { logger.Infof("Anonymising saved filters") table := savedFilterTableMgr.table lastID := 0 total := 0 const logEvery = 10000 for gotSome := true; gotSome; { if err := txn.WithTxn(ctx, db, func(ctx context.Context) error { query := dialect.From(table).Select( table.Col(idColumn), table.Col("name"), ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) gotSome = false const single = false return queryFunc(ctx, query, single, func(rows *sqlx.Rows) error { var ( id int name sql.NullString ) if err := rows.Scan( &id, &name, ); err != nil { return err } set := goqu.Record{} db.obfuscateNullString(set, "name", name) if len(set) > 0 { stmt := dialect.Update(table).Set(set).Where(table.Col(idColumn).Eq(id)) if _, err := exec(ctx, stmt); err != nil { return fmt.Errorf("anonymising %s: %w", table.GetTable(), err) } } lastID = id gotSome = true total++ if total%logEvery == 0 { logger.Infof("Anonymised %d saved filters", total) } return nil }) }); err != nil { return err } } return nil } func (db *Anonymiser) anonymiseText(ctx context.Context, table exp.IdentifierExpression, column string, value string) error { set := goqu.Record{} set[column] = db.obfuscateString(value, letters) stmt := dialect.Update(table).Set(set).Where(table.Col(column).Eq(value)) if _, err := exec(ctx, stmt); err != nil { return fmt.Errorf("anonymising %s: %w", column, err) } return nil } func (db *Anonymiser) anonymiseFingerprint(ctx context.Context, table exp.IdentifierExpression, column string, value string) error { set := goqu.Record{} set[column] = db.obfuscateString(value, hex) stmt := dialect.Update(table).Set(set).Where(table.Col(column).Eq(value)) if _, err := exec(ctx, stmt); err != nil { return fmt.Errorf("anonymising %s: %w", column, err) } return nil } func (db *Anonymiser) obfuscateNullString(out goqu.Record, column string, in sql.NullString) { if in.Valid { out[column] = db.obfuscateString(in.String, letters) } } func (db *Anonymiser) obfuscateString(in string, dict string) string { out := strings.Builder{} for _, c := range in { if unicode.IsSpace(c) { out.WriteRune(c) } else { num, err := rand.Int(rand.Reader, big.NewInt(int64(len(dict)))) if err != nil { panic("error generating random number") } out.WriteByte(dict[num.Int64()]) } } return out.String() } func (db *Anonymiser) anonymiseCustomFields(ctx context.Context, table exp.IdentifierExpression, idColumn string) error { lastID := 0 lastField := "" total := 0 const logEvery = 10000 for gotSome := true; gotSome; { if err := txn.WithTxn(ctx, db, func(ctx context.Context) error { query := dialect.From(table).Select( table.Col(idColumn), table.Col("field"), table.Col("value"), ).Where( goqu.L("("+idColumn+", field)").Gt(goqu.L("(?, ?)", lastID, lastField)), ).Order( table.Col(idColumn).Asc(), table.Col("field").Asc(), ).Limit(1000) gotSome = false const single = false return queryFunc(ctx, query, single, func(rows *sqlx.Rows) error { var ( id int field string value string ) if err := rows.Scan( &id, &field, &value, ); err != nil { return err } set := goqu.Record{} set["field"] = db.obfuscateString(field, letters) set["value"] = db.obfuscateString(value, letters) if len(set) > 0 { stmt := dialect.Update(table).Set(set).Where( table.Col(idColumn).Eq(id), table.Col("field").Eq(field), ) if _, err := exec(ctx, stmt); err != nil { return fmt.Errorf("anonymising %s: %w", table.GetTable(), err) } } lastID = id lastField = field gotSome = true total++ if total%logEvery == 0 { logger.Infof("Anonymised %d %s custom fields", total, table.GetTable()) } return nil }) }); err != nil { return err } } return nil } ================================================ FILE: pkg/sqlite/anonymise_test.go ================================================ //go:build integration // +build integration package sqlite_test import ( "context" "os" "testing" "github.com/stashapp/stash/pkg/sqlite" ) func TestAnonymiser_Anonymise(t *testing.T) { f, err := os.CreateTemp("", "*.sqlite") if err != nil { t.Errorf("Could not create temporary file: %v", err) return } f.Close() defer os.Remove(f.Name()) // use existing database anonymiser, err := sqlite.NewAnonymiser(db, f.Name()) if err != nil { t.Errorf("Could not create anonymiser: %v", err) return } if err := anonymiser.Anonymise(context.Background()); err != nil { t.Errorf("Could not anonymise: %v", err) return } t.Logf("Anonymised database written to %s", f.Name()) // TODO - ensure anonymous } ================================================ FILE: pkg/sqlite/batch.go ================================================ package sqlite const defaultBatchSize = 1000 // batchExec executes the provided function in batches of the provided size. func batchExec[T any](ids []T, batchSize int, fn func(batch []T) error) error { for i := 0; i < len(ids); i += batchSize { end := i + batchSize if end > len(ids) { end = len(ids) } batch := ids[i:end] if err := fn(batch); err != nil { return err } } return nil } ================================================ FILE: pkg/sqlite/blob/fs.go ================================================ package blob import ( "bytes" "context" "fmt" "io" "io/fs" "os" "path/filepath" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" ) const ( blobsDirDepth int = 2 blobsDirLength int = 2 // thumbDirDepth * thumbDirLength must be smaller than the length of checksum ) type FSReader interface { Open(name string) (fs.ReadDirFile, error) } type FSWriter interface { Create(name string) (*os.File, error) MkdirAll(path string, perm fs.FileMode) error Remove(name string) error file.RenamerRemover } type FS interface { FSReader FSWriter } type FilesystemReader struct { path string fs FSReader } func (s *FilesystemReader) checksumToPath(checksum string) string { return filepath.Join(s.path, fsutil.GetIntraDir(checksum, blobsDirDepth, blobsDirLength), checksum) } func (s *FilesystemReader) Read(ctx context.Context, checksum string) ([]byte, error) { if s.path == "" { return nil, fmt.Errorf("no path set") } fn := s.checksumToPath(checksum) f, err := s.fs.Open(fn) if err != nil { return nil, fmt.Errorf("opening file %q: %w", fn, err) } defer f.Close() return io.ReadAll(f) } type FilesystemStore struct { FilesystemReader deleter *file.Deleter } func NewFilesystemStore(path string, fs FS) *FilesystemStore { deleter := &file.Deleter{ RenamerRemover: fs, } return &FilesystemStore{ FilesystemReader: *NewReadonlyFilesystemStore(path, fs), deleter: deleter, } } func NewReadonlyFilesystemStore(path string, fs FSReader) *FilesystemReader { return &FilesystemReader{ path: path, fs: fs, } } func (s *FilesystemStore) Write(ctx context.Context, checksum string, data []byte) error { fs, ok := s.fs.(FS) if !ok { return fmt.Errorf("internal error: fs is not an FS") } if s.path == "" { return fmt.Errorf("no path set") } fn := s.checksumToPath(checksum) // create the directory if it doesn't exist if err := fs.MkdirAll(filepath.Dir(fn), 0755); err != nil { return fmt.Errorf("creating directory %q: %w", filepath.Dir(fn), err) } logger.Debugf("Writing blob file %s", fn) out, err := fs.Create(fn) if err != nil { return fmt.Errorf("creating file %q: %w", fn, err) } r := bytes.NewReader(data) if _, err = io.Copy(out, r); err != nil { return fmt.Errorf("writing file %q: %w", fn, err) } return nil } func (s *FilesystemStore) Delete(ctx context.Context, checksum string) error { if s.path == "" { return fmt.Errorf("no path set") } s.deleter.RegisterHooks(ctx) fn := s.checksumToPath(checksum) if err := s.deleter.Files([]string{fn}); err != nil { return fmt.Errorf("deleting file %q: %w", fn, err) } return nil } ================================================ FILE: pkg/sqlite/blob.go ================================================ package sqlite import ( "context" "database/sql" "errors" "fmt" "io/fs" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "github.com/mattn/go-sqlite3" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/hash/md5" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/sqlite/blob" "github.com/stashapp/stash/pkg/utils" "gopkg.in/guregu/null.v4" ) const ( blobTable = "blobs" blobChecksumColumn = "checksum" ) type BlobStoreOptions struct { // UseFilesystem should be true if blob data should be stored in the filesystem UseFilesystem bool // UseDatabase should be true if blob data should be stored in the database UseDatabase bool // Path is the filesystem path to use for storing blobs Path string // SupplementaryPaths are alternative filesystem paths that will be used to find blobs // No changes will be made to these filesystems SupplementaryPaths []string } type BlobStore struct { repository tableMgr *table fsStore *blob.FilesystemStore // supplementary stores otherStores []blob.FilesystemReader options BlobStoreOptions } func NewBlobStore(options BlobStoreOptions) *BlobStore { fs := &file.OsFS{} ret := &BlobStore{ repository: repository{ tableName: blobTable, idColumn: blobChecksumColumn, }, tableMgr: blobTableMgr, fsStore: blob.NewFilesystemStore(options.Path, fs), options: options, } for _, otherPath := range options.SupplementaryPaths { ret.otherStores = append(ret.otherStores, *blob.NewReadonlyFilesystemStore(otherPath, fs)) } return ret } type blobRow struct { Checksum string `db:"checksum"` Blob []byte `db:"blob"` } func (qb *BlobStore) table() exp.IdentifierExpression { return qb.tableMgr.table } func (qb *BlobStore) Count(ctx context.Context) (int, error) { table := qb.table() q := dialect.From(table).Select(goqu.COUNT(table.Col(blobChecksumColumn))) var ret int if err := querySimple(ctx, q, &ret); err != nil { return 0, err } return ret, nil } // Write stores the data and its checksum in enabled stores. // Always writes at least the checksum to the database. func (qb *BlobStore) Write(ctx context.Context, data []byte) (string, error) { if !qb.options.UseDatabase && !qb.options.UseFilesystem { panic("no blob store configured") } if len(data) == 0 { return "", fmt.Errorf("cannot write empty data") } checksum := md5.FromBytes(data) // only write blob to the database if UseDatabase is true // always at least write the checksum var storedData []byte if qb.options.UseDatabase { storedData = data } if err := qb.write(ctx, checksum, storedData); err != nil { return "", fmt.Errorf("writing to database: %w", err) } if qb.options.UseFilesystem { if err := qb.fsStore.Write(ctx, checksum, data); err != nil { return "", fmt.Errorf("writing to filesystem: %w", err) } } return checksum, nil } func (qb *BlobStore) write(ctx context.Context, checksum string, data []byte) error { table := qb.table() q := dialect.Insert(table).Prepared(true).Rows(blobRow{ Checksum: checksum, Blob: data, }).OnConflict(goqu.DoNothing()) _, err := exec(ctx, q) if err != nil { return fmt.Errorf("inserting into %s: %w", table, err) } return nil } func (qb *BlobStore) update(ctx context.Context, checksum string, data []byte) error { table := qb.table() q := dialect.Update(table).Prepared(true).Set(goqu.Record{ "blob": data, }).Where(goqu.C(blobChecksumColumn).Eq(checksum)) _, err := exec(ctx, q) if err != nil { return fmt.Errorf("updating %s: %w", table, err) } return nil } type ChecksumNotFoundError struct { Checksum string } func (e *ChecksumNotFoundError) Error() string { return fmt.Sprintf("checksum %s does not exist", e.Checksum) } type ChecksumBlobNotExistError struct { Checksum string } func (e *ChecksumBlobNotExistError) Error() string { return fmt.Sprintf("blob for checksum %s does not exist", e.Checksum) } func (qb *BlobStore) readSQL(ctx context.Context, querySQL string, args ...interface{}) ([]byte, string, error) { if !qb.options.UseDatabase && !qb.options.UseFilesystem { panic("no blob store configured") } // always try to get from the database first, even if set to use filesystem var row blobRow found := false const single = true if err := qb.queryFunc(ctx, querySQL, args, single, func(r *sqlx.Rows) error { found = true if err := r.StructScan(&row); err != nil { return err } return nil }); err != nil { return nil, "", fmt.Errorf("reading from database: %w", err) } if !found { // not found in the database - does not exist return nil, "", nil } checksum := row.Checksum if row.Blob != nil { return row.Blob, checksum, nil } // don't use the filesystem if not configured to do so if qb.options.UseFilesystem { ret, err := qb.readFromFilesystem(ctx, checksum) if err != nil { return nil, checksum, err } return ret, checksum, nil } return nil, checksum, &ChecksumBlobNotExistError{ Checksum: checksum, } } func (qb *BlobStore) readFromFilesystem(ctx context.Context, checksum string) ([]byte, error) { // try to read from primary store first, then supplementaries fsStores := append([]blob.FilesystemReader{qb.fsStore.FilesystemReader}, qb.otherStores...) for _, fsStore := range fsStores { ret, err := fsStore.Read(ctx, checksum) if err == nil { return ret, nil } if !errors.Is(err, fs.ErrNotExist) { return nil, fmt.Errorf("reading from filesystem: %w", err) } } // blob not found - should not happen return nil, &ChecksumBlobNotExistError{ Checksum: checksum, } } func (qb *BlobStore) EntryExists(ctx context.Context, checksum string) (bool, error) { q := dialect.From(qb.table()).Select(goqu.COUNT("*")).Where(qb.tableMgr.byID(checksum)) var found int if err := querySimple(ctx, q, &found); err != nil { return false, fmt.Errorf("querying %s: %w", qb.table(), err) } return found != 0, nil } // Read reads the data from the database or filesystem, depending on which is enabled. func (qb *BlobStore) Read(ctx context.Context, checksum string) ([]byte, error) { if !qb.options.UseDatabase && !qb.options.UseFilesystem { panic("no blob store configured") } // always try to get from the database first, even if set to use filesystem ret, err := qb.readFromDatabase(ctx, checksum) if err != nil { if !errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("reading from database: %w", err) } // not found in the database - does not exist return nil, &ChecksumNotFoundError{ Checksum: checksum, } } if ret != nil { return ret, nil } // don't use the filesystem if not configured to do so if qb.options.UseFilesystem { return qb.readFromFilesystem(ctx, checksum) } // blob not found - should not happen return nil, &ChecksumBlobNotExistError{ Checksum: checksum, } } func (qb *BlobStore) readFromDatabase(ctx context.Context, checksum string) ([]byte, error) { q := dialect.From(qb.table()).Select(qb.table().All()).Where(qb.tableMgr.byID(checksum)) var row blobRow const single = true if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { if err := r.StructScan(&row); err != nil { return err } return nil }); err != nil { return nil, fmt.Errorf("querying %s: %w", qb.table(), err) } return row.Blob, nil } // Delete marks a checksum as no longer in use by a single reference. // If no references remain, the blob is deleted from the database and filesystem. func (qb *BlobStore) Delete(ctx context.Context, checksum string) error { // try to delete the blob from the database if err := qb.delete(ctx, checksum); err != nil { if qb.isConstraintError(err) { // blob is still referenced - do not delete logger.Debugf("Blob %s is still referenced - not deleting", checksum) return nil } // unexpected error return fmt.Errorf("deleting from database: %w", err) } // blob was deleted from the database - delete from filesystem if enabled if qb.options.UseFilesystem { logger.Debugf("Deleting blob %s from filesystem", checksum) if err := qb.fsStore.Delete(ctx, checksum); err != nil { return fmt.Errorf("deleting from filesystem: %w", err) } } return nil } func (qb *BlobStore) isConstraintError(err error) bool { var sqliteError sqlite3.Error if errors.As(err, &sqliteError) { return sqliteError.Code == sqlite3.ErrConstraint } return false } func (qb *BlobStore) delete(ctx context.Context, checksum string) error { table := qb.table() q := dialect.Delete(table).Where(goqu.C(blobChecksumColumn).Eq(checksum)) _, err := exec(ctx, q) if err != nil { return fmt.Errorf("deleting from %s: %w", table, err) } return nil } type blobJoinQueryBuilder struct { repository repository blobStore *BlobStore joinTable string } func (qb *blobJoinQueryBuilder) GetImage(ctx context.Context, id int, blobCol string) ([]byte, error) { sqlQuery := utils.StrFormat(` SELECT blobs.checksum, blobs.blob FROM {joinTable} INNER JOIN blobs ON {joinTable}.{joinCol} = blobs.checksum WHERE {joinTable}.id = ? `, utils.StrFormatMap{ "joinTable": qb.joinTable, "joinCol": blobCol, }) ret, _, err := qb.blobStore.readSQL(ctx, sqlQuery, id) return ret, err } func (qb *blobJoinQueryBuilder) UpdateImage(ctx context.Context, id int, blobCol string, image []byte) error { if len(image) == 0 { return qb.DestroyImage(ctx, id, blobCol) } oldChecksum, err := qb.getChecksum(ctx, id, blobCol) if err != nil { return err } checksum, err := qb.blobStore.Write(ctx, image) if err != nil { return err } sqlQuery := fmt.Sprintf("UPDATE %s SET %s = ? WHERE id = ?", qb.joinTable, blobCol) if _, err := dbWrapper.Exec(ctx, sqlQuery, checksum, id); err != nil { return err } // #3595 - delete the old blob if the checksum is different if oldChecksum != nil && *oldChecksum != checksum { if err := qb.blobStore.Delete(ctx, *oldChecksum); err != nil { return err } } return nil } func (qb *blobJoinQueryBuilder) getChecksum(ctx context.Context, id int, blobCol string) (*string, error) { sqlQuery := utils.StrFormat(` SELECT {joinTable}.{joinCol} FROM {joinTable} WHERE {joinTable}.id = ? `, utils.StrFormatMap{ "joinTable": qb.joinTable, "joinCol": blobCol, }) var checksum null.String err := qb.repository.querySimple(ctx, sqlQuery, []interface{}{id}, &checksum) if err != nil { return nil, err } if !checksum.Valid { return nil, nil } return &checksum.String, nil } func (qb *blobJoinQueryBuilder) DestroyImage(ctx context.Context, id int, blobCol string) error { checksum, err := qb.getChecksum(ctx, id, blobCol) if err != nil { return err } if checksum == nil { // no image to delete return nil } updateQuery := fmt.Sprintf("UPDATE %s SET %s = NULL WHERE id = ?", qb.joinTable, blobCol) if _, err = dbWrapper.Exec(ctx, updateQuery, id); err != nil { return err } return qb.blobStore.Delete(ctx, *checksum) } func (qb *blobJoinQueryBuilder) HasImage(ctx context.Context, id int, blobCol string) (bool, error) { stmt := utils.StrFormat("SELECT COUNT(*) as count FROM (SELECT {joinCol} FROM {joinTable} WHERE id = ? AND {joinCol} IS NOT NULL LIMIT 1)", utils.StrFormatMap{ "joinTable": qb.joinTable, "joinCol": blobCol, }) c, err := qb.repository.runCountQuery(ctx, stmt, []interface{}{id}) if err != nil { return false, err } return c == 1, nil } ================================================ FILE: pkg/sqlite/blob_migrate.go ================================================ package sqlite import ( "context" "fmt" "github.com/jmoiron/sqlx" ) func (qb *BlobStore) FindBlobs(ctx context.Context, n uint, lastChecksum string) ([]string, error) { table := qb.table() q := dialect.From(table).Select(table.Col(blobChecksumColumn)).Order(table.Col(blobChecksumColumn).Asc()).Limit(n) if lastChecksum != "" { q = q.Where(table.Col(blobChecksumColumn).Gt(lastChecksum)) } const single = false var checksums []string if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { var checksum string if err := rows.Scan(&checksum); err != nil { return err } checksums = append(checksums, checksum) return nil }); err != nil { return nil, err } return checksums, nil } // MigrateBlob migrates a blob from the filesystem to the database, or vice versa. // The target is determined by the UseDatabase and UseFilesystem options. // If deleteOld is true, the blob is deleted from the source after migration. func (qb *BlobStore) MigrateBlob(ctx context.Context, checksum string, deleteOld bool) error { if !qb.options.UseDatabase && !qb.options.UseFilesystem { panic("no blob store configured") } if qb.options.UseDatabase && qb.options.UseFilesystem { panic("both filesystem and database configured") } if qb.options.Path == "" { panic("no blob path configured") } if qb.options.UseDatabase { return qb.migrateBlobDatabase(ctx, checksum, deleteOld) } return qb.migrateBlobFilesystem(ctx, checksum, deleteOld) } // migrateBlobDatabase migrates a blob from the filesystem to the database func (qb *BlobStore) migrateBlobDatabase(ctx context.Context, checksum string, deleteOld bool) error { // ignore if the blob is already present in the database // (still delete the old data if requested) existing, err := qb.readFromDatabase(ctx, checksum) if err != nil { return fmt.Errorf("reading from database: %w", err) } if len(existing) == 0 { // find the blob in the filesystem blob, err := qb.fsStore.Read(ctx, checksum) if err != nil { return fmt.Errorf("reading from filesystem: %w", err) } // write the blob to the database if err := qb.update(ctx, checksum, blob); err != nil { return fmt.Errorf("writing to database: %w", err) } } if deleteOld { // delete the blob from the filesystem after commit if err := qb.fsStore.Delete(ctx, checksum); err != nil { return fmt.Errorf("deleting from filesystem: %w", err) } } return nil } // migrateBlobFilesystem migrates a blob from the database to the filesystem func (qb *BlobStore) migrateBlobFilesystem(ctx context.Context, checksum string, deleteOld bool) error { // find the blob in the database blob, err := qb.readFromDatabase(ctx, checksum) if err != nil { return fmt.Errorf("reading from database: %w", err) } if len(blob) == 0 { // it's possible that the blob is already present in the filesystem // just ignore return nil } // write the blob to the filesystem if err := qb.fsStore.Write(ctx, checksum, blob); err != nil { return fmt.Errorf("writing to filesystem: %w", err) } if deleteOld { // delete the blob from the database row if err := qb.update(ctx, checksum, nil); err != nil { return err } } return nil } ================================================ FILE: pkg/sqlite/blob_test.go ================================================ //go:build integration // +build integration package sqlite_test import ( "context" "fmt" "testing" "github.com/stretchr/testify/assert" ) type updateImageFunc func(ctx context.Context, id int, image []byte) error type getImageFunc func(ctx context.Context, id int) ([]byte, error) func testUpdateImage(t *testing.T, ctx context.Context, id int, updateFn updateImageFunc, getFn getImageFunc) error { image := []byte("image") err := updateFn(ctx, id, image) if err != nil { return fmt.Errorf("Error updating performer image: %s", err.Error()) } // ensure image set storedImage, err := getFn(ctx, id) if err != nil { return fmt.Errorf("Error getting image: %s", err.Error()) } assert.Equal(t, storedImage, image) // set nil image err = updateFn(ctx, id, nil) if err != nil { return fmt.Errorf("error setting nil image: %w", err) } // ensure image null storedImage, err = getFn(ctx, id) if err != nil { return fmt.Errorf("Error getting image: %s", err.Error()) } assert.Nil(t, storedImage) return nil } ================================================ FILE: pkg/sqlite/common.go ================================================ package sqlite import ( "context" "fmt" "github.com/doug-martin/goqu/v9" "github.com/jmoiron/sqlx" ) type oCounterManager struct { tableMgr *table } func (qb *oCounterManager) getOCounter(ctx context.Context, id int) (int, error) { q := dialect.From(qb.tableMgr.table).Select("o_counter").Where(goqu.Ex{"id": id}) const single = true var ret int if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { if err := rows.Scan(&ret); err != nil { return err } return nil }); err != nil { return 0, err } return ret, nil } func (qb *oCounterManager) IncrementOCounter(ctx context.Context, id int) (int, error) { if err := qb.tableMgr.checkIDExists(ctx, id); err != nil { return 0, err } if err := qb.tableMgr.updateByID(ctx, id, goqu.Record{ "o_counter": goqu.L("o_counter + 1"), }); err != nil { return 0, err } return qb.getOCounter(ctx, id) } func (qb *oCounterManager) DecrementOCounter(ctx context.Context, id int) (int, error) { if err := qb.tableMgr.checkIDExists(ctx, id); err != nil { return 0, err } table := qb.tableMgr.table q := dialect.Update(table).Set(goqu.Record{ "o_counter": goqu.L("o_counter - 1"), }).Where(qb.tableMgr.byID(id), goqu.L("o_counter > 0")) if _, err := exec(ctx, q); err != nil { return 0, fmt.Errorf("updating %s: %w", table.GetTable(), err) } return qb.getOCounter(ctx, id) } func (qb *oCounterManager) ResetOCounter(ctx context.Context, id int) (int, error) { if err := qb.tableMgr.checkIDExists(ctx, id); err != nil { return 0, err } if err := qb.tableMgr.updateByID(ctx, id, goqu.Record{ "o_counter": 0, }); err != nil { return 0, err } return qb.getOCounter(ctx, id) } ================================================ FILE: pkg/sqlite/criterion_handlers.go ================================================ package sqlite import ( "context" "database/sql" "fmt" "path/filepath" "regexp" "strconv" "strings" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) type criterionHandler interface { handle(ctx context.Context, f *filterBuilder) } type criterionHandlerFunc func(ctx context.Context, f *filterBuilder) func (h criterionHandlerFunc) handle(ctx context.Context, f *filterBuilder) { h(ctx, f) } type compoundHandler []criterionHandler func (h compoundHandler) handle(ctx context.Context, f *filterBuilder) { for _, h := range h { h.handle(ctx, f) } } // shared criterion handlers go here func stringCriterionHandler(c *models.StringCriterionInput, column string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { if modifier := c.Modifier; c.Modifier.IsValid() { switch modifier { case models.CriterionModifierIncludes: f.whereClauses = append(f.whereClauses, getStringSearchClause([]string{column}, c.Value, false)) case models.CriterionModifierExcludes: f.whereClauses = append(f.whereClauses, getStringSearchClause([]string{column}, c.Value, true)) case models.CriterionModifierEquals: f.addWhere(column+" LIKE ?", c.Value) case models.CriterionModifierNotEquals: f.addWhere(column+" NOT LIKE ?", c.Value) case models.CriterionModifierMatchesRegex: if _, err := regexp.Compile(c.Value); err != nil { f.setError(err) return } f.addWhere(fmt.Sprintf("(%s IS NOT NULL AND %[1]s regexp ?)", column), c.Value) case models.CriterionModifierNotMatchesRegex: if _, err := regexp.Compile(c.Value); err != nil { f.setError(err) return } f.addWhere(fmt.Sprintf("(%s IS NULL OR %[1]s NOT regexp ?)", column), c.Value) case models.CriterionModifierIsNull: f.addWhere("(" + column + " IS NULL OR TRIM(" + column + ") = '')") case models.CriterionModifierNotNull: f.addWhere("(" + column + " IS NOT NULL AND TRIM(" + column + ") != '')") default: panic("unsupported string filter modifier") } } } } } func joinedStringCriterionHandler(c *models.StringCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { if addJoinFn != nil { addJoinFn(f) } stringCriterionHandler(c, column)(ctx, f) } } } func enumCriterionHandler(modifier models.CriterionModifier, values []string, column string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if modifier.IsValid() { switch modifier { case models.CriterionModifierIncludes, models.CriterionModifierEquals: if len(values) > 0 { f.whereClauses = append(f.whereClauses, getEnumSearchClause(column, values, false)) } case models.CriterionModifierExcludes, models.CriterionModifierNotEquals: if len(values) > 0 { f.whereClauses = append(f.whereClauses, getEnumSearchClause(column, values, true)) } case models.CriterionModifierIsNull: f.addWhere("(" + column + " IS NULL OR TRIM(" + column + ") = '')") case models.CriterionModifierNotNull: f.addWhere("(" + column + " IS NOT NULL AND TRIM(" + column + ") != '')") default: panic("unsupported string filter modifier") } } } } func pathCriterionHandler(c *models.StringCriterionInput, pathColumn string, basenameColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { if addJoinFn != nil { addJoinFn(f) } addWildcards := true not := false if modifier := c.Modifier; c.Modifier.IsValid() { switch modifier { case models.CriterionModifierIncludes: f.whereClauses = append(f.whereClauses, getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not)) case models.CriterionModifierExcludes: not = true f.whereClauses = append(f.whereClauses, getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not)) case models.CriterionModifierEquals: addWildcards = false f.whereClauses = append(f.whereClauses, getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not)) case models.CriterionModifierNotEquals: addWildcards = false not = true f.whereClauses = append(f.whereClauses, getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not)) case models.CriterionModifierMatchesRegex: if _, err := regexp.Compile(c.Value); err != nil { f.setError(err) return } filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) f.addWhere(fmt.Sprintf("%s IS NOT NULL AND %s IS NOT NULL AND %s regexp ?", pathColumn, basenameColumn, filepathColumn), c.Value) case models.CriterionModifierNotMatchesRegex: if _, err := regexp.Compile(c.Value); err != nil { f.setError(err) return } filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) f.addWhere(fmt.Sprintf("%s IS NULL OR %s IS NULL OR %s NOT regexp ?", pathColumn, basenameColumn, filepathColumn), c.Value) case models.CriterionModifierIsNull: f.addWhere(fmt.Sprintf("%s IS NULL OR TRIM(%[1]s) = '' OR %s IS NULL OR TRIM(%[2]s) = ''", pathColumn, basenameColumn)) case models.CriterionModifierNotNull: f.addWhere(fmt.Sprintf("%s IS NOT NULL AND TRIM(%[1]s) != '' AND %s IS NOT NULL AND TRIM(%[2]s) != ''", pathColumn, basenameColumn)) default: panic("unsupported string filter modifier") } } } } } func getPathSearchClause(pathColumn, basenameColumn, p string, addWildcards, not bool) sqlClause { if addWildcards { p = "%" + p + "%" } filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) ret := makeClause(fmt.Sprintf("%s LIKE ?", filepathColumn), p) if not { ret = ret.not() } return ret } // getPathSearchClauseMany splits the query string p on whitespace // Used for backwards compatibility for the includes/excludes modifiers func getPathSearchClauseMany(pathColumn, basenameColumn, p string, addWildcards, not bool) sqlClause { q := strings.TrimSpace(p) trimmedQuery := strings.Trim(q, "\"") if trimmedQuery == q { q = regexp.MustCompile(`\s+`).ReplaceAllString(q, " ") queryWords := strings.Split(q, " ") var ret []sqlClause // Search for any word for _, word := range queryWords { ret = append(ret, getPathSearchClause(pathColumn, basenameColumn, word, addWildcards, not)) } if !not { return orClauses(ret...) } return andClauses(ret...) } return getPathSearchClause(pathColumn, basenameColumn, trimmedQuery, addWildcards, not) } func intCriterionHandler(c *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { if addJoinFn != nil { addJoinFn(f) } clause, args := getIntCriterionWhereClause(column, *c) f.addWhere(clause, args...) } } } func floatCriterionHandler(c *models.FloatCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { if addJoinFn != nil { addJoinFn(f) } clause, args := getFloatCriterionWhereClause(column, *c) f.addWhere(clause, args...) } } } func floatIntCriterionHandler(durationFilter *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if durationFilter != nil { if addJoinFn != nil { addJoinFn(f) } clause, args := getIntCriterionWhereClause("cast("+column+" as int)", *durationFilter) f.addWhere(clause, args...) } } } func boolCriterionHandler(c *bool, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { if addJoinFn != nil { addJoinFn(f) } var v string if *c { v = "1" } else { v = "0" } f.addWhere(column + " = " + v) } } } type dateCriterionHandler struct { c *models.DateCriterionInput column string joinFn func(f *filterBuilder) } func (h *dateCriterionHandler) handle(ctx context.Context, f *filterBuilder) { if h.c != nil { if h.joinFn != nil { h.joinFn(f) } clause, args := getDateCriterionWhereClause(h.column, *h.c) f.addWhere(clause, args...) } } type timestampCriterionHandler struct { c *models.TimestampCriterionInput column string joinFn func(f *filterBuilder) } func (h *timestampCriterionHandler) handle(ctx context.Context, f *filterBuilder) { if h.c != nil { if h.joinFn != nil { h.joinFn(f) } clause, args := getTimestampCriterionWhereClause(h.column, *h.c) f.addWhere(clause, args...) } } func yearFilterCriterionHandler(year *models.IntCriterionInput, col string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if year != nil && year.Modifier.IsValid() { clause, args := getIntCriterionWhereClause("cast(strftime('%Y', "+col+") as int)", *year) f.addWhere(clause, args...) } } } func resolutionCriterionHandler(resolution *models.ResolutionCriterionInput, heightColumn string, widthColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if resolution != nil && resolution.Value.IsValid() { if addJoinFn != nil { addJoinFn(f) } mn := resolution.Value.GetMinResolution() mx := resolution.Value.GetMaxResolution() widthHeight := fmt.Sprintf("MIN(%s, %s)", widthColumn, heightColumn) switch resolution.Modifier { case models.CriterionModifierEquals: f.addWhere(fmt.Sprintf("%s BETWEEN %d AND %d", widthHeight, mn, mx)) case models.CriterionModifierNotEquals: f.addWhere(fmt.Sprintf("%s NOT BETWEEN %d AND %d", widthHeight, mn, mx)) case models.CriterionModifierLessThan: f.addWhere(fmt.Sprintf("%s < %d", widthHeight, mn)) case models.CriterionModifierGreaterThan: f.addWhere(fmt.Sprintf("%s > %d", widthHeight, mx)) } } } } func orientationCriterionHandler(orientation *models.OrientationCriterionInput, heightColumn string, widthColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if orientation != nil { if addJoinFn != nil { addJoinFn(f) } var clauses []sqlClause for _, v := range orientation.Value { // width mod height mod := "" switch v { case models.OrientationPortrait: mod = "<" case models.OrientationLandscape: mod = ">" case models.OrientationSquare: mod = "=" } if mod != "" { clauses = append(clauses, makeClause(fmt.Sprintf("%s %s %s", widthColumn, mod, heightColumn))) } } if len(clauses) > 0 { f.whereClauses = append(f.whereClauses, orClauses(clauses...)) } } } } // handle for MultiCriterion where there is a join table between the new // objects type joinedMultiCriterionHandlerBuilder struct { // table containing the primary objects primaryTable string // table joining primary and foreign objects joinTable string // alias for join table, if required joinAs string // foreign key of the primary object on the join table primaryFK string // foreign key of the foreign object on the join table foreignFK string addJoinTable func(f *filterBuilder) } func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { // make local copy so we can modify it criterion := *c joinAlias := m.joinAs if joinAlias == "" { joinAlias = m.joinTable } if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { var notClause string if criterion.Modifier == models.CriterionModifierNotNull { notClause = "NOT" } m.addJoinTable(f) f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ "table": joinAlias, "column": m.foreignFK, "not": notClause, })) return } if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { return } // combine excludes if excludes modifier is selected if criterion.Modifier == models.CriterionModifierExcludes { criterion.Modifier = models.CriterionModifierIncludesAll criterion.Excludes = append(criterion.Excludes, criterion.Value...) criterion.Value = nil } if len(criterion.Value) > 0 { whereClause := "" havingClause := "" var args []interface{} for _, tagID := range criterion.Value { args = append(args, tagID) } switch criterion.Modifier { case models.CriterionModifierIncludes: // includes any of the provided ids m.addJoinTable(f) whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) case models.CriterionModifierEquals: // includes only the provided ids m.addJoinTable(f) whereClause = utils.StrFormat("{joinAlias}.{foreignFK} IN {inBinding} AND (SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.id) = ?", utils.StrFormatMap{ "joinAlias": joinAlias, "foreignFK": m.foreignFK, "inBinding": getInBinding(len(criterion.Value)), "joinTable": m.joinTable, "primaryFK": m.primaryFK, "primaryTable": m.primaryTable, }) havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) args = append(args, len(criterion.Value)) case models.CriterionModifierNotEquals: f.setError(fmt.Errorf("not equals modifier is not supported for multi criterion input")) case models.CriterionModifierIncludesAll: // includes all of the provided ids m.addJoinTable(f) whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) } f.addWhere(whereClause, args...) f.addHaving(havingClause) } if len(criterion.Excludes) > 0 { var args []interface{} for _, tagID := range criterion.Excludes { args = append(args, tagID) } // excludes all of the provided ids // need to use actual join table name for this // .id NOT IN (select . from where . in ) whereClause := fmt.Sprintf("%[1]s.id NOT IN (SELECT %[3]s.%[2]s from %[3]s where %[3]s.%[4]s in %[5]s)", m.primaryTable, m.primaryFK, m.joinTable, m.foreignFK, getInBinding(len(criterion.Excludes))) f.addWhere(whereClause, args...) } } } } type multiCriterionHandlerBuilder struct { primaryTable string foreignTable string joinTable string primaryFK string foreignFK string // function that will be called to perform any necessary joins addJoinsFunc func(f *filterBuilder) } func (m *multiCriterionHandlerBuilder) handler(criterion *models.MultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if criterion != nil { if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { var notClause string if criterion.Modifier == models.CriterionModifierNotNull { notClause = "NOT" } table := m.primaryTable if m.joinTable != "" { table = m.joinTable f.addLeftJoin(table, "", fmt.Sprintf("%s.%s = %s.id", table, m.primaryFK, m.primaryTable)) } f.addWhere(fmt.Sprintf("%s.%s IS %s NULL", table, m.foreignFK, notClause)) return } if len(criterion.Value) == 0 { return } var args []interface{} for _, tagID := range criterion.Value { args = append(args, tagID) } if m.addJoinsFunc != nil { m.addJoinsFunc(f) } whereClause, havingClause := getMultiCriterionClause(m.primaryTable, m.foreignTable, m.joinTable, m.primaryFK, m.foreignFK, criterion) f.addWhere(whereClause, args...) f.addHaving(havingClause) } } } type countCriterionHandlerBuilder struct { primaryTable string joinTable string primaryFK string } func (m *countCriterionHandlerBuilder) handler(criterion *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if criterion != nil { clause, args := getCountCriterionClause(m.primaryTable, m.joinTable, m.primaryFK, *criterion) f.addWhere(clause, args...) } } } // handler for StringCriterion for string list fields type stringListCriterionHandlerBuilder struct { primaryTable string // foreign key of the primary object on the join table primaryFK string // table joining primary and foreign objects joinTable string // string field on the join table stringColumn string addJoinTable func(f *filterBuilder) excludeHandler func(f *filterBuilder, criterion *models.StringCriterionInput) } func (m *stringListCriterionHandlerBuilder) handler(criterion *models.StringCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if criterion != nil { if criterion.Modifier == models.CriterionModifierExcludes { // special handling for excludes if m.excludeHandler != nil { m.excludeHandler(f, criterion) return } // excludes all of the provided values // need to use actual join table name for this // .id NOT IN (select . from where . in ) whereClause := utils.StrFormat("{primaryTable}.id NOT IN (SELECT {joinTable}.{primaryFK} from {joinTable} where {joinTable}.{stringColumn} LIKE ?)", utils.StrFormatMap{ "primaryTable": m.primaryTable, "joinTable": m.joinTable, "primaryFK": m.primaryFK, "stringColumn": m.stringColumn, }, ) f.addWhere(whereClause, "%"+criterion.Value+"%") // TODO - should we also exclude null values? // m.addJoinTable(f) // stringCriterionHandler(&models.StringCriterionInput{ // Modifier: models.CriterionModifierNotNull, // }, m.joinTable+"."+m.stringColumn)(ctx, f) } else { m.addJoinTable(f) stringCriterionHandler(criterion, m.joinTable+"."+m.stringColumn)(ctx, f) } } } } func studioCriterionHandler(primaryTable string, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if studios == nil { return } studiosCopy := *studios switch studiosCopy.Modifier { case models.CriterionModifierEquals: studiosCopy.Modifier = models.CriterionModifierIncludesAll case models.CriterionModifierNotEquals: studiosCopy.Modifier = models.CriterionModifierExcludes } hh := hierarchicalMultiCriterionHandlerBuilder{ primaryTable: primaryTable, foreignTable: studioTable, foreignFK: studioIDColumn, parentFK: "parent_id", } hh.handler(&studiosCopy)(ctx, f) } } type hierarchicalMultiCriterionHandlerBuilder struct { primaryTable string foreignTable string foreignFK string parentFK string childFK string relationsTable string } func getHierarchicalValues(ctx context.Context, values []string, table, relationsTable, parentFK string, childFK string, depth *int) (string, error) { var args []interface{} if parentFK == "" { parentFK = "parent_id" } if childFK == "" { childFK = "child_id" } depthVal := 0 if depth != nil { depthVal = *depth } if depthVal == 0 { valid := true var valuesClauses []string for _, value := range values { id, err := strconv.Atoi(value) // In case of invalid value just run the query. // Building VALUES() based on provided values just saves a query when depth is 0. if err != nil { valid = false break } valuesClauses = append(valuesClauses, fmt.Sprintf("(%d,%d)", id, id)) } if valid { return "VALUES" + strings.Join(valuesClauses, ","), nil } } for _, value := range values { args = append(args, value) } inCount := len(args) var depthCondition string if depthVal != -1 { depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) } withClauseMap := utils.StrFormatMap{ "table": table, "relationsTable": relationsTable, "inBinding": getInBinding(inCount), "recursiveSelect": "", "parentFK": parentFK, "childFK": childFK, "depthCondition": depthCondition, "unionClause": "", } if relationsTable != "" { withClauseMap["recursiveSelect"] = utils.StrFormat(`SELECT p.root_id, c.{childFK}, depth + 1 FROM {relationsTable} AS c INNER JOIN items as p ON c.{parentFK} = p.item_id `, withClauseMap) } else { withClauseMap["recursiveSelect"] = utils.StrFormat(`SELECT p.root_id, c.id, depth + 1 FROM {table} as c INNER JOIN items as p ON c.{parentFK} = p.item_id `, withClauseMap) } if depthVal != 0 { withClauseMap["unionClause"] = utils.StrFormat(` UNION {recursiveSelect} {depthCondition} `, withClauseMap) } withClause := utils.StrFormat(`items AS ( SELECT id as root_id, id as item_id, 0 as depth FROM {table} WHERE id in {inBinding} {unionClause}) `, withClauseMap) query := fmt.Sprintf("WITH RECURSIVE %s SELECT 'VALUES' || GROUP_CONCAT('(' || root_id || ', ' || item_id || ')') AS val FROM items", withClause) var valuesClause sql.NullString err := dbWrapper.Get(ctx, &valuesClause, query, args...) if err != nil { return "", fmt.Errorf("failed to get hierarchical values: %w", err) } // if no values are found, just return a values string with the values only if !valuesClause.Valid { for i, value := range values { values[i] = fmt.Sprintf("(%s, %s)", value, value) } valuesClause.String = "VALUES" + strings.Join(values, ",") } return valuesClause.String, nil } func addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { switch criterion.Modifier { case models.CriterionModifierIncludes: f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) case models.CriterionModifierIncludesAll: f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", table, idColumn, len(criterion.Value))) case models.CriterionModifierExcludes: f.addWhere(fmt.Sprintf("%s.%s IS NULL", table, idColumn)) } } func (m *hierarchicalMultiCriterionHandlerBuilder) handler(c *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { // make a copy so we don't modify the original criterion := *c // don't support equals/not equals if criterion.Modifier == models.CriterionModifierEquals || criterion.Modifier == models.CriterionModifierNotEquals { f.setError(fmt.Errorf("modifier %s is not supported for hierarchical multi criterion", criterion.Modifier)) return } if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { var notClause string if criterion.Modifier == models.CriterionModifierNotNull { notClause = "NOT" } f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ "table": m.primaryTable, "column": m.foreignFK, "not": notClause, })) return } if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { return } // combine excludes if excludes modifier is selected if criterion.Modifier == models.CriterionModifierExcludes { criterion.Modifier = models.CriterionModifierIncludesAll criterion.Excludes = append(criterion.Excludes, criterion.Value...) criterion.Value = nil } if len(criterion.Value) > 0 { valuesClause, err := getHierarchicalValues(ctx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) if err != nil { f.setError(err) return } switch criterion.Modifier { case models.CriterionModifierIncludes: f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) case models.CriterionModifierIncludesAll: f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", m.primaryTable, m.foreignFK, len(criterion.Value))) } } if len(criterion.Excludes) > 0 { valuesClause, err := getHierarchicalValues(ctx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) if err != nil { f.setError(err) return } f.addWhere(fmt.Sprintf("%s.%s NOT IN (SELECT column2 FROM (%s)) OR %[1]s.%[2]s IS NULL", m.primaryTable, m.foreignFK, valuesClause)) } } } } type joinedHierarchicalMultiCriterionHandlerBuilder struct { primaryTable string primaryKey string foreignTable string foreignFK string parentFK string childFK string relationsTable string joinAs string joinTable string primaryFK string } func (m *joinedHierarchicalMultiCriterionHandlerBuilder) addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { primaryKey := m.primaryKey if primaryKey == "" { primaryKey = "id" } switch criterion.Modifier { case models.CriterionModifierEquals: // includes only the provided ids f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", table, idColumn, len(criterion.Value))) f.addWhere(utils.StrFormat("(SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.{primaryKey}) = ?", utils.StrFormatMap{ "joinTable": m.joinTable, "primaryFK": m.primaryFK, "primaryTable": m.primaryTable, "primaryKey": primaryKey, }), len(criterion.Value)) case models.CriterionModifierNotEquals: f.setError(fmt.Errorf("not equals modifier is not supported for hierarchical multi criterion input")) default: addHierarchicalConditionClauses(f, criterion, table, idColumn) } } func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { // make a copy so we don't modify the original criterion := *c joinAlias := m.joinAs primaryKey := m.primaryKey if primaryKey == "" { primaryKey = "id" } if criterion.Modifier == models.CriterionModifierEquals && criterion.Depth != nil && *criterion.Depth != 0 { f.setError(fmt.Errorf("depth is not supported for equals modifier in hierarchical multi criterion input")) return } if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { var notClause string if criterion.Modifier == models.CriterionModifierNotNull { notClause = "NOT" } f.addLeftJoin(m.joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.%s", joinAlias, m.primaryFK, m.primaryTable, primaryKey)) f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ "table": joinAlias, "column": m.foreignFK, "not": notClause, })) return } // combine excludes if excludes modifier is selected if criterion.Modifier == models.CriterionModifierExcludes { criterion.Modifier = models.CriterionModifierIncludesAll criterion.Excludes = append(criterion.Excludes, criterion.Value...) criterion.Value = nil } if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { return } if len(criterion.Value) > 0 { valuesClause, err := getHierarchicalValues(ctx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) if err != nil { f.setError(err) return } joinTable := utils.StrFormat(`( SELECT j.*, d.column1 AS root_id, d.column2 AS item_id FROM {joinTable} AS j INNER JOIN ({valuesClause}) AS d ON j.{foreignFK} = d.column2 ) `, utils.StrFormatMap{ "joinTable": m.joinTable, "foreignFK": m.foreignFK, "valuesClause": valuesClause, }) f.addLeftJoin(joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.%s", joinAlias, m.primaryFK, m.primaryTable, primaryKey)) m.addHierarchicalConditionClauses(f, criterion, joinAlias, "root_id") } if len(criterion.Excludes) > 0 { valuesClause, err := getHierarchicalValues(ctx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) if err != nil { f.setError(err) return } joinTable := utils.StrFormat(`( SELECT j2.*, e.column1 AS root_id, e.column2 AS item_id FROM {joinTable} AS j2 INNER JOIN ({valuesClause}) AS e ON j2.{foreignFK} = e.column2 ) `, utils.StrFormatMap{ "joinTable": m.joinTable, "foreignFK": m.foreignFK, "valuesClause": valuesClause, }) joinAlias2 := joinAlias + "2" f.addLeftJoin(joinTable, joinAlias2, fmt.Sprintf("%s.%s = %s.%s", joinAlias2, m.primaryFK, m.primaryTable, primaryKey)) // modify for exclusion criterionCopy := criterion criterionCopy.Modifier = models.CriterionModifierExcludes criterionCopy.Value = c.Excludes m.addHierarchicalConditionClauses(f, criterionCopy, joinAlias2, "root_id") } } } } type joinedPerformerTagsHandler struct { criterion *models.HierarchicalMultiCriterionInput primaryTable string // eg scenes joinTable string // eg performers_scenes joinPrimaryKey string // eg scene_id } func (h *joinedPerformerTagsHandler) handle(ctx context.Context, f *filterBuilder) { tags := h.criterion if tags != nil { criterion := tags.CombineExcludes() // validate the modifier switch criterion.Modifier { case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: // valid default: f.setError(fmt.Errorf("invalid modifier %s for performer tags", criterion.Modifier)) } strFormatMap := utils.StrFormatMap{ "primaryTable": h.primaryTable, "joinTable": h.joinTable, "joinPrimaryKey": h.joinPrimaryKey, "inBinding": getInBinding(len(criterion.Value)), } if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { var notClause string if criterion.Modifier == models.CriterionModifierNotNull { notClause = "NOT" } f.addLeftJoin(h.joinTable, "", utils.StrFormat("{primaryTable}.id = {joinTable}.{joinPrimaryKey}", strFormatMap)) f.addLeftJoin("performers_tags", "", utils.StrFormat("{joinTable}.performer_id = performers_tags.performer_id", strFormatMap)) f.addWhere(fmt.Sprintf("performers_tags.tag_id IS %s NULL", notClause)) return } if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { return } if len(criterion.Value) > 0 { valuesClause, err := getHierarchicalValues(ctx, criterion.Value, tagTable, "tags_relations", "", "", criterion.Depth) if err != nil { f.setError(err) return } f.addWith(utils.StrFormat(`performer_tags AS ( SELECT ps.{joinPrimaryKey} as primaryID, t.column1 AS root_tag_id FROM {joinTable} ps INNER JOIN performers_tags pt ON pt.performer_id = ps.performer_id INNER JOIN (`+valuesClause+`) t ON t.column2 = pt.tag_id )`, strFormatMap)) f.addLeftJoin("performer_tags", "", utils.StrFormat("performer_tags.primaryID = {primaryTable}.id", strFormatMap)) addHierarchicalConditionClauses(f, criterion, "performer_tags", "root_tag_id") } if len(criterion.Excludes) > 0 { valuesClause, err := getHierarchicalValues(ctx, criterion.Excludes, tagTable, "tags_relations", "", "", criterion.Depth) if err != nil { f.setError(err) return } clause := utils.StrFormat("{primaryTable}.id NOT IN (SELECT {joinTable}.{joinPrimaryKey} FROM {joinTable} INNER JOIN performers_tags ON {joinTable}.performer_id = performers_tags.performer_id WHERE performers_tags.tag_id IN (SELECT column2 FROM (%s)))", strFormatMap) f.addWhere(fmt.Sprintf(clause, valuesClause)) } } } type stashIDCriterionHandler struct { c *models.StashIDCriterionInput stashIDRepository *stashIDRepository stashIDTableAs string parentIDCol string } func (h *stashIDCriterionHandler) handle(ctx context.Context, f *filterBuilder) { if h.c == nil { return } // ideally, this handler should just convert to stashIDsCriterionHandler // but there are some differences in how the existing handler works compared // to the new code, specifically because this code uses the stringCriterionHandler. // To minimise potential regressions, we'll keep the existing logic for now. stashIDRepo := h.stashIDRepository t := stashIDRepo.tableName if h.stashIDTableAs != "" { t = h.stashIDTableAs } joinClause := fmt.Sprintf("%s.%s = %s", t, stashIDRepo.idColumn, h.parentIDCol) if h.c.Endpoint != nil && *h.c.Endpoint != "" { joinClause += fmt.Sprintf(" AND %s.endpoint = '%s'", t, *h.c.Endpoint) } f.addLeftJoin(stashIDRepo.tableName, h.stashIDTableAs, joinClause) v := "" if h.c.StashID != nil { v = *h.c.StashID } stringCriterionHandler(&models.StringCriterionInput{ Value: v, Modifier: h.c.Modifier, }, t+".stash_id")(ctx, f) } type stashIDsCriterionHandler struct { c *models.StashIDsCriterionInput stashIDRepository *stashIDRepository stashIDTableAs string parentIDCol string } func (h *stashIDsCriterionHandler) handle(ctx context.Context, f *filterBuilder) { if h.c == nil { return } stashIDRepo := h.stashIDRepository t := stashIDRepo.tableName if h.stashIDTableAs != "" { t = h.stashIDTableAs } joinClause := fmt.Sprintf("%s.%s = %s", t, stashIDRepo.idColumn, h.parentIDCol) if h.c.Endpoint != nil && *h.c.Endpoint != "" { joinClause += fmt.Sprintf(" AND %s.endpoint = '%s'", t, *h.c.Endpoint) } f.addLeftJoin(stashIDRepo.tableName, h.stashIDTableAs, joinClause) switch h.c.Modifier { case models.CriterionModifierIsNull: f.addWhere(fmt.Sprintf("%s.stash_id IS NULL", t)) case models.CriterionModifierNotNull: f.addWhere(fmt.Sprintf("%s.stash_id IS NOT NULL", t)) case models.CriterionModifierEquals: var clauses []sqlClause for _, id := range h.c.StashIDs { clauses = append(clauses, makeClause(fmt.Sprintf("%s.stash_id = ?", t), id)) } f.whereClauses = append(f.whereClauses, orClauses(clauses...)) case models.CriterionModifierNotEquals: var clauses []sqlClause for _, id := range h.c.StashIDs { clauses = append(clauses, makeClause(fmt.Sprintf("%s.stash_id != ?", t), id)) } f.whereClauses = append(f.whereClauses, andClauses(clauses...)) default: f.setError(fmt.Errorf("invalid modifier %s for stash IDs criterion", h.c.Modifier)) } } type relatedFilterHandler struct { // column on the primary table that relates to the related table (eg scene_id) relatedIDCol string // repository for the related table (eg sceneRepository) relatedRepo repository // handler for the filter on the related table relatedHandler criterionHandler // optional function to perform the necessary join(s) to the related table joinFn func(f *filterBuilder) // if true, related filter handler will be run using the existing filterBuilder instead of a subquery. directJoin bool } func (h *relatedFilterHandler) handle(ctx context.Context, f *filterBuilder) { ff := filterBuilderFromHandler(ctx, h.relatedHandler) if ff.err != nil { f.setError(ff.err) return } if ff.empty() { return } if h.joinFn != nil { h.joinFn(f) } if h.directJoin { // rerun handler using existing filter builder h.relatedHandler.handle(ctx, f) return } subQuery := h.relatedRepo.newQuery() selectIDs(&subQuery, subQuery.repository.tableName) if err := subQuery.addFilter(ff); err != nil { f.setError(err) return } f.addWhere(fmt.Sprintf("%s IN ("+subQuery.toSQL(false)+")", h.relatedIDCol), subQuery.allArgs()...) } type phashDistanceCriterionHandler struct { // assumes that applicable fingerprints table is joined as fingerprints_phash joinFn func(f *filterBuilder) criterion *models.PhashDistanceCriterionInput } func (h *phashDistanceCriterionHandler) handle(ctx context.Context, f *filterBuilder) { phashDistance := h.criterion if phashDistance == nil { return } h.joinFn(f) value, _ := utils.StringToPhash(phashDistance.Value) distance := 0 if phashDistance.Distance != nil { distance = *phashDistance.Distance } switch { case phashDistance.Modifier == models.CriterionModifierEquals && distance > 0: // needed to avoid a type mismatch f.addWhere("typeof(fingerprints_phash.fingerprint) = 'integer'") f.addWhere("phash_distance(fingerprints_phash.fingerprint, ?) < ?", value, distance) case phashDistance.Modifier == models.CriterionModifierNotEquals && distance > 0: // needed to avoid a type mismatch f.addWhere("typeof(fingerprints_phash.fingerprint) = 'integer'") f.addWhere("phash_distance(fingerprints_phash.fingerprint, ?) > ?", value, distance) default: intCriterionHandler(&models.IntCriterionInput{ Value: int(value), Modifier: phashDistance.Modifier, }, "fingerprints_phash.fingerprint", nil)(ctx, f) } } ================================================ FILE: pkg/sqlite/custom_fields.go ================================================ package sqlite import ( "context" "fmt" "regexp" "strings" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/models" ) const maxCustomFieldNameLength = 64 type customFieldsStore struct { table exp.IdentifierExpression fk exp.IdentifierExpression } func (s *customFieldsStore) deleteForID(ctx context.Context, id int) error { table := s.table q := dialect.Delete(table).Where(s.fk.Eq(id)) _, err := exec(ctx, q) if err != nil { return fmt.Errorf("deleting from %s: %w", s.table.GetTable(), err) } return nil } func (s *customFieldsStore) SetCustomFields(ctx context.Context, id int, values models.CustomFieldsInput) error { var partial bool var valMap map[string]interface{} switch { case values.Full != nil: partial = false valMap = values.Full case values.Partial != nil: partial = true valMap = values.Partial } if valMap != nil { if err := s.validateCustomFields(valMap, values.Remove); err != nil { return err } if err := s.setCustomFields(ctx, id, valMap, partial); err != nil { return err } } if err := s.deleteCustomFields(ctx, id, values.Remove); err != nil { return err } return nil } func (s *customFieldsStore) validateCustomFields(values map[string]interface{}, deleteKeys []string) error { // if values is nil, nothing to validate if values == nil { return nil } // ensure that custom field names are valid // no leading or trailing whitespace, no empty strings for k := range values { if err := s.validateCustomFieldName(k); err != nil { return fmt.Errorf("custom field name %q: %w", k, err) } } // ensure delete keys are not also in values for _, k := range deleteKeys { if _, ok := values[k]; ok { return fmt.Errorf("custom field name %q cannot be in both values and delete keys", k) } } return nil } func (s *customFieldsStore) validateCustomFieldName(fieldName string) error { // ensure that custom field names are valid // no leading or trailing whitespace, no empty strings if strings.TrimSpace(fieldName) == "" { return fmt.Errorf("custom field name cannot be empty") } if fieldName != strings.TrimSpace(fieldName) { return fmt.Errorf("custom field name cannot have leading or trailing whitespace") } if len(fieldName) > maxCustomFieldNameLength { return fmt.Errorf("custom field name must be less than %d characters", maxCustomFieldNameLength+1) } return nil } func getSQLValueFromCustomFieldInput(input interface{}) (interface{}, error) { switch v := input.(type) { case []interface{}, map[string]interface{}: // TODO - in future it would be nice to convert to a JSON string // however, we would need some way to differentiate between a JSON string and a regular string // for now, we will not support objects and arrays return nil, fmt.Errorf("unsupported custom field value type: %T", input) default: return v, nil } } func (s *customFieldsStore) sqlValueToValue(value interface{}) interface{} { // TODO - if we ever support objects and arrays we will need to add support here return value } func (s *customFieldsStore) setCustomFields(ctx context.Context, id int, values map[string]interface{}, partial bool) error { if !partial { // delete existing custom fields if err := s.deleteForID(ctx, id); err != nil { return err } } if len(values) == 0 { return nil } conflictKey := s.fk.GetCol().(string) + ", field" // upsert new custom fields q := dialect.Insert(s.table).Prepared(true).Cols(s.fk, "field", "value"). OnConflict(goqu.DoUpdate(conflictKey, goqu.Record{"value": goqu.I("excluded.value")})) r := make([]interface{}, len(values)) var i int for key, value := range values { v, err := getSQLValueFromCustomFieldInput(value) if err != nil { return fmt.Errorf("getting SQL value for field %q: %w", key, err) } r[i] = goqu.Record{"field": key, "value": v, s.fk.GetCol().(string): id} i++ } if _, err := exec(ctx, q.Rows(r...)); err != nil { return fmt.Errorf("inserting custom fields: %w", err) } return nil } func (s *customFieldsStore) deleteCustomFields(ctx context.Context, id int, keys []string) error { if len(keys) == 0 { return nil } q := dialect.Delete(s.table). Where(s.fk.Eq(id)). Where(goqu.I("field").In(keys)) if _, err := exec(ctx, q); err != nil { return fmt.Errorf("deleting custom fields: %w", err) } return nil } func (s *customFieldsStore) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { q := dialect.Select("field", "value").From(s.table).Where(s.fk.Eq(id)) const single = false ret := make(map[string]interface{}) err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { var field string var value interface{} if err := rows.Scan(&field, &value); err != nil { return fmt.Errorf("scanning custom fields: %w", err) } ret[field] = s.sqlValueToValue(value) return nil }) if err != nil { return nil, fmt.Errorf("getting custom fields: %w", err) } return ret, nil } func (s *customFieldsStore) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { q := dialect.Select(s.fk.As("id"), "field", "value").From(s.table).Where(s.fk.In(ids)) const single = false ret := make([]models.CustomFieldMap, len(ids)) // initialise ret with empty maps for each id for i := range ret { ret[i] = make(map[string]interface{}) } idi := make(map[int]int, len(ids)) for i, id := range ids { idi[id] = i } err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { var id int var field string var value interface{} if err := rows.Scan(&id, &field, &value); err != nil { return fmt.Errorf("scanning custom fields: %w", err) } i := idi[id] m := ret[i] if m == nil { m = make(map[string]interface{}) ret[i] = m } m[field] = s.sqlValueToValue(value) return nil }) if err != nil { return nil, fmt.Errorf("getting custom fields: %w", err) } return ret, nil } type customFieldsFilterHandler struct { table string fkCol string c []models.CustomFieldCriterionInput idCol string } func (h *customFieldsFilterHandler) innerJoin(f *filterBuilder, as string, field string) { joinOn := fmt.Sprintf("%s = %s.%s AND %s.field = ?", h.idCol, as, h.fkCol, as) f.addInnerJoin(h.table, as, joinOn, field) } func (h *customFieldsFilterHandler) leftJoin(f *filterBuilder, as string, field string) { joinOn := fmt.Sprintf("%s = %s.%s AND %s.field = ?", h.idCol, as, h.fkCol, as) f.addLeftJoin(h.table, as, joinOn, field) } func (h *customFieldsFilterHandler) handleCriterion(f *filterBuilder, joinAs string, cc models.CustomFieldCriterionInput) { // convert values cv := make([]interface{}, len(cc.Value)) for i, v := range cc.Value { var err error cv[i], err = getSQLValueFromCustomFieldInput(v) if err != nil { f.setError(err) return } } switch cc.Modifier { case models.CriterionModifierEquals: h.innerJoin(f, joinAs, cc.Field) f.addWhere(fmt.Sprintf("%[1]s.value IN %s", joinAs, getInBinding(len(cv))), cv...) case models.CriterionModifierNotEquals: h.innerJoin(f, joinAs, cc.Field) f.addWhere(fmt.Sprintf("%[1]s.value NOT IN %s", joinAs, getInBinding(len(cv))), cv...) case models.CriterionModifierIncludes: clauses := make([]sqlClause, len(cv)) for i, v := range cv { clauses[i] = makeClause(fmt.Sprintf("%s.value LIKE ?", joinAs), fmt.Sprintf("%%%v%%", v)) } h.innerJoin(f, joinAs, cc.Field) f.whereClauses = append(f.whereClauses, clauses...) case models.CriterionModifierExcludes: for _, v := range cv { f.addWhere(fmt.Sprintf("%[1]s.value NOT LIKE ?", joinAs), fmt.Sprintf("%%%v%%", v)) } h.leftJoin(f, joinAs, cc.Field) case models.CriterionModifierMatchesRegex: for _, v := range cv { vs, ok := v.(string) if !ok { f.setError(fmt.Errorf("unsupported custom field criterion value type: %T", v)) } if _, err := regexp.Compile(vs); err != nil { f.setError(err) return } f.addWhere(fmt.Sprintf("(%s.value regexp ?)", joinAs), v) } h.innerJoin(f, joinAs, cc.Field) case models.CriterionModifierNotMatchesRegex: for _, v := range cv { vs, ok := v.(string) if !ok { f.setError(fmt.Errorf("unsupported custom field criterion value type: %T", v)) } if _, err := regexp.Compile(vs); err != nil { f.setError(err) return } f.addWhere(fmt.Sprintf("(%s.value IS NULL OR %[1]s.value NOT regexp ?)", joinAs), v) } h.leftJoin(f, joinAs, cc.Field) case models.CriterionModifierIsNull: h.leftJoin(f, joinAs, cc.Field) f.addWhere(fmt.Sprintf("%s.value IS NULL OR TRIM(%[1]s.value) = ''", joinAs)) case models.CriterionModifierNotNull: h.innerJoin(f, joinAs, cc.Field) f.addWhere(fmt.Sprintf("TRIM(%[1]s.value) != ''", joinAs)) case models.CriterionModifierBetween: if len(cv) != 2 { f.setError(fmt.Errorf("expected 2 values for custom field criterion modifier BETWEEN, got %d", len(cv))) return } h.innerJoin(f, joinAs, cc.Field) f.addWhere(fmt.Sprintf("%s.value BETWEEN ? AND ?", joinAs), cv[0], cv[1]) case models.CriterionModifierNotBetween: h.innerJoin(f, joinAs, cc.Field) f.addWhere(fmt.Sprintf("%s.value NOT BETWEEN ? AND ?", joinAs), cv[0], cv[1]) case models.CriterionModifierLessThan: if len(cv) != 1 { f.setError(fmt.Errorf("expected 1 value for custom field criterion modifier LESS_THAN, got %d", len(cv))) return } h.innerJoin(f, joinAs, cc.Field) f.addWhere(fmt.Sprintf("%s.value < ?", joinAs), cv[0]) case models.CriterionModifierGreaterThan: if len(cv) != 1 { f.setError(fmt.Errorf("expected 1 value for custom field criterion modifier LESS_THAN, got %d", len(cv))) return } h.innerJoin(f, joinAs, cc.Field) f.addWhere(fmt.Sprintf("%s.value > ?", joinAs), cv[0]) default: f.setError(fmt.Errorf("unsupported custom field criterion modifier: %s", cc.Modifier)) } } func (h *customFieldsFilterHandler) handle(ctx context.Context, f *filterBuilder) { if len(h.c) == 0 { return } for i, cc := range h.c { join := fmt.Sprintf("custom_fields_%d", i) h.handleCriterion(f, join, cc) } } ================================================ FILE: pkg/sqlite/custom_fields_test.go ================================================ //go:build integration // +build integration package sqlite_test import ( "context" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stretchr/testify/assert" ) type customFieldsReaderWriter interface { models.CustomFieldsReader models.CustomFieldsWriter } func testSetCustomFields(t *testing.T, namePrefix string, store customFieldsReaderWriter, id int, origCustomFields map[string]interface{}) { getCustomFields := func() map[string]interface{} { m := make(map[string]interface{}) for k, v := range origCustomFields { m[k] = v } return m } mergeCustomFields := func(i map[string]interface{}) map[string]interface{} { m := getCustomFields() for k, v := range i { m[k] = v } return m } tests := []struct { name string input models.CustomFieldsInput expected map[string]interface{} wantErr bool }{ { "valid full", models.CustomFieldsInput{ Full: map[string]interface{}{ "key": "value", }, }, map[string]interface{}{ "key": "value", }, false, }, { "valid partial", models.CustomFieldsInput{ Partial: map[string]interface{}{ "key": "value", }, }, mergeCustomFields(map[string]interface{}{ "key": "value", }), false, }, { "valid partial overwrite", models.CustomFieldsInput{ Partial: map[string]interface{}{ "real": float64(4.56), }, }, mergeCustomFields(map[string]interface{}{ "real": float64(4.56), }), false, }, { "valid remove", models.CustomFieldsInput{ Remove: []string{"real"}, }, func() map[string]interface{} { m := getCustomFields() delete(m, "real") return m }(), false, }, { "leading space full", models.CustomFieldsInput{ Full: map[string]interface{}{ " key": "value", }, }, nil, true, }, { "trailing space full", models.CustomFieldsInput{ Full: map[string]interface{}{ "key ": "value", }, }, nil, true, }, { "leading space partial", models.CustomFieldsInput{ Partial: map[string]interface{}{ " key": "value", }, }, nil, true, }, { "trailing space partial", models.CustomFieldsInput{ Partial: map[string]interface{}{ "key ": "value", }, }, nil, true, }, { "big key full", models.CustomFieldsInput{ Full: map[string]interface{}{ "12345678901234567890123456789012345678901234567890123456789012345": "value", }, }, nil, true, }, { "big key partial", models.CustomFieldsInput{ Partial: map[string]interface{}{ "12345678901234567890123456789012345678901234567890123456789012345": "value", }, }, nil, true, }, { "empty key full", models.CustomFieldsInput{ Full: map[string]interface{}{ "": "value", }, }, nil, true, }, { "empty key partial", models.CustomFieldsInput{ Partial: map[string]interface{}{ "": "value", }, }, nil, true, }, { "invalid remove full", models.CustomFieldsInput{ Full: map[string]interface{}{ "key": "value", }, Remove: []string{"key"}, }, nil, true, }, { "invalid remove partial", models.CustomFieldsInput{ Partial: map[string]interface{}{ "real": float64(4.56), }, Remove: []string{"real"}, }, nil, true, }, } for _, tt := range tests { runWithRollbackTxn(t, namePrefix+" "+tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) err := store.SetCustomFields(ctx, id, tt.input) if (err != nil) != tt.wantErr { t.Errorf("SetCustomFields() error = %v, wantErr %v", err, tt.wantErr) return } if tt.wantErr { return } actual, err := store.GetCustomFields(ctx, id) if err != nil { t.Errorf("GetCustomFields() error = %v", err) return } assert.Equal(tt.expected, actual) }) } } func TestPerformerSetCustomFields(t *testing.T) { performerIdx := performerIdx1WithScene testSetCustomFields(t, "Performer", db.Performer, performerIDs[performerIdx], getPerformerCustomFields(performerIdx)) } func TestTagSetCustomFields(t *testing.T) { tagIdx := tagIdx1WithScene testSetCustomFields(t, "Tag", db.Tag, tagIDs[tagIdx], getTagCustomFields(tagIdx)) } func TestStudioSetCustomFields(t *testing.T) { studioIdx := studioIdxWithScene testSetCustomFields(t, "Studio", db.Studio, studioIDs[studioIdx], getStudioCustomFields(studioIdx)) } func TestSceneSetCustomFields(t *testing.T) { sceneIdx := sceneIdxWithPerformer testSetCustomFields(t, "Scene", db.Scene, sceneIDs[sceneIdx], getSceneCustomFields(sceneIdx)) } func TestGallerySetCustomFields(t *testing.T) { galleryIdx := galleryIdxWithChapters testSetCustomFields(t, "Gallery", db.Gallery, galleryIDs[galleryIdx], getGalleryCustomFields(galleryIdx)) } func TestImageSetCustomFields(t *testing.T) { imageIdx := imageIdx2WithGallery testSetCustomFields(t, "Image", db.Image, imageIDs[imageIdx], getImageCustomFields(imageIdx)) } func TestGroupSetCustomFields(t *testing.T) { groupIdx := groupIdxWithScene testSetCustomFields(t, "Group", db.Group, groupIDs[groupIdx], getGroupCustomFields(groupIdx)) } ================================================ FILE: pkg/sqlite/custom_migrations.go ================================================ package sqlite import ( "context" "github.com/jmoiron/sqlx" ) type customMigrationFunc func(ctx context.Context, db *sqlx.DB) error func RegisterPostMigration(schemaVersion uint, fn customMigrationFunc) { v := postMigrations[schemaVersion] v = append(v, fn) postMigrations[schemaVersion] = v } func RegisterPreMigration(schemaVersion uint, fn customMigrationFunc) { v := preMigrations[schemaVersion] v = append(v, fn) preMigrations[schemaVersion] = v } var postMigrations = make(map[uint][]customMigrationFunc) var preMigrations = make(map[uint][]customMigrationFunc) ================================================ FILE: pkg/sqlite/database.go ================================================ package sqlite import ( "context" "database/sql" "embed" "errors" "fmt" "os" "path/filepath" "time" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" ) const ( maxWriteConnections = 1 // Number of database read connections to use // The same value is used for both the maximum and idle limit, // to prevent opening connections on the fly which has a notieable performance penalty. // Fewer connections use less memory, more connections increase performance, // but have diminishing returns. // 10 was found to be a good tradeoff. maxReadConnections = 10 // Idle connection timeout, in seconds // Closes a connection after a period of inactivity, which saves on memory and // causes the sqlite -wal and -shm files to be automatically deleted. dbConnTimeout = 30 * time.Second // environment variable to set the cache size cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) var appSchemaVersion uint = 85 //go:embed migrations/*.sql var migrationsBox embed.FS var ( // ErrDatabaseNotInitialized indicates that the database is not // initialized, usually due to an incomplete configuration. ErrDatabaseNotInitialized = errors.New("database not initialized") ) // ErrMigrationNeeded indicates that a database migration is needed // before the database can be initialized type MigrationNeededError struct { CurrentSchemaVersion uint RequiredSchemaVersion uint } func (e *MigrationNeededError) Error() string { return fmt.Sprintf("database schema version %d does not match required schema version %d", e.CurrentSchemaVersion, e.RequiredSchemaVersion) } type MismatchedSchemaVersionError struct { CurrentSchemaVersion uint RequiredSchemaVersion uint } func (e *MismatchedSchemaVersionError) Error() string { return fmt.Sprintf("schema version %d is incompatible with required schema version %d", e.CurrentSchemaVersion, e.RequiredSchemaVersion) } type storeRepository struct { Blobs *BlobStore File *FileStore Folder *FolderStore Image *ImageStore Gallery *GalleryStore GalleryChapter *GalleryChapterStore Scene *SceneStore SceneMarker *SceneMarkerStore Performer *PerformerStore SavedFilter *SavedFilterStore Studio *StudioStore Tag *TagStore Group *GroupStore } type Database struct { *storeRepository readDB *sqlx.DB writeDB *sqlx.DB dbPath string schemaVersion uint lockChan chan struct{} } func NewDatabase() *Database { fileStore := NewFileStore() folderStore := NewFolderStore() galleryStore := NewGalleryStore(fileStore, folderStore) blobStore := NewBlobStore(BlobStoreOptions{}) performerStore := NewPerformerStore(blobStore) studioStore := NewStudioStore(blobStore) tagStore := NewTagStore(blobStore) r := &storeRepository{} *r = storeRepository{ Blobs: blobStore, File: fileStore, Folder: folderStore, Scene: NewSceneStore(r, blobStore), SceneMarker: NewSceneMarkerStore(), Image: NewImageStore(r), Gallery: galleryStore, GalleryChapter: NewGalleryChapterStore(), Performer: performerStore, Studio: studioStore, Tag: tagStore, Group: NewGroupStore(blobStore), SavedFilter: NewSavedFilterStore(), } ret := &Database{ storeRepository: r, lockChan: make(chan struct{}, 1), } return ret } func (db *Database) SetBlobStoreOptions(options BlobStoreOptions) { *db.Blobs = *NewBlobStore(options) } // Ready returns an error if the database is not ready to begin transactions. func (db *Database) Ready() error { if db.readDB == nil || db.writeDB == nil { return ErrDatabaseNotInitialized } return nil } // Open initializes the database. If the database is new, then it // performs a full migration to the latest schema version. Otherwise, any // necessary migrations must be run separately using RunMigrations. // Returns true if the database is new. func (db *Database) Open(dbPath string) error { db.lock() defer db.unlock() db.dbPath = dbPath databaseSchemaVersion, err := db.getDatabaseSchemaVersion() if err != nil { return fmt.Errorf("getting database schema version: %w", err) } db.schemaVersion = databaseSchemaVersion isNew := databaseSchemaVersion == 0 if isNew { // new database, just run the migrations if err := db.RunAllMigrations(); err != nil { return fmt.Errorf("error running initial schema migrations: %w", err) } } else { if databaseSchemaVersion > appSchemaVersion { return &MismatchedSchemaVersionError{ CurrentSchemaVersion: databaseSchemaVersion, RequiredSchemaVersion: appSchemaVersion, } } // if migration is needed, then don't open the connection if db.needsMigration() { return &MigrationNeededError{ CurrentSchemaVersion: databaseSchemaVersion, RequiredSchemaVersion: appSchemaVersion, } } } if err := db.initialise(); err != nil { return err } if isNew { // optimize database after migration err = db.Optimise(context.Background()) if err != nil { logger.Warnf("error while performing post-migration optimisation: %v", err) } } return nil } // lock locks the database for writing. This method will block until the lock is acquired. func (db *Database) lock() { db.lockChan <- struct{}{} } // unlock unlocks the database func (db *Database) unlock() { // will block the caller if the lock is not held, so check first select { case <-db.lockChan: return default: panic("database is not locked") } } func (db *Database) Close() error { db.lock() defer db.unlock() if db.readDB != nil { if err := db.readDB.Close(); err != nil { return err } db.readDB = nil } if db.writeDB != nil { if err := db.writeDB.Close(); err != nil { return err } db.writeDB = nil } return nil } func (db *Database) open(disableForeignKeys bool, writable bool) (*sqlx.DB, error) { // https://github.com/mattn/go-sqlite3 url := "file:" + db.dbPath + "?_journal=WAL&_sync=NORMAL&_busy_timeout=50" if !disableForeignKeys { url += "&_fk=true" } if writable { url += "&_txlock=immediate" } else { url += "&mode=ro" } // #5155 - set the cache size if the environment variable is set // default is -2000 which is 2MB if cacheSize := os.Getenv(cacheSizeEnv); cacheSize != "" { url += "&_cache_size=" + cacheSize } conn, err := sqlx.Open(sqlite3Driver, url) if err != nil { return nil, fmt.Errorf("db.Open(): %w", err) } return conn, nil } func (db *Database) initialise() error { if err := db.openReadDB(); err != nil { return fmt.Errorf("opening read database: %w", err) } if err := db.openWriteDB(); err != nil { return fmt.Errorf("opening write database: %w", err) } return nil } func (db *Database) openReadDB() error { const ( disableForeignKeys = false writable = false ) var err error db.readDB, err = db.open(disableForeignKeys, writable) db.readDB.SetMaxOpenConns(maxReadConnections) db.readDB.SetMaxIdleConns(maxReadConnections) db.readDB.SetConnMaxIdleTime(dbConnTimeout) return err } func (db *Database) openWriteDB() error { const ( disableForeignKeys = false writable = true ) var err error db.writeDB, err = db.open(disableForeignKeys, writable) db.writeDB.SetMaxOpenConns(maxWriteConnections) db.writeDB.SetMaxIdleConns(maxWriteConnections) db.writeDB.SetConnMaxIdleTime(dbConnTimeout) return err } func (db *Database) Remove() error { databasePath := db.dbPath err := db.Close() if err != nil { return fmt.Errorf("error closing database: %w", err) } err = os.Remove(databasePath) if err != nil { return fmt.Errorf("error removing database: %w", err) } // remove the -shm, -wal files ( if they exist ) walFiles := []string{databasePath + "-shm", databasePath + "-wal"} for _, wf := range walFiles { if exists, _ := fsutil.FileExists(wf); exists { err = os.Remove(wf) if err != nil { return fmt.Errorf("error removing database: %w", err) } } } return nil } func (db *Database) Reset() error { databasePath := db.dbPath if err := db.Remove(); err != nil { return err } if err := db.Open(databasePath); err != nil { return fmt.Errorf("[reset DB] unable to initialize: %w", err) } return nil } // Backup the database. If db is nil, then uses the existing database // connection. func (db *Database) Backup(backupPath string) (err error) { thisDB := db.writeDB if thisDB == nil { thisDB, err = sqlx.Connect(sqlite3Driver, "file:"+db.dbPath+"?_fk=true") if err != nil { return fmt.Errorf("open database %s failed: %w", db.dbPath, err) } defer thisDB.Close() } // if backup path is not in the same directory as the database, // then backup to the same directory first, then move to the final location. // This is to prevent errors if the backup directory is over a network share. dbDir := filepath.Dir(db.dbPath) moveAfter := filepath.Dir(backupPath) != dbDir vacuumOut := backupPath if moveAfter { vacuumOut = filepath.Join(dbDir, filepath.Base(backupPath)) } logger.Infof("Backing up database into: %s", vacuumOut) _, err = thisDB.Exec(`VACUUM INTO "` + vacuumOut + `"`) if err != nil { return fmt.Errorf("vacuum failed: %w", err) } if moveAfter { logger.Infof("Moving database backup to: %s", backupPath) err = fsutil.SafeMove(vacuumOut, backupPath) if err != nil { return fmt.Errorf("moving database backup failed: %w", err) } } return nil } func (db *Database) Anonymise(outPath string) error { anon, err := NewAnonymiser(db, outPath) if err != nil { return err } return anon.Anonymise(context.Background()) } func (db *Database) RestoreFromBackup(backupPath string) error { logger.Infof("Restoring backup database %s into %s", backupPath, db.dbPath) return os.Rename(backupPath, db.dbPath) } func (db *Database) AppSchemaVersion() uint { return appSchemaVersion } func (db *Database) DatabasePath() string { return db.dbPath } func (db *Database) DatabaseBackupPath(backupDirectoryPath string) string { fn := fmt.Sprintf("%s.%d.%s", filepath.Base(db.dbPath), db.schemaVersion, time.Now().Format("20060102_150405")) if backupDirectoryPath != "" { return filepath.Join(backupDirectoryPath, fn) } return fn } func (db *Database) AnonymousDatabasePath(backupDirectoryPath string) string { fn := fmt.Sprintf("%s.anonymous.%d.%s", filepath.Base(db.dbPath), db.schemaVersion, time.Now().Format("20060102_150405")) if backupDirectoryPath != "" { return filepath.Join(backupDirectoryPath, fn) } return fn } func (db *Database) Version() uint { return db.schemaVersion } func (db *Database) Optimise(ctx context.Context) error { logger.Info("Optimising database") err := db.Analyze(ctx) if err != nil { return fmt.Errorf("performing optimization: %w", err) } err = db.Vacuum(ctx) if err != nil { return fmt.Errorf("performing vacuum: %w", err) } return nil } // Vacuum runs a VACUUM on the database, rebuilding the database file into a minimal amount of disk space. func (db *Database) Vacuum(ctx context.Context) error { _, err := db.writeDB.ExecContext(ctx, "VACUUM") return err } // Analyze runs an ANALYZE on the database to improve query performance. func (db *Database) Analyze(ctx context.Context) error { return analyze(ctx, db.writeDB) } // analyze runs an ANALYZE on the database to improve query performance. func analyze(ctx context.Context, db *sqlx.DB) error { _, err := db.ExecContext(ctx, "ANALYZE") return err } // flushWAL flushes the Write-Ahead Log (WAL) to the main database file. // It also truncates the WAL file to 0 bytes. func flushWAL(ctx context.Context, db *sqlx.DB) error { _, err := db.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)") return err } func (db *Database) ExecSQL(ctx context.Context, query string, args []interface{}) (*int64, *int64, error) { wrapper := dbWrapperType{} result, err := wrapper.Exec(ctx, query, args...) if err != nil { return nil, nil, err } var rowsAffected *int64 ra, err := result.RowsAffected() if err == nil { rowsAffected = &ra } var lastInsertId *int64 li, err := result.LastInsertId() if err == nil { lastInsertId = &li } return rowsAffected, lastInsertId, nil } func (db *Database) QuerySQL(ctx context.Context, query string, args []interface{}) ([]string, [][]interface{}, error) { wrapper := dbWrapperType{} rows, err := wrapper.QueryxContext(ctx, query, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, nil, err } defer rows.Close() cols, err := rows.Columns() if err != nil { return nil, nil, err } var ret [][]interface{} for rows.Next() { row, err := rows.SliceScan() if err != nil { return nil, nil, err } ret = append(ret, row) } if err := rows.Err(); err != nil { return nil, nil, err } return cols, ret, nil } ================================================ FILE: pkg/sqlite/date.go ================================================ package sqlite import ( "database/sql/driver" "time" "github.com/stashapp/stash/pkg/models" "gopkg.in/guregu/null.v4" ) const sqliteDateLayout = "2006-01-02" // Date represents a date stored as "YYYY-MM-DD" type Date struct { Date time.Time } // Scan implements the Scanner interface. func (d *Date) Scan(value interface{}) error { d.Date = value.(time.Time) return nil } // Value implements the driver Valuer interface. func (d Date) Value() (driver.Value, error) { return d.Date.Format(sqliteDateLayout), nil } // NullDate represents a nullable date stored as "YYYY-MM-DD" type NullDate struct { Date time.Time Valid bool } // Scan implements the Scanner interface. func (d *NullDate) Scan(value interface{}) error { var ok bool d.Date, ok = value.(time.Time) if !ok { d.Date = time.Time{} d.Valid = false return nil } d.Valid = true return nil } // Value implements the driver Valuer interface. func (d NullDate) Value() (driver.Value, error) { if !d.Valid { return nil, nil } return d.Date.Format(sqliteDateLayout), nil } func (d *NullDate) DatePtr(precision null.Int) *models.Date { if d == nil || !d.Valid { return nil } return &models.Date{Time: d.Date, Precision: models.DatePrecision(precision.Int64)} } func NullDateFromDatePtr(d *models.Date) NullDate { if d == nil { return NullDate{Valid: false} } return NullDate{Date: d.Time, Valid: true} } func datePrecisionFromDatePtr(d *models.Date) null.Int { if d == nil { // default to day precision return null.Int{} } return null.IntFrom(int64(d.Precision)) } ================================================ FILE: pkg/sqlite/doc.go ================================================ // Package sqlite provides interfaces to interact with the sqlite database. package sqlite ================================================ FILE: pkg/sqlite/driver.go ================================================ package sqlite import ( "database/sql" "database/sql/driver" "fmt" "github.com/WithoutPants/sortorder/casefolded" sqlite3 "github.com/mattn/go-sqlite3" ) const sqlite3Driver = "sqlite3ex" func init() { // register custom driver sql.Register(sqlite3Driver, &CustomSQLiteDriver{}) } type CustomSQLiteDriver struct{} type CustomSQLiteConn struct { *sqlite3.SQLiteConn } func (d *CustomSQLiteDriver) Open(dsn string) (driver.Conn, error) { sqlite3Driver := &sqlite3.SQLiteDriver{ ConnectHook: func(conn *sqlite3.SQLiteConn) error { funcs := map[string]interface{}{ "regexp": regexFn, "durationToTinyInt": durationToTinyIntFn, "basename": basenameFn, "phash_distance": phashDistanceFn, } for name, fn := range funcs { if err := conn.RegisterFunc(name, fn, true); err != nil { return fmt.Errorf("error registering function %s: %v", name, err) } } // COLLATE NATURAL_CI - Case insensitive natural sort err := conn.RegisterCollation("NATURAL_CI", func(s string, s2 string) int { if casefolded.NaturalLess(s, s2) { return -1 } else { return 1 } }) if err != nil { return fmt.Errorf("error registering natural sort collation: %v", err) } return nil }, } conn, err := sqlite3Driver.Open(dsn) if err != nil { return nil, err } return &CustomSQLiteConn{conn.(*sqlite3.SQLiteConn)}, nil } func (c *CustomSQLiteConn) Close() error { conn := c.SQLiteConn _, _ = conn.Exec("PRAGMA analysis_limit=1000; PRAGMA optimize;", []driver.Value{}) return conn.Close() } ================================================ FILE: pkg/sqlite/file.go ================================================ package sqlite import ( "context" "database/sql" "errors" "fmt" "io/fs" "path/filepath" "strings" "time" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/models" "gopkg.in/guregu/null.v4" ) const ( fileTable = "files" videoFileTable = "video_files" imageFileTable = "image_files" fileIDColumn = "file_id" videoCaptionsTable = "video_captions" captionCodeColumn = "language_code" captionFilenameColumn = "filename" captionTypeColumn = "caption_type" ) type basicFileRow struct { ID models.FileID `db:"id" goqu:"skipinsert"` Basename string `db:"basename"` ZipFileID null.Int `db:"zip_file_id"` ParentFolderID models.FolderID `db:"parent_folder_id"` Size int64 `db:"size"` ModTime Timestamp `db:"mod_time"` CreatedAt Timestamp `db:"created_at"` UpdatedAt Timestamp `db:"updated_at"` } func (r *basicFileRow) fromBasicFile(o models.BaseFile) { r.ID = o.ID r.Basename = o.Basename r.ZipFileID = nullIntFromFileIDPtr(o.ZipFileID) r.ParentFolderID = o.ParentFolderID r.Size = o.Size r.ModTime = Timestamp{Timestamp: o.ModTime} r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} } type videoFileRow struct { FileID models.FileID `db:"file_id"` Format string `db:"format"` Width int `db:"width"` Height int `db:"height"` Duration float64 `db:"duration"` VideoCodec string `db:"video_codec"` AudioCodec string `db:"audio_codec"` FrameRate float64 `db:"frame_rate"` BitRate int64 `db:"bit_rate"` Interactive bool `db:"interactive"` InteractiveSpeed null.Int `db:"interactive_speed"` } func (f *videoFileRow) fromVideoFile(ff models.VideoFile) { f.FileID = ff.ID f.Format = ff.Format f.Width = ff.Width f.Height = ff.Height f.Duration = ff.Duration f.VideoCodec = ff.VideoCodec f.AudioCodec = ff.AudioCodec f.FrameRate = ff.FrameRate f.BitRate = ff.BitRate f.Interactive = ff.Interactive f.InteractiveSpeed = intFromPtr(ff.InteractiveSpeed) } type imageFileRow struct { FileID models.FileID `db:"file_id"` Format string `db:"format"` Width int `db:"width"` Height int `db:"height"` } func (f *imageFileRow) fromImageFile(ff models.ImageFile) { f.FileID = ff.ID f.Format = ff.Format f.Width = ff.Width f.Height = ff.Height } // we redefine this to change the columns around // otherwise, we collide with the image file columns type videoFileQueryRow struct { FileID null.Int `db:"file_id_video"` Format null.String `db:"video_format"` Width null.Int `db:"video_width"` Height null.Int `db:"video_height"` Duration null.Float `db:"duration"` VideoCodec null.String `db:"video_codec"` AudioCodec null.String `db:"audio_codec"` FrameRate null.Float `db:"frame_rate"` BitRate null.Int `db:"bit_rate"` Interactive null.Bool `db:"interactive"` InteractiveSpeed null.Int `db:"interactive_speed"` } func (f *videoFileQueryRow) resolve() *models.VideoFile { return &models.VideoFile{ Format: f.Format.String, Width: int(f.Width.Int64), Height: int(f.Height.Int64), Duration: f.Duration.Float64, VideoCodec: f.VideoCodec.String, AudioCodec: f.AudioCodec.String, FrameRate: f.FrameRate.Float64, BitRate: f.BitRate.Int64, Interactive: f.Interactive.Bool, InteractiveSpeed: nullIntPtr(f.InteractiveSpeed), } } func videoFileQueryColumns() []interface{} { table := videoFileTableMgr.table return []interface{}{ table.Col("file_id").As("file_id_video"), table.Col("format").As("video_format"), table.Col("width").As("video_width"), table.Col("height").As("video_height"), table.Col("duration"), table.Col("video_codec"), table.Col("audio_codec"), table.Col("frame_rate"), table.Col("bit_rate"), table.Col("interactive"), table.Col("interactive_speed"), } } // we redefine this to change the columns around // otherwise, we collide with the video file columns type imageFileQueryRow struct { Format null.String `db:"image_format"` Width null.Int `db:"image_width"` Height null.Int `db:"image_height"` } func (imageFileQueryRow) columns(table *table) []interface{} { ex := table.table return []interface{}{ ex.Col("format").As("image_format"), ex.Col("width").As("image_width"), ex.Col("height").As("image_height"), } } func (f *imageFileQueryRow) resolve() *models.ImageFile { return &models.ImageFile{ Format: f.Format.String, Width: int(f.Width.Int64), Height: int(f.Height.Int64), } } type fileQueryRow struct { FileID null.Int `db:"file_id"` Basename null.String `db:"basename"` ZipFileID null.Int `db:"zip_file_id"` ParentFolderID null.Int `db:"parent_folder_id"` Size null.Int `db:"size"` ModTime NullTimestamp `db:"mod_time"` CreatedAt NullTimestamp `db:"file_created_at"` UpdatedAt NullTimestamp `db:"file_updated_at"` ZipBasename null.String `db:"zip_basename"` ZipFolderPath null.String `db:"zip_folder_path"` ZipSize null.Int `db:"zip_size"` FolderPath null.String `db:"parent_folder_path"` fingerprintQueryRow videoFileQueryRow imageFileQueryRow } func (r *fileQueryRow) resolve() models.File { basic := &models.BaseFile{ ID: models.FileID(r.FileID.Int64), DirEntry: models.DirEntry{ ZipFileID: nullIntFileIDPtr(r.ZipFileID), ModTime: r.ModTime.Timestamp, }, Path: filepath.Join(r.FolderPath.String, r.Basename.String), ParentFolderID: models.FolderID(r.ParentFolderID.Int64), Basename: r.Basename.String, Size: r.Size.Int64, CreatedAt: r.CreatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp, } if basic.ZipFileID != nil && r.ZipFolderPath.Valid && r.ZipBasename.Valid { basic.ZipFile = &models.BaseFile{ ID: *basic.ZipFileID, Path: filepath.Join(r.ZipFolderPath.String, r.ZipBasename.String), Basename: r.ZipBasename.String, Size: r.ZipSize.Int64, } } var ret models.File = basic if r.videoFileQueryRow.Format.Valid { vf := r.videoFileQueryRow.resolve() vf.BaseFile = basic ret = vf } if r.imageFileQueryRow.Format.Valid { imf := r.imageFileQueryRow.resolve() imf.BaseFile = basic ret = imf } r.appendRelationships(basic) return ret } func appendFingerprintsUnique(vs []models.Fingerprint, v ...models.Fingerprint) []models.Fingerprint { for _, vv := range v { found := false for _, vsv := range vs { if vsv.Type == vv.Type { found = true break } } if !found { vs = append(vs, vv) } } return vs } func (r *fileQueryRow) appendRelationships(i *models.BaseFile) { if r.fingerprintQueryRow.valid() { i.Fingerprints = appendFingerprintsUnique(i.Fingerprints, r.fingerprintQueryRow.resolve()) } } type fileQueryRows []fileQueryRow func (r fileQueryRows) resolve() []models.File { var ret []models.File var last models.File var lastID models.FileID for _, row := range r { if last == nil || lastID != models.FileID(row.FileID.Int64) { f := row.resolve() last = f lastID = models.FileID(row.FileID.Int64) ret = append(ret, last) continue } // must be merging with previous row row.appendRelationships(last.Base()) } return ret } type fileRepositoryType struct { repository scenes joinRepository images joinRepository galleries joinRepository } var ( fileRepository = fileRepositoryType{ repository: repository{ tableName: fileTable, idColumn: idColumn, }, scenes: joinRepository{ repository: repository{ tableName: scenesFilesTable, idColumn: fileIDColumn, }, fkColumn: sceneIDColumn, }, images: joinRepository{ repository: repository{ tableName: imagesFilesTable, idColumn: fileIDColumn, }, fkColumn: imageIDColumn, }, galleries: joinRepository{ repository: repository{ tableName: galleriesFilesTable, idColumn: fileIDColumn, }, fkColumn: galleryIDColumn, }, } ) type FileStore struct { repository tableMgr *table } func NewFileStore() *FileStore { return &FileStore{ repository: repository{ tableName: fileTable, idColumn: idColumn, }, tableMgr: fileTableMgr, } } func (qb *FileStore) table() exp.IdentifierExpression { return qb.tableMgr.table } func (qb *FileStore) Create(ctx context.Context, f models.File) error { var r basicFileRow r.fromBasicFile(*f.Base()) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { return err } fileID := models.FileID(id) // create extended stuff here switch ef := f.(type) { case *models.VideoFile: if err := qb.createVideoFile(ctx, fileID, *ef); err != nil { return err } case *models.ImageFile: if err := qb.createImageFile(ctx, fileID, *ef); err != nil { return err } } if err := FingerprintReaderWriter.insertJoins(ctx, fileID, f.Base().Fingerprints); err != nil { return err } updated, err := qb.Find(ctx, fileID) if err != nil { return fmt.Errorf("finding after create: %w", err) } base := f.Base() *base = *updated[0].Base() return nil } func (qb *FileStore) Update(ctx context.Context, f models.File) error { var r basicFileRow r.fromBasicFile(*f.Base()) id := f.Base().ID if err := qb.tableMgr.updateByID(ctx, id, r); err != nil { return err } // create extended stuff here switch ef := f.(type) { case *models.VideoFile: if err := qb.updateOrCreateVideoFile(ctx, id, *ef); err != nil { return err } case *models.ImageFile: if err := qb.updateOrCreateImageFile(ctx, id, *ef); err != nil { return err } } if err := FingerprintReaderWriter.replaceJoins(ctx, id, f.Base().Fingerprints); err != nil { return err } return nil } // ModifyFingerprints updates existing fingerprints and adds new ones. func (qb *FileStore) ModifyFingerprints(ctx context.Context, fileID models.FileID, fingerprints []models.Fingerprint) error { return FingerprintReaderWriter.upsertJoins(ctx, fileID, fingerprints) } func (qb *FileStore) DestroyFingerprints(ctx context.Context, fileID models.FileID, types []string) error { return FingerprintReaderWriter.destroyJoins(ctx, fileID, types) } func (qb *FileStore) Destroy(ctx context.Context, id models.FileID) error { return qb.tableMgr.destroyExisting(ctx, []int{int(id)}) } func (qb *FileStore) createVideoFile(ctx context.Context, id models.FileID, f models.VideoFile) error { var r videoFileRow r.fromVideoFile(f) r.FileID = id if _, err := videoFileTableMgr.insert(ctx, r); err != nil { return err } return nil } func (qb *FileStore) updateOrCreateVideoFile(ctx context.Context, id models.FileID, f models.VideoFile) error { exists, err := videoFileTableMgr.idExists(ctx, id) if err != nil { return err } if !exists { return qb.createVideoFile(ctx, id, f) } var r videoFileRow r.fromVideoFile(f) r.FileID = id if err := videoFileTableMgr.updateByID(ctx, id, r); err != nil { return err } return nil } func (qb *FileStore) createImageFile(ctx context.Context, id models.FileID, f models.ImageFile) error { var r imageFileRow r.fromImageFile(f) r.FileID = id if _, err := imageFileTableMgr.insert(ctx, r); err != nil { return err } return nil } func (qb *FileStore) updateOrCreateImageFile(ctx context.Context, id models.FileID, f models.ImageFile) error { exists, err := imageFileTableMgr.idExists(ctx, id) if err != nil { return err } if !exists { return qb.createImageFile(ctx, id, f) } var r imageFileRow r.fromImageFile(f) r.FileID = id if err := imageFileTableMgr.updateByID(ctx, id, r); err != nil { return err } return nil } func (qb *FileStore) selectDataset() *goqu.SelectDataset { table := qb.table() folderTable := folderTableMgr.table fingerprintTable := fingerprintTableMgr.table videoFileTable := videoFileTableMgr.table imageFileTable := imageFileTableMgr.table zipFileTable := table.As("zip_files") zipFolderTable := folderTable.As("zip_files_folders") cols := []interface{}{ table.Col("id").As("file_id"), table.Col("basename"), table.Col("zip_file_id"), table.Col("parent_folder_id"), table.Col("size"), table.Col("mod_time"), table.Col("created_at").As("file_created_at"), table.Col("updated_at").As("file_updated_at"), folderTable.Col("path").As("parent_folder_path"), fingerprintTable.Col("type").As("fingerprint_type"), fingerprintTable.Col("fingerprint"), zipFileTable.Col("basename").As("zip_basename"), zipFolderTable.Col("path").As("zip_folder_path"), // size is needed to open containing zip files zipFileTable.Col("size").As("zip_size"), } cols = append(cols, videoFileQueryColumns()...) cols = append(cols, imageFileQueryRow{}.columns(imageFileTableMgr)...) ret := dialect.From(table).Select(cols...) return ret.InnerJoin( folderTable, goqu.On(table.Col("parent_folder_id").Eq(folderTable.Col(idColumn))), ).LeftJoin( fingerprintTable, goqu.On(table.Col(idColumn).Eq(fingerprintTable.Col(fileIDColumn))), ).LeftJoin( videoFileTable, goqu.On(table.Col(idColumn).Eq(videoFileTable.Col(fileIDColumn))), ).LeftJoin( imageFileTable, goqu.On(table.Col(idColumn).Eq(imageFileTable.Col(fileIDColumn))), ).LeftJoin( zipFileTable, goqu.On(table.Col("zip_file_id").Eq(zipFileTable.Col("id"))), ).LeftJoin( zipFolderTable, goqu.On(zipFileTable.Col("parent_folder_id").Eq(zipFolderTable.Col(idColumn))), ) } func (qb *FileStore) countDataset() *goqu.SelectDataset { table := qb.table() folderTable := folderTableMgr.table fingerprintTable := fingerprintTableMgr.table videoFileTable := videoFileTableMgr.table imageFileTable := imageFileTableMgr.table zipFileTable := table.As("zip_files") zipFolderTable := folderTable.As("zip_files_folders") ret := dialect.From(table).Select(goqu.COUNT(goqu.DISTINCT(table.Col("id")))) return ret.InnerJoin( folderTable, goqu.On(table.Col("parent_folder_id").Eq(folderTable.Col(idColumn))), ).LeftJoin( fingerprintTable, goqu.On(table.Col(idColumn).Eq(fingerprintTable.Col(fileIDColumn))), ).LeftJoin( videoFileTable, goqu.On(table.Col(idColumn).Eq(videoFileTable.Col(fileIDColumn))), ).LeftJoin( imageFileTable, goqu.On(table.Col(idColumn).Eq(imageFileTable.Col(fileIDColumn))), ).LeftJoin( zipFileTable, goqu.On(table.Col("zip_file_id").Eq(zipFileTable.Col("id"))), ).LeftJoin( zipFolderTable, goqu.On(zipFileTable.Col("parent_folder_id").Eq(zipFolderTable.Col(idColumn))), ) } func (qb *FileStore) get(ctx context.Context, q *goqu.SelectDataset) (models.File, error) { ret, err := qb.getMany(ctx, q) if err != nil { return nil, err } if len(ret) == 0 { return nil, sql.ErrNoRows } return ret[0], nil } func (qb *FileStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]models.File, error) { const single = false var rows fileQueryRows if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { var f fileQueryRow if err := r.StructScan(&f); err != nil { return err } rows = append(rows, f) return nil }); err != nil { return nil, err } return rows.resolve(), nil } func (qb *FileStore) Find(ctx context.Context, ids ...models.FileID) ([]models.File, error) { var files []models.File for _, id := range ids { file, err := qb.find(ctx, id) if err != nil { return nil, err } if file == nil { return nil, fmt.Errorf("file with id %d not found", id) } files = append(files, file) } return files, nil } func (qb *FileStore) find(ctx context.Context, id models.FileID) (models.File, error) { q := qb.selectDataset().Where(qb.tableMgr.byID(id)) ret, err := qb.get(ctx, q) if err != nil { return nil, fmt.Errorf("getting file by id %d: %w", id, err) } return ret, nil } // FindByPath returns the first file that matches the given path. Wildcard characters are supported. func (qb *FileStore) FindByPath(ctx context.Context, p string, caseSensitive bool) (models.File, error) { ret, err := qb.FindAllByPath(ctx, p, caseSensitive) if err != nil { return nil, err } if len(ret) == 0 { return nil, nil } return ret[0], nil } // FindAllByPath returns all the files that match the given path. // Wildcard characters are supported. func (qb *FileStore) FindAllByPath(ctx context.Context, p string, caseSensitive bool) ([]models.File, error) { // separate basename from path basename := filepath.Base(p) dirName := filepath.Dir(p) // replace wildcards basename = strings.ReplaceAll(basename, "*", "%") dirName = strings.ReplaceAll(dirName, "*", "%") table := qb.table() folderTable := folderTableMgr.table // like uses case-insensitive matching. Only use like if wildcards are used q := qb.selectDataset().Prepared(true) if strings.Contains(basename, "%") || strings.Contains(dirName, "%") || !caseSensitive { q = q.Where( folderTable.Col("path").Like(dirName), table.Col("basename").Like(basename), ) } else { q = q.Where( folderTable.Col("path").Eq(dirName), table.Col("basename").Eq(basename), ) } ret, err := qb.getMany(ctx, q) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("getting file by path %s: %w", p, err) } return ret, nil } func (qb *FileStore) allInPaths(q *goqu.SelectDataset, p []string) *goqu.SelectDataset { folderTable := folderTableMgr.table var conds []exp.Expression for _, pp := range p { ppWildcard := pp + string(filepath.Separator) + "%" conds = append(conds, folderTable.Col("path").Eq(pp), folderTable.Col("path").Like(ppWildcard)) } return q.Where( goqu.Or(conds...), ) } // FindAllByPaths returns the all files that are within any of the given paths. // Returns all if limit is < 0. // Returns all files if p is empty. func (qb *FileStore) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]models.File, error) { table := qb.table() folderTable := folderTableMgr.table q := dialect.From(table).Prepared(true).InnerJoin( folderTable, goqu.On(table.Col("parent_folder_id").Eq(folderTable.Col(idColumn))), ).Select(table.Col(idColumn)) q = qb.allInPaths(q, p) if !includeZipContents { q = q.Where(table.Col("zip_file_id").IsNull()) } if limit > -1 { q = q.Limit(uint(limit)) } q = q.Offset(uint(offset)) ret, err := qb.findBySubquery(ctx, q) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("getting files by path %s: %w", p, err) } return ret, nil } // CountAllInPaths returns a count of all files that are within any of the given paths. // Returns count of all files if p is empty. func (qb *FileStore) CountAllInPaths(ctx context.Context, p []string) (int, error) { q := qb.countDataset().Prepared(true) q = qb.allInPaths(q, p) return count(ctx, q) } func (qb *FileStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]models.File, error) { table := qb.table() q := qb.selectDataset().Prepared(true).Where( table.Col(idColumn).Eq( sq, ), ) return qb.getMany(ctx, q) } func (qb *FileStore) FindByFingerprint(ctx context.Context, fp models.Fingerprint) ([]models.File, error) { fingerprintTable := fingerprintTableMgr.table fingerprints := fingerprintTable.As("fp") sq := dialect.From(fingerprints).Select(fingerprints.Col(fileIDColumn)).Where( fingerprints.Col("type").Eq(fp.Type), fingerprints.Col("fingerprint").Eq(fp.Fingerprint), ) return qb.findBySubquery(ctx, sq) } func (qb *FileStore) FindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]models.File, error) { table := qb.table() q := qb.selectDataset().Prepared(true).Where( table.Col("zip_file_id").Eq(zipFileID), ) return qb.getMany(ctx, q) } // FindByFileInfo finds files that match the base name, size, and mod time of the given file. func (qb *FileStore) FindByFileInfo(ctx context.Context, info fs.FileInfo, size int64) ([]models.File, error) { table := qb.table() modTime := info.ModTime().Format(time.RFC3339) q := qb.selectDataset().Prepared(true).Where( table.Col("basename").Eq(info.Name()), table.Col("size").Eq(size), table.Col("mod_time").Eq(modTime), ) return qb.getMany(ctx, q) } func (qb *FileStore) CountByFolderID(ctx context.Context, folderID models.FolderID) (int, error) { table := qb.table() q := qb.countDataset().Prepared(true).Where( table.Col("parent_folder_id").Eq(folderID), ) return count(ctx, q) } func (qb *FileStore) IsPrimary(ctx context.Context, fileID models.FileID) (bool, error) { joinTables := []exp.IdentifierExpression{ scenesFilesJoinTable, galleriesFilesJoinTable, imagesFilesJoinTable, } var sq *goqu.SelectDataset for _, t := range joinTables { qq := dialect.From(t).Select(t.Col(fileIDColumn)).Where( t.Col(fileIDColumn).Eq(fileID), t.Col("primary").Eq(1), ) if sq == nil { sq = qq } else { sq = sq.Union(qq) } } q := dialect.Select(goqu.COUNT("*").As("count")).Prepared(true).From( sq, ) var ret int if err := querySimple(ctx, q, &ret); err != nil { return false, err } return ret > 0, nil } func (qb *FileStore) validateFilter(fileFilter *models.FileFilterType) error { const and = "AND" const or = "OR" const not = "NOT" if fileFilter.And != nil { if fileFilter.Or != nil { return illegalFilterCombination(and, or) } if fileFilter.Not != nil { return illegalFilterCombination(and, not) } return qb.validateFilter(fileFilter.And) } if fileFilter.Or != nil { if fileFilter.Not != nil { return illegalFilterCombination(or, not) } return qb.validateFilter(fileFilter.Or) } if fileFilter.Not != nil { return qb.validateFilter(fileFilter.Not) } return nil } func (qb *FileStore) makeFilter(ctx context.Context, fileFilter *models.FileFilterType) *filterBuilder { query := &filterBuilder{} if fileFilter.And != nil { query.and(qb.makeFilter(ctx, fileFilter.And)) } if fileFilter.Or != nil { query.or(qb.makeFilter(ctx, fileFilter.Or)) } if fileFilter.Not != nil { query.not(qb.makeFilter(ctx, fileFilter.Not)) } filter := filterBuilderFromHandler(ctx, &fileFilterHandler{ fileFilter: fileFilter, }) return filter } func (qb *FileStore) Query(ctx context.Context, options models.FileQueryOptions) (*models.FileQueryResult, error) { fileFilter := options.FileFilter findFilter := options.FindFilter if fileFilter == nil { fileFilter = &models.FileFilterType{} } if findFilter == nil { findFilter = &models.FindFilterType{} } query := qb.newQuery() query.join(folderTable, "", "files.parent_folder_id = folders.id") distinctIDs(&query, fileTable) if q := findFilter.Q; q != nil && *q != "" { filepathColumn := "folders.path || '" + string(filepath.Separator) + "' || files.basename" searchColumns := []string{filepathColumn} query.parseQueryString(searchColumns, *q) } if err := qb.validateFilter(fileFilter); err != nil { return nil, err } filter := qb.makeFilter(ctx, fileFilter) if err := query.addFilter(filter); err != nil { return nil, err } if err := qb.setQuerySort(&query, findFilter); err != nil { return nil, err } query.sortAndPagination += getPagination(findFilter) result, err := qb.queryGroupedFields(ctx, options, query) if err != nil { return nil, fmt.Errorf("error querying aggregate fields: %w", err) } idsResult, err := query.findIDs(ctx) if err != nil { return nil, fmt.Errorf("error finding IDs: %w", err) } result.IDs = make([]models.FileID, len(idsResult)) for i, id := range idsResult { result.IDs[i] = models.FileID(id) } return result, nil } func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.FileQueryOptions, query queryBuilder) (*models.FileQueryResult, error) { if !options.Count && !options.TotalDuration && !options.Megapixels && !options.TotalSize { // nothing to do - return empty result return models.NewFileQueryResult(qb), nil } aggregateQuery := qb.newQuery() if options.Count { aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total") } if options.TotalDuration { query.addJoins( join{ table: videoFileTable, onClause: "files.id = video_files.file_id", }, ) query.addColumn("COALESCE(video_files.duration, 0) as duration") aggregateQuery.addColumn("COALESCE(SUM(temp.duration), 0) as duration") } if options.Megapixels { query.addJoins( join{ table: imageFileTable, onClause: "files.id = image_files.file_id", }, ) query.addColumn("COALESCE(image_files.width, 0) * COALESCE(image_files.height, 0) as megapixels") aggregateQuery.addColumn("COALESCE(SUM(temp.megapixels), 0) / 1000000 as megapixels") } if options.TotalSize { query.addColumn("COALESCE(files.size, 0) as size") aggregateQuery.addColumn("COALESCE(SUM(temp.size), 0) as size") } const includeSortPagination = false aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination)) out := struct { Total int Duration float64 Megapixels float64 Size int64 }{} if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil { return nil, err } ret := models.NewFileQueryResult(qb) ret.Count = out.Total ret.Megapixels = out.Megapixels ret.TotalDuration = out.Duration ret.TotalSize = out.Size return ret, nil } var fileSortOptions = sortOptions{ "created_at", "id", "path", "random", "updated_at", } func (qb *FileStore) setQuerySort(query *queryBuilder, findFilter *models.FindFilterType) error { if findFilter == nil || findFilter.Sort == nil || *findFilter.Sort == "" { return nil } sort := findFilter.GetSort("path") // CVE-2024-32231 - ensure sort is in the list of allowed sorts if err := fileSortOptions.validateSort(sort); err != nil { return err } direction := findFilter.GetDirection() switch sort { case "path": // special handling for path query.sortAndPagination += fmt.Sprintf(" ORDER BY folders.path %s, files.basename %[1]s", direction) default: query.sortAndPagination += getSort(sort, direction, "files") } return nil } func (qb *FileStore) captionRepository() *captionRepository { return &captionRepository{ repository: repository{ tableName: videoCaptionsTable, idColumn: fileIDColumn, }, } } func (qb *FileStore) GetCaptions(ctx context.Context, fileID models.FileID) ([]*models.VideoCaption, error) { return qb.captionRepository().get(ctx, fileID) } func (qb *FileStore) UpdateCaptions(ctx context.Context, fileID models.FileID, captions []*models.VideoCaption) error { return qb.captionRepository().replace(ctx, fileID, captions) } ================================================ FILE: pkg/sqlite/file_filter.go ================================================ package sqlite import ( "context" "fmt" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) type fileFilterHandler struct { fileFilter *models.FileFilterType // if true, don't allow use of related filters isRelated bool } func (qb *fileFilterHandler) validate() error { fileFilter := qb.fileFilter if fileFilter == nil { return nil } if err := validateFilterCombination(fileFilter.OperatorFilter); err != nil { return err } if qb.isRelated && (fileFilter.ScenesFilter != nil || fileFilter.ImagesFilter != nil || fileFilter.GalleriesFilter != nil) { return fmt.Errorf("cannot use related filters inside a related filter") } if subFilter := fileFilter.SubFilter(); subFilter != nil { sqb := &fileFilterHandler{fileFilter: subFilter, isRelated: qb.isRelated} if err := sqb.validate(); err != nil { return err } } return nil } func (qb *fileFilterHandler) handle(ctx context.Context, f *filterBuilder) { fileFilter := qb.fileFilter if fileFilter == nil { return } if err := qb.validate(); err != nil { f.setError(err) return } sf := fileFilter.SubFilter() if sf != nil { sub := &fileFilterHandler{sf, qb.isRelated} handleSubFilter(ctx, sub, f, fileFilter.OperatorFilter) } f.handleCriterion(ctx, qb.criterionHandler()) } func (qb *fileFilterHandler) criterionHandler() criterionHandler { fileFilter := qb.fileFilter return compoundHandler{ &videoFileFilterHandler{ filter: fileFilter.VideoFileFilter, }, &imageFileFilterHandler{ filter: fileFilter.ImageFileFilter, }, pathCriterionHandler(fileFilter.Path, "folders.path", "files.basename", nil), stringCriterionHandler(fileFilter.Basename, "files.basename"), stringCriterionHandler(fileFilter.Dir, "folders.path"), ×tampCriterionHandler{fileFilter.ModTime, "files.mod_time", nil}, qb.parentFolderCriterionHandler(fileFilter.ParentFolder), qb.zipFileCriterionHandler(fileFilter.ZipFile), qb.sceneCountCriterionHandler(fileFilter.SceneCount), qb.imageCountCriterionHandler(fileFilter.ImageCount), qb.galleryCountCriterionHandler(fileFilter.GalleryCount), qb.hashesCriterionHandler(fileFilter.Hashes), qb.duplicatedCriterionHandler(fileFilter.Duplicated), ×tampCriterionHandler{fileFilter.CreatedAt, "files.created_at", nil}, ×tampCriterionHandler{fileFilter.UpdatedAt, "files.updated_at", nil}, &relatedFilterHandler{ relatedIDCol: "scenes_files.scene_id", relatedRepo: sceneRepository.repository, relatedHandler: &sceneFilterHandler{fileFilter.ScenesFilter}, joinFn: func(f *filterBuilder) { fileRepository.scenes.innerJoin(f, "", "files.id") }, }, &relatedFilterHandler{ relatedIDCol: "images_files.image_id", relatedRepo: imageRepository.repository, relatedHandler: &imageFilterHandler{fileFilter.ImagesFilter}, joinFn: func(f *filterBuilder) { fileRepository.images.innerJoin(f, "", "files.id") }, }, &relatedFilterHandler{ relatedIDCol: "galleries_files.gallery_id", relatedRepo: galleryRepository.repository, relatedHandler: &galleryFilterHandler{fileFilter.GalleriesFilter}, joinFn: func(f *filterBuilder) { fileRepository.galleries.innerJoin(f, "", "files.id") }, }, } } func (qb *fileFilterHandler) zipFileCriterionHandler(criterion *models.MultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if criterion != nil { if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { var notClause string if criterion.Modifier == models.CriterionModifierNotNull { notClause = "NOT" } f.addWhere(fmt.Sprintf("files.zip_file_id IS %s NULL", notClause)) return } if len(criterion.Value) == 0 { return } var args []interface{} for _, tagID := range criterion.Value { args = append(args, tagID) } whereClause := "" havingClause := "" switch criterion.Modifier { case models.CriterionModifierIncludes: whereClause = "files.zip_file_id IN " + getInBinding(len(criterion.Value)) case models.CriterionModifierExcludes: whereClause = "files.zip_file_id NOT IN " + getInBinding(len(criterion.Value)) } f.addWhere(whereClause, args...) f.addHaving(havingClause) } } } func (qb *fileFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if folder == nil { return } folderCopy := *folder switch folderCopy.Modifier { case models.CriterionModifierEquals: folderCopy.Modifier = models.CriterionModifierIncludesAll case models.CriterionModifierNotEquals: folderCopy.Modifier = models.CriterionModifierExcludes } hh := hierarchicalMultiCriterionHandlerBuilder{ primaryTable: fileTable, foreignTable: folderTable, foreignFK: "parent_folder_id", parentFK: "parent_folder_id", } hh.handler(&folderCopy)(ctx, f) } } func (qb *fileFilterHandler) sceneCountCriterionHandler(c *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: fileTable, joinTable: scenesFilesTable, primaryFK: fileIDColumn, } return h.handler(c) } func (qb *fileFilterHandler) imageCountCriterionHandler(c *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: fileTable, joinTable: imagesFilesTable, primaryFK: fileIDColumn, } return h.handler(c) } func (qb *fileFilterHandler) galleryCountCriterionHandler(c *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: fileTable, joinTable: galleriesFilesTable, primaryFK: fileIDColumn, } return h.handler(c) } func (qb *fileFilterHandler) duplicatedCriterionHandler(duplicatedFilter *models.FileDuplicationCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { // TODO: Wishlist item: Implement Distance matching // For files, only phash duplication applies if duplicatedFilter == nil { return } var phashValue *bool // Handle legacy 'duplicated' field for backwards compatibility //nolint:staticcheck if duplicatedFilter.Duplicated != nil && duplicatedFilter.Phash == nil { //nolint:staticcheck phashValue = duplicatedFilter.Duplicated } else if duplicatedFilter.Phash != nil { phashValue = duplicatedFilter.Phash } if phashValue != nil { v := getCountOperator(*phashValue) f.addInnerJoin("(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) "+v+" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)", "scph", "files.id = scph.file_id") } } } func (qb *fileFilterHandler) hashesCriterionHandler(hashes []*models.FingerprintFilterInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { // TODO - this won't work for AND/OR combinations for i, hash := range hashes { t := fmt.Sprintf("file_fingerprints_%d", i) f.addLeftJoin(fingerprintTable, t, fmt.Sprintf("files.id = %s.file_id AND %s.type = ?", t, t), hash.Type) distance := 0 if hash.Distance != nil { distance = *hash.Distance } // Only phash supports distance matching and is stored as integer if hash.Type == models.FingerprintTypePhash { value, err := utils.StringToPhash(hash.Value) if err != nil { f.setError(fmt.Errorf("invalid phash value: %w", err)) return } if distance > 0 { // needed to avoid a type mismatch f.addWhere(fmt.Sprintf("typeof(%s.fingerprint) = 'integer'", t)) f.addWhere(fmt.Sprintf("phash_distance(%s.fingerprint, ?) < ?", t), value, distance) } else { intCriterionHandler(&models.IntCriterionInput{ Value: int(value), Modifier: models.CriterionModifierEquals, }, t+".fingerprint", nil)(ctx, f) } } else { // All other fingerprint types (md5, oshash, sha1, etc.) are stored as strings // Use exact match for string-based fingerprints f.addWhere(fmt.Sprintf("%s.fingerprint = ?", t), hash.Value) } } } } type videoFileFilterHandler struct { filter *models.VideoFileFilterInput } func (qb *videoFileFilterHandler) handle(ctx context.Context, f *filterBuilder) { videoFileFilter := qb.filter if videoFileFilter == nil { return } f.handleCriterion(ctx, qb.criterionHandler()) } func (qb *videoFileFilterHandler) criterionHandler() criterionHandler { videoFileFilter := qb.filter return compoundHandler{ joinedStringCriterionHandler(videoFileFilter.Format, "video_files.format", qb.addVideoFilesTable), floatIntCriterionHandler(videoFileFilter.Duration, "video_files.duration", qb.addVideoFilesTable), resolutionCriterionHandler(videoFileFilter.Resolution, "video_files.height", "video_files.width", qb.addVideoFilesTable), orientationCriterionHandler(videoFileFilter.Orientation, "video_files.height", "video_files.width", qb.addVideoFilesTable), floatIntCriterionHandler(videoFileFilter.Framerate, "ROUND(video_files.frame_rate)", qb.addVideoFilesTable), intCriterionHandler(videoFileFilter.Bitrate, "video_files.bit_rate", qb.addVideoFilesTable), qb.codecCriterionHandler(videoFileFilter.VideoCodec, "video_files.video_codec", qb.addVideoFilesTable), qb.codecCriterionHandler(videoFileFilter.AudioCodec, "video_files.audio_codec", qb.addVideoFilesTable), boolCriterionHandler(videoFileFilter.Interactive, "video_files.interactive", qb.addVideoFilesTable), intCriterionHandler(videoFileFilter.InteractiveSpeed, "video_files.interactive_speed", qb.addVideoFilesTable), qb.captionCriterionHandler(videoFileFilter.Captions), } } func (qb *videoFileFilterHandler) addVideoFilesTable(f *filterBuilder) { f.addLeftJoin(videoFileTable, "", "video_files.file_id = files.id") } func (qb *videoFileFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if codec != nil { if addJoinFn != nil { addJoinFn(f) } stringCriterionHandler(codec, codecColumn)(ctx, f) } } } func (qb *videoFileFilterHandler) captionCriterionHandler(captions *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ primaryTable: sceneTable, primaryFK: sceneIDColumn, joinTable: videoCaptionsTable, stringColumn: captionCodeColumn, addJoinTable: func(f *filterBuilder) { f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = files.id") }, excludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) { excludeClause := `files.id NOT IN ( SELECT files.id from files INNER JOIN video_captions on video_captions.file_id = files.id WHERE video_captions.language_code LIKE ? )` f.addWhere(excludeClause, criterion.Value) // TODO - should we also exclude null values? }, } return h.handler(captions) } type imageFileFilterHandler struct { filter *models.ImageFileFilterInput } func (qb *imageFileFilterHandler) handle(ctx context.Context, f *filterBuilder) { ff := qb.filter if ff == nil { return } f.handleCriterion(ctx, qb.criterionHandler()) } func (qb *imageFileFilterHandler) criterionHandler() criterionHandler { ff := qb.filter return compoundHandler{ joinedStringCriterionHandler(ff.Format, "image_files.format", qb.addImageFilesTable), resolutionCriterionHandler(ff.Resolution, "image_files.height", "image_files.width", qb.addImageFilesTable), orientationCriterionHandler(ff.Orientation, "image_files.height", "image_files.width", qb.addImageFilesTable), } } func (qb *imageFileFilterHandler) addImageFilesTable(f *filterBuilder) { f.addLeftJoin(imageFileTable, "", "image_files.file_id = files.id") } ================================================ FILE: pkg/sqlite/file_filter_test.go ================================================ //go:build integration // +build integration package sqlite_test import ( "context" "strconv" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" "github.com/stretchr/testify/assert" ) func TestFileQuery(t *testing.T) { tests := []struct { name string findFilter *models.FindFilterType filter *models.FileFilterType includeIdxs []int includeIDs []models.FileID excludeIdxs []int wantErr bool }{ { name: "path", filter: &models.FileFilterType{ Path: &models.StringCriterionInput{ Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "basename"), Modifier: models.CriterionModifierIncludes, }, }, includeIdxs: []int{fileIdxStartVideoFiles}, excludeIdxs: []int{fileIdxStartImageFiles}, }, { name: "basename", filter: &models.FileFilterType{ Basename: &models.StringCriterionInput{ Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "basename"), Modifier: models.CriterionModifierIncludes, }, }, includeIdxs: []int{fileIdxStartVideoFiles}, excludeIdxs: []int{fileIdxStartImageFiles}, }, { name: "dir", filter: &models.FileFilterType{ Path: &models.StringCriterionInput{ Value: folderPaths[folderIdxWithSceneFiles], Modifier: models.CriterionModifierIncludes, }, }, includeIDs: []models.FileID{sceneFileIDs[sceneIdxWithGroup]}, excludeIdxs: []int{fileIdxStartImageFiles}, }, { name: "parent folder", filter: &models.FileFilterType{ ParentFolder: &models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(int(folderIDs[folderIdxWithSceneFiles])), }, Modifier: models.CriterionModifierIncludes, }, }, includeIDs: []models.FileID{sceneFileIDs[sceneIdxWithGroup]}, excludeIdxs: []int{fileIdxStartImageFiles}, }, { name: "zip file", filter: &models.FileFilterType{ ZipFile: &models.MultiCriterionInput{ Value: []string{ strconv.Itoa(int(fileIDs[fileIdxZip])), }, Modifier: models.CriterionModifierIncludes, }, }, includeIDs: []models.FileID{fileIDs[fileIdxInZip]}, excludeIdxs: []int{fileIdxStartImageFiles}, }, { name: "hashes md5", filter: &models.FileFilterType{ Hashes: []*models.FingerprintFilterInput{ { Type: models.FingerprintTypeMD5, Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "md5"), }, }, }, includeIdxs: []int{fileIdxStartVideoFiles}, excludeIdxs: []int{fileIdxStartImageFiles}, }, { name: "hashes oshash", filter: &models.FileFilterType{ Hashes: []*models.FingerprintFilterInput{ { Type: models.FingerprintTypeOshash, Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "oshash"), }, }, }, includeIdxs: []int{fileIdxStartVideoFiles}, excludeIdxs: []int{fileIdxStartImageFiles}, }, { name: "hashes phash", filter: &models.FileFilterType{ Hashes: []*models.FingerprintFilterInput{ { Type: models.FingerprintTypePhash, Value: utils.PhashToString(getFilePhash(fileIdxStartImageFiles)), }, }, }, includeIdxs: []int{fileIdxStartImageFiles}, excludeIdxs: []int{fileIdxStartVideoFiles}, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) results, err := db.File.Query(ctx, models.FileQueryOptions{ FileFilter: tt.filter, QueryOptions: models.QueryOptions{ FindFilter: tt.findFilter, }, }) if (err != nil) != tt.wantErr { t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr) return } include := indexesToIDPtrs(fileIDs, tt.includeIdxs) for _, id := range tt.includeIDs { v := id include = append(include, &v) } exclude := indexesToIDPtrs(fileIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(results.IDs, models.FileID(*i)) } for _, e := range exclude { assert.NotContains(results.IDs, models.FileID(*e)) } }) } } ================================================ FILE: pkg/sqlite/file_test.go ================================================ //go:build integration // +build integration package sqlite_test import ( "context" "path/filepath" "testing" "time" "github.com/stashapp/stash/pkg/models" "github.com/stretchr/testify/assert" ) func getFilePath(folderIdx int, basename string) string { return filepath.Join(folderPaths[folderIdx], basename) } func makeZipFileWithID(index int) models.File { f := makeFile(index) return &models.BaseFile{ ID: fileIDs[index], Basename: f.Base().Basename, Path: getFilePath(fileFolders[index], getFileBaseName(index)), } } func Test_fileFileStore_Create(t *testing.T) { var ( basename = "basename" fingerprintType = "MD5" fingerprintValue = "checksum" fileModTime = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) size int64 = 1234 duration = 1.234 width = 640 height = 480 framerate = 2.345 bitrate int64 = 234 videoCodec = "videoCodec" audioCodec = "audioCodec" format = "format" ) tests := []struct { name string newObject models.File wantErr bool }{ { "full", &models.BaseFile{ DirEntry: models.DirEntry{ ZipFileID: &fileIDs[fileIdxZip], ZipFile: makeZipFileWithID(fileIdxZip), ModTime: fileModTime, }, Path: getFilePath(folderIdxWithFiles, basename), ParentFolderID: folderIDs[folderIdxWithFiles], Basename: basename, Size: size, Fingerprints: []models.Fingerprint{ { Type: fingerprintType, Fingerprint: fingerprintValue, }, }, CreatedAt: createdAt, UpdatedAt: updatedAt, }, false, }, { "video file", &models.VideoFile{ BaseFile: &models.BaseFile{ DirEntry: models.DirEntry{ ZipFileID: &fileIDs[fileIdxZip], ZipFile: makeZipFileWithID(fileIdxZip), ModTime: fileModTime, }, Path: getFilePath(folderIdxWithFiles, basename), ParentFolderID: folderIDs[folderIdxWithFiles], Basename: basename, Size: size, Fingerprints: []models.Fingerprint{ { Type: fingerprintType, Fingerprint: fingerprintValue, }, }, CreatedAt: createdAt, UpdatedAt: updatedAt, }, Duration: duration, VideoCodec: videoCodec, AudioCodec: audioCodec, Format: format, Width: width, Height: height, FrameRate: framerate, BitRate: bitrate, }, false, }, { "image file", &models.ImageFile{ BaseFile: &models.BaseFile{ DirEntry: models.DirEntry{ ZipFileID: &fileIDs[fileIdxZip], ZipFile: makeZipFileWithID(fileIdxZip), ModTime: fileModTime, }, Path: getFilePath(folderIdxWithFiles, basename), ParentFolderID: folderIDs[folderIdxWithFiles], Basename: basename, Size: size, Fingerprints: []models.Fingerprint{ { Type: fingerprintType, Fingerprint: fingerprintValue, }, }, CreatedAt: createdAt, UpdatedAt: updatedAt, }, Format: format, Width: width, Height: height, }, false, }, { "duplicate path", &models.BaseFile{ DirEntry: models.DirEntry{ ModTime: fileModTime, }, Path: getFilePath(folderIdxWithFiles, getFileBaseName(fileIdxZip)), ParentFolderID: folderIDs[folderIdxWithFiles], Basename: getFileBaseName(fileIdxZip), Size: size, Fingerprints: []models.Fingerprint{ { Type: fingerprintType, Fingerprint: fingerprintValue, }, }, CreatedAt: createdAt, UpdatedAt: updatedAt, }, true, }, { "empty basename", &models.BaseFile{ ParentFolderID: folderIDs[folderIdxWithFiles], }, true, }, { "missing folder id", &models.BaseFile{ Basename: basename, }, true, }, { "invalid folder id", &models.BaseFile{ DirEntry: models.DirEntry{}, ParentFolderID: invalidFolderID, Basename: basename, }, true, }, { "invalid zip file id", &models.BaseFile{ DirEntry: models.DirEntry{ ZipFileID: &invalidFileID, }, Basename: basename, }, true, }, } qb := db.File for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) s := tt.newObject if err := qb.Create(ctx, s); (err != nil) != tt.wantErr { t.Errorf("fileStore.Create() error = %v, wantErr = %v", err, tt.wantErr) } if tt.wantErr { assert.Zero(s.Base().ID) return } assert.NotZero(s.Base().ID) var copy models.File switch t := s.(type) { case *models.BaseFile: v := *t copy = &v case *models.VideoFile: v := *t copy = &v case *models.ImageFile: v := *t copy = &v } copy.Base().ID = s.Base().ID assert.Equal(copy, s) // ensure can find the scene found, err := qb.Find(ctx, s.Base().ID) if err != nil { t.Errorf("fileStore.Find() error = %v", err) } if !assert.Len(found, 1) { return } assert.Equal(copy, found[0]) return }) } } func Test_fileStore_Update(t *testing.T) { var ( basename = "basename" fingerprintType = "MD5" fingerprintValue = "checksum" fileModTime = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) size int64 = 1234 duration = 1.234 width = 640 height = 480 framerate = 2.345 bitrate int64 = 234 videoCodec = "videoCodec" audioCodec = "audioCodec" format = "format" ) tests := []struct { name string updatedObject models.File wantErr bool }{ { "full", &models.BaseFile{ ID: fileIDs[fileIdxInZip], DirEntry: models.DirEntry{ ZipFileID: &fileIDs[fileIdxZip], ZipFile: makeZipFileWithID(fileIdxZip), ModTime: fileModTime, }, Path: getFilePath(folderIdxWithFiles, basename), ParentFolderID: folderIDs[folderIdxWithFiles], Basename: basename, Size: size, Fingerprints: []models.Fingerprint{ { Type: fingerprintType, Fingerprint: fingerprintValue, }, }, CreatedAt: createdAt, UpdatedAt: updatedAt, }, false, }, { "video file", &models.VideoFile{ BaseFile: &models.BaseFile{ ID: fileIDs[fileIdxStartVideoFiles], DirEntry: models.DirEntry{ ZipFileID: &fileIDs[fileIdxZip], ZipFile: makeZipFileWithID(fileIdxZip), ModTime: fileModTime, }, Path: getFilePath(folderIdxWithFiles, basename), ParentFolderID: folderIDs[folderIdxWithFiles], Basename: basename, Size: size, Fingerprints: []models.Fingerprint{ { Type: fingerprintType, Fingerprint: fingerprintValue, }, }, CreatedAt: createdAt, UpdatedAt: updatedAt, }, Duration: duration, VideoCodec: videoCodec, AudioCodec: audioCodec, Format: format, Width: width, Height: height, FrameRate: framerate, BitRate: bitrate, }, false, }, { "image file", &models.ImageFile{ BaseFile: &models.BaseFile{ ID: fileIDs[fileIdxStartImageFiles], DirEntry: models.DirEntry{ ZipFileID: &fileIDs[fileIdxZip], ZipFile: makeZipFileWithID(fileIdxZip), ModTime: fileModTime, }, Path: getFilePath(folderIdxWithFiles, basename), ParentFolderID: folderIDs[folderIdxWithFiles], Basename: basename, Size: size, Fingerprints: []models.Fingerprint{ { Type: fingerprintType, Fingerprint: fingerprintValue, }, }, CreatedAt: createdAt, UpdatedAt: updatedAt, }, Format: format, Width: width, Height: height, }, false, }, { "duplicate path", &models.BaseFile{ ID: fileIDs[fileIdxInZip], DirEntry: models.DirEntry{ ModTime: fileModTime, }, Path: getFilePath(folderIdxWithFiles, getFileBaseName(fileIdxZip)), ParentFolderID: folderIDs[folderIdxWithFiles], Basename: getFileBaseName(fileIdxZip), Size: size, Fingerprints: []models.Fingerprint{ { Type: fingerprintType, Fingerprint: fingerprintValue, }, }, CreatedAt: createdAt, UpdatedAt: updatedAt, }, true, }, { "clear zip", &models.BaseFile{ ID: fileIDs[fileIdxInZip], Path: getFilePath(folderIdxWithFiles, getFileBaseName(fileIdxZip)+".renamed"), Basename: getFileBaseName(fileIdxZip) + ".renamed", ParentFolderID: folderIDs[folderIdxWithFiles], }, false, }, { "clear folder", &models.BaseFile{ ID: fileIDs[fileIdxZip], Path: basename, }, true, }, { "invalid parent folder id", &models.BaseFile{ ID: fileIDs[fileIdxZip], Path: basename, ParentFolderID: invalidFolderID, }, true, }, { "invalid zip file id", &models.BaseFile{ ID: fileIDs[fileIdxZip], Path: basename, DirEntry: models.DirEntry{ ZipFileID: &invalidFileID, }, ParentFolderID: folderIDs[folderIdxWithFiles], }, true, }, } qb := db.File for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) copy := tt.updatedObject if err := qb.Update(ctx, tt.updatedObject); (err != nil) != tt.wantErr { t.Errorf("FileStore.Update() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { return } s, err := qb.Find(ctx, tt.updatedObject.Base().ID) if err != nil { t.Errorf("FileStore.Find() error = %v", err) } if !assert.Len(s, 1) { return } assert.Equal(copy, s[0]) return }) } } func makeFileWithID(index int) models.File { ret := makeFile(index) ret.Base().Path = getFilePath(fileFolders[index], getFileBaseName(index)) ret.Base().ID = fileIDs[index] return ret } func Test_fileStore_Find(t *testing.T) { tests := []struct { name string id models.FileID want models.File wantErr bool }{ { "valid", fileIDs[fileIdxZip], makeFileWithID(fileIdxZip), false, }, { "invalid", models.FileID(invalidID), nil, true, }, { "video file", fileIDs[fileIdxStartVideoFiles], makeFileWithID(fileIdxStartVideoFiles), false, }, { "image file", fileIDs[fileIdxStartImageFiles], makeFileWithID(fileIdxStartImageFiles), false, }, } qb := db.File for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.Find(ctx, tt.id) if (err != nil) != tt.wantErr { t.Errorf("fileStore.Find() error = %v, wantErr %v", err, tt.wantErr) return } if tt.want == nil { assert.Len(got, 0) return } if !assert.Len(got, 1) { return } assert.Equal(tt.want, got[0]) }) } } func Test_FileStore_FindByPath(t *testing.T) { getPath := func(index int) string { folderIdx, found := fileFolders[index] if !found { folderIdx = folderIdxWithFiles } return getFilePath(folderIdx, getFileBaseName(index)) } tests := []struct { name string path string want models.File wantErr bool }{ { "valid", getPath(fileIdxZip), makeFileWithID(fileIdxZip), false, }, { "invalid", "invalid path", nil, false, }, } qb := db.File for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.FindByPath(ctx, tt.path, true) if (err != nil) != tt.wantErr { t.Errorf("FileStore.FindByPath() error = %v, wantErr %v", err, tt.wantErr) return } assert.Equal(tt.want, got) }) } } func TestFileStore_FindByFingerprint(t *testing.T) { tests := []struct { name string fp models.Fingerprint want []models.File wantErr bool }{ { "by MD5", models.Fingerprint{ Type: models.FingerprintTypeMD5, Fingerprint: getPrefixedStringValue("file", fileIdxZip, "md5"), }, []models.File{makeFileWithID(fileIdxZip)}, false, }, { "by OSHASH", models.Fingerprint{ Type: models.FingerprintTypeOshash, Fingerprint: getPrefixedStringValue("file", fileIdxZip, "oshash"), }, []models.File{makeFileWithID(fileIdxZip)}, false, }, { "non-existing", models.Fingerprint{ Type: models.FingerprintTypeOshash, Fingerprint: "foo", }, nil, false, }, } qb := db.File for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.FindByFingerprint(ctx, tt.fp) if (err != nil) != tt.wantErr { t.Errorf("FileStore.FindByFingerprint() error = %v, wantErr %v", err, tt.wantErr) return } assert.Equal(tt.want, got) }) } } func TestFileStore_IsPrimary(t *testing.T) { tests := []struct { name string fileID models.FileID want bool }{ { "scene file", sceneFileIDs[sceneIdx1WithPerformer], true, }, { "image file", imageFileIDs[imageIdx1WithGallery], true, }, { "gallery file", galleryFileIDs[galleryIdx1WithImage], true, }, { "orphan file", fileIDs[fileIdxZip], false, }, { "invalid file", invalidFileID, false, }, } qb := db.File for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.IsPrimary(ctx, tt.fileID) if err != nil { t.Errorf("FileStore.IsPrimary() error = %v", err) return } assert.Equal(tt.want, got) }) } } ================================================ FILE: pkg/sqlite/filter.go ================================================ package sqlite import ( "context" "errors" "fmt" "strings" "github.com/stashapp/stash/pkg/models" ) func illegalFilterCombination(type1, type2 string) error { return fmt.Errorf("cannot have %s and %s in the same filter", type1, type2) } func validateFilterCombination[T any](sf models.OperatorFilter[T]) error { const and = "AND" const or = "OR" const not = "NOT" if sf.And != nil { if sf.Or != nil { return illegalFilterCombination(and, or) } if sf.Not != nil { return illegalFilterCombination(and, not) } } if sf.Or != nil { if sf.Not != nil { return illegalFilterCombination(or, not) } } return nil } func handleSubFilter[T any](ctx context.Context, handler criterionHandler, f *filterBuilder, subFilter models.OperatorFilter[T]) { subQuery := &filterBuilder{} handler.handle(ctx, subQuery) if subFilter.And != nil { f.and(subQuery) } if subFilter.Or != nil { f.or(subQuery) } if subFilter.Not != nil { f.not(subQuery) } } type sqlClause struct { sql string args []interface{} } func (c sqlClause) not() sqlClause { return sqlClause{ sql: "NOT (" + c.sql + ")", args: c.args, } } func makeClause(sql string, args ...interface{}) sqlClause { return sqlClause{ sql: sql, args: args, } } func joinClauses(joinType string, clauses ...sqlClause) sqlClause { var ret []string var args []interface{} for _, clause := range clauses { ret = append(ret, "("+clause.sql+")") args = append(args, clause.args...) } return sqlClause{sql: strings.Join(ret, " "+joinType+" "), args: args} } func orClauses(clauses ...sqlClause) sqlClause { return joinClauses("OR", clauses...) } func andClauses(clauses ...sqlClause) sqlClause { return joinClauses("AND", clauses...) } type join struct { table string as string onClause string joinType string args []interface{} // if true, indicates this is required for sorting only sort bool } // equals returns true if the other join alias/table is equal to this one func (j join) equals(o join) bool { return j.alias() == o.alias() } // alias returns the as string, or the table if as is empty func (j join) alias() string { if j.as == "" { return j.table } return j.as } func (j join) toSQL() string { asStr := "" joinStr := j.joinType if j.as != "" && j.as != j.table { asStr = " AS " + j.as } if j.joinType == "" { joinStr = "LEFT" } return fmt.Sprintf("%s JOIN %s%s ON %s", joinStr, j.table, asStr, j.onClause) } type joins []join // addUnique only adds if not already present // returns true if added func (j *joins) addUnique(newJoin join) bool { found := false for i, jj := range *j { if jj.equals(newJoin) { found = true // if sort is false on the new join, but true on the existing, set the false if !newJoin.sort && jj.sort { (*j)[i].sort = false } break } } if !found { *j = append(*j, newJoin) } return !found } func (j *joins) add(newJoins ...join) { // only add if not already joined for _, newJoin := range newJoins { j.addUnique(newJoin) } } func (j *joins) toSQL(includeSortPagination bool) string { if len(*j) == 0 { return "" } var ret []string for _, jj := range *j { // skip sort-only joins if not including sort/pagination if !includeSortPagination && jj.sort { continue } ret = append(ret, jj.toSQL()) } return " " + strings.Join(ret, " ") } type filterBuilder struct { subFilter *filterBuilder subFilterOp string joins joins whereClauses []sqlClause havingClauses []sqlClause withClauses []sqlClause recursiveWith bool err error } func (f *filterBuilder) empty() bool { return f == nil || (len(f.whereClauses) == 0 && len(f.joins) == 0 && len(f.havingClauses) == 0 && f.subFilter == nil) } func filterBuilderFromHandler(ctx context.Context, handler criterionHandler) *filterBuilder { f := &filterBuilder{} handler.handle(ctx, f) return f } var errSubFilterAlreadySet = errors.New(`sub-filter already set`) // sub-filter operator values var ( andOp = "AND" orOp = "OR" notOp = "AND NOT" ) // and sets the sub-filter that will be ANDed with this one. // Sets the error state if sub-filter is already set. func (f *filterBuilder) and(a *filterBuilder) { if f.subFilter != nil { f.setError(errSubFilterAlreadySet) return } f.subFilter = a f.subFilterOp = andOp } // or sets the sub-filter that will be ORed with this one. // Sets the error state if a sub-filter is already set. func (f *filterBuilder) or(o *filterBuilder) { if f.subFilter != nil { f.setError(errSubFilterAlreadySet) return } f.subFilter = o f.subFilterOp = orOp } // not sets the sub-filter that will be AND NOTed with this one. // Sets the error state if a sub-filter is already set. func (f *filterBuilder) not(n *filterBuilder) { if f.subFilter != nil { f.setError(errSubFilterAlreadySet) return } f.subFilter = n f.subFilterOp = notOp } // addLeftJoin adds a left join to the filter. The join is expressed in SQL as: // LEFT JOIN [AS ] ON // The AS is omitted if as is empty. // This method does not add a join if it its alias/table name is already // present in another existing join. func (f *filterBuilder) addLeftJoin(table, as, onClause string, args ...interface{}) { newJoin := join{ table: table, as: as, onClause: onClause, joinType: "LEFT", args: args, } f.joins.add(newJoin) } // addInnerJoin adds an inner join to the filter. The join is expressed in SQL as: // INNER JOIN
[AS ] ON // The AS is omitted if as is empty. // This method does not add a join if it its alias/table name is already // present in another existing join. func (f *filterBuilder) addInnerJoin(table, as, onClause string, args ...interface{}) { newJoin := join{ table: table, as: as, onClause: onClause, joinType: "INNER", args: args, } f.joins.add(newJoin) } // addWhere adds a where clause and arguments to the filter. Where clauses // are ANDed together. Does not add anything if the provided string is empty. func (f *filterBuilder) addWhere(sql string, args ...interface{}) { if sql == "" { return } f.whereClauses = append(f.whereClauses, makeClause(sql, args...)) } // addHaving adds a where clause and arguments to the filter. Having clauses // are ANDed together. Does not add anything if the provided string is empty. func (f *filterBuilder) addHaving(sql string, args ...interface{}) { if sql == "" { return } f.havingClauses = append(f.havingClauses, makeClause(sql, args...)) } // addWith adds a with clause and arguments to the filter func (f *filterBuilder) addWith(sql string, args ...interface{}) { if sql == "" { return } f.withClauses = append(f.withClauses, makeClause(sql, args...)) } // addRecursiveWith adds a with clause and arguments to the filter, and sets it to recursive // //nolint:unused func (f *filterBuilder) addRecursiveWith(sql string, args ...interface{}) { if sql == "" { return } f.addWith(sql, args...) f.recursiveWith = true } func (f *filterBuilder) getSubFilterClause(clause, subFilterClause string) string { ret := clause if subFilterClause != "" { var op string if len(ret) > 0 { op = " " + f.subFilterOp + " " } else if f.subFilterOp == notOp { op = "NOT " } ret += op + "(" + subFilterClause + ")" } return ret } // generateWhereClauses generates the SQL where clause for this filter. // All where clauses within the filter are ANDed together. This is combined // with the sub-filter, which will use the applicable operator (AND/OR/AND NOT). func (f *filterBuilder) generateWhereClauses() (clause string, args []interface{}) { clause, args = f.andClauses(f.whereClauses) if f.subFilter != nil { c, a := f.subFilter.generateWhereClauses() if c != "" { clause = f.getSubFilterClause(clause, c) if len(a) > 0 { args = append(args, a...) } } } return } // generateHavingClauses generates the SQL having clause for this filter. // All having clauses within the filter are ANDed together. This is combined // with the sub-filter, which will use the applicable operator (AND/OR/AND NOT). func (f *filterBuilder) generateHavingClauses() (string, []interface{}) { clause, args := f.andClauses(f.havingClauses) if f.subFilter != nil { c, a := f.subFilter.generateHavingClauses() if c != "" { clause = f.getSubFilterClause(clause, c) if len(a) > 0 { args = append(args, a...) } } } return clause, args } func (f *filterBuilder) generateWithClauses() (string, []interface{}) { var clauses []string var args []interface{} for _, w := range f.withClauses { clauses = append(clauses, w.sql) args = append(args, w.args...) } if len(clauses) > 0 { return strings.Join(clauses, ", "), args } return "", nil } // getAllJoins returns all of the joins in this filter and any sub-filter(s). // Redundant joins will not be duplicated in the return value. func (f *filterBuilder) getAllJoins() joins { var ret joins ret.add(f.joins...) if f.subFilter != nil { subJoins := f.subFilter.getAllJoins() if len(subJoins) > 0 { ret.add(subJoins...) } } return ret } // getError returns the error state on this filter, or on any sub-filter(s) if // the error state is nil. func (f *filterBuilder) getError() error { if f.err != nil { return f.err } if f.subFilter != nil { return f.subFilter.getError() } return nil } // handleCriterion calls the handle function on the provided criterionHandler, // providing itself. func (f *filterBuilder) handleCriterion(ctx context.Context, handler criterionHandler) { handler.handle(ctx, f) } func (f *filterBuilder) setError(e error) { if f.err == nil { f.err = e } } func (f *filterBuilder) andClauses(input []sqlClause) (string, []interface{}) { var clauses []string var args []interface{} for _, w := range input { clauses = append(clauses, w.sql) args = append(args, w.args...) } if len(clauses) > 0 { c := "(" + strings.Join(clauses, ") AND (") + ")" if len(clauses) > 1 { c = "(" + c + ")" } return c, args } return "", nil } ================================================ FILE: pkg/sqlite/filter_hierarchical.go ================================================ package sqlite import ( "context" "fmt" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) // hierarchicalRelationshipHandler provides handlers for parent, children, parent count, and child count criteria. type hierarchicalRelationshipHandler struct { primaryTable string relationTable string aliasPrefix string parentIDCol string childIDCol string } func (h hierarchicalRelationshipHandler) validateModifier(m models.CriterionModifier) error { switch m { case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: // valid return nil default: return fmt.Errorf("invalid modifier %s", m) } } func (h hierarchicalRelationshipHandler) handleNullNotNull(f *filterBuilder, m models.CriterionModifier, isParents bool) { var notClause string if m == models.CriterionModifierNotNull { notClause = "NOT" } as := h.aliasPrefix + "_parents" col := h.childIDCol if !isParents { as = h.aliasPrefix + "_children" col = h.parentIDCol } // Based on: // f.addLeftJoin("tags_relations", "parent_relations", "tags.id = parent_relations.child_id") // f.addWhere(fmt.Sprintf("parent_relations.parent_id IS %s NULL", notClause)) f.addLeftJoin(h.relationTable, as, fmt.Sprintf("%s.id = %s.%s", h.primaryTable, as, col)) f.addWhere(fmt.Sprintf("%s.%s IS %s NULL", as, col, notClause)) } func (h hierarchicalRelationshipHandler) parentsAlias() string { return h.aliasPrefix + "_parents" } func (h hierarchicalRelationshipHandler) childrenAlias() string { return h.aliasPrefix + "_children" } func (h hierarchicalRelationshipHandler) valueQuery(value []string, depth int, alias string, isParents bool) string { var depthCondition string if depth != -1 { depthCondition = fmt.Sprintf("WHERE depth < %d", depth) } queryTempl := `{alias} AS ( SELECT {root_id_col} AS root_id, {item_id_col} AS item_id, 0 AS depth FROM {relation_table} WHERE {root_id_col} IN` + getInBinding(len(value)) + ` UNION SELECT root_id, {item_id_col}, depth + 1 FROM {relation_table} INNER JOIN {alias} ON item_id = {root_id_col} ` + depthCondition + ` )` var queryMap utils.StrFormatMap if isParents { queryMap = utils.StrFormatMap{ "root_id_col": h.parentIDCol, "item_id_col": h.childIDCol, } } else { queryMap = utils.StrFormatMap{ "root_id_col": h.childIDCol, "item_id_col": h.parentIDCol, } } queryMap["alias"] = alias queryMap["relation_table"] = h.relationTable return utils.StrFormat(queryTempl, queryMap) } func (h hierarchicalRelationshipHandler) handleValues(f *filterBuilder, c models.HierarchicalMultiCriterionInput, isParents bool, aliasSuffix string) { if len(c.Value) == 0 { return } var args []interface{} for _, val := range c.Value { args = append(args, val) } depthVal := 0 if c.Depth != nil { depthVal = *c.Depth } tableAlias := h.parentsAlias() if !isParents { tableAlias = h.childrenAlias() } tableAlias += aliasSuffix query := h.valueQuery(c.Value, depthVal, tableAlias, isParents) f.addRecursiveWith(query, args...) f.addLeftJoin(tableAlias, "", fmt.Sprintf("%s.item_id = %s.id", tableAlias, h.primaryTable)) addHierarchicalConditionClauses(f, c, tableAlias, "root_id") } func (h hierarchicalRelationshipHandler) handleValuesSimple(f *filterBuilder, value string, isParents bool) { joinCol := h.childIDCol valueCol := h.parentIDCol if !isParents { joinCol = h.parentIDCol valueCol = h.childIDCol } tableAlias := h.parentsAlias() if !isParents { tableAlias = h.childrenAlias() } f.addInnerJoin(h.relationTable, tableAlias, fmt.Sprintf("%s.%s = %s.id", tableAlias, joinCol, h.primaryTable)) f.addWhere(fmt.Sprintf("%s.%s = ?", tableAlias, valueCol), value) } func (h hierarchicalRelationshipHandler) hierarchicalCriterionHandler(criterion *models.HierarchicalMultiCriterionInput, isParents bool) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if criterion != nil { c := criterion.CombineExcludes() // validate the modifier if err := h.validateModifier(c.Modifier); err != nil { f.setError(err) return } if c.Modifier == models.CriterionModifierIsNull || c.Modifier == models.CriterionModifierNotNull { h.handleNullNotNull(f, c.Modifier, isParents) return } if len(c.Value) == 0 && len(c.Excludes) == 0 { return } depth := 0 if c.Depth != nil { depth = *c.Depth } // if we have a single include, no excludes, and no depth, we can use a simple join and where clause if (c.Modifier == models.CriterionModifierIncludes || c.Modifier == models.CriterionModifierIncludesAll) && len(c.Value) == 1 && len(c.Excludes) == 0 && depth == 0 { h.handleValuesSimple(f, c.Value[0], isParents) return } aliasSuffix := "" h.handleValues(f, c, isParents, aliasSuffix) if len(c.Excludes) > 0 { exCriterion := models.HierarchicalMultiCriterionInput{ Value: c.Excludes, Depth: c.Depth, Modifier: models.CriterionModifierExcludes, } aliasSuffix := "2" h.handleValues(f, exCriterion, isParents, aliasSuffix) } } } } func (h hierarchicalRelationshipHandler) ParentsCriterionHandler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { const isParents = true return h.hierarchicalCriterionHandler(criterion, isParents) } func (h hierarchicalRelationshipHandler) ChildrenCriterionHandler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { const isParents = false return h.hierarchicalCriterionHandler(criterion, isParents) } func (h hierarchicalRelationshipHandler) countCriterionHandler(c *models.IntCriterionInput, isParents bool) criterionHandlerFunc { tableAlias := h.parentsAlias() col := h.childIDCol otherCol := h.parentIDCol if !isParents { tableAlias = h.childrenAlias() col = h.parentIDCol otherCol = h.childIDCol } tableAlias += "_count" return func(ctx context.Context, f *filterBuilder) { if c != nil { f.addLeftJoin(h.relationTable, tableAlias, fmt.Sprintf("%s.%s = %s.id", tableAlias, col, h.primaryTable)) clause, args := getIntCriterionWhereClause(fmt.Sprintf("count(distinct %s.%s)", tableAlias, otherCol), *c) f.addHaving(clause, args...) } } } func (h hierarchicalRelationshipHandler) ParentCountCriterionHandler(parentCount *models.IntCriterionInput) criterionHandlerFunc { const isParents = true return h.countCriterionHandler(parentCount, isParents) } func (h hierarchicalRelationshipHandler) ChildCountCriterionHandler(childCount *models.IntCriterionInput) criterionHandlerFunc { const isParents = false return h.countCriterionHandler(childCount, isParents) } ================================================ FILE: pkg/sqlite/filter_internal_test.go ================================================ package sqlite import ( "context" "errors" "fmt" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stretchr/testify/assert" ) var testCtx = context.Background() func TestJoinsAddJoin(t *testing.T) { var joins joins // add a single join joins.add(join{table: "test"}) assert := assert.New(t) // ensure join was added assert.Len(joins, 1) // add the same join and another joins.add([]join{ { table: "test", }, { table: "foo", }, }...) // should have added a single join assert.Len(joins, 2) } func TestFilterBuilderAnd(t *testing.T) { assert := assert.New(t) f := &filterBuilder{} other := &filterBuilder{} newBuilder := &filterBuilder{} // and should set the subFilter f.and(other) assert.Equal(other, f.subFilter) assert.Nil(f.getError()) // and should set error if and is set f.and(newBuilder) assert.Equal(other, f.subFilter) assert.Equal(errSubFilterAlreadySet, f.getError()) // and should set error if or is set // and should not set subFilter if or is set f = &filterBuilder{} f.or(other) f.and(newBuilder) assert.Equal(other, f.subFilter) assert.Equal(errSubFilterAlreadySet, f.getError()) // and should set error if not is set // and should not set subFilter if not is set f = &filterBuilder{} f.not(other) f.and(newBuilder) assert.Equal(other, f.subFilter) assert.Equal(errSubFilterAlreadySet, f.getError()) } func TestFilterBuilderOr(t *testing.T) { assert := assert.New(t) f := &filterBuilder{} other := &filterBuilder{} newBuilder := &filterBuilder{} // or should set the orFilter f.or(other) assert.Equal(other, f.subFilter) assert.Nil(f.getError()) // or should set error if or is set f.or(newBuilder) assert.Equal(newBuilder, f.subFilter) assert.Equal(errSubFilterAlreadySet, f.getError()) // or should set error if and is set // or should not set subFilter if and is set f = &filterBuilder{} f.and(other) f.or(newBuilder) assert.Equal(other, f.subFilter) assert.Equal(errSubFilterAlreadySet, f.getError()) // or should set error if not is set // or should not set subFilter if not is set f = &filterBuilder{} f.not(other) f.or(newBuilder) assert.Equal(other, f.subFilter) assert.Equal(errSubFilterAlreadySet, f.getError()) } func TestFilterBuilderNot(t *testing.T) { assert := assert.New(t) f := &filterBuilder{} other := &filterBuilder{} newBuilder := &filterBuilder{} // not should set the subFilter f.not(other) // ensure and filter is set assert.Equal(other, f.subFilter) assert.Nil(f.getError()) // not should set error if not is set f.not(newBuilder) assert.Equal(newBuilder, f.subFilter) assert.Equal(errSubFilterAlreadySet, f.getError()) // not should set error if and is set // not should not set subFilter if and is set f = &filterBuilder{} f.and(other) f.not(newBuilder) assert.Equal(other, f.subFilter) assert.Equal(errSubFilterAlreadySet, f.getError()) // not should set error if or is set // not should not set subFilter if or is set f = &filterBuilder{} f.or(other) f.not(newBuilder) assert.Equal(other, f.subFilter) assert.Equal(errSubFilterAlreadySet, f.getError()) } func TestAddJoin(t *testing.T) { assert := assert.New(t) f := &filterBuilder{} const ( table1Name = "table1Name" table2Name = "table2Name" as1Name = "as1" as2Name = "as2" onClause = "onClause1" ) f.addLeftJoin(table1Name, as1Name, onClause) // ensure join is added assert.Len(f.joins, 1) assert.Equal(fmt.Sprintf("LEFT JOIN %s AS %s ON %s", table1Name, as1Name, onClause), f.joins[0].toSQL()) // ensure join with same as is not added f.addLeftJoin(table2Name, as1Name, onClause) assert.Len(f.joins, 1) // ensure same table with different alias can be added f.addLeftJoin(table1Name, as2Name, onClause) assert.Len(f.joins, 2) assert.Equal(fmt.Sprintf("LEFT JOIN %s AS %s ON %s", table1Name, as2Name, onClause), f.joins[1].toSQL()) // ensure table without alias can be added if tableName != existing alias/tableName f.addLeftJoin(table1Name, "", onClause) assert.Len(f.joins, 3) assert.Equal(fmt.Sprintf("LEFT JOIN %s ON %s", table1Name, onClause), f.joins[2].toSQL()) // ensure table with alias == table name of a join without alias is not added f.addLeftJoin(table2Name, table1Name, onClause) assert.Len(f.joins, 3) // ensure table without alias cannot be added if tableName == existing alias f.addLeftJoin(as2Name, "", onClause) assert.Len(f.joins, 3) // ensure AS is not used if same as table name f.addLeftJoin(table2Name, table2Name, onClause) assert.Len(f.joins, 4) assert.Equal(fmt.Sprintf("LEFT JOIN %s ON %s", table2Name, onClause), f.joins[3].toSQL()) } func TestAddWhere(t *testing.T) { assert := assert.New(t) f := &filterBuilder{} // ensure empty sql adds nothing f.addWhere("") assert.Len(f.whereClauses, 0) const whereClause = "a = b" var args = []interface{}{"1", "2"} // ensure addWhere sets where clause and args f.addWhere(whereClause, args...) assert.Len(f.whereClauses, 1) assert.Equal(whereClause, f.whereClauses[0].sql) assert.Equal(args, f.whereClauses[0].args) // ensure addWhere without args sets where clause f.addWhere(whereClause) assert.Len(f.whereClauses, 2) assert.Equal(whereClause, f.whereClauses[1].sql) assert.Len(f.whereClauses[1].args, 0) } func TestAddHaving(t *testing.T) { assert := assert.New(t) f := &filterBuilder{} // ensure empty sql adds nothing f.addHaving("") assert.Len(f.havingClauses, 0) const havingClause = "a = b" var args = []interface{}{"1", "2"} // ensure addWhere sets where clause and args f.addHaving(havingClause, args...) assert.Len(f.havingClauses, 1) assert.Equal(havingClause, f.havingClauses[0].sql) assert.Equal(args, f.havingClauses[0].args) // ensure addWhere without args sets where clause f.addHaving(havingClause) assert.Len(f.havingClauses, 2) assert.Equal(havingClause, f.havingClauses[1].sql) assert.Len(f.havingClauses[1].args, 0) } func TestGenerateWhereClauses(t *testing.T) { assert := assert.New(t) f := &filterBuilder{} const clause1 = "a = 1" const clause2 = "b = 2" const clause3 = "c = 3" const arg1 = "1" const arg2 = "2" const arg3 = "3" // ensure single where clause is generated correctly f.addWhere(clause1) r, rArgs := f.generateWhereClauses() assert.Equal(fmt.Sprintf("(%s)", clause1), r) assert.Len(rArgs, 0) // ensure multiple where clauses are surrounded with parenthesis and // ANDed together f.addWhere(clause2, arg1, arg2) r, rArgs = f.generateWhereClauses() assert.Equal(fmt.Sprintf("((%s) AND (%s))", clause1, clause2), r) assert.Len(rArgs, 2) // ensure empty subfilter is not added to generated where clause sf := &filterBuilder{} f.and(sf) r, rArgs = f.generateWhereClauses() assert.Equal(fmt.Sprintf("((%s) AND (%s))", clause1, clause2), r) assert.Len(rArgs, 2) // ensure sub-filter is generated correctly sf.addWhere(clause3, arg3) r, rArgs = f.generateWhereClauses() assert.Equal(fmt.Sprintf("((%s) AND (%s)) AND ((%s))", clause1, clause2, clause3), r) assert.Len(rArgs, 3) // ensure OR sub-filter is generated correctly f = &filterBuilder{} f.addWhere(clause1) f.addWhere(clause2, arg1, arg2) f.or(sf) r, rArgs = f.generateWhereClauses() assert.Equal(fmt.Sprintf("((%s) AND (%s)) OR ((%s))", clause1, clause2, clause3), r) assert.Len(rArgs, 3) // ensure NOT sub-filter is generated correctly f = &filterBuilder{} f.addWhere(clause1) f.addWhere(clause2, arg1, arg2) f.not(sf) r, rArgs = f.generateWhereClauses() assert.Equal(fmt.Sprintf("((%s) AND (%s)) AND NOT ((%s))", clause1, clause2, clause3), r) assert.Len(rArgs, 3) // ensure empty filter with ANDed sub-filter does not include AND f = &filterBuilder{} f.and(sf) r, rArgs = f.generateWhereClauses() assert.Equal(fmt.Sprintf("((%s))", clause3), r) assert.Len(rArgs, 1) // ensure empty filter with ORed sub-filter does not include OR f = &filterBuilder{} f.or(sf) r, rArgs = f.generateWhereClauses() assert.Equal(fmt.Sprintf("((%s))", clause3), r) assert.Len(rArgs, 1) // ensure empty filter with NOTed sub-filter does not include AND f = &filterBuilder{} f.not(sf) r, rArgs = f.generateWhereClauses() assert.Equal(fmt.Sprintf("NOT ((%s))", clause3), r) assert.Len(rArgs, 1) // (clause1) AND ((clause2) OR (clause3)) f = &filterBuilder{} f.addWhere(clause1) sf2 := &filterBuilder{} sf2.addWhere(clause2, arg1, arg2) f.and(sf2) sf2.or(sf) r, rArgs = f.generateWhereClauses() assert.Equal(fmt.Sprintf("(%s) AND ((%s) OR ((%s)))", clause1, clause2, clause3), r) assert.Len(rArgs, 3) } func TestGenerateHavingClauses(t *testing.T) { assert := assert.New(t) f := &filterBuilder{} const clause1 = "a = 1" const clause2 = "b = 2" const clause3 = "c = 3" const arg1 = "1" const arg2 = "2" const arg3 = "3" // ensure single Having clause is generated correctly f.addHaving(clause1) r, rArgs := f.generateHavingClauses() assert.Equal(fmt.Sprintf("(%s)", clause1), r) assert.Len(rArgs, 0) // ensure multiple Having clauses are surrounded with parenthesis and // ANDed together f.addHaving(clause2, arg1, arg2) r, rArgs = f.generateHavingClauses() assert.Equal("(("+clause1+") AND ("+clause2+"))", r) assert.Len(rArgs, 2) // ensure empty subfilter is not added to generated Having clause sf := &filterBuilder{} f.and(sf) r, rArgs = f.generateHavingClauses() assert.Equal("(("+clause1+") AND ("+clause2+"))", r) assert.Len(rArgs, 2) // ensure sub-filter is generated correctly sf.addHaving(clause3, arg3) r, rArgs = f.generateHavingClauses() assert.Equal("(("+clause1+") AND ("+clause2+")) AND (("+clause3+"))", r) assert.Len(rArgs, 3) // ensure OR sub-filter is generated correctly f = &filterBuilder{} f.addHaving(clause1) f.addHaving(clause2, arg1, arg2) f.or(sf) r, rArgs = f.generateHavingClauses() assert.Equal("(("+clause1+") AND ("+clause2+")) OR (("+clause3+"))", r) assert.Len(rArgs, 3) // ensure NOT sub-filter is generated correctly f = &filterBuilder{} f.addHaving(clause1) f.addHaving(clause2, arg1, arg2) f.not(sf) r, rArgs = f.generateHavingClauses() assert.Equal("(("+clause1+") AND ("+clause2+")) AND NOT (("+clause3+"))", r) assert.Len(rArgs, 3) } func TestGetAllJoins(t *testing.T) { assert := assert.New(t) f := &filterBuilder{} const ( table1Name = "table1Name" table2Name = "table2Name" as1Name = "as1" as2Name = "as2" onClause = "onClause1" ) f.addLeftJoin(table1Name, as1Name, onClause) // ensure join is returned joins := f.getAllJoins() assert.Len(joins, 1) assert.Equal(fmt.Sprintf("LEFT JOIN %s AS %s ON %s", table1Name, as1Name, onClause), joins[0].toSQL()) // ensure joins in sub-filter are returned subFilter := &filterBuilder{} f.and(subFilter) subFilter.addLeftJoin(table2Name, as2Name, onClause) joins = f.getAllJoins() assert.Len(joins, 2) assert.Equal(fmt.Sprintf("LEFT JOIN %s AS %s ON %s", table2Name, as2Name, onClause), joins[1].toSQL()) // ensure redundant joins are not returned subFilter.addLeftJoin(as1Name, "", onClause) joins = f.getAllJoins() assert.Len(joins, 2) } func TestGetError(t *testing.T) { assert := assert.New(t) f := &filterBuilder{} subFilter := &filterBuilder{} f.and(subFilter) expectedErr := errors.New("test error") expectedErr2 := errors.New("test error2") f.err = expectedErr subFilter.err = expectedErr2 // ensure getError returns the top-level error state assert.Equal(expectedErr, f.getError()) // ensure getError returns sub-filter error state if top-level error // is nil f.err = nil assert.Equal(expectedErr2, f.getError()) // ensure getError returns nil if all error states are nil subFilter.err = nil assert.Nil(f.getError()) } func TestStringCriterionHandlerIncludes(t *testing.T) { assert := assert.New(t) const column = "column" const value1 = "two words" const quotedValue = `"two words"` f := &filterBuilder{} f.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{ Modifier: models.CriterionModifierIncludes, Value: value1, }, column)) assert.Len(f.whereClauses, 1) assert.Equal(fmt.Sprintf("(%[1]s LIKE ? OR %[1]s LIKE ?)", column), f.whereClauses[0].sql) assert.Len(f.whereClauses[0].args, 2) assert.Equal("%two%", f.whereClauses[0].args[0]) assert.Equal("%words%", f.whereClauses[0].args[1]) f = &filterBuilder{} f.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{ Modifier: models.CriterionModifierIncludes, Value: quotedValue, }, column)) assert.Len(f.whereClauses, 1) assert.Equal(fmt.Sprintf("(%[1]s LIKE ?)", column), f.whereClauses[0].sql) assert.Len(f.whereClauses[0].args, 1) assert.Equal("%two words%", f.whereClauses[0].args[0]) } func TestStringCriterionHandlerExcludes(t *testing.T) { assert := assert.New(t) const column = "column" const value1 = "two words" const quotedValue = `"two words"` f := &filterBuilder{} f.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{ Modifier: models.CriterionModifierExcludes, Value: value1, }, column)) assert.Len(f.whereClauses, 1) assert.Equal(fmt.Sprintf("(%[1]s NOT LIKE ? AND %[1]s NOT LIKE ?)", column), f.whereClauses[0].sql) assert.Len(f.whereClauses[0].args, 2) assert.Equal("%two%", f.whereClauses[0].args[0]) assert.Equal("%words%", f.whereClauses[0].args[1]) f = &filterBuilder{} f.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{ Modifier: models.CriterionModifierExcludes, Value: quotedValue, }, column)) assert.Len(f.whereClauses, 1) assert.Equal(fmt.Sprintf("(%[1]s NOT LIKE ?)", column), f.whereClauses[0].sql) assert.Len(f.whereClauses[0].args, 1) assert.Equal("%two words%", f.whereClauses[0].args[0]) } func TestStringCriterionHandlerEquals(t *testing.T) { assert := assert.New(t) const column = "column" const value1 = "two words" f := &filterBuilder{} f.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{ Modifier: models.CriterionModifierEquals, Value: value1, }, column)) assert.Len(f.whereClauses, 1) assert.Equal(fmt.Sprintf("%[1]s LIKE ?", column), f.whereClauses[0].sql) assert.Len(f.whereClauses[0].args, 1) assert.Equal(value1, f.whereClauses[0].args[0]) } func TestStringCriterionHandlerNotEquals(t *testing.T) { assert := assert.New(t) const column = "column" const value1 = "two words" f := &filterBuilder{} f.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{ Modifier: models.CriterionModifierNotEquals, Value: value1, }, column)) assert.Len(f.whereClauses, 1) assert.Equal(fmt.Sprintf("%[1]s NOT LIKE ?", column), f.whereClauses[0].sql) assert.Len(f.whereClauses[0].args, 1) assert.Equal(value1, f.whereClauses[0].args[0]) } func TestStringCriterionHandlerMatchesRegex(t *testing.T) { assert := assert.New(t) const column = "column" const validValue = "two words" const invalidValue = "*two words" f := &filterBuilder{} f.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{ Modifier: models.CriterionModifierMatchesRegex, Value: validValue, }, column)) assert.Len(f.whereClauses, 1) assert.Equal(fmt.Sprintf("(%s IS NOT NULL AND %[1]s regexp ?)", column), f.whereClauses[0].sql) assert.Len(f.whereClauses[0].args, 1) assert.Equal(validValue, f.whereClauses[0].args[0]) // ensure invalid regex sets error state f = &filterBuilder{} f.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{ Modifier: models.CriterionModifierMatchesRegex, Value: invalidValue, }, column)) assert.NotNil(f.getError()) } func TestStringCriterionHandlerNotMatchesRegex(t *testing.T) { assert := assert.New(t) const column = "column" const validValue = "two words" const invalidValue = "*two words" f := &filterBuilder{} f.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{ Modifier: models.CriterionModifierNotMatchesRegex, Value: validValue, }, column)) assert.Len(f.whereClauses, 1) assert.Equal(fmt.Sprintf("(%s IS NULL OR %[1]s NOT regexp ?)", column), f.whereClauses[0].sql) assert.Len(f.whereClauses[0].args, 1) assert.Equal(validValue, f.whereClauses[0].args[0]) // ensure invalid regex sets error state f = &filterBuilder{} f.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{ Modifier: models.CriterionModifierNotMatchesRegex, Value: invalidValue, }, column)) assert.NotNil(f.getError()) } func TestStringCriterionHandlerIsNull(t *testing.T) { assert := assert.New(t) const column = "column" f := &filterBuilder{} f.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{ Modifier: models.CriterionModifierIsNull, }, column)) assert.Len(f.whereClauses, 1) assert.Equal(fmt.Sprintf("(%[1]s IS NULL OR TRIM(%[1]s) = '')", column), f.whereClauses[0].sql) assert.Len(f.whereClauses[0].args, 0) } func TestStringCriterionHandlerNotNull(t *testing.T) { assert := assert.New(t) const column = "column" f := &filterBuilder{} f.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{ Modifier: models.CriterionModifierNotNull, }, column)) assert.Len(f.whereClauses, 1) assert.Equal(fmt.Sprintf("(%[1]s IS NOT NULL AND TRIM(%[1]s) != '')", column), f.whereClauses[0].sql) assert.Len(f.whereClauses[0].args, 0) } ================================================ FILE: pkg/sqlite/fingerprint.go ================================================ package sqlite import ( "context" "fmt" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/stashapp/stash/pkg/models" "gopkg.in/guregu/null.v4" ) const ( fingerprintTable = "files_fingerprints" ) type fingerprintQueryRow struct { Type null.String `db:"fingerprint_type"` Fingerprint interface{} `db:"fingerprint"` } func (r fingerprintQueryRow) valid() bool { return r.Type.Valid } func (r *fingerprintQueryRow) resolve() models.Fingerprint { return models.Fingerprint{ Type: r.Type.String, Fingerprint: r.Fingerprint, } } type fingerprintQueryBuilder struct { repository tableMgr *table } var FingerprintReaderWriter = &fingerprintQueryBuilder{ repository: repository{ tableName: fingerprintTable, idColumn: fileIDColumn, }, tableMgr: fingerprintTableMgr, } func (qb *fingerprintQueryBuilder) insert(ctx context.Context, fileID models.FileID, f models.Fingerprint) error { table := qb.table() q := dialect.Insert(table).Cols(fileIDColumn, "type", "fingerprint").Vals( goqu.Vals{fileID, f.Type, f.Fingerprint}, ) _, err := exec(ctx, q) if err != nil { return fmt.Errorf("inserting into %s: %w", table.GetTable(), err) } return nil } func (qb *fingerprintQueryBuilder) insertJoins(ctx context.Context, fileID models.FileID, f []models.Fingerprint) error { for _, ff := range f { if err := qb.insert(ctx, fileID, ff); err != nil { return err } } return nil } func (qb *fingerprintQueryBuilder) upsertJoins(ctx context.Context, fileID models.FileID, f []models.Fingerprint) error { types := make([]string, len(f)) for i, ff := range f { types[i] = ff.Type } if err := qb.destroyJoins(ctx, fileID, types); err != nil { return err } for _, ff := range f { if err := qb.insert(ctx, fileID, ff); err != nil { return err } } return nil } func (qb *fingerprintQueryBuilder) replaceJoins(ctx context.Context, fileID models.FileID, f []models.Fingerprint) error { if err := qb.destroy(ctx, []int{int(fileID)}); err != nil { return err } return qb.insertJoins(ctx, fileID, f) } func (qb *fingerprintQueryBuilder) destroyJoins(ctx context.Context, fileID models.FileID, types []string) error { table := qb.table() q := dialect.Delete(table).Where( table.Col(fileIDColumn).Eq(fileID), table.Col("type").In(types), ) _, err := exec(ctx, q) if err != nil { return fmt.Errorf("deleting from %s: %w", table.GetTable(), err) } return nil } func (qb *fingerprintQueryBuilder) table() exp.IdentifierExpression { return qb.tableMgr.table } ================================================ FILE: pkg/sqlite/folder.go ================================================ package sqlite import ( "context" "database/sql" "errors" "fmt" "path/filepath" "slices" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/models" "gopkg.in/guregu/null.v4" ) const folderTable = "folders" const folderIDColumn = "folder_id" type folderRow struct { ID models.FolderID `db:"id" goqu:"skipinsert"` Basename string `db:"basename"` Path string `db:"path"` ZipFileID null.Int `db:"zip_file_id"` ParentFolderID null.Int `db:"parent_folder_id"` ModTime Timestamp `db:"mod_time"` CreatedAt Timestamp `db:"created_at"` UpdatedAt Timestamp `db:"updated_at"` } func (r *folderRow) fromFolder(o models.Folder) { r.ID = o.ID // derive basename from path r.Basename = filepath.Base(o.Path) r.Path = o.Path r.ZipFileID = nullIntFromFileIDPtr(o.ZipFileID) r.ParentFolderID = nullIntFromFolderIDPtr(o.ParentFolderID) r.ModTime = Timestamp{Timestamp: o.ModTime} r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} } type folderQueryRow struct { folderRow ZipBasename null.String `db:"zip_basename"` ZipFolderPath null.String `db:"zip_folder_path"` ZipSize null.Int `db:"zip_size"` } func (r *folderQueryRow) resolve() *models.Folder { ret := &models.Folder{ ID: r.ID, DirEntry: models.DirEntry{ ZipFileID: nullIntFileIDPtr(r.ZipFileID), ModTime: r.ModTime.Timestamp, }, Path: string(r.Path), ParentFolderID: nullIntFolderIDPtr(r.ParentFolderID), CreatedAt: r.CreatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp, } if ret.ZipFileID != nil && r.ZipFolderPath.Valid && r.ZipBasename.Valid { ret.ZipFile = &models.BaseFile{ ID: *ret.ZipFileID, Path: filepath.Join(r.ZipFolderPath.String, r.ZipBasename.String), Basename: r.ZipBasename.String, Size: r.ZipSize.Int64, } } return ret } type folderQueryRows []folderQueryRow func (r folderQueryRows) resolve() []*models.Folder { var ret []*models.Folder for _, row := range r { f := row.resolve() ret = append(ret, f) } return ret } type folderRepositoryType struct { repository galleries repository } var ( folderRepository = folderRepositoryType{ repository: repository{ tableName: folderTable, idColumn: idColumn, }, galleries: repository{ tableName: galleryTable, idColumn: folderIDColumn, }, } ) type FolderStore struct { repository tableMgr *table } func NewFolderStore() *FolderStore { return &FolderStore{ repository: repository{ tableName: folderTable, idColumn: idColumn, }, tableMgr: folderTableMgr, } } func (qb *FolderStore) Create(ctx context.Context, f *models.Folder) error { var r folderRow r.fromFolder(*f) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { return err } // only assign id once we are successful f.ID = models.FolderID(id) return nil } func (qb *FolderStore) Update(ctx context.Context, updatedObject *models.Folder) error { var r folderRow r.fromFolder(*updatedObject) if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { return err } return nil } func (qb *FolderStore) Destroy(ctx context.Context, id models.FolderID) error { return qb.tableMgr.destroyExisting(ctx, []int{int(id)}) } func (qb *FolderStore) table() exp.IdentifierExpression { return qb.tableMgr.table } func (qb *FolderStore) selectDataset() *goqu.SelectDataset { table := qb.table() fileTable := fileTableMgr.table zipFileTable := fileTable.As("zip_files") zipFolderTable := table.As("zip_files_folders") cols := []interface{}{ table.Col("id"), table.Col("path"), table.Col("zip_file_id"), table.Col("parent_folder_id"), table.Col("mod_time"), table.Col("created_at"), table.Col("updated_at"), zipFileTable.Col("basename").As("zip_basename"), zipFolderTable.Col("path").As("zip_folder_path"), // size is needed to open containing zip files zipFileTable.Col("size").As("zip_size"), } ret := dialect.From(table).Select(cols...) return ret.LeftJoin( zipFileTable, goqu.On(table.Col("zip_file_id").Eq(zipFileTable.Col("id"))), ).LeftJoin( zipFolderTable, goqu.On(zipFileTable.Col("parent_folder_id").Eq(zipFolderTable.Col(idColumn))), ) } func (qb *FolderStore) countDataset() *goqu.SelectDataset { table := qb.table() fileTable := fileTableMgr.table zipFileTable := fileTable.As("zip_files") zipFolderTable := table.As("zip_files_folders") ret := dialect.From(table).Select(goqu.COUNT(goqu.DISTINCT(table.Col("id")))) return ret.LeftJoin( zipFileTable, goqu.On(table.Col("zip_file_id").Eq(zipFileTable.Col("id"))), ).LeftJoin( zipFolderTable, goqu.On(zipFileTable.Col("parent_folder_id").Eq(zipFolderTable.Col(idColumn))), ) } func (qb *FolderStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Folder, error) { ret, err := qb.getMany(ctx, q) if err != nil { return nil, err } if len(ret) == 0 { return nil, sql.ErrNoRows } return ret[0], nil } func (qb *FolderStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Folder, error) { const single = false var rows folderQueryRows if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { var f folderQueryRow if err := r.StructScan(&f); err != nil { return err } rows = append(rows, f) return nil }); err != nil { return nil, err } return rows.resolve(), nil } func (qb *FolderStore) Find(ctx context.Context, id models.FolderID) (*models.Folder, error) { q := qb.selectDataset().Where(qb.tableMgr.byID(id)) ret, err := qb.get(ctx, q) if err != nil { return nil, fmt.Errorf("getting folder by id %d: %w", id, err) } return ret, nil } // FindByIDs finds multiple folders by their IDs. // No check is made to see if the folders exist, and the order of the returned folders // is not guaranteed to be the same as the order of the input IDs. func (qb *FolderStore) FindByIDs(ctx context.Context, ids []models.FolderID) ([]*models.Folder, error) { folders := make([]*models.Folder, 0, len(ids)) table := qb.table() if err := batchExec(ids, defaultBatchSize, func(batch []models.FolderID) error { q := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(batch)) unsorted, err := qb.getMany(ctx, q) if err != nil { return err } folders = append(folders, unsorted...) return nil }); err != nil { return nil, err } return folders, nil } func (qb *FolderStore) FindMany(ctx context.Context, ids []models.FolderID) ([]*models.Folder, error) { folders := make([]*models.Folder, len(ids)) unsorted, err := qb.FindByIDs(ctx, ids) if err != nil { return nil, err } for _, s := range unsorted { i := slices.Index(ids, s.ID) folders[i] = s } for i := range folders { if folders[i] == nil { return nil, fmt.Errorf("folder with id %d not found", ids[i]) } } return folders, nil } func (qb *FolderStore) FindByPath(ctx context.Context, p string, caseSensitive bool) (*models.Folder, error) { // use like for case insensitive search var criterion exp.BooleanExpression if caseSensitive { criterion = qb.table().Col("path").Eq(p) } else { criterion = qb.table().Col("path").ILike(p) } q := qb.selectDataset().Prepared(true).Where(criterion) ret, err := qb.get(ctx, q) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("getting folder by path %s: %w", p, err) } return ret, nil } func (qb *FolderStore) FindByParentFolderID(ctx context.Context, parentFolderID models.FolderID) ([]*models.Folder, error) { q := qb.selectDataset().Where(qb.table().Col("parent_folder_id").Eq(int(parentFolderID))) ret, err := qb.getMany(ctx, q) if err != nil { return nil, fmt.Errorf("getting folders by parent folder id %d: %w", parentFolderID, err) } return ret, nil } func (qb *FolderStore) GetManyParentFolderIDs(ctx context.Context, folderIDs []models.FolderID) ([][]models.FolderID, error) { table := qb.table() // SQL recursive query to get all parent folder IDs for each folder ID /* WITH RECURSIVE parent_folders AS ( SELECT id, parent_folder_id FROM folders WHERE id IN (folderIDs) UNION ALL SELECT f.id, f.parent_folder_id FROM folders f INNER JOIN parent_folders pf ON f.id = pf.parent_folder_id ) SELECT id, parent_folder_id FROM parent_folders; */ const parentFolders = "parent_folders" const parentFolderID = "parent_folder_id" const parentID = "parent_id" const foldersAlias = "f" const parentFoldersAlias = "pf" foldersAliasedI := table.As(foldersAlias) parentFoldersI := goqu.T(parentFolders).As(parentFoldersAlias) q := dialect.From(parentFolders).Prepared(true). WithRecursive(parentFolders, dialect.From(table).Select(table.Col(idColumn), table.Col(parentFolderID).As(parentID)). Where(table.Col(idColumn).In(folderIDs)). Union( dialect.From(foldersAliasedI).InnerJoin( parentFoldersI, goqu.On(foldersAliasedI.Col(idColumn).Eq(parentFoldersI.Col(parentID))), ).Select(foldersAliasedI.Col(idColumn), foldersAliasedI.Col(parentFolderID).As(parentID)), ), ).Select(idColumn, parentID) type resultRow struct { FolderID models.FolderID `db:"id"` ParentFolderID null.Int `db:"parent_id"` } folderMap := make(map[models.FolderID]models.FolderID) if err := queryFunc(ctx, q, false, func(r *sqlx.Rows) error { var row resultRow if err := r.StructScan(&row); err != nil { return err } if row.ParentFolderID.Valid { folderMap[row.FolderID] = models.FolderID(row.ParentFolderID.Int64) } else { folderMap[row.FolderID] = 0 } return nil }); err != nil { return nil, err } ret := make([][]models.FolderID, len(folderIDs)) for i, folderID := range folderIDs { var parents []models.FolderID currentID := folderID for { parentID, exists := folderMap[currentID] if !exists || parentID == 0 { break } parents = append(parents, parentID) currentID = parentID } ret[i] = parents } return ret, nil } func (qb *FolderStore) allInPaths(q *goqu.SelectDataset, p []string) *goqu.SelectDataset { table := qb.table() var conds []exp.Expression for _, pp := range p { ppWildcard := pp + string(filepath.Separator) + "%" conds = append(conds, table.Col("path").Eq(pp), table.Col("path").Like(ppWildcard)) } return q.Where( goqu.Or(conds...), ) } // FindAllInPaths returns the all folders that are or are within any of the given paths. // Returns all if limit is < 0. // Returns all folders if p is empty. func (qb *FolderStore) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]*models.Folder, error) { q := qb.selectDataset().Prepared(true) q = qb.allInPaths(q, p) if !includeZipContents { q = q.Where(qb.table().Col("zip_file_id").IsNull()) } if limit > -1 { q = q.Limit(uint(limit)) } q = q.Offset(uint(offset)) ret, err := qb.getMany(ctx, q) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("getting folders in path %s: %w", p, err) } return ret, nil } // CountAllInPaths returns a count of all folders that are within any of the given paths. // Returns count of all folders if p is empty. func (qb *FolderStore) CountAllInPaths(ctx context.Context, p []string) (int, error) { q := qb.countDataset().Prepared(true) q = qb.allInPaths(q, p) return count(ctx, q) } // func (qb *FolderStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]*file.Folder, error) { // table := qb.table() // q := qb.selectDataset().Prepared(true).Where( // table.Col(idColumn).Eq( // sq, // ), // ) // return qb.getMany(ctx, q) // } func (qb *FolderStore) FindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]*models.Folder, error) { table := qb.table() q := qb.selectDataset().Prepared(true).Where( table.Col("zip_file_id").Eq(zipFileID), ) return qb.getMany(ctx, q) } func (qb *FolderStore) validateFilter(fileFilter *models.FolderFilterType) error { const and = "AND" const or = "OR" const not = "NOT" if fileFilter.And != nil { if fileFilter.Or != nil { return illegalFilterCombination(and, or) } if fileFilter.Not != nil { return illegalFilterCombination(and, not) } return qb.validateFilter(fileFilter.And) } if fileFilter.Or != nil { if fileFilter.Not != nil { return illegalFilterCombination(or, not) } return qb.validateFilter(fileFilter.Or) } if fileFilter.Not != nil { return qb.validateFilter(fileFilter.Not) } return nil } func (qb *FolderStore) makeFilter(ctx context.Context, folderFilter *models.FolderFilterType) *filterBuilder { query := &filterBuilder{} if folderFilter.And != nil { query.and(qb.makeFilter(ctx, folderFilter.And)) } if folderFilter.Or != nil { query.or(qb.makeFilter(ctx, folderFilter.Or)) } if folderFilter.Not != nil { query.not(qb.makeFilter(ctx, folderFilter.Not)) } filter := filterBuilderFromHandler(ctx, &folderFilterHandler{ folderFilter: folderFilter, }) return filter } func (qb *FolderStore) Query(ctx context.Context, options models.FolderQueryOptions) (*models.FolderQueryResult, error) { folderFilter := options.FolderFilter findFilter := options.FindFilter if folderFilter == nil { folderFilter = &models.FolderFilterType{} } if findFilter == nil { findFilter = &models.FindFilterType{} } query := qb.newQuery() distinctIDs(&query, folderTable) if q := findFilter.Q; q != nil && *q != "" { searchColumns := []string{"folders.path"} query.parseQueryString(searchColumns, *q) } if err := qb.validateFilter(folderFilter); err != nil { return nil, err } filter := qb.makeFilter(ctx, folderFilter) if err := query.addFilter(filter); err != nil { return nil, err } if err := qb.setQuerySort(&query, findFilter); err != nil { return nil, err } query.sortAndPagination += getPagination(findFilter) result, err := qb.queryGroupedFields(ctx, options, query) if err != nil { return nil, fmt.Errorf("error querying aggregate fields: %w", err) } idsResult, err := query.findIDs(ctx) if err != nil { return nil, fmt.Errorf("error finding IDs: %w", err) } result.IDs = make([]models.FolderID, len(idsResult)) for i, id := range idsResult { result.IDs[i] = models.FolderID(id) } return result, nil } func (qb *FolderStore) queryGroupedFields(ctx context.Context, options models.FolderQueryOptions, query queryBuilder) (*models.FolderQueryResult, error) { if !options.Count { // nothing to do - return empty result return models.NewFolderQueryResult(qb), nil } aggregateQuery := qb.newQuery() if options.Count { aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total") } const includeSortPagination = false aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination)) out := struct { Total int Duration float64 Megapixels float64 Size int64 }{} if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil { return nil, err } ret := models.NewFolderQueryResult(qb) ret.Count = out.Total return ret, nil } var folderSortOptions = sortOptions{ "created_at", "id", "path", "basename", "random", "updated_at", } func (qb *FolderStore) setQuerySort(query *queryBuilder, findFilter *models.FindFilterType) error { if findFilter == nil || findFilter.Sort == nil || *findFilter.Sort == "" { return nil } sort := findFilter.GetSort("path") // CVE-2024-32231 - ensure sort is in the list of allowed sorts if err := folderSortOptions.validateSort(sort); err != nil { return err } direction := findFilter.GetDirection() query.sortAndPagination += getSort(sort, direction, "folders") return nil } ================================================ FILE: pkg/sqlite/folder_filter.go ================================================ package sqlite import ( "context" "fmt" "github.com/stashapp/stash/pkg/models" ) type folderFilterHandler struct { folderFilter *models.FolderFilterType table sqlTable isRelated bool } func (qb *folderFilterHandler) validate() error { folderFilter := qb.folderFilter if folderFilter == nil { return nil } if err := validateFilterCombination(folderFilter.OperatorFilter); err != nil { return err } if qb.isRelated && (folderFilter.GalleriesFilter != nil) { return fmt.Errorf("cannot use related filters inside a related filter") } if subFilter := folderFilter.SubFilter(); subFilter != nil { sqb := &folderFilterHandler{folderFilter: subFilter, isRelated: qb.isRelated} if err := sqb.validate(); err != nil { return err } } return nil } func (qb *folderFilterHandler) handle(ctx context.Context, f *filterBuilder) { folderFilter := qb.folderFilter if folderFilter == nil { return } if err := qb.validate(); err != nil { f.setError(err) return } sf := folderFilter.SubFilter() if sf != nil { sub := &folderFilterHandler{folderFilter: sf, table: qb.table} handleSubFilter(ctx, sub, f, folderFilter.OperatorFilter) } f.handleCriterion(ctx, qb.criterionHandler()) } func (qb *folderFilterHandler) criterionHandler() criterionHandler { if qb.table == "" { qb.table = folderTable } folderFilter := qb.folderFilter return compoundHandler{ stringCriterionHandler(folderFilter.Path, qb.table.Col("path")), stringCriterionHandler(folderFilter.Basename, qb.table.Col("basename")), ×tampCriterionHandler{folderFilter.ModTime, qb.table.Col("mod_time"), nil}, qb.parentFolderCriterionHandler(folderFilter.ParentFolder), qb.zipFileCriterionHandler(folderFilter.ZipFile), qb.galleryCountCriterionHandler(folderFilter.GalleryCount), ×tampCriterionHandler{folderFilter.CreatedAt, qb.table.Col("created_at"), nil}, ×tampCriterionHandler{folderFilter.UpdatedAt, qb.table.Col("updated_at"), nil}, &relatedFilterHandler{ relatedIDCol: qb.table.Col("id"), relatedRepo: galleryRepository.repository, relatedHandler: &galleryFilterHandler{folderFilter.GalleriesFilter}, joinFn: func(f *filterBuilder) { folderRepository.galleries.innerJoin(f, "", qb.table.Col("id")) }, }, } } func (qb *folderFilterHandler) zipFileCriterionHandler(criterion *models.MultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if criterion != nil { if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { var notClause string if criterion.Modifier == models.CriterionModifierNotNull { notClause = "NOT" } f.addWhere(fmt.Sprintf("%s.zip_file_id IS %s NULL", qb.table.Name(), notClause)) return } if len(criterion.Value) == 0 { return } var args []interface{} for _, tagID := range criterion.Value { args = append(args, tagID) } whereClause := "" havingClause := "" switch criterion.Modifier { case models.CriterionModifierIncludes: whereClause = fmt.Sprintf("%s.zip_file_id IN %s", qb.table.Name(), getInBinding(len(criterion.Value))) case models.CriterionModifierExcludes: whereClause = fmt.Sprintf("%s.zip_file_id NOT IN %s", qb.table.Name(), getInBinding(len(criterion.Value))) } f.addWhere(whereClause, args...) f.addHaving(havingClause) } } } func (qb *folderFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if folder == nil { return } folderCopy := *folder switch folderCopy.Modifier { case models.CriterionModifierEquals: folderCopy.Modifier = models.CriterionModifierIncludesAll case models.CriterionModifierNotEquals: folderCopy.Modifier = models.CriterionModifierExcludes } hh := hierarchicalMultiCriterionHandlerBuilder{ primaryTable: qb.table.Name(), foreignTable: qb.table.Name(), foreignFK: "parent_folder_id", parentFK: "parent_folder_id", } hh.handler(&folderCopy)(ctx, f) } } func (qb *folderFilterHandler) galleryCountCriterionHandler(galleryCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if galleryCount != nil { f.addLeftJoin("galleries", "", "galleries.folder_id = folders.id") clause, args := getIntCriterionWhereClause("count(distinct galleries.id)", *galleryCount) f.addHaving(clause, args...) } } } ================================================ FILE: pkg/sqlite/folder_filter_test.go ================================================ //go:build integration // +build integration package sqlite_test import ( "context" "strconv" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stretchr/testify/assert" ) func TestFolderQuery(t *testing.T) { tests := []struct { name string findFilter *models.FindFilterType filter *models.FolderFilterType includeIdxs []int includeIDs []models.FolderID excludeIdxs []int wantErr bool }{ { name: "path", filter: &models.FolderFilterType{ Path: &models.StringCriterionInput{ Value: getFolderPath(folderIdxWithSubFolder, nil), Modifier: models.CriterionModifierIncludes, }, }, includeIdxs: []int{folderIdxWithSubFolder, folderIdxWithParentFolder}, excludeIdxs: []int{folderIdxInZip}, }, { name: "basename", filter: &models.FolderFilterType{ Basename: &models.StringCriterionInput{ Value: getFolderBasename(folderIdxWithParentFolder, nil), Modifier: models.CriterionModifierIncludes, }, }, includeIdxs: []int{folderIdxWithParentFolder}, excludeIdxs: []int{folderIdxInZip}, }, { name: "parent folder", filter: &models.FolderFilterType{ ParentFolder: &models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(int(folderIDs[folderIdxWithSubFolder])), }, Modifier: models.CriterionModifierIncludes, }, }, includeIdxs: []int{folderIdxWithParentFolder}, excludeIdxs: []int{folderIdxWithSubFolder, folderIdxInZip}, }, { name: "zip file", filter: &models.FolderFilterType{ ZipFile: &models.MultiCriterionInput{ Value: []string{ strconv.Itoa(int(fileIDs[fileIdxZip])), }, Modifier: models.CriterionModifierIncludes, }, }, includeIdxs: []int{folderIdxInZip}, excludeIdxs: []int{folderIdxForObjectFiles}, }, // TODO - add more tests for other folder filters } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) results, err := db.Folder.Query(ctx, models.FolderQueryOptions{ FolderFilter: tt.filter, QueryOptions: models.QueryOptions{ FindFilter: tt.findFilter, }, }) if (err != nil) != tt.wantErr { t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr) return } include := indexesToIDPtrs(folderIDs, tt.includeIdxs) for _, id := range tt.includeIDs { v := id include = append(include, &v) } exclude := indexesToIDPtrs(folderIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(results.IDs, models.FolderID(*i)) } for _, e := range exclude { assert.NotContains(results.IDs, models.FolderID(*e)) } }) } } ================================================ FILE: pkg/sqlite/folder_test.go ================================================ //go:build integration // +build integration package sqlite_test import ( "context" "reflect" "testing" "time" "github.com/stashapp/stash/pkg/models" "github.com/stretchr/testify/assert" ) var ( invalidFolderID = models.FolderID(invalidID) invalidFileID = models.FileID(invalidID) ) func Test_FolderStore_Create(t *testing.T) { var ( path = "path" fileModTime = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) ) tests := []struct { name string newObject models.Folder wantErr bool }{ { "full", models.Folder{ DirEntry: models.DirEntry{ ZipFileID: &fileIDs[fileIdxZip], ZipFile: makeZipFileWithID(fileIdxZip), ModTime: fileModTime, }, Path: path, CreatedAt: createdAt, UpdatedAt: updatedAt, }, false, }, { "invalid parent folder id", models.Folder{ Path: path, ParentFolderID: &invalidFolderID, }, true, }, { "invalid zip file id", models.Folder{ DirEntry: models.DirEntry{ ZipFileID: &invalidFileID, }, Path: path, }, true, }, } qb := db.Folder for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) s := tt.newObject if err := qb.Create(ctx, &s); (err != nil) != tt.wantErr { t.Errorf("FolderStore.Create() error = %v, wantErr = %v", err, tt.wantErr) } if tt.wantErr { assert.Zero(s.ID) return } assert.NotZero(s.ID) copy := tt.newObject copy.ID = s.ID assert.Equal(copy, s) // ensure can find the folder found, err := qb.FindByPath(ctx, path, true) if err != nil { t.Errorf("FolderStore.Find() error = %v", err) } assert.Equal(copy, *found) }) } } func Test_FolderStore_Update(t *testing.T) { var ( path = "path" fileModTime = time.Date(2000, 1, 2, 3, 4, 5, 0, time.UTC) createdAt = time.Date(2001, 1, 2, 3, 4, 5, 0, time.UTC) updatedAt = time.Date(2002, 1, 2, 3, 4, 5, 0, time.UTC) ) tests := []struct { name string updatedObject *models.Folder wantErr bool }{ { "full", &models.Folder{ ID: folderIDs[folderIdxWithParentFolder], DirEntry: models.DirEntry{ ZipFileID: &fileIDs[fileIdxZip], ZipFile: makeZipFileWithID(fileIdxZip), ModTime: fileModTime, }, Path: path, CreatedAt: createdAt, UpdatedAt: updatedAt, }, false, }, { "clear zip", &models.Folder{ ID: folderIDs[folderIdxInZip], Path: path, }, false, }, { "clear folder", &models.Folder{ ID: folderIDs[folderIdxWithParentFolder], Path: path, }, false, }, { "invalid parent folder id", &models.Folder{ ID: folderIDs[folderIdxWithParentFolder], Path: path, ParentFolderID: &invalidFolderID, }, true, }, { "invalid zip file id", &models.Folder{ ID: folderIDs[folderIdxWithParentFolder], DirEntry: models.DirEntry{ ZipFileID: &invalidFileID, }, Path: path, }, true, }, } qb := db.Folder for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) copy := *tt.updatedObject if err := qb.Update(ctx, tt.updatedObject); (err != nil) != tt.wantErr { t.Errorf("FolderStore.Update() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { return } s, err := qb.FindByPath(ctx, path, true) if err != nil { t.Errorf("FolderStore.Find() error = %v", err) } assert.Equal(copy, *s) }) } } func makeFolderWithID(index int) *models.Folder { ret := makeFolder(index) ret.ID = folderIDs[index] return &ret } func Test_FolderStore_FindByPath(t *testing.T) { getPath := func(index int) string { return folderPaths[index] } tests := []struct { name string path string want *models.Folder wantErr bool }{ { "valid", getPath(folderIdxWithFiles), makeFolderWithID(folderIdxWithFiles), false, }, { "invalid", "invalid path", nil, false, }, } qb := db.Folder for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { got, err := qb.FindByPath(ctx, tt.path, true) if (err != nil) != tt.wantErr { t.Errorf("FolderStore.FindByPath() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("FolderStore.FindByPath() = %v, want %v", got, tt.want) } }) } } func Test_FolderStore_GetManyParentFolderIDs(t *testing.T) { var empty []models.FolderID emptyResult := [][]models.FolderID{empty} tests := []struct { name string parentFolderIDs []models.FolderID want [][]models.FolderID wantErr bool }{ { "valid with parent folders", []models.FolderID{folderIDs[folderIdxWithParentFolder]}, [][]models.FolderID{ { folderIDs[folderIdxWithSubFolder], folderIDs[folderIdxRoot], }, }, false, }, { "valid multiple folders", []models.FolderID{ folderIDs[folderIdxWithParentFolder], folderIDs[folderIdxWithSceneFiles], }, [][]models.FolderID{ { folderIDs[folderIdxWithSubFolder], folderIDs[folderIdxRoot], }, { folderIDs[folderIdxForObjectFiles], folderIDs[folderIdxRoot], }, }, false, }, { "valid without parent folders", []models.FolderID{folderIDs[folderIdxRoot]}, emptyResult, false, }, { "invalid folder id", []models.FolderID{invalidFolderID}, emptyResult, // does not error, just returns empty result false, }, } qb := db.Folder for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.GetManyParentFolderIDs(ctx, tt.parentFolderIDs) if (err != nil) != tt.wantErr { assert.Errorf(err, "FolderStore.GetManyParentFolderIDs() error = %v, wantErr %v", err, tt.wantErr) return } if tt.wantErr { return } assert.Equal(got, tt.want) }) } } ================================================ FILE: pkg/sqlite/functions.go ================================================ package sqlite import ( "path/filepath" "strconv" "strings" ) func durationToTinyIntFn(str string) (int64, error) { splits := strings.Split(str, ":") if len(splits) > 3 { return 0, nil } seconds := 0 factor := 1 for len(splits) > 0 { // pop the last split var thisSplit string thisSplit, splits = splits[len(splits)-1], splits[:len(splits)-1] thisInt, err := strconv.Atoi(thisSplit) if err != nil { return 0, nil } seconds += factor * thisInt factor *= 60 } return int64(seconds), nil } func basenameFn(str string) (string, error) { return filepath.Base(str), nil } ================================================ FILE: pkg/sqlite/gallery.go ================================================ package sqlite import ( "context" "database/sql" "errors" "fmt" "path/filepath" "slices" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/models" "gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4/zero" ) const ( galleryTable = "galleries" galleriesFilesTable = "galleries_files" performersGalleriesTable = "performers_galleries" galleriesTagsTable = "galleries_tags" galleriesImagesTable = "galleries_images" galleriesScenesTable = "scenes_galleries" galleryIDColumn = "gallery_id" galleriesURLsTable = "gallery_urls" galleriesURLColumn = "url" ) type galleryRow struct { ID int `db:"id" goqu:"skipinsert"` Title zero.String `db:"title"` Code zero.String `db:"code"` Date NullDate `db:"date"` DatePrecision null.Int `db:"date_precision"` Details zero.String `db:"details"` Photographer zero.String `db:"photographer"` // expressed as 1-100 Rating null.Int `db:"rating"` Organized bool `db:"organized"` StudioID null.Int `db:"studio_id,omitempty"` FolderID null.Int `db:"folder_id,omitempty"` CreatedAt Timestamp `db:"created_at"` UpdatedAt Timestamp `db:"updated_at"` } func (r *galleryRow) fromGallery(o models.Gallery) { r.ID = o.ID r.Title = zero.StringFrom(o.Title) r.Code = zero.StringFrom(o.Code) r.Date = NullDateFromDatePtr(o.Date) r.DatePrecision = datePrecisionFromDatePtr(o.Date) r.Details = zero.StringFrom(o.Details) r.Photographer = zero.StringFrom(o.Photographer) r.Rating = intFromPtr(o.Rating) r.Organized = o.Organized r.StudioID = intFromPtr(o.StudioID) r.FolderID = nullIntFromFolderIDPtr(o.FolderID) r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} } type galleryQueryRow struct { galleryRow FolderPath zero.String `db:"folder_path"` PrimaryFileID null.Int `db:"primary_file_id"` PrimaryFileFolderPath zero.String `db:"primary_file_folder_path"` PrimaryFileBasename zero.String `db:"primary_file_basename"` PrimaryFileChecksum zero.String `db:"primary_file_checksum"` } func (r *galleryQueryRow) resolve() *models.Gallery { ret := &models.Gallery{ ID: r.ID, Title: r.Title.String, Code: r.Code.String, Date: r.Date.DatePtr(r.DatePrecision), Details: r.Details.String, Photographer: r.Photographer.String, Rating: nullIntPtr(r.Rating), Organized: r.Organized, StudioID: nullIntPtr(r.StudioID), FolderID: nullIntFolderIDPtr(r.FolderID), PrimaryFileID: nullIntFileIDPtr(r.PrimaryFileID), CreatedAt: r.CreatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp, } if r.PrimaryFileFolderPath.Valid && r.PrimaryFileBasename.Valid { ret.Path = filepath.Join(r.PrimaryFileFolderPath.String, r.PrimaryFileBasename.String) } else if r.FolderPath.Valid { ret.Path = r.FolderPath.String } return ret } type galleryRowRecord struct { updateRecord } func (r *galleryRowRecord) fromPartial(o models.GalleryPartial) { r.setNullString("title", o.Title) r.setNullString("code", o.Code) r.setNullDate("date", "date_precision", o.Date) r.setNullString("details", o.Details) r.setNullString("photographer", o.Photographer) r.setNullInt("rating", o.Rating) r.setBool("organized", o.Organized) r.setNullInt("studio_id", o.StudioID) r.setTimestamp("created_at", o.CreatedAt) r.setTimestamp("updated_at", o.UpdatedAt) } type galleryRepositoryType struct { repository performers joinRepository images joinRepository tags joinRepository scenes joinRepository files filesRepository } func (r *galleryRepositoryType) addGalleriesFilesTable(f *filterBuilder) { f.addLeftJoin(galleriesFilesTable, "", "galleries_files.gallery_id = galleries.id") } func (r *galleryRepositoryType) addFilesTable(f *filterBuilder) { r.addGalleriesFilesTable(f) f.addLeftJoin(fileTable, "", "galleries_files.file_id = files.id") } func (r *galleryRepositoryType) addFoldersTable(f *filterBuilder) { r.addFilesTable(f) f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id") } var ( galleryRepository = galleryRepositoryType{ repository: repository{ tableName: galleryTable, idColumn: idColumn, }, performers: joinRepository{ repository: repository{ tableName: performersGalleriesTable, idColumn: galleryIDColumn, }, fkColumn: "performer_id", }, tags: joinRepository{ repository: repository{ tableName: galleriesTagsTable, idColumn: galleryIDColumn, }, fkColumn: "tag_id", foreignTable: tagTable, orderBy: tagTableSortSQL, }, images: joinRepository{ repository: repository{ tableName: galleriesImagesTable, idColumn: galleryIDColumn, }, fkColumn: "image_id", }, scenes: joinRepository{ repository: repository{ tableName: galleriesScenesTable, idColumn: galleryIDColumn, }, fkColumn: sceneIDColumn, }, files: filesRepository{ repository: repository{ tableName: galleriesFilesTable, idColumn: galleryIDColumn, }, }, } ) type GalleryStore struct { customFieldsStore tableMgr *table fileStore *FileStore folderStore *FolderStore } func NewGalleryStore(fileStore *FileStore, folderStore *FolderStore) *GalleryStore { return &GalleryStore{ customFieldsStore: customFieldsStore{ table: galleriesCustomFieldsTable, fk: galleriesCustomFieldsTable.Col(galleryIDColumn), }, tableMgr: galleryTableMgr, fileStore: fileStore, folderStore: folderStore, } } func (qb *GalleryStore) table() exp.IdentifierExpression { return qb.tableMgr.table } func (qb *GalleryStore) selectDataset() *goqu.SelectDataset { table := qb.table() files := fileTableMgr.table folders := folderTableMgr.table galleryFolder := folderTableMgr.table.As("gallery_folder") return dialect.From(table).LeftJoin( galleriesFilesJoinTable, goqu.On( galleriesFilesJoinTable.Col(galleryIDColumn).Eq(table.Col(idColumn)), galleriesFilesJoinTable.Col("primary").Eq(1), ), ).LeftJoin( files, goqu.On(files.Col(idColumn).Eq(galleriesFilesJoinTable.Col(fileIDColumn))), ).LeftJoin( folders, goqu.On(folders.Col(idColumn).Eq(files.Col("parent_folder_id"))), ).LeftJoin( galleryFolder, goqu.On(galleryFolder.Col(idColumn).Eq(table.Col("folder_id"))), ).Select( qb.table().All(), galleriesFilesJoinTable.Col(fileIDColumn).As("primary_file_id"), folders.Col("path").As("primary_file_folder_path"), files.Col("basename").As("primary_file_basename"), galleryFolder.Col("path").As("folder_path"), ) } func (qb *GalleryStore) Create(ctx context.Context, newObject *models.CreateGalleryInput) error { var r galleryRow r.fromGallery(*newObject.Gallery) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { return err } if len(newObject.FileIDs) > 0 { const firstPrimary = true if err := galleriesFilesTableMgr.insertJoins(ctx, id, firstPrimary, newObject.FileIDs); err != nil { return err } } if newObject.URLs.Loaded() { const startPos = 0 if err := galleriesURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { return err } } if newObject.PerformerIDs.Loaded() { if err := galleriesPerformersTableMgr.insertJoins(ctx, id, newObject.PerformerIDs.List()); err != nil { return err } } if newObject.TagIDs.Loaded() { if err := galleriesTagsTableMgr.insertJoins(ctx, id, newObject.TagIDs.List()); err != nil { return err } } if newObject.SceneIDs.Loaded() { if err := galleriesScenesTableMgr.insertJoins(ctx, id, newObject.SceneIDs.List()); err != nil { return err } } const partial = false if err := qb.setCustomFields(ctx, id, newObject.CustomFields, partial); err != nil { return err } updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } *newObject.Gallery = *updated return nil } func (qb *GalleryStore) Update(ctx context.Context, updatedObject *models.UpdateGalleryInput) error { var r galleryRow r.fromGallery(*updatedObject.Gallery) if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { return err } if updatedObject.URLs.Loaded() { if err := galleriesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { return err } } if updatedObject.PerformerIDs.Loaded() { if err := galleriesPerformersTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.PerformerIDs.List()); err != nil { return err } } if updatedObject.TagIDs.Loaded() { if err := galleriesTagsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.TagIDs.List()); err != nil { return err } } if updatedObject.SceneIDs.Loaded() { if err := galleriesScenesTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.SceneIDs.List()); err != nil { return err } } if updatedObject.Files.Loaded() { fileIDs := make([]models.FileID, len(updatedObject.Files.List())) for i, f := range updatedObject.Files.List() { fileIDs[i] = f.Base().ID } if err := galleriesFilesTableMgr.replaceJoins(ctx, updatedObject.ID, fileIDs); err != nil { return err } } if err := qb.SetCustomFields(ctx, updatedObject.ID, updatedObject.CustomFields); err != nil { return err } return nil } func (qb *GalleryStore) UpdatePartial(ctx context.Context, id int, partial models.GalleryPartial) (*models.Gallery, error) { r := galleryRowRecord{ updateRecord{ Record: make(exp.Record), }, } r.fromPartial(partial) if len(r.Record) > 0 { if err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil { return nil, err } } if partial.URLs != nil { if err := galleriesURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { return nil, err } } if partial.PerformerIDs != nil { if err := galleriesPerformersTableMgr.modifyJoins(ctx, id, partial.PerformerIDs.IDs, partial.PerformerIDs.Mode); err != nil { return nil, err } } if partial.TagIDs != nil { if err := galleriesTagsTableMgr.modifyJoins(ctx, id, partial.TagIDs.IDs, partial.TagIDs.Mode); err != nil { return nil, err } } if partial.SceneIDs != nil { if err := galleriesScenesTableMgr.modifyJoins(ctx, id, partial.SceneIDs.IDs, partial.SceneIDs.Mode); err != nil { return nil, err } } if partial.PrimaryFileID != nil { if err := galleriesFilesTableMgr.setPrimary(ctx, id, *partial.PrimaryFileID); err != nil { return nil, err } } if err := qb.SetCustomFields(ctx, id, partial.CustomFields); err != nil { return nil, err } return qb.find(ctx, id) } func (qb *GalleryStore) Destroy(ctx context.Context, id int) error { return qb.tableMgr.destroyExisting(ctx, []int{id}) } func (qb *GalleryStore) GetFiles(ctx context.Context, id int) ([]models.File, error) { fileIDs, err := galleryRepository.files.get(ctx, id) if err != nil { return nil, err } // use fileStore to load files files, err := qb.fileStore.Find(ctx, fileIDs...) if err != nil { return nil, err } ret := make([]models.File, len(files)) copy(ret, files) return ret, nil } func (qb *GalleryStore) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) { const primaryOnly = false return galleryRepository.files.getMany(ctx, ids, primaryOnly) } // returns nil, nil if not found func (qb *GalleryStore) Find(ctx context.Context, id int) (*models.Gallery, error) { ret, err := qb.find(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } return ret, err } func (qb *GalleryStore) FindMany(ctx context.Context, ids []int) ([]*models.Gallery, error) { galleries := make([]*models.Gallery, len(ids)) if err := batchExec(ids, defaultBatchSize, func(batch []int) error { q := qb.selectDataset().Prepared(true).Where(qb.table().Col(idColumn).In(batch)) unsorted, err := qb.getMany(ctx, q) if err != nil { return err } for _, s := range unsorted { i := slices.Index(ids, s.ID) galleries[i] = s } return nil }); err != nil { return nil, err } for i := range galleries { if galleries[i] == nil { return nil, fmt.Errorf("gallery with id %d not found", ids[i]) } } return galleries, nil } // returns nil, sql.ErrNoRows if not found func (qb *GalleryStore) find(ctx context.Context, id int) (*models.Gallery, error) { q := qb.selectDataset().Where(qb.tableMgr.byID(id)) ret, err := qb.get(ctx, q) if err != nil { return nil, err } return ret, nil } func (qb *GalleryStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]*models.Gallery, error) { table := qb.table() q := qb.selectDataset().Prepared(true).Where( table.Col(idColumn).Eq( sq, ), ) return qb.getMany(ctx, q) } // returns nil, sql.ErrNoRows if not found func (qb *GalleryStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Gallery, error) { ret, err := qb.getMany(ctx, q) if err != nil { return nil, err } if len(ret) == 0 { return nil, sql.ErrNoRows } return ret[0], nil } func (qb *GalleryStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Gallery, error) { const single = false var ret []*models.Gallery var lastID int if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { var f galleryQueryRow if err := r.StructScan(&f); err != nil { return err } s := f.resolve() if s.ID == lastID { return fmt.Errorf("internal error: multiple rows returned for single gallery id %d", s.ID) } lastID = s.ID ret = append(ret, s) return nil }); err != nil { return nil, err } return ret, nil } func (qb *GalleryStore) FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Gallery, error) { sq := dialect.From(galleriesFilesJoinTable).Select(galleriesFilesJoinTable.Col(galleryIDColumn)).Where( galleriesFilesJoinTable.Col(fileIDColumn).Eq(fileID), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting gallery by file id %d: %w", fileID, err) } return ret, nil } func (qb *GalleryStore) CountByFileID(ctx context.Context, fileID models.FileID) (int, error) { joinTable := galleriesFilesJoinTable q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col(fileIDColumn).Eq(fileID)) return count(ctx, q) } func (qb *GalleryStore) FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Gallery, error) { fingerprintTable := fingerprintTableMgr.table var ex []exp.Expression for _, v := range fp { ex = append(ex, goqu.And( fingerprintTable.Col("type").Eq(v.Type), fingerprintTable.Col("fingerprint").Eq(v.Fingerprint), )) } sq := dialect.From(galleriesFilesJoinTable). InnerJoin( fingerprintTable, goqu.On(fingerprintTable.Col(fileIDColumn).Eq(galleriesFilesJoinTable.Col(fileIDColumn))), ). Select(galleriesFilesJoinTable.Col(galleryIDColumn)).Where(goqu.Or(ex...)) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting gallery by fingerprints: %w", err) } return ret, nil } func (qb *GalleryStore) FindByChecksum(ctx context.Context, checksum string) ([]*models.Gallery, error) { return qb.FindByFingerprints(ctx, []models.Fingerprint{ { Type: models.FingerprintTypeMD5, Fingerprint: checksum, }, }) } func (qb *GalleryStore) FindByChecksums(ctx context.Context, checksums []string) ([]*models.Gallery, error) { fingerprints := make([]models.Fingerprint, len(checksums)) for i, c := range checksums { fingerprints[i] = models.Fingerprint{ Type: models.FingerprintTypeMD5, Fingerprint: c, } } return qb.FindByFingerprints(ctx, fingerprints) } func (qb *GalleryStore) FindByPath(ctx context.Context, p string) ([]*models.Gallery, error) { table := qb.table() filesTable := fileTableMgr.table fileFoldersTable := folderTableMgr.table.As("file_folders") foldersTable := folderTableMgr.table basename := filepath.Base(p) dir := filepath.Dir(p) sq := dialect.From(table).LeftJoin( galleriesFilesJoinTable, goqu.On(galleriesFilesJoinTable.Col(galleryIDColumn).Eq(table.Col(idColumn))), ).LeftJoin( filesTable, goqu.On(filesTable.Col(idColumn).Eq(galleriesFilesJoinTable.Col(fileIDColumn))), ).LeftJoin( fileFoldersTable, goqu.On(fileFoldersTable.Col(idColumn).Eq(filesTable.Col("parent_folder_id"))), ).LeftJoin( foldersTable, goqu.On(foldersTable.Col(idColumn).Eq(table.Col("folder_id"))), ).Select(table.Col(idColumn)).Where( goqu.Or( goqu.And( fileFoldersTable.Col("path").Eq(dir), filesTable.Col("basename").Eq(basename), ), foldersTable.Col("path").Eq(p), ), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("getting gallery by path %s: %w", p, err) } return ret, nil } func (qb *GalleryStore) FindByFolderID(ctx context.Context, folderID models.FolderID) ([]*models.Gallery, error) { table := qb.table() sq := dialect.From(table).Select(table.Col(idColumn)).Where( table.Col("folder_id").Eq(folderID), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting galleries for folder %d: %w", folderID, err) } return ret, nil } func (qb *GalleryStore) FindBySceneID(ctx context.Context, sceneID int) ([]*models.Gallery, error) { sq := dialect.From(galleriesScenesJoinTable).Select(galleriesScenesJoinTable.Col(galleryIDColumn)).Where( galleriesScenesJoinTable.Col(sceneIDColumn).Eq(sceneID), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting galleries for scene %d: %w", sceneID, err) } return ret, nil } func (qb *GalleryStore) FindByImageID(ctx context.Context, imageID int) ([]*models.Gallery, error) { sq := dialect.From(galleriesImagesJoinTable).Select(galleriesImagesJoinTable.Col(galleryIDColumn)).Where( galleriesImagesJoinTable.Col(imageIDColumn).Eq(imageID), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting galleries for image %d: %w", imageID, err) } return ret, nil } func (qb *GalleryStore) CountByImageID(ctx context.Context, imageID int) (int, error) { joinTable := galleriesImagesJoinTable q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col(imageIDColumn).Eq(imageID)) return count(ctx, q) } func (qb *GalleryStore) FindUserGalleryByTitle(ctx context.Context, title string) ([]*models.Gallery, error) { table := qb.table() sq := dialect.From(table).LeftJoin( galleriesFilesJoinTable, goqu.On(galleriesFilesJoinTable.Col(galleryIDColumn).Eq(table.Col(idColumn))), ).Select(table.Col(idColumn)).Where( table.Col("folder_id").IsNull(), galleriesFilesJoinTable.Col("file_id").IsNull(), table.Col("title").Eq(title), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting user galleries for title %s: %w", title, err) } return ret, nil } func (qb *GalleryStore) Count(ctx context.Context) (int, error) { q := dialect.Select(goqu.COUNT("*")).From(qb.table()) return count(ctx, q) } func (qb *GalleryStore) All(ctx context.Context) ([]*models.Gallery, error) { return qb.getMany(ctx, qb.selectDataset()) } func (qb *GalleryStore) makeQuery(ctx context.Context, galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if galleryFilter == nil { galleryFilter = &models.GalleryFilterType{} } if findFilter == nil { findFilter = &models.FindFilterType{} } query := galleryRepository.newQuery() distinctIDs(&query, galleryTable) if q := findFilter.Q; q != nil && *q != "" { query.addJoins( join{ table: galleriesFilesTable, onClause: "galleries_files.gallery_id = galleries.id", }, join{ table: fileTable, onClause: "galleries_files.file_id = files.id", }, join{ table: folderTable, onClause: "files.parent_folder_id = folders.id", }, join{ table: fingerprintTable, onClause: "files_fingerprints.file_id = galleries_files.file_id", }, join{ table: folderTable, as: "gallery_folder", onClause: "galleries.folder_id = gallery_folder.id", }, join{ table: galleriesChaptersTable, onClause: "galleries_chapters.gallery_id = galleries.id", }, ) // add joins for files and checksum filepathColumn := "folders.path || '" + string(filepath.Separator) + "' || files.basename" searchColumns := []string{"galleries.title", "gallery_folder.path", filepathColumn, "files_fingerprints.fingerprint", "galleries_chapters.title"} query.parseQueryString(searchColumns, *q) } filter := filterBuilderFromHandler(ctx, &galleryFilterHandler{ galleryFilter: galleryFilter, }) if err := query.addFilter(filter); err != nil { return nil, err } if err := qb.setGallerySort(&query, findFilter); err != nil { return nil, err } query.sortAndPagination += getPagination(findFilter) return &query, nil } func (qb *GalleryStore) Query(ctx context.Context, galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) ([]*models.Gallery, int, error) { query, err := qb.makeQuery(ctx, galleryFilter, findFilter) if err != nil { return nil, 0, err } idsResult, countResult, err := query.executeFind(ctx) if err != nil { return nil, 0, err } galleries, err := qb.FindMany(ctx, idsResult) if err != nil { return nil, 0, err } return galleries, countResult, nil } func (qb *GalleryStore) QueryCount(ctx context.Context, galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) (int, error) { query, err := qb.makeQuery(ctx, galleryFilter, findFilter) if err != nil { return 0, err } return query.executeCount(ctx) } var gallerySortOptions = sortOptions{ "created_at", "date", "file_count", "file_mod_time", "id", "images_count", "path", "performer_count", "random", "rating", "tag_count", "title", "updated_at", } func (qb *GalleryStore) setGallerySort(query *queryBuilder, findFilter *models.FindFilterType) error { if findFilter == nil || findFilter.Sort == nil || *findFilter.Sort == "" { return nil } sort := findFilter.GetSort("path") direction := findFilter.GetDirection() // CVE-2024-32231 - ensure sort is in the list of allowed sorts if err := gallerySortOptions.validateSort(sort); err != nil { return err } addFileTable := func() { query.addJoins( join{ sort: true, table: galleriesFilesTable, onClause: "galleries_files.gallery_id = galleries.id", }, join{ sort: true, table: fileTable, onClause: "galleries_files.file_id = files.id", }, ) } addFolderTable := func() { query.addJoins( join{ sort: true, table: folderTable, onClause: "folders.id = galleries.folder_id", }, join{ sort: true, table: folderTable, as: "file_folder", onClause: "files.parent_folder_id = file_folder.id", }, ) } switch sort { case "file_count": query.sortAndPagination += getCountSort(galleryTable, galleriesFilesTable, galleryIDColumn, direction) case "images_count": query.sortAndPagination += getCountSort(galleryTable, galleriesImagesTable, galleryIDColumn, direction) case "tag_count": query.sortAndPagination += getCountSort(galleryTable, galleriesTagsTable, galleryIDColumn, direction) case "performer_count": query.sortAndPagination += getCountSort(galleryTable, performersGalleriesTable, galleryIDColumn, direction) case "path": // special handling for path addFileTable() addFolderTable() query.sortAndPagination += fmt.Sprintf(" ORDER BY COALESCE(folders.path, '') || COALESCE(file_folder.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI %s", direction) case "file_mod_time": sort = "mod_time" addFileTable() query.sortAndPagination += getSort(sort, direction, fileTable) case "title": addFileTable() addFolderTable() query.sortAndPagination += " ORDER BY COALESCE(galleries.title, files.basename, basename(COALESCE(folders.path, ''))) COLLATE NATURAL_CI " + direction + ", file_folder.path COLLATE NATURAL_CI " + direction default: query.sortAndPagination += getSort(sort, direction, "galleries") } // Whatever the sorting, always use title/id as a final sort query.sortAndPagination += ", COALESCE(galleries.title, galleries.id) COLLATE NATURAL_CI ASC" return nil } func (qb *GalleryStore) GetURLs(ctx context.Context, galleryID int) ([]string, error) { return galleriesURLsTableMgr.get(ctx, galleryID) } func (qb *GalleryStore) AddFileID(ctx context.Context, id int, fileID models.FileID) error { const firstPrimary = false return galleriesFilesTableMgr.insertJoins(ctx, id, firstPrimary, []models.FileID{fileID}) } func (qb *GalleryStore) GetPerformerIDs(ctx context.Context, id int) ([]int, error) { return galleryRepository.performers.getIDs(ctx, id) } func (qb *GalleryStore) GetTagIDs(ctx context.Context, id int) ([]int, error) { return galleryRepository.tags.getIDs(ctx, id) } func (qb *GalleryStore) GetImageIDs(ctx context.Context, galleryID int) ([]int, error) { return galleryRepository.images.getIDs(ctx, galleryID) } func (qb *GalleryStore) AddImages(ctx context.Context, galleryID int, imageIDs ...int) error { return galleryRepository.images.insertOrIgnore(ctx, galleryID, imageIDs...) } func (qb *GalleryStore) RemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error { return galleryRepository.images.destroyJoins(ctx, galleryID, imageIDs...) } func (qb *GalleryStore) UpdateImages(ctx context.Context, galleryID int, imageIDs []int) error { // Delete the existing joins and then create new ones return galleryRepository.images.replace(ctx, galleryID, imageIDs) } func (qb *GalleryStore) SetCover(ctx context.Context, galleryID int, coverImageID int) error { return imageGalleriesTableMgr.setCover(ctx, coverImageID, galleryID) } func (qb *GalleryStore) ResetCover(ctx context.Context, galleryID int) error { return imageGalleriesTableMgr.resetCover(ctx, galleryID) } func (qb *GalleryStore) GetSceneIDs(ctx context.Context, id int) ([]int, error) { return galleryRepository.scenes.getIDs(ctx, id) } func (qb *GalleryStore) AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error { return galleriesScenesTableMgr.insertJoins(ctx, galleryID, sceneIDs) } ================================================ FILE: pkg/sqlite/gallery_chapter.go ================================================ package sqlite import ( "context" "database/sql" "errors" "fmt" "slices" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/models" ) const ( galleriesChaptersTable = "galleries_chapters" ) type galleryChapterRow struct { ID int `db:"id" goqu:"skipinsert"` Title string `db:"title"` // TODO: make db schema (and gql schema) nullable ImageIndex int `db:"image_index"` GalleryID int `db:"gallery_id"` CreatedAt Timestamp `db:"created_at"` UpdatedAt Timestamp `db:"updated_at"` } func (r *galleryChapterRow) fromGalleryChapter(o models.GalleryChapter) { r.ID = o.ID r.Title = o.Title r.ImageIndex = o.ImageIndex r.GalleryID = o.GalleryID r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} } func (r *galleryChapterRow) resolve() *models.GalleryChapter { ret := &models.GalleryChapter{ ID: r.ID, Title: r.Title, ImageIndex: r.ImageIndex, GalleryID: r.GalleryID, CreatedAt: r.CreatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp, } return ret } type galleryChapterRowRecord struct { updateRecord } func (r *galleryChapterRowRecord) fromPartial(o models.GalleryChapterPartial) { // TODO: replace with setNullString after schema is made nullable // r.setNullString("title", o.Title) // saves a null input as the empty string if o.Title.Set { r.set("title", o.Title.Value) } r.setInt("image_index", o.ImageIndex) r.setInt("gallery_id", o.GalleryID) r.setTimestamp("created_at", o.CreatedAt) r.setTimestamp("updated_at", o.UpdatedAt) } type GalleryChapterStore struct { repository tableMgr *table } func NewGalleryChapterStore() *GalleryChapterStore { return &GalleryChapterStore{ repository: repository{ tableName: galleriesChaptersTable, idColumn: idColumn, }, tableMgr: galleriesChaptersTableMgr, } } func (qb *GalleryChapterStore) table() exp.IdentifierExpression { return qb.tableMgr.table } func (qb *GalleryChapterStore) selectDataset() *goqu.SelectDataset { return dialect.From(qb.table()).Select(qb.table().All()) } func (qb *GalleryChapterStore) Create(ctx context.Context, newObject *models.GalleryChapter) error { var r galleryChapterRow r.fromGalleryChapter(*newObject) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { return err } updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } *newObject = *updated return nil } func (qb *GalleryChapterStore) Update(ctx context.Context, updatedObject *models.GalleryChapter) error { var r galleryChapterRow r.fromGalleryChapter(*updatedObject) if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { return err } return nil } func (qb *GalleryChapterStore) UpdatePartial(ctx context.Context, id int, partial models.GalleryChapterPartial) (*models.GalleryChapter, error) { r := galleryChapterRowRecord{ updateRecord{ Record: make(exp.Record), }, } r.fromPartial(partial) if len(r.Record) > 0 { if err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil { return nil, err } } return qb.find(ctx, id) } func (qb *GalleryChapterStore) Destroy(ctx context.Context, id int) error { return qb.destroyExisting(ctx, []int{id}) } // returns nil, nil if not found func (qb *GalleryChapterStore) Find(ctx context.Context, id int) (*models.GalleryChapter, error) { ret, err := qb.find(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } return ret, err } func (qb *GalleryChapterStore) FindMany(ctx context.Context, ids []int) ([]*models.GalleryChapter, error) { ret := make([]*models.GalleryChapter, len(ids)) table := qb.table() q := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(ids)) unsorted, err := qb.getMany(ctx, q) if err != nil { return nil, err } for _, s := range unsorted { i := slices.Index(ids, s.ID) ret[i] = s } for i := range ret { if ret[i] == nil { return nil, fmt.Errorf("gallery chapter with id %d not found", ids[i]) } } return ret, nil } // returns nil, sql.ErrNoRows if not found func (qb *GalleryChapterStore) find(ctx context.Context, id int) (*models.GalleryChapter, error) { q := qb.selectDataset().Where(qb.tableMgr.byID(id)) ret, err := qb.get(ctx, q) if err != nil { return nil, err } return ret, nil } // returns nil, sql.ErrNoRows if not found func (qb *GalleryChapterStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.GalleryChapter, error) { ret, err := qb.getMany(ctx, q) if err != nil { return nil, err } if len(ret) == 0 { return nil, sql.ErrNoRows } return ret[0], nil } func (qb *GalleryChapterStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.GalleryChapter, error) { const single = false var ret []*models.GalleryChapter if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { var f galleryChapterRow if err := r.StructScan(&f); err != nil { return err } s := f.resolve() ret = append(ret, s) return nil }); err != nil { return nil, err } return ret, nil } func (qb *GalleryChapterStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.GalleryChapter, error) { query := ` SELECT galleries_chapters.* FROM galleries_chapters WHERE galleries_chapters.gallery_id = ? GROUP BY galleries_chapters.id ORDER BY galleries_chapters.image_index ASC ` args := []interface{}{galleryID} return qb.queryGalleryChapters(ctx, query, args) } func (qb *GalleryChapterStore) queryGalleryChapters(ctx context.Context, query string, args []interface{}) ([]*models.GalleryChapter, error) { const single = false var ret []*models.GalleryChapter if err := qb.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { var f galleryChapterRow if err := r.StructScan(&f); err != nil { return err } s := f.resolve() ret = append(ret, s) return nil }); err != nil { return nil, err } return ret, nil } ================================================ FILE: pkg/sqlite/gallery_chapter_test.go ================================================ //go:build integration // +build integration package sqlite_test import ( "context" "testing" "github.com/stretchr/testify/assert" ) func TestChapterFindByGalleryID(t *testing.T) { withTxn(func(ctx context.Context) error { mqb := db.GalleryChapter galleryID := galleryIDs[galleryIdxWithChapters] chapters, err := mqb.FindByGalleryID(ctx, galleryID) if err != nil { t.Errorf("Error finding chapters: %s", err.Error()) } assert.Greater(t, len(chapters), 0) for _, chapter := range chapters { assert.Equal(t, galleryIDs[galleryIdxWithChapters], chapter.GalleryID) } chapters, err = mqb.FindByGalleryID(ctx, 0) if err != nil { t.Errorf("Error finding chapter: %s", err.Error()) } assert.Len(t, chapters, 0) return nil }) } // TODO Update // TODO Destroy // TODO Find ================================================ FILE: pkg/sqlite/gallery_filter.go ================================================ package sqlite import ( "context" "fmt" "path/filepath" "regexp" "github.com/stashapp/stash/pkg/models" ) type galleryFilterHandler struct { galleryFilter *models.GalleryFilterType } func (qb *galleryFilterHandler) validate() error { galleryFilter := qb.galleryFilter if galleryFilter == nil { return nil } if err := validateFilterCombination(galleryFilter.OperatorFilter); err != nil { return err } if subFilter := galleryFilter.SubFilter(); subFilter != nil { sqb := &galleryFilterHandler{galleryFilter: subFilter} if err := sqb.validate(); err != nil { return err } } return nil } func (qb *galleryFilterHandler) handle(ctx context.Context, f *filterBuilder) { galleryFilter := qb.galleryFilter if galleryFilter == nil { return } if err := qb.validate(); err != nil { f.setError(err) return } sf := galleryFilter.SubFilter() if sf != nil { sub := &galleryFilterHandler{sf} handleSubFilter(ctx, sub, f, galleryFilter.OperatorFilter) } f.handleCriterion(ctx, qb.criterionHandler()) } func (qb *galleryFilterHandler) criterionHandler() criterionHandler { filter := qb.galleryFilter return compoundHandler{ intCriterionHandler(filter.ID, "galleries.id", nil), stringCriterionHandler(filter.Title, "galleries.title"), stringCriterionHandler(filter.Code, "galleries.code"), stringCriterionHandler(filter.Details, "galleries.details"), stringCriterionHandler(filter.Photographer, "galleries.photographer"), criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if filter.Checksum != nil { galleryRepository.addGalleriesFilesTable(f) f.addLeftJoin(fingerprintTable, "fingerprints_md5", "galleries_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") } stringCriterionHandler(filter.Checksum, "fingerprints_md5.fingerprint")(ctx, f) }), criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if filter.IsZip != nil { galleryRepository.addGalleriesFilesTable(f) if *filter.IsZip { f.addWhere("galleries_files.file_id IS NOT NULL") } else { f.addWhere("galleries_files.file_id IS NULL") } } }), qb.pathCriterionHandler(filter.Path), qb.parentFolderCriterionHandler(filter.ParentFolder), qb.fileCountCriterionHandler(filter.FileCount), intCriterionHandler(filter.Rating100, "galleries.rating", nil), qb.urlsCriterionHandler(filter.URL), boolCriterionHandler(filter.Organized, "galleries.organized", nil), qb.missingCriterionHandler(filter.IsMissing), qb.tagsCriterionHandler(filter.Tags), qb.tagCountCriterionHandler(filter.TagCount), qb.performersCriterionHandler(filter.Performers), qb.performerCountCriterionHandler(filter.PerformerCount), qb.scenesCriterionHandler(filter.Scenes), qb.hasChaptersCriterionHandler(filter.HasChapters), studioCriterionHandler(galleryTable, filter.Studios), qb.performerTagsCriterionHandler(filter.PerformerTags), qb.averageResolutionCriterionHandler(filter.AverageResolution), qb.imageCountCriterionHandler(filter.ImageCount), qb.performerFavoriteCriterionHandler(filter.PerformerFavorite), qb.performerAgeCriterionHandler(filter.PerformerAge), &dateCriterionHandler{filter.Date, "galleries.date", nil}, ×tampCriterionHandler{filter.CreatedAt, "galleries.created_at", nil}, ×tampCriterionHandler{filter.UpdatedAt, "galleries.updated_at", nil}, &customFieldsFilterHandler{ table: galleriesCustomFieldsTable.GetTable(), fkCol: galleryIDColumn, c: filter.CustomFields, idCol: "galleries.id", }, &relatedFilterHandler{ relatedIDCol: "scenes_galleries.scene_id", relatedRepo: sceneRepository.repository, relatedHandler: &sceneFilterHandler{filter.ScenesFilter}, joinFn: func(f *filterBuilder) { galleryRepository.scenes.innerJoin(f, "", "galleries.id") }, }, &relatedFilterHandler{ relatedIDCol: "galleries_images.image_id", relatedRepo: imageRepository.repository, relatedHandler: &imageFilterHandler{filter.ImagesFilter}, joinFn: func(f *filterBuilder) { galleryRepository.images.innerJoin(f, "", "galleries.id") }, }, &relatedFilterHandler{ relatedIDCol: "performers_join.performer_id", relatedRepo: performerRepository.repository, relatedHandler: &performerFilterHandler{filter.PerformersFilter}, joinFn: func(f *filterBuilder) { galleryRepository.performers.innerJoin(f, "performers_join", "galleries.id") }, }, &relatedFilterHandler{ relatedIDCol: "galleries.studio_id", relatedRepo: studioRepository.repository, relatedHandler: &studioFilterHandler{filter.StudiosFilter}, }, &relatedFilterHandler{ relatedIDCol: "gallery_tag.tag_id", relatedRepo: tagRepository.repository, relatedHandler: &tagFilterHandler{filter.TagsFilter}, joinFn: func(f *filterBuilder) { galleryRepository.tags.innerJoin(f, "gallery_tag", "galleries.id") }, }, &relatedFilterHandler{ relatedIDCol: "files.id", relatedRepo: fileRepository.repository, relatedHandler: &fileFilterHandler{ fileFilter: filter.FilesFilter, isRelated: true, }, joinFn: func(f *filterBuilder) { galleryRepository.addFilesTable(f) galleryRepository.addFoldersTable(f) }, // don't use a subquery; join directly directJoin: true, }, &relatedFilterHandler{ relatedIDCol: "gallery_folder.id", relatedRepo: folderRepository.repository, relatedHandler: &folderFilterHandler{ folderFilter: filter.FoldersFilter, table: "gallery_folder", isRelated: true, }, joinFn: func(f *filterBuilder) { f.addLeftJoin(folderTable, "gallery_folder", "galleries.folder_id = gallery_folder.id") }, // don't use a subquery; join directly directJoin: true, }, } } func (qb *galleryFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ primaryTable: galleryTable, primaryFK: galleryIDColumn, joinTable: galleriesURLsTable, stringColumn: galleriesURLColumn, addJoinTable: func(f *filterBuilder) { galleriesURLsTableMgr.join(f, "", "galleries.id") }, } return h.handler(url) } func (qb *galleryFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { return multiCriterionHandlerBuilder{ primaryTable: galleryTable, foreignTable: foreignTable, joinTable: joinTable, primaryFK: galleryIDColumn, foreignFK: foreignFK, addJoinsFunc: addJoinsFunc, } } func (qb *galleryFilterHandler) pathCriterionHandler(c *models.StringCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { galleryRepository.addFoldersTable(f) f.addLeftJoin(folderTable, "gallery_folder", "galleries.folder_id = gallery_folder.id") const pathColumn = "folders.path" const basenameColumn = "files.basename" const folderPathColumn = "gallery_folder.path" addWildcards := true not := false if modifier := c.Modifier; c.Modifier.IsValid() { switch modifier { case models.CriterionModifierIncludes: clause := getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not) clause2 := getStringSearchClause([]string{folderPathColumn}, c.Value, false) f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) case models.CriterionModifierExcludes: not = true clause := getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not) clause2 := getStringSearchClause([]string{folderPathColumn}, c.Value, true) f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) case models.CriterionModifierEquals: addWildcards = false clause := getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not) clause2 := makeClause(folderPathColumn+" LIKE ?", c.Value) f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) case models.CriterionModifierNotEquals: addWildcards = false not = true clause := getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not) clause2 := makeClause(folderPathColumn+" NOT LIKE ?", c.Value) f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) case models.CriterionModifierMatchesRegex: if _, err := regexp.Compile(c.Value); err != nil { f.setError(err) return } filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) clause := makeClause(fmt.Sprintf("%s IS NOT NULL AND %s IS NOT NULL AND %s regexp ?", pathColumn, basenameColumn, filepathColumn), c.Value) clause2 := makeClause(fmt.Sprintf("%s IS NOT NULL AND %[1]s regexp ?", folderPathColumn), c.Value) f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) case models.CriterionModifierNotMatchesRegex: if _, err := regexp.Compile(c.Value); err != nil { f.setError(err) return } filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) f.addWhere(fmt.Sprintf("%s IS NULL OR %s IS NULL OR %s NOT regexp ?", pathColumn, basenameColumn, filepathColumn), c.Value) f.addWhere(fmt.Sprintf("%s IS NULL OR %[1]s NOT regexp ?", folderPathColumn), c.Value) case models.CriterionModifierIsNull: f.addWhere(fmt.Sprintf("%s IS NULL OR TRIM(%[1]s) = '' OR %s IS NULL OR TRIM(%[2]s) = ''", pathColumn, basenameColumn)) f.addWhere(fmt.Sprintf("%s IS NULL OR TRIM(%[1]s) = ''", folderPathColumn)) case models.CriterionModifierNotNull: clause := makeClause(fmt.Sprintf("%s IS NOT NULL AND TRIM(%[1]s) != '' AND %s IS NOT NULL AND TRIM(%[2]s) != ''", pathColumn, basenameColumn)) clause2 := makeClause(fmt.Sprintf("%s IS NOT NULL AND TRIM(%[1]s) != ''", folderPathColumn)) f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) default: panic("unsupported string filter modifier") } } } } } func (qb *galleryFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if folder == nil { return } galleryRepository.addFoldersTable(f) f.addLeftJoin(folderTable, "gallery_folder", "galleries.folder_id = gallery_folder.id") criterion := *folder switch criterion.Modifier { case models.CriterionModifierEquals: criterion.Modifier = models.CriterionModifierIncludes case models.CriterionModifierNotEquals: criterion.Modifier = models.CriterionModifierExcludes } // only allow includes or excludes filters if criterion.Modifier != models.CriterionModifierIncludes && criterion.Modifier != models.CriterionModifierExcludes { f.setError(fmt.Errorf("invalid modifier for parent folder criterion: %s", criterion.Modifier)) } if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { return } // combine excludes if excludes modifier is selected if criterion.Modifier == models.CriterionModifierExcludes { criterion.Modifier = models.CriterionModifierIncludes criterion.Excludes = append(criterion.Excludes, criterion.Value...) criterion.Value = nil } if len(criterion.Value) > 0 { valuesClause, err := getHierarchicalValues(ctx, criterion.Value, "folders", "", "parent_folder_id", "parent_folder_id", criterion.Depth) if err != nil { f.setError(err) return } // combine clauses with OR to handle zip file or folder c1 := makeClause(fmt.Sprintf("folders.parent_folder_id IN (SELECT column2 FROM (%s))", valuesClause)) c2 := makeClause(fmt.Sprintf("gallery_folder.parent_folder_id IN (SELECT column2 FROM (%s))", valuesClause)) f.whereClauses = append(f.whereClauses, orClauses(c1, c2)) } if len(criterion.Excludes) > 0 { valuesClause, err := getHierarchicalValues(ctx, criterion.Excludes, "folders", "", "parent_folder_id", "parent_folder_id", criterion.Depth) if err != nil { f.setError(err) return } f.addWhere(fmt.Sprintf("folders.parent_folder_id NOT IN (SELECT column2 FROM (%s)) OR folders.parent_folder_id IS NULL", valuesClause)) f.addWhere(fmt.Sprintf("gallery_folder.parent_folder_id NOT IN (SELECT column2 FROM (%s)) OR gallery_folder.parent_folder_id IS NULL", valuesClause)) } } } func (qb *galleryFilterHandler) fileCountCriterionHandler(fileCount *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: galleryTable, joinTable: galleriesFilesTable, primaryFK: galleryIDColumn, } return h.handler(fileCount) } func (qb *galleryFilterHandler) missingCriterionHandler(isMissing *string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if isMissing != nil && *isMissing != "" { switch *isMissing { case "url": galleriesURLsTableMgr.join(f, "", "galleries.id") f.addWhere("gallery_urls.url IS NULL") case "scenes": f.addLeftJoin("scenes_galleries", "scenes_join", "scenes_join.gallery_id = galleries.id") f.addWhere("scenes_join.gallery_id IS NULL") case "studio": f.addWhere("galleries.studio_id IS NULL") case "performers": galleryRepository.performers.join(f, "performers_join", "galleries.id") f.addWhere("performers_join.gallery_id IS NULL") case "date": f.addWhere("galleries.date IS NULL OR galleries.date IS \"\"") case "tags": galleryRepository.tags.join(f, "tags_join", "galleries.id") f.addWhere("tags_join.gallery_id IS NULL") case "cover": f.addLeftJoin("galleries_images", "cover_join", "cover_join.gallery_id = galleries.id AND cover_join.cover = 1") f.addWhere("cover_join.image_id IS NULL") default: if err := validateIsMissing(*isMissing, []string{ "title", "code", "rating", "details", "photographer", }); err != nil { f.setError(err) return } f.addWhere("(galleries." + *isMissing + " IS NULL OR TRIM(galleries." + *isMissing + ") = '')") } } } } func (qb *galleryFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { h := joinedHierarchicalMultiCriterionHandlerBuilder{ primaryTable: galleryTable, foreignTable: tagTable, foreignFK: "tag_id", relationsTable: "tags_relations", joinAs: "gallery_tag", joinTable: galleriesTagsTable, primaryFK: galleryIDColumn, } return h.handler(tags) } func (qb *galleryFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: galleryTable, joinTable: galleriesTagsTable, primaryFK: galleryIDColumn, } return h.handler(tagCount) } func (qb *galleryFilterHandler) scenesCriterionHandler(scenes *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { galleryRepository.scenes.join(f, "", "galleries.id") f.addLeftJoin("scenes", "", "scenes_galleries.scene_id = scenes.id") } h := qb.getMultiCriterionHandlerBuilder(sceneTable, galleriesScenesTable, "scene_id", addJoinsFunc) return h.handler(scenes) } func (qb *galleryFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { h := joinedMultiCriterionHandlerBuilder{ primaryTable: galleryTable, joinTable: performersGalleriesTable, joinAs: "performers_join", primaryFK: galleryIDColumn, foreignFK: performerIDColumn, addJoinTable: func(f *filterBuilder) { galleryRepository.performers.join(f, "performers_join", "galleries.id") }, } return h.handler(performers) } func (qb *galleryFilterHandler) performerCountCriterionHandler(performerCount *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: galleryTable, joinTable: performersGalleriesTable, primaryFK: galleryIDColumn, } return h.handler(performerCount) } func (qb *galleryFilterHandler) imageCountCriterionHandler(imageCount *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: galleryTable, joinTable: galleriesImagesTable, primaryFK: galleryIDColumn, } return h.handler(imageCount) } func (qb *galleryFilterHandler) hasChaptersCriterionHandler(hasChapters *string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if hasChapters != nil { f.addLeftJoin("galleries_chapters", "", "galleries_chapters.gallery_id = galleries.id") if *hasChapters == "true" { f.addHaving("count(galleries_chapters.gallery_id) > 0") } else { f.addWhere("galleries_chapters.id IS NULL") } } } } func (qb *galleryFilterHandler) performerTagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandler { return &joinedPerformerTagsHandler{ criterion: tags, primaryTable: galleryTable, joinTable: performersGalleriesTable, joinPrimaryKey: galleryIDColumn, } } func (qb *galleryFilterHandler) performerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if performerfavorite != nil { f.addLeftJoin("performers_galleries", "", "galleries.id = performers_galleries.gallery_id") if *performerfavorite { // contains at least one favorite f.addLeftJoin("performers", "", "performers.id = performers_galleries.performer_id") f.addWhere("performers.favorite = 1") } else { // contains zero favorites f.addLeftJoin(`(SELECT performers_galleries.gallery_id as id FROM performers_galleries JOIN performers ON performers.id = performers_galleries.performer_id GROUP BY performers_galleries.gallery_id HAVING SUM(performers.favorite) = 0)`, "nofaves", "galleries.id = nofaves.id") f.addWhere("performers_galleries.gallery_id IS NULL OR nofaves.id IS NOT NULL") } } } } func (qb *galleryFilterHandler) performerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if performerAge != nil { f.addInnerJoin("performers_galleries", "", "galleries.id = performers_galleries.gallery_id") f.addInnerJoin("performers", "", "performers_galleries.performer_id = performers.id") f.addWhere("galleries.date != '' AND performers.birthdate != ''") f.addWhere("galleries.date IS NOT NULL AND performers.birthdate IS NOT NULL") ageCalc := "cast(strftime('%Y.%m%d', galleries.date) - strftime('%Y.%m%d', performers.birthdate) as int)" whereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2) f.addWhere(whereClause, args...) } } } func (qb *galleryFilterHandler) averageResolutionCriterionHandler(resolution *models.ResolutionCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if resolution != nil && resolution.Value.IsValid() { galleryRepository.images.join(f, "images_join", "galleries.id") f.addLeftJoin("images", "", "images_join.image_id = images.id") f.addLeftJoin("images_files", "", "images.id = images_files.image_id") f.addLeftJoin("image_files", "", "images_files.file_id = image_files.file_id") mn := resolution.Value.GetMinResolution() mx := resolution.Value.GetMaxResolution() const widthHeight = "avg(MIN(image_files.width, image_files.height))" switch resolution.Modifier { case models.CriterionModifierEquals: f.addHaving(fmt.Sprintf("%s BETWEEN %d AND %d", widthHeight, mn, mx)) case models.CriterionModifierNotEquals: f.addHaving(fmt.Sprintf("%s NOT BETWEEN %d AND %d", widthHeight, mn, mx)) case models.CriterionModifierLessThan: f.addHaving(fmt.Sprintf("%s < %d", widthHeight, mn)) case models.CriterionModifierGreaterThan: f.addHaving(fmt.Sprintf("%s > %d", widthHeight, mx)) } } } } ================================================ FILE: pkg/sqlite/gallery_test.go ================================================ //go:build integration // +build integration package sqlite_test import ( "context" "math" "strconv" "testing" "time" "github.com/stashapp/stash/pkg/models" "github.com/stretchr/testify/assert" ) var invalidID = -1 func loadGalleryRelationships(ctx context.Context, expected models.Gallery, actual *models.Gallery) error { if expected.URLs.Loaded() { if err := actual.LoadURLs(ctx, db.Gallery); err != nil { return err } } if expected.SceneIDs.Loaded() { if err := actual.LoadSceneIDs(ctx, db.Gallery); err != nil { return err } } if expected.TagIDs.Loaded() { if err := actual.LoadTagIDs(ctx, db.Gallery); err != nil { return err } } if expected.PerformerIDs.Loaded() { if err := actual.LoadPerformerIDs(ctx, db.Gallery); err != nil { return err } } if expected.Files.Loaded() { if err := actual.LoadFiles(ctx, db.Gallery); err != nil { return err } } // clear Path, Checksum, PrimaryFileID if expected.Path == "" { actual.Path = "" } if expected.PrimaryFileID == nil { actual.PrimaryFileID = nil } return nil } func Test_galleryQueryBuilder_Create(t *testing.T) { var ( title = "title" code = "1337" url = "url" rating = 60 details = "details" photographer = "photographer" createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) galleryFile = makeFileWithID(fileIdxStartGalleryFiles) ) date, _ := models.ParseDate("2003-02-01") tests := []struct { name string newObject models.Gallery wantErr bool }{ { "full", models.Gallery{ Title: title, Code: code, URLs: models.NewRelatedStrings([]string{url}), Date: &date, Details: details, Photographer: photographer, Rating: &rating, Organized: true, StudioID: &studioIDs[studioIdxWithScene], CreatedAt: createdAt, UpdatedAt: updatedAt, SceneIDs: models.NewRelatedIDs([]int{sceneIDs[sceneIdx1WithPerformer], sceneIDs[sceneIdx1WithStudio]}), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithScene]}), PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithDupName]}), }, false, }, { "with file", models.Gallery{ Title: title, Code: code, URLs: models.NewRelatedStrings([]string{url}), Date: &date, Details: details, Photographer: photographer, Rating: &rating, Organized: true, StudioID: &studioIDs[studioIdxWithScene], Files: models.NewRelatedFiles([]models.File{ galleryFile, }), CreatedAt: createdAt, UpdatedAt: updatedAt, SceneIDs: models.NewRelatedIDs([]int{sceneIDs[sceneIdx1WithPerformer], sceneIDs[sceneIdx1WithStudio]}), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithScene]}), PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithDupName]}), }, false, }, { "invalid studio id", models.Gallery{ StudioID: &invalidID, }, true, }, { "invalid scene id", models.Gallery{ SceneIDs: models.NewRelatedIDs([]int{invalidID}), }, true, }, { "invalid tag id", models.Gallery{ TagIDs: models.NewRelatedIDs([]int{invalidID}), }, true, }, { "invalid performer id", models.Gallery{ PerformerIDs: models.NewRelatedIDs([]int{invalidID}), }, true, }, } qb := db.Gallery for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) s := tt.newObject var fileIDs []models.FileID if s.Files.Loaded() { fileIDs = []models.FileID{s.Files.List()[0].Base().ID} } if err := qb.Create(ctx, &models.CreateGalleryInput{ Gallery: &s, FileIDs: fileIDs, }); (err != nil) != tt.wantErr { t.Errorf("galleryQueryBuilder.Create() error = %v, wantErr = %v", err, tt.wantErr) } if tt.wantErr { assert.Zero(s.ID) return } assert.NotZero(s.ID) copy := tt.newObject copy.ID = s.ID // load relationships if err := loadGalleryRelationships(ctx, copy, &s); err != nil { t.Errorf("loadGalleryRelationships() error = %v", err) return } assert.Equal(copy, s) // ensure can find the scene found, err := qb.Find(ctx, s.ID) if err != nil { t.Errorf("galleryQueryBuilder.Find() error = %v", err) } if !assert.NotNil(found) { return } // load relationships if err := loadGalleryRelationships(ctx, copy, found); err != nil { t.Errorf("loadGalleryRelationships() error = %v", err) return } assert.Equal(copy, *found) return }) } } func makeGalleryFileWithID(i int) *models.BaseFile { ret := makeGalleryFile(i) ret.ID = galleryFileIDs[i] return ret } func Test_galleryQueryBuilder_Update(t *testing.T) { var ( title = "title" code = "code" url = "url" rating = 60 details = "details" photographer = "photographer" createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) ) date, _ := models.ParseDate("2003-02-01") tests := []struct { name string updatedObject *models.Gallery wantErr bool }{ { "full", &models.Gallery{ ID: galleryIDs[galleryIdxWithScene], Title: title, Code: code, URLs: models.NewRelatedStrings([]string{url}), Date: &date, Details: details, Photographer: photographer, Rating: &rating, Organized: true, StudioID: &studioIDs[studioIdxWithScene], Files: models.NewRelatedFiles([]models.File{ makeGalleryFileWithID(galleryIdxWithScene), }), CreatedAt: createdAt, UpdatedAt: updatedAt, SceneIDs: models.NewRelatedIDs([]int{sceneIDs[sceneIdx1WithPerformer], sceneIDs[sceneIdx1WithStudio]}), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithScene]}), PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithDupName]}), }, false, }, { "clear nullables", &models.Gallery{ ID: galleryIDs[galleryIdxWithImage], URLs: models.NewRelatedStrings([]string{}), SceneIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), PerformerIDs: models.NewRelatedIDs([]int{}), Organized: true, CreatedAt: createdAt, UpdatedAt: updatedAt, }, false, }, { "clear scene ids", &models.Gallery{ ID: galleryIDs[galleryIdxWithScene], SceneIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), PerformerIDs: models.NewRelatedIDs([]int{}), Organized: true, CreatedAt: createdAt, UpdatedAt: updatedAt, }, false, }, { "clear tag ids", &models.Gallery{ ID: galleryIDs[galleryIdxWithTag], SceneIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), PerformerIDs: models.NewRelatedIDs([]int{}), Organized: true, CreatedAt: createdAt, UpdatedAt: updatedAt, }, false, }, { "clear performer ids", &models.Gallery{ ID: galleryIDs[galleryIdxWithPerformer], SceneIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), PerformerIDs: models.NewRelatedIDs([]int{}), Organized: true, CreatedAt: createdAt, UpdatedAt: updatedAt, }, false, }, { "invalid studio id", &models.Gallery{ ID: galleryIDs[galleryIdxWithImage], Organized: true, StudioID: &invalidID, CreatedAt: createdAt, UpdatedAt: updatedAt, }, true, }, { "invalid scene id", &models.Gallery{ ID: galleryIDs[galleryIdxWithImage], Organized: true, SceneIDs: models.NewRelatedIDs([]int{invalidID}), CreatedAt: createdAt, UpdatedAt: updatedAt, }, true, }, { "invalid tag id", &models.Gallery{ ID: galleryIDs[galleryIdxWithImage], Organized: true, TagIDs: models.NewRelatedIDs([]int{invalidID}), CreatedAt: createdAt, UpdatedAt: updatedAt, }, true, }, { "invalid performer id", &models.Gallery{ ID: galleryIDs[galleryIdxWithImage], Organized: true, PerformerIDs: models.NewRelatedIDs([]int{invalidID}), CreatedAt: createdAt, UpdatedAt: updatedAt, }, true, }, } qb := db.Gallery for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) copy := *tt.updatedObject if err := qb.Update(ctx, &models.UpdateGalleryInput{ Gallery: tt.updatedObject, }); (err != nil) != tt.wantErr { t.Errorf("galleryQueryBuilder.Update() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { return } s, err := qb.Find(ctx, tt.updatedObject.ID) if err != nil { t.Errorf("galleryQueryBuilder.Find() error = %v", err) return } // load relationships if err := loadGalleryRelationships(ctx, copy, s); err != nil { t.Errorf("loadGalleryRelationships() error = %v", err) return } assert.Equal(copy, *s) return }) } } func clearGalleryFileIDs(gallery *models.Gallery) { if gallery.Files.Loaded() { for _, f := range gallery.Files.List() { f.Base().ID = 0 } } } func clearGalleryPartial() models.GalleryPartial { // leave mandatory fields return models.GalleryPartial{ Title: models.OptionalString{Set: true, Null: true}, Code: models.OptionalString{Set: true, Null: true}, Details: models.OptionalString{Set: true, Null: true}, Photographer: models.OptionalString{Set: true, Null: true}, URLs: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet}, Date: models.OptionalDate{Set: true, Null: true}, Rating: models.OptionalInt{Set: true, Null: true}, StudioID: models.OptionalInt{Set: true, Null: true}, TagIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet}, PerformerIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet}, } } func Test_galleryQueryBuilder_UpdatePartial(t *testing.T) { var ( title = "title" code = "code" details = "details" photographer = "photographer" url = "url" rating = 60 createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) date, _ = models.ParseDate("2003-02-01") ) tests := []struct { name string id int partial models.GalleryPartial want models.Gallery wantErr bool }{ { "full", galleryIDs[galleryIdxWithImage], models.GalleryPartial{ Title: models.NewOptionalString(title), Code: models.NewOptionalString(code), Details: models.NewOptionalString(details), Photographer: models.NewOptionalString(photographer), URLs: &models.UpdateStrings{ Values: []string{url}, Mode: models.RelationshipUpdateModeSet, }, Date: models.NewOptionalDate(date), Rating: models.NewOptionalInt(rating), Organized: models.NewOptionalBool(true), StudioID: models.NewOptionalInt(studioIDs[studioIdxWithGallery]), CreatedAt: models.NewOptionalTime(createdAt), UpdatedAt: models.NewOptionalTime(updatedAt), SceneIDs: &models.UpdateIDs{ IDs: []int{sceneIDs[sceneIdxWithGallery]}, Mode: models.RelationshipUpdateModeSet, }, TagIDs: &models.UpdateIDs{ IDs: []int{tagIDs[tagIdx1WithGallery], tagIDs[tagIdx1WithDupName]}, Mode: models.RelationshipUpdateModeSet, }, PerformerIDs: &models.UpdateIDs{ IDs: []int{performerIDs[performerIdx1WithGallery], performerIDs[performerIdx1WithDupName]}, Mode: models.RelationshipUpdateModeSet, }, }, models.Gallery{ ID: galleryIDs[galleryIdxWithImage], Title: title, Code: code, Details: details, Photographer: photographer, URLs: models.NewRelatedStrings([]string{url}), Date: &date, Rating: &rating, Organized: true, StudioID: &studioIDs[studioIdxWithGallery], Files: models.NewRelatedFiles([]models.File{ makeGalleryFile(galleryIdxWithImage), }), CreatedAt: createdAt, UpdatedAt: updatedAt, SceneIDs: models.NewRelatedIDs([]int{sceneIDs[sceneIdxWithGallery]}), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGallery]}), PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithGallery], performerIDs[performerIdx1WithDupName]}), }, false, }, { "clear all", galleryIDs[galleryIdxWithImage], clearGalleryPartial(), models.Gallery{ ID: galleryIDs[galleryIdxWithImage], Files: models.NewRelatedFiles([]models.File{ makeGalleryFile(galleryIdxWithImage), }), SceneIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), PerformerIDs: models.NewRelatedIDs([]int{}), }, false, }, { "invalid id", invalidID, models.GalleryPartial{}, models.Gallery{}, true, }, } for _, tt := range tests { qb := db.Gallery runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.UpdatePartial(ctx, tt.id, tt.partial) if (err != nil) != tt.wantErr { t.Errorf("galleryQueryBuilder.UpdatePartial() error = %v, wantErr %v", err, tt.wantErr) return } if tt.wantErr { return } // load relationships if err := loadGalleryRelationships(ctx, tt.want, got); err != nil { t.Errorf("loadGalleryRelationships() error = %v", err) return } clearGalleryFileIDs(got) assert.Equal(tt.want, *got) s, err := qb.Find(ctx, tt.id) if err != nil { t.Errorf("galleryQueryBuilder.Find() error = %v", err) } // load relationships if err := loadGalleryRelationships(ctx, tt.want, s); err != nil { t.Errorf("loadGalleryRelationships() error = %v", err) return } clearGalleryFileIDs(s) assert.Equal(tt.want, *s) }) } } func Test_galleryQueryBuilder_UpdatePartialRelationships(t *testing.T) { tests := []struct { name string id int partial models.GalleryPartial want models.Gallery wantErr bool }{ { "add scenes", galleryIDs[galleryIdx1WithImage], models.GalleryPartial{ SceneIDs: &models.UpdateIDs{ IDs: []int{tagIDs[sceneIdx1WithStudio], tagIDs[sceneIdx1WithPerformer]}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Gallery{ SceneIDs: models.NewRelatedIDs(append(indexesToIDs(sceneIDs, sceneGalleries.reverseLookup(galleryIdx1WithImage)), sceneIDs[sceneIdx1WithStudio], sceneIDs[sceneIdx1WithPerformer], )), }, false, }, { "add tags", galleryIDs[galleryIdxWithTwoTags], models.GalleryPartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Gallery{ TagIDs: models.NewRelatedIDs(append(indexesToIDs(tagIDs, galleryTags[galleryIdxWithTwoTags]), tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage], )), }, false, }, { "add performers", galleryIDs[galleryIdxWithTwoPerformers], models.GalleryPartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerIDs[performerIdx1WithDupName], performerIDs[performerIdx1WithImage]}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Gallery{ PerformerIDs: models.NewRelatedIDs(append(indexesToIDs(performerIDs, galleryPerformers[galleryIdxWithTwoPerformers]), performerIDs[performerIdx1WithDupName], performerIDs[performerIdx1WithImage], )), }, false, }, { "add duplicate scenes", galleryIDs[galleryIdxWithScene], models.GalleryPartial{ SceneIDs: &models.UpdateIDs{ IDs: []int{sceneIDs[sceneIdxWithGallery], sceneIDs[sceneIdx1WithPerformer]}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Gallery{ SceneIDs: models.NewRelatedIDs(append(indexesToIDs(sceneIDs, sceneGalleries.reverseLookup(galleryIdxWithScene)), sceneIDs[sceneIdx1WithPerformer], )), }, false, }, { "add duplicate tags", galleryIDs[galleryIdxWithTwoTags], models.GalleryPartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagIDs[tagIdx1WithGallery], tagIDs[tagIdx1WithScene]}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Gallery{ TagIDs: models.NewRelatedIDs(append(indexesToIDs(tagIDs, galleryTags[galleryIdxWithTwoTags]), tagIDs[tagIdx1WithScene], )), }, false, }, { "add duplicate performers", galleryIDs[galleryIdxWithTwoPerformers], models.GalleryPartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerIDs[performerIdx1WithGallery], performerIDs[performerIdx1WithScene]}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Gallery{ PerformerIDs: models.NewRelatedIDs(append(indexesToIDs(performerIDs, galleryPerformers[galleryIdxWithTwoPerformers]), performerIDs[performerIdx1WithScene], )), }, false, }, { "add invalid scenes", galleryIDs[galleryIdxWithScene], models.GalleryPartial{ SceneIDs: &models.UpdateIDs{ IDs: []int{invalidID}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Gallery{}, true, }, { "add invalid tags", galleryIDs[galleryIdxWithTwoTags], models.GalleryPartial{ TagIDs: &models.UpdateIDs{ IDs: []int{invalidID}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Gallery{}, true, }, { "add invalid performers", galleryIDs[galleryIdxWithTwoPerformers], models.GalleryPartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{invalidID}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Gallery{}, true, }, { "remove scenes", galleryIDs[galleryIdxWithScene], models.GalleryPartial{ SceneIDs: &models.UpdateIDs{ IDs: []int{sceneIDs[sceneIdxWithGallery]}, Mode: models.RelationshipUpdateModeRemove, }, }, models.Gallery{ SceneIDs: models.NewRelatedIDs([]int{}), }, false, }, { "remove tags", galleryIDs[galleryIdxWithTwoTags], models.GalleryPartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagIDs[tagIdx1WithGallery]}, Mode: models.RelationshipUpdateModeRemove, }, }, models.Gallery{ TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx2WithGallery]}), }, false, }, { "remove performers", galleryIDs[galleryIdxWithTwoPerformers], models.GalleryPartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerIDs[performerIdx1WithGallery]}, Mode: models.RelationshipUpdateModeRemove, }, }, models.Gallery{ PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx2WithGallery]}), }, false, }, { "remove unrelated scenes", galleryIDs[galleryIdxWithScene], models.GalleryPartial{ SceneIDs: &models.UpdateIDs{ IDs: []int{tagIDs[sceneIdx1WithPerformer]}, Mode: models.RelationshipUpdateModeRemove, }, }, models.Gallery{ SceneIDs: models.NewRelatedIDs([]int{sceneIDs[sceneIdxWithGallery]}), }, false, }, { "remove unrelated tags", galleryIDs[galleryIdxWithTwoTags], models.GalleryPartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagIDs[tagIdx1WithPerformer]}, Mode: models.RelationshipUpdateModeRemove, }, }, models.Gallery{ TagIDs: models.NewRelatedIDs(indexesToIDs(tagIDs, galleryTags[galleryIdxWithTwoTags])), }, false, }, { "remove unrelated performers", galleryIDs[galleryIdxWithTwoPerformers], models.GalleryPartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerIDs[performerIdx1WithDupName]}, Mode: models.RelationshipUpdateModeRemove, }, }, models.Gallery{ PerformerIDs: models.NewRelatedIDs(indexesToIDs(performerIDs, galleryPerformers[galleryIdxWithTwoPerformers])), }, false, }, } for _, tt := range tests { qb := db.Gallery runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.UpdatePartial(ctx, tt.id, tt.partial) if (err != nil) != tt.wantErr { t.Errorf("galleryQueryBuilder.UpdatePartial() error = %v, wantErr %v", err, tt.wantErr) return } if tt.wantErr { return } s, err := qb.Find(ctx, tt.id) if err != nil { t.Errorf("galleryQueryBuilder.Find() error = %v", err) } // load relationships if err := loadGalleryRelationships(ctx, tt.want, got); err != nil { t.Errorf("loadGalleryRelationships() error = %v", err) return } if err := loadGalleryRelationships(ctx, tt.want, s); err != nil { t.Errorf("loadGalleryRelationships() error = %v", err) return } // only compare fields that were in the partial if tt.partial.PerformerIDs != nil { assert.ElementsMatch(tt.want.PerformerIDs.List(), got.PerformerIDs.List()) assert.ElementsMatch(tt.want.PerformerIDs.List(), s.PerformerIDs.List()) } if tt.partial.TagIDs != nil { assert.ElementsMatch(tt.want.TagIDs.List(), got.TagIDs.List()) assert.ElementsMatch(tt.want.TagIDs.List(), s.TagIDs.List()) } if tt.partial.SceneIDs != nil { assert.ElementsMatch(tt.want.SceneIDs.List(), got.SceneIDs.List()) assert.ElementsMatch(tt.want.SceneIDs.List(), s.SceneIDs.List()) } }) } } func Test_GalleryStore_UpdatePartialCustomFields(t *testing.T) { tests := []struct { name string id int partial models.GalleryPartial expected map[string]interface{} // nil to use the partial }{ { "set custom fields", galleryIDs[galleryIdx1WithImage], models.GalleryPartial{ CustomFields: models.CustomFieldsInput{ Full: testCustomFields, }, }, nil, }, { "clear custom fields", galleryIDs[galleryIdx1WithImage], models.GalleryPartial{ CustomFields: models.CustomFieldsInput{ Full: map[string]interface{}{}, }, }, nil, }, { "partial custom fields", galleryIDs[galleryIdxWithTwoTags], models.GalleryPartial{ CustomFields: models.CustomFieldsInput{ Partial: map[string]interface{}{ "string": "bbb", "new_field": "new", }, }, }, map[string]interface{}{ "int": int64(2), "real": 1.2, "string": "bbb", "new_field": "new", }, }, } for _, tt := range tests { qb := db.Gallery runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) _, err := qb.UpdatePartial(ctx, tt.id, tt.partial) if err != nil { t.Errorf("GalleryStore.UpdatePartial() error = %v", err) return } // ensure custom fields are correct cf, err := qb.GetCustomFields(ctx, tt.id) if err != nil { t.Errorf("GalleryStore.GetCustomFields() error = %v", err) return } if tt.expected == nil { assert.Equal(tt.partial.CustomFields.Full, cf) } else { assert.Equal(tt.expected, cf) } }) } } func Test_galleryQueryBuilder_Destroy(t *testing.T) { tests := []struct { name string id int wantErr bool }{ { "valid", galleryIDs[galleryIdxWithScene], false, }, { "invalid", invalidID, true, }, } qb := db.Gallery for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) if err := qb.Destroy(ctx, tt.id); (err != nil) != tt.wantErr { t.Errorf("galleryQueryBuilder.Destroy() error = %v, wantErr %v", err, tt.wantErr) } // ensure cannot be found i, err := qb.Find(ctx, tt.id) assert.Nil(err) assert.Nil(i) return }) } } func makeGalleryWithID(index int) *models.Gallery { const includeScenes = true ret := makeGallery(index, includeScenes) ret.ID = galleryIDs[index] ret.Files = models.NewRelatedFiles([]models.File{makeGalleryFile(index)}) return ret } func Test_galleryQueryBuilder_Find(t *testing.T) { tests := []struct { name string id int want *models.Gallery wantErr bool }{ { "valid", galleryIDs[galleryIdxWithImage], makeGalleryWithID(galleryIdxWithImage), false, }, { "invalid", invalidID, nil, false, }, { "with performers", galleryIDs[galleryIdxWithTwoPerformers], makeGalleryWithID(galleryIdxWithTwoPerformers), false, }, { "with tags", galleryIDs[galleryIdxWithTwoTags], makeGalleryWithID(galleryIdxWithTwoTags), false, }, } qb := db.Gallery for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.Find(ctx, tt.id) if (err != nil) != tt.wantErr { t.Errorf("galleryQueryBuilder.Find() error = %v, wantErr %v", err, tt.wantErr) return } if got != nil { // load relationships if err := loadGalleryRelationships(ctx, *tt.want, got); err != nil { t.Errorf("loadGalleryRelationships() error = %v", err) return } clearGalleryFileIDs(got) } assert.Equal(tt.want, got) }) } } func postFindGalleries(ctx context.Context, want []*models.Gallery, got []*models.Gallery) error { for i, s := range got { // load relationships if i < len(want) { if err := loadGalleryRelationships(ctx, *want[i], s); err != nil { return err } } clearGalleryFileIDs(s) } return nil } func Test_galleryQueryBuilder_FindMany(t *testing.T) { tests := []struct { name string ids []int want []*models.Gallery wantErr bool }{ { "valid with relationships", []int{galleryIDs[galleryIdxWithImage], galleryIDs[galleryIdxWithTwoPerformers], galleryIDs[galleryIdxWithTwoTags]}, []*models.Gallery{ makeGalleryWithID(galleryIdxWithImage), makeGalleryWithID(galleryIdxWithTwoPerformers), makeGalleryWithID(galleryIdxWithTwoTags), }, false, }, { "invalid", []int{galleryIDs[galleryIdxWithImage], galleryIDs[galleryIdxWithTwoPerformers], invalidID}, nil, true, }, } qb := db.Gallery for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.FindMany(ctx, tt.ids) if (err != nil) != tt.wantErr { t.Errorf("galleryQueryBuilder.FindMany() error = %v, wantErr %v", err, tt.wantErr) return } if err := postFindGalleries(ctx, tt.want, got); err != nil { t.Errorf("loadGalleryRelationships() error = %v", err) return } assert.Equal(tt.want, got) }) } } func Test_galleryQueryBuilder_FindByChecksum(t *testing.T) { getChecksum := func(index int) string { return getGalleryStringValue(index, checksumField) } tests := []struct { name string checksum string want []*models.Gallery wantErr bool }{ { "valid", getChecksum(galleryIdxWithImage), []*models.Gallery{makeGalleryWithID(galleryIdxWithImage)}, false, }, { "invalid", "invalid checksum", nil, false, }, { "with performers", getChecksum(galleryIdxWithTwoPerformers), []*models.Gallery{makeGalleryWithID(galleryIdxWithTwoPerformers)}, false, }, { "with tags", getChecksum(galleryIdxWithTwoTags), []*models.Gallery{makeGalleryWithID(galleryIdxWithTwoTags)}, false, }, } qb := db.Gallery for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.FindByChecksum(ctx, tt.checksum) if (err != nil) != tt.wantErr { t.Errorf("galleryQueryBuilder.FindByChecksum() error = %v, wantErr %v", err, tt.wantErr) return } if err := postFindGalleries(ctx, tt.want, got); err != nil { t.Errorf("loadGalleryRelationships() error = %v", err) return } assert.Equal(tt.want, got) }) } } func Test_galleryQueryBuilder_FindByChecksums(t *testing.T) { getChecksum := func(index int) string { return getGalleryStringValue(index, checksumField) } tests := []struct { name string checksums []string want []*models.Gallery wantErr bool }{ { "valid with relationships", []string{ getChecksum(galleryIdxWithImage), getChecksum(galleryIdxWithTwoPerformers), getChecksum(galleryIdxWithTwoTags), }, []*models.Gallery{ makeGalleryWithID(galleryIdxWithImage), makeGalleryWithID(galleryIdxWithTwoPerformers), makeGalleryWithID(galleryIdxWithTwoTags), }, false, }, { "with invalid", []string{ getChecksum(galleryIdxWithImage), getChecksum(galleryIdxWithTwoPerformers), "invalid checksum", getChecksum(galleryIdxWithTwoTags), }, []*models.Gallery{ makeGalleryWithID(galleryIdxWithImage), makeGalleryWithID(galleryIdxWithTwoPerformers), makeGalleryWithID(galleryIdxWithTwoTags), }, false, }, } qb := db.Gallery for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.FindByChecksums(ctx, tt.checksums) if (err != nil) != tt.wantErr { t.Errorf("galleryQueryBuilder.FindByChecksum() error = %v, wantErr %v", err, tt.wantErr) return } if err := postFindGalleries(ctx, tt.want, got); err != nil { t.Errorf("loadGalleryRelationships() error = %v", err) return } assert.Equal(tt.want, got) }) } } func Test_galleryQueryBuilder_FindByPath(t *testing.T) { getPath := func(index int) string { return getFilePath(folderIdxWithGalleryFiles, getGalleryBasename(index)) } tests := []struct { name string path string want []*models.Gallery wantErr bool }{ { "valid", getPath(galleryIdxWithImage), []*models.Gallery{makeGalleryWithID(galleryIdxWithImage)}, false, }, { "invalid", "invalid path", nil, false, }, { "with performers", getPath(galleryIdxWithTwoPerformers), []*models.Gallery{makeGalleryWithID(galleryIdxWithTwoPerformers)}, false, }, { "with tags", getPath(galleryIdxWithTwoTags), []*models.Gallery{makeGalleryWithID(galleryIdxWithTwoTags)}, false, }, } qb := db.Gallery for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.FindByPath(ctx, tt.path) if (err != nil) != tt.wantErr { t.Errorf("galleryQueryBuilder.FindByPath() error = %v, wantErr %v", err, tt.wantErr) return } if err := postFindGalleries(ctx, tt.want, got); err != nil { t.Errorf("loadGalleryRelationships() error = %v", err) return } assert.Equal(tt.want, got) }) } } func Test_galleryQueryBuilder_FindBySceneID(t *testing.T) { tests := []struct { name string sceneID int want []*models.Gallery wantErr bool }{ { "valid", sceneIDs[sceneIdxWithGallery], []*models.Gallery{makeGalleryWithID(galleryIdxWithScene)}, false, }, { "none", sceneIDs[sceneIdx1WithPerformer], nil, false, }, } qb := db.Gallery for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.FindBySceneID(ctx, tt.sceneID) if (err != nil) != tt.wantErr { t.Errorf("galleryQueryBuilder.FindBySceneID() error = %v, wantErr %v", err, tt.wantErr) return } if err := postFindGalleries(ctx, tt.want, got); err != nil { t.Errorf("loadGalleryRelationships() error = %v", err) return } assert.Equal(tt.want, got) }) } } func Test_galleryQueryBuilder_FindByImageID(t *testing.T) { tests := []struct { name string imageID int want []*models.Gallery wantErr bool }{ { "valid", imageIDs[imageIdxWithTwoGalleries], []*models.Gallery{ makeGalleryWithID(galleryIdx1WithImage), makeGalleryWithID(galleryIdx2WithImage), }, false, }, { "none", imageIDs[imageIdx1WithPerformer], nil, false, }, } qb := db.Gallery for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.FindByImageID(ctx, tt.imageID) if (err != nil) != tt.wantErr { t.Errorf("galleryQueryBuilder.FindByImageID() error = %v, wantErr %v", err, tt.wantErr) return } if err := postFindGalleries(ctx, tt.want, got); err != nil { t.Errorf("loadGalleryRelationships() error = %v", err) return } assert.Equal(tt.want, got) }) } } func Test_galleryQueryBuilder_CountByImageID(t *testing.T) { tests := []struct { name string imageID int want int wantErr bool }{ { "valid", imageIDs[imageIdxWithTwoGalleries], 2, false, }, { "none", imageIDs[imageIdx1WithPerformer], 0, false, }, } qb := db.Gallery for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { got, err := qb.CountByImageID(ctx, tt.imageID) if (err != nil) != tt.wantErr { t.Errorf("galleryQueryBuilder.CountByImageID() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { t.Errorf("galleryQueryBuilder.CountByImageID() = %v, want %v", got, tt.want) } }) } } func galleriesToIDs(i []*models.Gallery) []int { var ret []int for _, ii := range i { ret = append(ret, ii.ID) } return ret } func Test_galleryStore_FindByFileID(t *testing.T) { tests := []struct { name string fileID models.FileID include []int exclude []int }{ { "valid", galleryFileIDs[galleryIdx1WithImage], []int{galleryIdx1WithImage}, nil, }, { "invalid", invalidFileID, nil, []int{galleryIdx1WithImage}, }, } qb := db.Gallery for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.FindByFileID(ctx, tt.fileID) if err != nil { t.Errorf("GalleryStore.FindByFileID() error = %v", err) return } for _, f := range got { clearGalleryFileIDs(f) } ids := galleriesToIDs(got) include := indexesToIDs(galleryIDs, tt.include) exclude := indexesToIDs(galleryIDs, tt.exclude) for _, i := range include { assert.Contains(ids, i) } for _, e := range exclude { assert.NotContains(ids, e) } }) } } func Test_galleryStore_FindByFolderID(t *testing.T) { tests := []struct { name string folderID models.FolderID include []int exclude []int }{ // TODO - add folder gallery { "invalid", invalidFolderID, nil, []int{galleryIdxWithImage}, }, } qb := db.Gallery for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.FindByFolderID(ctx, tt.folderID) if err != nil { t.Errorf("GalleryStore.FindByFolderID() error = %v", err) return } for _, f := range got { clearGalleryFileIDs(f) } ids := galleriesToIDs(got) include := indexesToIDs(imageIDs, tt.include) exclude := indexesToIDs(imageIDs, tt.exclude) for _, i := range include { assert.Contains(ids, i) } for _, e := range exclude { assert.NotContains(ids, e) } }) } } func TestGalleryQueryQ(t *testing.T) { withTxn(func(ctx context.Context) error { const galleryIdx = 0 q := getGalleryStringValue(galleryIdx, pathField) galleryQueryQ(ctx, t, q, galleryIdx) return nil }) } func galleryQueryQ(ctx context.Context, t *testing.T, q string, expectedGalleryIdx int) { qb := db.Gallery filter := models.FindFilterType{ Q: &q, } galleries, _, err := qb.Query(ctx, nil, &filter) if err != nil { t.Errorf("Error querying gallery: %s", err.Error()) return } assert.Len(t, galleries, 1) gallery := galleries[0] assert.Equal(t, galleryIDs[expectedGalleryIdx], gallery.ID) // no Q should return all results filter.Q = nil galleries, _, err = qb.Query(ctx, nil, &filter) if err != nil { t.Errorf("Error querying gallery: %s", err.Error()) } assert.Len(t, galleries, totalGalleries) } func TestGalleryQueryPath(t *testing.T) { const galleryIdx = 1 galleryPath := getFilePath(folderIdxWithGalleryFiles, getGalleryBasename(galleryIdx)) tests := []struct { name string input models.StringCriterionInput }{ { "equals", models.StringCriterionInput{ Value: galleryPath, Modifier: models.CriterionModifierEquals, }, }, { "not equals", models.StringCriterionInput{ Value: galleryPath, Modifier: models.CriterionModifierNotEquals, }, }, { "matches regex", models.StringCriterionInput{ Value: "gallery.*1_Path", Modifier: models.CriterionModifierMatchesRegex, }, }, { "not matches regex", models.StringCriterionInput{ Value: "gallery.*1_Path", Modifier: models.CriterionModifierNotMatchesRegex, }, }, { "is null", models.StringCriterionInput{ Modifier: models.CriterionModifierIsNull, }, }, { "not null", models.StringCriterionInput{ Modifier: models.CriterionModifierNotNull, }, }, } qb := db.Gallery for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { got, count, err := qb.Query(ctx, &models.GalleryFilterType{ Path: &tt.input, }, nil) if err != nil { t.Errorf("GalleryStore.TestSceneQueryPath() error = %v", err) return } assert.NotEqual(t, 0, count) for _, gallery := range got { verifyString(t, gallery.Path, tt.input) } }) } } func verifyGalleriesPath(ctx context.Context, t *testing.T, pathCriterion models.StringCriterionInput) { galleryFilter := models.GalleryFilterType{ Path: &pathCriterion, } sqb := db.Gallery galleries, _, err := sqb.Query(ctx, &galleryFilter, nil) if err != nil { t.Errorf("Error querying gallery: %s", err.Error()) } for _, gallery := range galleries { verifyString(t, gallery.Path, pathCriterion) } } func TestGalleryQueryPathOr(t *testing.T) { const gallery1Idx = 1 const gallery2Idx = 2 gallery1Path := getFilePath(folderIdxWithGalleryFiles, getGalleryBasename(gallery1Idx)) gallery2Path := getFilePath(folderIdxWithGalleryFiles, getGalleryBasename(gallery2Idx)) galleryFilter := models.GalleryFilterType{ Path: &models.StringCriterionInput{ Value: gallery1Path, Modifier: models.CriterionModifierEquals, }, OperatorFilter: models.OperatorFilter[models.GalleryFilterType]{ Or: &models.GalleryFilterType{ Path: &models.StringCriterionInput{ Value: gallery2Path, Modifier: models.CriterionModifierEquals, }, }, }, } withTxn(func(ctx context.Context) error { sqb := db.Gallery galleries := queryGallery(ctx, t, sqb, &galleryFilter, nil) if !assert.Len(t, galleries, 2) { return nil } assert.Equal(t, gallery1Path, galleries[0].Path) assert.Equal(t, gallery2Path, galleries[1].Path) return nil }) } func TestGalleryQueryPathAndRating(t *testing.T) { const galleryIdx = 1 galleryPath := getFilePath(folderIdxWithGalleryFiles, getGalleryBasename(galleryIdx)) galleryRating := getIntPtr(getRating(galleryIdx)) galleryFilter := models.GalleryFilterType{ Path: &models.StringCriterionInput{ Value: galleryPath, Modifier: models.CriterionModifierEquals, }, OperatorFilter: models.OperatorFilter[models.GalleryFilterType]{ And: &models.GalleryFilterType{ Rating100: &models.IntCriterionInput{ Value: *galleryRating, Modifier: models.CriterionModifierEquals, }, }, }, } withTxn(func(ctx context.Context) error { sqb := db.Gallery galleries := queryGallery(ctx, t, sqb, &galleryFilter, nil) if !assert.Len(t, galleries, 1) { return nil } assert.Equal(t, galleryPath, galleries[0].Path) assert.Equal(t, *galleryRating, *galleries[0].Rating) return nil }) } func TestGalleryQueryPathNotRating(t *testing.T) { const galleryIdx = 1 galleryRating := getRating(galleryIdx) pathCriterion := models.StringCriterionInput{ Value: "gallery_.*1_Path", Modifier: models.CriterionModifierMatchesRegex, } ratingCriterion := models.IntCriterionInput{ Value: int(galleryRating.Int64), Modifier: models.CriterionModifierEquals, } galleryFilter := models.GalleryFilterType{ Path: &pathCriterion, OperatorFilter: models.OperatorFilter[models.GalleryFilterType]{ Not: &models.GalleryFilterType{ Rating100: &ratingCriterion, }, }, } withTxn(func(ctx context.Context) error { sqb := db.Gallery galleries := queryGallery(ctx, t, sqb, &galleryFilter, nil) for _, gallery := range galleries { verifyString(t, gallery.Path, pathCriterion) ratingCriterion.Modifier = models.CriterionModifierNotEquals verifyIntPtr(t, gallery.Rating, ratingCriterion) } return nil }) } func TestGalleryIllegalQuery(t *testing.T) { assert := assert.New(t) const galleryIdx = 1 subFilter := models.GalleryFilterType{ Path: &models.StringCriterionInput{ Value: getGalleryStringValue(galleryIdx, "Path"), Modifier: models.CriterionModifierEquals, }, } galleryFilter := &models.GalleryFilterType{ OperatorFilter: models.OperatorFilter[models.GalleryFilterType]{ And: &subFilter, Or: &subFilter, }, } withTxn(func(ctx context.Context) error { sqb := db.Gallery _, _, err := sqb.Query(ctx, galleryFilter, nil) assert.NotNil(err) galleryFilter.Or = nil galleryFilter.Not = &subFilter _, _, err = sqb.Query(ctx, galleryFilter, nil) assert.NotNil(err) galleryFilter.And = nil galleryFilter.Or = &subFilter _, _, err = sqb.Query(ctx, galleryFilter, nil) assert.NotNil(err) return nil }) } func TestGalleryQueryURL(t *testing.T) { const sceneIdx = 1 galleryURL := getGalleryStringValue(sceneIdx, urlField) urlCriterion := models.StringCriterionInput{ Value: galleryURL, Modifier: models.CriterionModifierEquals, } filter := models.GalleryFilterType{ URL: &urlCriterion, } verifyFn := func(g *models.Gallery) { t.Helper() urls := g.URLs.List() var url string if len(urls) > 0 { url = urls[0] } verifyString(t, url, urlCriterion) } verifyGalleryQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierNotEquals verifyGalleryQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierMatchesRegex urlCriterion.Value = "gallery_.*1_URL" verifyGalleryQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierNotMatchesRegex verifyGalleryQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierIsNull urlCriterion.Value = "" verifyGalleryQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierNotNull verifyGalleryQuery(t, filter, verifyFn) } func verifyGalleryQuery(t *testing.T, filter models.GalleryFilterType, verifyFn func(s *models.Gallery)) { withTxn(func(ctx context.Context) error { t.Helper() sqb := db.Gallery galleries := queryGallery(ctx, t, sqb, &filter, nil) for _, g := range galleries { if err := g.LoadURLs(ctx, sqb); err != nil { t.Errorf("Error loading gallery URLs: %v", err) } } // assume it should find at least one assert.Greater(t, len(galleries), 0) for _, gallery := range galleries { verifyFn(gallery) } return nil }) } func TestGalleryQueryRating100(t *testing.T) { const rating = 60 ratingCriterion := models.IntCriterionInput{ Value: rating, Modifier: models.CriterionModifierEquals, } verifyGalleriesRating100(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierNotEquals verifyGalleriesRating100(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierGreaterThan verifyGalleriesRating100(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierLessThan verifyGalleriesRating100(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierIsNull verifyGalleriesRating100(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierNotNull verifyGalleriesRating100(t, ratingCriterion) } func verifyGalleriesRating100(t *testing.T, ratingCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Gallery galleryFilter := models.GalleryFilterType{ Rating100: &ratingCriterion, } galleries, _, err := sqb.Query(ctx, &galleryFilter, nil) if err != nil { t.Errorf("Error querying gallery: %s", err.Error()) } for _, gallery := range galleries { verifyIntPtr(t, gallery.Rating, ratingCriterion) } return nil }) } func TestGalleryQueryIsMissingScene(t *testing.T) { withTxn(func(ctx context.Context) error { qb := db.Gallery isMissing := "scenes" galleryFilter := models.GalleryFilterType{ IsMissing: &isMissing, } q := getGalleryStringValue(galleryIdxWithScene, titleField) findFilter := models.FindFilterType{ Q: &q, } galleries, _, err := qb.Query(ctx, &galleryFilter, &findFilter) if err != nil { t.Errorf("Error querying gallery: %s", err.Error()) } assert.Len(t, galleries, 0) findFilter.Q = nil galleries, _, err = qb.Query(ctx, &galleryFilter, &findFilter) if err != nil { t.Errorf("Error querying gallery: %s", err.Error()) } // ensure non of the ids equal the one with gallery for _, gallery := range galleries { assert.NotEqual(t, galleryIDs[galleryIdxWithScene], gallery.ID) } return nil }) } func queryGallery(ctx context.Context, t *testing.T, sqb models.GalleryReader, galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) []*models.Gallery { galleries, _, err := sqb.Query(ctx, galleryFilter, findFilter) if err != nil { t.Errorf("Error querying gallery: %s", err.Error()) } return galleries } func TestGalleryQueryIsMissingStudio(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Gallery isMissing := "studio" galleryFilter := models.GalleryFilterType{ IsMissing: &isMissing, } q := getGalleryStringValue(galleryIdxWithStudio, titleField) findFilter := models.FindFilterType{ Q: &q, } galleries := queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) assert.Len(t, galleries, 0) findFilter.Q = nil galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) // ensure non of the ids equal the one with studio for _, gallery := range galleries { assert.NotEqual(t, galleryIDs[galleryIdxWithStudio], gallery.ID) } return nil }) } func TestGalleryQueryIsMissingPerformers(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Gallery isMissing := "performers" galleryFilter := models.GalleryFilterType{ IsMissing: &isMissing, } q := getGalleryStringValue(galleryIdxWithPerformer, titleField) findFilter := models.FindFilterType{ Q: &q, } galleries := queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) assert.Len(t, galleries, 0) findFilter.Q = nil galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) assert.True(t, len(galleries) > 0) // ensure non of the ids equal the one with galleries for _, gallery := range galleries { assert.NotEqual(t, galleryIDs[galleryIdxWithPerformer], gallery.ID) } return nil }) } func TestGalleryQueryIsMissingTags(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Gallery isMissing := "tags" galleryFilter := models.GalleryFilterType{ IsMissing: &isMissing, } q := getGalleryStringValue(galleryIdxWithTwoTags, titleField) findFilter := models.FindFilterType{ Q: &q, } galleries := queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) assert.Len(t, galleries, 0) findFilter.Q = nil galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) assert.True(t, len(galleries) > 0) return nil }) } func TestGalleryQueryIsMissingDate(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Gallery isMissing := "date" galleryFilter := models.GalleryFilterType{ IsMissing: &isMissing, } galleries := queryGallery(ctx, t, sqb, &galleryFilter, nil) // one in four galleries have no date assert.Len(t, galleries, int(math.Ceil(float64(totalGalleries)/4))) // ensure date is null for _, g := range galleries { assert.Nil(t, g.Date) } return nil }) } func TestGalleryQueryPerformers(t *testing.T) { tests := []struct { name string filter models.MultiCriterionInput includeIdxs []int excludeIdxs []int wantErr bool }{ { "includes", models.MultiCriterionInput{ Value: []string{ strconv.Itoa(performerIDs[performerIdxWithGallery]), strconv.Itoa(performerIDs[performerIdx1WithGallery]), }, Modifier: models.CriterionModifierIncludes, }, []int{ galleryIdxWithPerformer, galleryIdxWithTwoPerformers, }, []int{ galleryIdxWithImage, }, false, }, { "includes all", models.MultiCriterionInput{ Value: []string{ strconv.Itoa(performerIDs[performerIdx1WithGallery]), strconv.Itoa(performerIDs[performerIdx2WithGallery]), }, Modifier: models.CriterionModifierIncludesAll, }, []int{ galleryIdxWithTwoPerformers, }, []int{ galleryIdxWithPerformer, }, false, }, { "excludes", models.MultiCriterionInput{ Modifier: models.CriterionModifierExcludes, Value: []string{strconv.Itoa(tagIDs[performerIdx1WithGallery])}, }, nil, []int{galleryIdxWithTwoPerformers}, false, }, { "is null", models.MultiCriterionInput{ Modifier: models.CriterionModifierIsNull, }, []int{galleryIdxWithTag}, []int{ galleryIdxWithPerformer, galleryIdxWithTwoPerformers, galleryIdxWithPerformerTwoTags, }, false, }, { "not null", models.MultiCriterionInput{ Modifier: models.CriterionModifierNotNull, }, []int{ galleryIdxWithPerformer, galleryIdxWithTwoPerformers, galleryIdxWithPerformerTwoTags, }, []int{galleryIdxWithTag}, false, }, { "equals", models.MultiCriterionInput{ Modifier: models.CriterionModifierEquals, Value: []string{ strconv.Itoa(tagIDs[performerIdx1WithGallery]), strconv.Itoa(tagIDs[performerIdx2WithGallery]), }, }, []int{galleryIdxWithTwoPerformers}, []int{ galleryIdxWithThreePerformers, }, false, }, { "not equals", models.MultiCriterionInput{ Modifier: models.CriterionModifierNotEquals, Value: []string{ strconv.Itoa(tagIDs[performerIdx1WithGallery]), strconv.Itoa(tagIDs[performerIdx2WithGallery]), }, }, nil, nil, true, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) results, _, err := db.Gallery.Query(ctx, &models.GalleryFilterType{ Performers: &tt.filter, }, nil) if (err != nil) != tt.wantErr { t.Errorf("GalleryStore.Query() error = %v, wantErr %v", err, tt.wantErr) return } ids := galleriesToIDs(results) include := indexesToIDs(galleryIDs, tt.includeIdxs) exclude := indexesToIDs(galleryIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(ids, i) } for _, e := range exclude { assert.NotContains(ids, e) } }) } } func TestGalleryQueryTags(t *testing.T) { tests := []struct { name string filter models.HierarchicalMultiCriterionInput includeIdxs []int excludeIdxs []int wantErr bool }{ { "includes", models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithGallery]), strconv.Itoa(tagIDs[tagIdx1WithGallery]), }, Modifier: models.CriterionModifierIncludes, }, []int{ galleryIdxWithTag, galleryIdxWithTwoTags, }, []int{ galleryIdxWithImage, }, false, }, { "includes all", models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdx1WithGallery]), strconv.Itoa(tagIDs[tagIdx2WithGallery]), }, Modifier: models.CriterionModifierIncludesAll, }, []int{ galleryIdxWithTwoTags, }, []int{ galleryIdxWithTag, }, false, }, { "excludes", models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierExcludes, Value: []string{strconv.Itoa(tagIDs[tagIdx1WithGallery])}, }, nil, []int{galleryIdxWithTwoTags}, false, }, { "is null", models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIsNull, }, []int{galleryIdx1WithPerformer}, []int{ galleryIdxWithTag, galleryIdxWithTwoTags, galleryIdxWithThreeTags, }, false, }, { "not null", models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierNotNull, }, []int{ galleryIdxWithTag, galleryIdxWithTwoTags, galleryIdxWithThreeTags, }, []int{galleryIdx1WithPerformer}, false, }, { "equals", models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierEquals, Value: []string{ strconv.Itoa(tagIDs[tagIdx1WithGallery]), strconv.Itoa(tagIDs[tagIdx2WithGallery]), }, }, []int{galleryIdxWithTwoTags}, []int{ galleryIdxWithThreeTags, }, false, }, { "not equals", models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierNotEquals, Value: []string{ strconv.Itoa(tagIDs[tagIdx1WithGallery]), strconv.Itoa(tagIDs[tagIdx2WithGallery]), }, }, nil, nil, true, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) results, _, err := db.Gallery.Query(ctx, &models.GalleryFilterType{ Tags: &tt.filter, }, nil) if (err != nil) != tt.wantErr { t.Errorf("GalleryStore.Query() error = %v, wantErr %v", err, tt.wantErr) return } ids := galleriesToIDs(results) include := indexesToIDs(imageIDs, tt.includeIdxs) exclude := indexesToIDs(imageIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(ids, i) } for _, e := range exclude { assert.NotContains(ids, e) } }) } } func TestGalleryQueryStudio(t *testing.T) { tests := []struct { name string q string studioCriterion models.HierarchicalMultiCriterionInput expectedIDs []int wantErr bool }{ { "includes", "", models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithGallery]), }, Modifier: models.CriterionModifierIncludes, }, []int{galleryIDs[galleryIdxWithStudio]}, false, }, { "excludes", getGalleryStringValue(galleryIdxWithStudio, titleField), models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithGallery]), }, Modifier: models.CriterionModifierExcludes, }, []int{}, false, }, { "excludes includes null", getGalleryStringValue(galleryIdxWithImage, titleField), models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithGallery]), }, Modifier: models.CriterionModifierExcludes, }, []int{galleryIDs[galleryIdxWithImage]}, false, }, { "equals", "", models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithGallery]), }, Modifier: models.CriterionModifierEquals, }, []int{galleryIDs[galleryIdxWithStudio]}, false, }, { "not equals", getGalleryStringValue(galleryIdxWithStudio, titleField), models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithGallery]), }, Modifier: models.CriterionModifierNotEquals, }, []int{}, false, }, } qb := db.Gallery for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { studioCriterion := tt.studioCriterion galleryFilter := models.GalleryFilterType{ Studios: &studioCriterion, } var findFilter *models.FindFilterType if tt.q != "" { findFilter = &models.FindFilterType{ Q: &tt.q, } } gallerys := queryGallery(ctx, t, qb, &galleryFilter, findFilter) assert.ElementsMatch(t, galleriesToIDs(gallerys), tt.expectedIDs) }) } } func TestGalleryQueryStudioDepth(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Gallery depth := 2 studioCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithGrandChild]), }, Modifier: models.CriterionModifierIncludes, Depth: &depth, } galleryFilter := models.GalleryFilterType{ Studios: &studioCriterion, } galleries := queryGallery(ctx, t, sqb, &galleryFilter, nil) assert.Len(t, galleries, 1) depth = 1 galleries = queryGallery(ctx, t, sqb, &galleryFilter, nil) assert.Len(t, galleries, 0) studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])} galleries = queryGallery(ctx, t, sqb, &galleryFilter, nil) assert.Len(t, galleries, 1) // ensure id is correct assert.Equal(t, galleryIDs[galleryIdxWithGrandChildStudio], galleries[0].ID) depth = 2 studioCriterion = models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithGrandChild]), }, Modifier: models.CriterionModifierExcludes, Depth: &depth, } q := getGalleryStringValue(galleryIdxWithGrandChildStudio, pathField) findFilter := models.FindFilterType{ Q: &q, } galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) assert.Len(t, galleries, 0) depth = 1 galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) assert.Len(t, galleries, 1) studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])} galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) assert.Len(t, galleries, 0) return nil }) } func TestGalleryQueryPerformerTags(t *testing.T) { allDepth := -1 tests := []struct { name string findFilter *models.FindFilterType filter *models.GalleryFilterType includeIdxs []int excludeIdxs []int wantErr bool }{ { "includes", nil, &models.GalleryFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithPerformer]), strconv.Itoa(tagIDs[tagIdx1WithPerformer]), }, Modifier: models.CriterionModifierIncludes, }, }, []int{ galleryIdxWithPerformerTag, galleryIdxWithPerformerTwoTags, galleryIdxWithTwoPerformerTag, }, []int{ galleryIdxWithPerformer, }, false, }, { "includes sub-tags", nil, &models.GalleryFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), }, Depth: &allDepth, Modifier: models.CriterionModifierIncludes, }, }, []int{ galleryIdxWithPerformerParentTag, }, []int{ galleryIdxWithPerformer, galleryIdxWithPerformerTag, galleryIdxWithPerformerTwoTags, galleryIdxWithTwoPerformerTag, }, false, }, { "includes all", nil, &models.GalleryFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdx1WithPerformer]), strconv.Itoa(tagIDs[tagIdx2WithPerformer]), }, Modifier: models.CriterionModifierIncludesAll, }, }, []int{ galleryIdxWithPerformerTwoTags, }, []int{ galleryIdxWithPerformer, galleryIdxWithPerformerTag, galleryIdxWithTwoPerformerTag, }, false, }, { "excludes performer tag tagIdx2WithPerformer", nil, &models.GalleryFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierExcludes, Value: []string{strconv.Itoa(tagIDs[tagIdx2WithPerformer])}, }, }, nil, []int{galleryIdxWithTwoPerformerTag}, false, }, { "excludes sub-tags", nil, &models.GalleryFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), }, Depth: &allDepth, Modifier: models.CriterionModifierExcludes, }, }, []int{ galleryIdxWithPerformer, galleryIdxWithPerformerTag, galleryIdxWithPerformerTwoTags, galleryIdxWithTwoPerformerTag, }, []int{ galleryIdxWithPerformerParentTag, }, false, }, { "is null", nil, &models.GalleryFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIsNull, }, }, []int{galleryIdx1WithImage}, []int{galleryIdxWithPerformerTag}, false, }, { "not null", nil, &models.GalleryFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierNotNull, }, }, []int{galleryIdxWithPerformerTag}, []int{galleryIdx1WithImage}, false, }, { "equals", nil, &models.GalleryFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierEquals, Value: []string{ strconv.Itoa(tagIDs[tagIdx2WithPerformer]), }, }, }, nil, nil, true, }, { "not equals", nil, &models.GalleryFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierNotEquals, Value: []string{ strconv.Itoa(tagIDs[tagIdx2WithPerformer]), }, }, }, nil, nil, true, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) results, _, err := db.Gallery.Query(ctx, tt.filter, tt.findFilter) if (err != nil) != tt.wantErr { t.Errorf("ImageStore.Query() error = %v, wantErr %v", err, tt.wantErr) return } ids := galleriesToIDs(results) include := indexesToIDs(galleryIDs, tt.includeIdxs) exclude := indexesToIDs(galleryIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(ids, i) } for _, e := range exclude { assert.NotContains(ids, e) } }) } } func TestGalleryQueryTagCount(t *testing.T) { const tagCount = 1 tagCountCriterion := models.IntCriterionInput{ Value: tagCount, Modifier: models.CriterionModifierEquals, } verifyGalleriesTagCount(t, tagCountCriterion) tagCountCriterion.Modifier = models.CriterionModifierNotEquals verifyGalleriesTagCount(t, tagCountCriterion) tagCountCriterion.Modifier = models.CriterionModifierGreaterThan verifyGalleriesTagCount(t, tagCountCriterion) tagCountCriterion.Modifier = models.CriterionModifierLessThan verifyGalleriesTagCount(t, tagCountCriterion) } func verifyGalleriesTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Gallery galleryFilter := models.GalleryFilterType{ TagCount: &tagCountCriterion, } galleries := queryGallery(ctx, t, sqb, &galleryFilter, nil) assert.Greater(t, len(galleries), 0) for _, gallery := range galleries { if err := gallery.LoadTagIDs(ctx, sqb); err != nil { t.Errorf("gallery.LoadTagIDs() error = %v", err) return nil } verifyInt(t, len(gallery.TagIDs.List()), tagCountCriterion) } return nil }) } func TestGalleryQueryPerformerCount(t *testing.T) { const performerCount = 1 performerCountCriterion := models.IntCriterionInput{ Value: performerCount, Modifier: models.CriterionModifierEquals, } verifyGalleriesPerformerCount(t, performerCountCriterion) performerCountCriterion.Modifier = models.CriterionModifierNotEquals verifyGalleriesPerformerCount(t, performerCountCriterion) performerCountCriterion.Modifier = models.CriterionModifierGreaterThan verifyGalleriesPerformerCount(t, performerCountCriterion) performerCountCriterion.Modifier = models.CriterionModifierLessThan verifyGalleriesPerformerCount(t, performerCountCriterion) } func verifyGalleriesPerformerCount(t *testing.T, performerCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Gallery galleryFilter := models.GalleryFilterType{ PerformerCount: &performerCountCriterion, } galleries := queryGallery(ctx, t, sqb, &galleryFilter, nil) assert.Greater(t, len(galleries), 0) for _, gallery := range galleries { if err := gallery.LoadPerformerIDs(ctx, sqb); err != nil { t.Errorf("gallery.LoadPerformerIDs() error = %v", err) return nil } verifyInt(t, len(gallery.PerformerIDs.List()), performerCountCriterion) } return nil }) } func TestGalleryQueryAverageResolution(t *testing.T) { withTxn(func(ctx context.Context) error { qb := db.Gallery resolution := models.ResolutionEnumLow galleryFilter := models.GalleryFilterType{ AverageResolution: &models.ResolutionCriterionInput{ Value: resolution, Modifier: models.CriterionModifierEquals, }, } // not verifying average - just ensure we get at least one galleries := queryGallery(ctx, t, qb, &galleryFilter, nil) assert.Greater(t, len(galleries), 0) return nil }) } func TestGalleryQueryImageCount(t *testing.T) { const imageCount = 0 imageCountCriterion := models.IntCriterionInput{ Value: imageCount, Modifier: models.CriterionModifierEquals, } verifyGalleriesImageCount(t, imageCountCriterion) imageCountCriterion.Modifier = models.CriterionModifierNotEquals verifyGalleriesImageCount(t, imageCountCriterion) imageCountCriterion.Modifier = models.CriterionModifierGreaterThan verifyGalleriesImageCount(t, imageCountCriterion) imageCountCriterion.Modifier = models.CriterionModifierLessThan verifyGalleriesImageCount(t, imageCountCriterion) } func verifyGalleriesImageCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Gallery galleryFilter := models.GalleryFilterType{ ImageCount: &imageCountCriterion, } galleries := queryGallery(ctx, t, sqb, &galleryFilter, nil) assert.Greater(t, len(galleries), -1) for _, gallery := range galleries { pp := 0 result, err := db.Image.Query(ctx, models.ImageQueryOptions{ QueryOptions: models.QueryOptions{ FindFilter: &models.FindFilterType{ PerPage: &pp, }, Count: true, }, ImageFilter: &models.ImageFilterType{ Galleries: &models.MultiCriterionInput{ Value: []string{strconv.Itoa(gallery.ID)}, Modifier: models.CriterionModifierIncludes, }, }, }) if err != nil { return err } verifyInt(t, result.Count, imageCountCriterion) } return nil }) } func TestGalleryQuerySorting(t *testing.T) { tests := []struct { name string sortBy string dir models.SortDirectionEnum firstGalleryIdx int // -1 to ignore lastGalleryIdx int }{ { "file mod time", "file_mod_time", models.SortDirectionEnumDesc, -1, -1, }, { "path", "path", models.SortDirectionEnumDesc, -1, -1, }, { "title", "title", models.SortDirectionEnumDesc, -1, -1, }, } qb := db.Gallery for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, _, err := qb.Query(ctx, nil, &models.FindFilterType{ Sort: &tt.sortBy, Direction: &tt.dir, }) if err != nil { t.Errorf("GalleryStore.TestGalleryQuerySorting() error = %v", err) return } if !assert.Greater(len(got), 0) { return } // scenes should be in same order as indexes firstGallery := got[0] lastGallery := got[len(got)-1] if tt.firstGalleryIdx != -1 { firstID := galleryIDs[tt.firstGalleryIdx] assert.Equal(firstID, firstGallery.ID) } if tt.lastGalleryIdx != -1 { lastID := galleryIDs[tt.lastGalleryIdx] assert.Equal(lastID, lastGallery.ID) } }) } } func TestGalleryStore_AddImages(t *testing.T) { tests := []struct { name string galleryID int imageIDs []int wantErr bool }{ { "single", galleryIDs[galleryIdx1WithImage], []int{imageIDs[imageIdx1WithPerformer]}, false, }, { "multiple", galleryIDs[galleryIdx1WithImage], []int{imageIDs[imageIdx1WithPerformer], imageIDs[imageIdx1WithStudio]}, false, }, { "invalid gallery id", invalidID, []int{imageIDs[imageIdx1WithPerformer]}, true, }, { "single invalid", galleryIDs[galleryIdx1WithImage], []int{invalidID}, true, }, { "one invalid", galleryIDs[galleryIdx1WithImage], []int{imageIDs[imageIdx1WithPerformer], invalidID}, true, }, { "existing", galleryIDs[galleryIdx1WithImage], []int{imageIDs[imageIdxWithGallery]}, false, }, { "one new", galleryIDs[galleryIdx1WithImage], []int{imageIDs[imageIdx1WithPerformer], imageIDs[imageIdxWithGallery]}, false, }, } qb := db.Gallery for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { if err := qb.AddImages(ctx, tt.galleryID, tt.imageIDs...); (err != nil) != tt.wantErr { t.Errorf("GalleryStore.AddImages() error = %v, wantErr %v", err, tt.wantErr) return } if tt.wantErr { return } // ensure image was added imageIDs, err := qb.GetImageIDs(ctx, tt.galleryID) if err != nil { t.Errorf("GalleryStore.GetImageIDs() error = %v", err) return } assert := assert.New(t) for _, wantedID := range tt.imageIDs { assert.Contains(imageIDs, wantedID) } }) } } func TestGalleryStore_RemoveImages(t *testing.T) { tests := []struct { name string galleryID int imageIDs []int wantErr bool }{ { "single", galleryIDs[galleryIdxWithTwoImages], []int{imageIDs[imageIdx1WithGallery]}, false, }, { "multiple", galleryIDs[galleryIdxWithTwoImages], []int{imageIDs[imageIdx1WithGallery], imageIDs[imageIdx2WithGallery]}, false, }, { "invalid gallery id", invalidID, []int{imageIDs[imageIdx1WithGallery]}, false, }, { "single invalid", galleryIDs[galleryIdxWithTwoImages], []int{invalidID}, false, }, { "one invalid", galleryIDs[galleryIdxWithTwoImages], []int{imageIDs[imageIdx1WithGallery], invalidID}, false, }, { "not existing", galleryIDs[galleryIdxWithTwoImages], []int{imageIDs[imageIdxWithPerformer]}, false, }, { "one existing", galleryIDs[galleryIdxWithTwoImages], []int{imageIDs[imageIdx1WithPerformer], imageIDs[imageIdx1WithGallery]}, false, }, } qb := db.Gallery for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { if err := qb.RemoveImages(ctx, tt.galleryID, tt.imageIDs...); (err != nil) != tt.wantErr { t.Errorf("GalleryStore.RemoveImages() error = %v, wantErr %v", err, tt.wantErr) return } if tt.wantErr { return } // ensure image was removed imageIDs, err := qb.GetImageIDs(ctx, tt.galleryID) if err != nil { t.Errorf("GalleryStore.GetImageIDs() error = %v", err) return } assert := assert.New(t) for _, excludedID := range tt.imageIDs { assert.NotContains(imageIDs, excludedID) } }) } } func TestGalleryQueryHasChapters(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Gallery hasChapters := "true" galleryFilter := models.GalleryFilterType{ HasChapters: &hasChapters, } q := getGalleryStringValue(galleryIdxWithChapters, titleField) findFilter := models.FindFilterType{ Q: &q, } galleries := queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) assert.Len(t, galleries, 1) assert.Equal(t, galleryIDs[galleryIdxWithChapters], galleries[0].ID) hasChapters = "false" galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) assert.Len(t, galleries, 0) findFilter.Q = nil galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) assert.NotEqual(t, 0, len(galleries)) return nil }) } func TestGallerySetAndResetCover(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Gallery imagePath2 := getFilePath(folderIdxWithImageFiles, getImageBasename(imageIdx2WithGallery)) result, err := db.Image.CoverByGalleryID(ctx, galleryIDs[galleryIdxWithTwoImages]) assert.Nil(t, err) assert.Nil(t, result) err = sqb.SetCover(ctx, galleryIDs[galleryIdxWithTwoImages], imageIDs[imageIdx2WithGallery]) assert.Nil(t, err) result, err = db.Image.CoverByGalleryID(ctx, galleryIDs[galleryIdxWithTwoImages]) assert.Nil(t, err) assert.Equal(t, result.Path, imagePath2) err = sqb.ResetCover(ctx, galleryIDs[galleryIdxWithTwoImages]) assert.Nil(t, err) result, err = db.Image.CoverByGalleryID(ctx, galleryIDs[galleryIdxWithTwoImages]) assert.Nil(t, err) assert.Nil(t, result) return nil }) } func TestGalleryQueryCustomFields(t *testing.T) { tests := []struct { name string filter *models.GalleryFilterType includeIdxs []int excludeIdxs []int wantErr bool }{ { "equals", &models.GalleryFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierEquals, Value: []any{getGalleryStringValue(galleryIdxWithImage, "custom")}, }, }, }, []int{galleryIdxWithImage}, nil, false, }, { "not equals", &models.GalleryFilterType{ Title: &models.StringCriterionInput{ Value: getGalleryStringValue(galleryIdxWithImage, titleField), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotEquals, Value: []any{getGalleryStringValue(galleryIdxWithImage, "custom")}, }, }, }, nil, []int{galleryIdxWithImage}, false, }, { "includes", &models.GalleryFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierIncludes, Value: []any{getGalleryStringValue(galleryIdxWithImage, "custom")[9:]}, }, }, }, []int{galleryIdxWithImage}, nil, false, }, { "excludes", &models.GalleryFilterType{ Title: &models.StringCriterionInput{ Value: getGalleryStringValue(galleryIdxWithImage, titleField), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierExcludes, Value: []any{getGalleryStringValue(galleryIdxWithImage, "custom")[9:]}, }, }, }, nil, []int{galleryIdxWithImage}, false, }, { "regex", &models.GalleryFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierMatchesRegex, Value: []any{".*17_custom"}, }, }, }, []int{galleryIdxWithPerformerTag}, nil, false, }, { "invalid regex", &models.GalleryFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierMatchesRegex, Value: []any{"["}, }, }, }, nil, nil, true, }, { "not matches regex", &models.GalleryFilterType{ Title: &models.StringCriterionInput{ Value: getGalleryStringValue(galleryIdxWithPerformerTag, titleField), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotMatchesRegex, Value: []any{".*17_custom"}, }, }, }, nil, []int{galleryIdxWithPerformerTag}, false, }, { "invalid not matches regex", &models.GalleryFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotMatchesRegex, Value: []any{"["}, }, }, }, nil, nil, true, }, { "null", &models.GalleryFilterType{ Title: &models.StringCriterionInput{ Value: getGalleryStringValue(galleryIdxWithImage, titleField), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "not existing", Modifier: models.CriterionModifierIsNull, }, }, }, []int{galleryIdxWithImage}, nil, false, }, { "not null", &models.GalleryFilterType{ Title: &models.StringCriterionInput{ Value: getGalleryStringValue(galleryIdxWithImage, titleField), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotNull, }, }, }, []int{galleryIdxWithImage}, nil, false, }, { "between", &models.GalleryFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "real", Modifier: models.CriterionModifierBetween, Value: []any{0.15, 0.25}, }, }, }, []int{galleryIdxWithImage}, nil, false, }, { "not between", &models.GalleryFilterType{ Title: &models.StringCriterionInput{ Value: getGalleryStringValue(galleryIdxWithImage, titleField), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "real", Modifier: models.CriterionModifierNotBetween, Value: []any{0.15, 0.25}, }, }, }, nil, []int{galleryIdxWithImage}, false, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) galleries, _, err := db.Gallery.Query(ctx, tt.filter, nil) if (err != nil) != tt.wantErr { t.Errorf("GalleryStore.Query() error = %v, wantErr %v", err, tt.wantErr) } if err != nil { return } ids := galleriesToIDs(galleries) include := indexesToIDs(galleryIDs, tt.includeIdxs) exclude := indexesToIDs(galleryIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(ids, i) } for _, e := range exclude { assert.NotContains(ids, e) } }) } } // TODO Count // TODO All // TODO Query // TODO Destroy ================================================ FILE: pkg/sqlite/group.go ================================================ package sqlite import ( "context" "database/sql" "errors" "fmt" "slices" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4/zero" "github.com/stashapp/stash/pkg/models" ) const ( groupTable = "groups" groupIDColumn = "group_id" groupFrontImageBlobColumn = "front_image_blob" groupBackImageBlobColumn = "back_image_blob" groupsTagsTable = "groups_tags" groupURLsTable = "group_urls" groupURLColumn = "url" groupRelationsTable = "groups_relations" ) type groupRow struct { ID int `db:"id" goqu:"skipinsert"` Name zero.String `db:"name"` Aliases zero.String `db:"aliases"` Duration null.Int `db:"duration"` Date NullDate `db:"date"` DatePrecision null.Int `db:"date_precision"` // expressed as 1-100 Rating null.Int `db:"rating"` StudioID null.Int `db:"studio_id,omitempty"` Director zero.String `db:"director"` Description zero.String `db:"description"` CreatedAt Timestamp `db:"created_at"` UpdatedAt Timestamp `db:"updated_at"` // not used in resolutions or updates FrontImageBlob zero.String `db:"front_image_blob"` BackImageBlob zero.String `db:"back_image_blob"` } func (r *groupRow) fromGroup(o models.Group) { r.ID = o.ID r.Name = zero.StringFrom(o.Name) r.Aliases = zero.StringFrom(o.Aliases) r.Duration = intFromPtr(o.Duration) r.Date = NullDateFromDatePtr(o.Date) r.DatePrecision = datePrecisionFromDatePtr(o.Date) r.Rating = intFromPtr(o.Rating) r.StudioID = intFromPtr(o.StudioID) r.Director = zero.StringFrom(o.Director) r.Description = zero.StringFrom(o.Synopsis) r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} } func (r *groupRow) resolve() *models.Group { ret := &models.Group{ ID: r.ID, Name: r.Name.String, Aliases: r.Aliases.String, Duration: nullIntPtr(r.Duration), Date: r.Date.DatePtr(r.DatePrecision), Rating: nullIntPtr(r.Rating), StudioID: nullIntPtr(r.StudioID), Director: r.Director.String, Synopsis: r.Description.String, CreatedAt: r.CreatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp, } return ret } type groupRowRecord struct { updateRecord } func (r *groupRowRecord) fromPartial(o models.GroupPartial) { r.setNullString("name", o.Name) r.setNullString("aliases", o.Aliases) r.setNullInt("duration", o.Duration) r.setNullDate("date", "date_precision", o.Date) r.setNullInt("rating", o.Rating) r.setNullInt("studio_id", o.StudioID) r.setNullString("director", o.Director) r.setNullString("description", o.Synopsis) r.setTimestamp("created_at", o.CreatedAt) r.setTimestamp("updated_at", o.UpdatedAt) } type groupRepositoryType struct { repository scenes repository tags joinRepository } var ( groupRepository = groupRepositoryType{ repository: repository{ tableName: groupTable, idColumn: idColumn, }, scenes: repository{ tableName: groupsScenesTable, idColumn: groupIDColumn, }, tags: joinRepository{ repository: repository{ tableName: groupsTagsTable, idColumn: groupIDColumn, }, fkColumn: tagIDColumn, foreignTable: tagTable, orderBy: tagTableSortSQL, }, } ) type GroupStore struct { blobJoinQueryBuilder customFieldsStore tagRelationshipStore groupRelationshipStore tableMgr *table } func NewGroupStore(blobStore *BlobStore) *GroupStore { return &GroupStore{ blobJoinQueryBuilder: blobJoinQueryBuilder{ blobStore: blobStore, joinTable: groupTable, }, customFieldsStore: customFieldsStore{ table: groupsCustomFieldsTable, fk: groupsCustomFieldsTable.Col(groupIDColumn), }, tagRelationshipStore: tagRelationshipStore{ idRelationshipStore: idRelationshipStore{ joinTable: groupsTagsTableMgr, }, }, groupRelationshipStore: groupRelationshipStore{ table: groupRelationshipTableMgr, }, tableMgr: groupTableMgr, } } func (qb *GroupStore) table() exp.IdentifierExpression { return qb.tableMgr.table } func (qb *GroupStore) selectDataset() *goqu.SelectDataset { return dialect.From(qb.table()).Select(qb.table().All()) } func (qb *GroupStore) Create(ctx context.Context, newObject *models.Group) error { var r groupRow r.fromGroup(*newObject) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { return err } if newObject.URLs.Loaded() { const startPos = 0 if err := groupsURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { return err } } if err := qb.tagRelationshipStore.createRelationships(ctx, id, newObject.TagIDs); err != nil { return err } if err := qb.groupRelationshipStore.createContainingRelationships(ctx, id, newObject.ContainingGroups); err != nil { return err } if err := qb.groupRelationshipStore.createSubRelationships(ctx, id, newObject.SubGroups); err != nil { return err } updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } *newObject = *updated return nil } func (qb *GroupStore) UpdatePartial(ctx context.Context, id int, partial models.GroupPartial) (*models.Group, error) { r := groupRowRecord{ updateRecord{ Record: make(exp.Record), }, } r.fromPartial(partial) if len(r.Record) > 0 { if err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil { return nil, err } } if partial.URLs != nil { if err := groupsURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { return nil, err } } if err := qb.tagRelationshipStore.modifyRelationships(ctx, id, partial.TagIDs); err != nil { return nil, err } if err := qb.groupRelationshipStore.modifyContainingRelationships(ctx, id, partial.ContainingGroups); err != nil { return nil, err } if err := qb.groupRelationshipStore.modifySubRelationships(ctx, id, partial.SubGroups); err != nil { return nil, err } if err := qb.SetCustomFields(ctx, id, partial.CustomFields); err != nil { return nil, err } return qb.find(ctx, id) } func (qb *GroupStore) Update(ctx context.Context, updatedObject *models.Group) error { var r groupRow r.fromGroup(*updatedObject) if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { return err } if updatedObject.URLs.Loaded() { if err := groupsURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { return err } } if err := qb.tagRelationshipStore.replaceRelationships(ctx, updatedObject.ID, updatedObject.TagIDs); err != nil { return err } if err := qb.groupRelationshipStore.replaceContainingRelationships(ctx, updatedObject.ID, updatedObject.ContainingGroups); err != nil { return err } if err := qb.groupRelationshipStore.replaceSubRelationships(ctx, updatedObject.ID, updatedObject.SubGroups); err != nil { return err } return nil } func (qb *GroupStore) Destroy(ctx context.Context, id int) error { // must handle image checksums manually if err := qb.destroyImages(ctx, id); err != nil { return err } return groupRepository.destroyExisting(ctx, []int{id}) } // returns nil, nil if not found func (qb *GroupStore) Find(ctx context.Context, id int) (*models.Group, error) { ret, err := qb.find(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } return ret, err } func (qb *GroupStore) FindMany(ctx context.Context, ids []int) ([]*models.Group, error) { ret := make([]*models.Group, len(ids)) table := qb.table() if err := batchExec(ids, defaultBatchSize, func(batch []int) error { q := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(batch)) unsorted, err := qb.getMany(ctx, q) if err != nil { return err } for _, s := range unsorted { i := slices.Index(ids, s.ID) ret[i] = s } return nil }); err != nil { return nil, err } for i := range ret { if ret[i] == nil { return nil, fmt.Errorf("group with id %d not found", ids[i]) } } return ret, nil } // returns nil, sql.ErrNoRows if not found func (qb *GroupStore) find(ctx context.Context, id int) (*models.Group, error) { q := qb.selectDataset().Where(qb.tableMgr.byID(id)) ret, err := qb.get(ctx, q) if err != nil { return nil, err } return ret, nil } // returns nil, sql.ErrNoRows if not found func (qb *GroupStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Group, error) { ret, err := qb.getMany(ctx, q) if err != nil { return nil, err } if len(ret) == 0 { return nil, sql.ErrNoRows } return ret[0], nil } func (qb *GroupStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Group, error) { const single = false var ret []*models.Group if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { var f groupRow if err := r.StructScan(&f); err != nil { return err } s := f.resolve() ret = append(ret, s) return nil }); err != nil { return nil, err } return ret, nil } func (qb *GroupStore) FindByName(ctx context.Context, name string, nocase bool) (*models.Group, error) { // query := "SELECT * FROM groups WHERE name = ?" // if nocase { // query += " COLLATE NOCASE" // } // query += " LIMIT 1" where := "name = ?" if nocase { where += " COLLATE NOCASE" } sq := qb.selectDataset().Prepared(true).Where(goqu.L(where, name)).Limit(1) ret, err := qb.get(ctx, sq) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, err } return ret, nil } func (qb *GroupStore) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Group, error) { // query := "SELECT * FROM groups WHERE name" // if nocase { // query += " COLLATE NOCASE" // } // query += " IN " + getInBinding(len(names)) where := "name" if nocase { where += " COLLATE NOCASE" } where += " IN " + getInBinding(len(names)) var args []interface{} for _, name := range names { args = append(args, name) } sq := qb.selectDataset().Prepared(true).Where(goqu.L(where, args...)) ret, err := qb.getMany(ctx, sq) if err != nil { return nil, err } return ret, nil } func (qb *GroupStore) Count(ctx context.Context) (int, error) { q := dialect.Select(goqu.COUNT("*")).From(qb.table()) return count(ctx, q) } func (qb *GroupStore) All(ctx context.Context) ([]*models.Group, error) { table := qb.table() return qb.getMany(ctx, qb.selectDataset().Order( table.Col("name").Asc(), table.Col(idColumn).Asc(), )) } func (qb *GroupStore) makeQuery(ctx context.Context, groupFilter *models.GroupFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if findFilter == nil { findFilter = &models.FindFilterType{} } if groupFilter == nil { groupFilter = &models.GroupFilterType{} } query := groupRepository.newQuery() distinctIDs(&query, groupTable) if q := findFilter.Q; q != nil && *q != "" { searchColumns := []string{"groups.name", "groups.aliases"} query.parseQueryString(searchColumns, *q) } filter := filterBuilderFromHandler(ctx, &groupFilterHandler{ groupFilter: groupFilter, }) if err := query.addFilter(filter); err != nil { return nil, err } if err := qb.setGroupSort(&query, findFilter); err != nil { return nil, err } query.sortAndPagination += getPagination(findFilter) return &query, nil } func (qb *GroupStore) Query(ctx context.Context, groupFilter *models.GroupFilterType, findFilter *models.FindFilterType) ([]*models.Group, int, error) { query, err := qb.makeQuery(ctx, groupFilter, findFilter) if err != nil { return nil, 0, err } idsResult, countResult, err := query.executeFind(ctx) if err != nil { return nil, 0, err } groups, err := qb.FindMany(ctx, idsResult) if err != nil { return nil, 0, err } return groups, countResult, nil } func (qb *GroupStore) QueryCount(ctx context.Context, groupFilter *models.GroupFilterType, findFilter *models.FindFilterType) (int, error) { query, err := qb.makeQuery(ctx, groupFilter, findFilter) if err != nil { return 0, err } return query.executeCount(ctx) } var groupSortOptions = sortOptions{ "created_at", "date", "duration", "id", "name", "random", "rating", "scenes_count", "o_counter", "sub_group_order", "tag_count", "updated_at", } func (qb *GroupStore) setGroupSort(query *queryBuilder, findFilter *models.FindFilterType) error { var sort string var direction string if findFilter == nil { sort = "name" direction = "ASC" } else { sort = findFilter.GetSort("name") direction = findFilter.GetDirection() } // CVE-2024-32231 - ensure sort is in the list of allowed sorts if err := groupSortOptions.validateSort(sort); err != nil { return err } switch sort { case "sub_group_order": // sub_group_order is a special sort that sorts by the order_index of the subgroups if query.hasJoin("groups_parents") { query.sortAndPagination += getSort("order_index", direction, "groups_parents") } else { // this will give unexpected results if the query is not filtered by a parent group and // the group has multiple parents and order indexes query.joinSort(groupRelationsTable, "", "groups.id = groups_relations.sub_id") query.sortAndPagination += getSort("order_index", direction, groupRelationsTable) } case "tag_count": query.sortAndPagination += getCountSort(groupTable, groupsTagsTable, groupIDColumn, direction) case "scenes_count": // generic getSort won't work for this query.sortAndPagination += getCountSort(groupTable, groupsScenesTable, groupIDColumn, direction) case "o_counter": query.sortAndPagination += qb.sortByOCounter(direction) default: query.sortAndPagination += getSort(sort, direction, "groups") } // Whatever the sorting, always use name/id as a final sort query.sortAndPagination += ", COALESCE(groups.name, groups.id) COLLATE NATURAL_CI ASC" return nil } func (qb *GroupStore) queryGroups(ctx context.Context, query string, args []interface{}) ([]*models.Group, error) { const single = false var ret []*models.Group if err := groupRepository.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { var f groupRow if err := r.StructScan(&f); err != nil { return err } s := f.resolve() ret = append(ret, s) return nil }); err != nil { return nil, err } return ret, nil } func (qb *GroupStore) UpdateFrontImage(ctx context.Context, groupID int, frontImage []byte) error { return qb.UpdateImage(ctx, groupID, groupFrontImageBlobColumn, frontImage) } func (qb *GroupStore) UpdateBackImage(ctx context.Context, groupID int, backImage []byte) error { return qb.UpdateImage(ctx, groupID, groupBackImageBlobColumn, backImage) } func (qb *GroupStore) destroyImages(ctx context.Context, groupID int) error { if err := qb.DestroyImage(ctx, groupID, groupFrontImageBlobColumn); err != nil { return err } if err := qb.DestroyImage(ctx, groupID, groupBackImageBlobColumn); err != nil { return err } return nil } func (qb *GroupStore) GetFrontImage(ctx context.Context, groupID int) ([]byte, error) { return qb.GetImage(ctx, groupID, groupFrontImageBlobColumn) } func (qb *GroupStore) HasFrontImage(ctx context.Context, groupID int) (bool, error) { return qb.HasImage(ctx, groupID, groupFrontImageBlobColumn) } func (qb *GroupStore) GetBackImage(ctx context.Context, groupID int) ([]byte, error) { return qb.GetImage(ctx, groupID, groupBackImageBlobColumn) } func (qb *GroupStore) HasBackImage(ctx context.Context, groupID int) (bool, error) { return qb.HasImage(ctx, groupID, groupBackImageBlobColumn) } func (qb *GroupStore) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Group, error) { query := `SELECT DISTINCT groups.* FROM groups INNER JOIN groups_scenes ON groups.id = groups_scenes.group_id INNER JOIN performers_scenes ON performers_scenes.scene_id = groups_scenes.scene_id WHERE performers_scenes.performer_id = ? ` args := []interface{}{performerID} return qb.queryGroups(ctx, query, args) } func (qb *GroupStore) CountByPerformerID(ctx context.Context, performerID int) (int, error) { query := `SELECT COUNT(DISTINCT groups_scenes.group_id) AS count FROM groups_scenes INNER JOIN performers_scenes ON performers_scenes.scene_id = groups_scenes.scene_id WHERE performers_scenes.performer_id = ? ` args := []interface{}{performerID} return groupRepository.runCountQuery(ctx, query, args) } func (qb *GroupStore) FindByStudioID(ctx context.Context, studioID int) ([]*models.Group, error) { query := `SELECT groups.* FROM groups WHERE groups.studio_id = ? ` args := []interface{}{studioID} return qb.queryGroups(ctx, query, args) } func (qb *GroupStore) CountByStudioID(ctx context.Context, studioID int) (int, error) { query := `SELECT COUNT(1) AS count FROM groups WHERE groups.studio_id = ? ` args := []interface{}{studioID} return groupRepository.runCountQuery(ctx, query, args) } func (qb *GroupStore) GetURLs(ctx context.Context, groupID int) ([]string, error) { return groupsURLsTableMgr.get(ctx, groupID) } // FindSubGroupIDs returns a list of group IDs where a group in the ids list is a sub-group of the parent group func (qb *GroupStore) FindSubGroupIDs(ctx context.Context, containingID int, ids []int) ([]int, error) { /* SELECT gr.sub_id FROM groups_relations gr WHERE gr.containing_id = :parentID AND gr.sub_id IN (:ids); */ table := groupRelationshipTableMgr.table q := dialect.From(table).Prepared(true). Select(table.Col("sub_id")).Where( table.Col("containing_id").Eq(containingID), table.Col("sub_id").In(ids), ) const single = false var ret []int if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { var id int if err := r.Scan(&id); err != nil { return err } ret = append(ret, id) return nil }); err != nil { return nil, err } return ret, nil } // FindInAscestors returns a list of group IDs where a group in the ids list is an ascestor of the ancestor group IDs func (qb *GroupStore) FindInAncestors(ctx context.Context, ascestorIDs []int, ids []int) ([]int, error) { /* WITH RECURSIVE ascestors AS ( SELECT g.id AS parent_id FROM groups g WHERE g.id IN (:ascestorIDs) UNION SELECT gr.containing_id FROM groups_relations gr INNER JOIN ascestors a ON a.parent_id = gr.sub_id ) SELECT p.parent_id FROM ascestors p WHERE p.parent_id IN (:ids); */ table := qb.table() const ascestors = "ancestors" const parentID = "parent_id" q := dialect.From(ascestors).Prepared(true). WithRecursive(ascestors, dialect.From(qb.table()).Select(table.Col(idColumn).As(parentID)). Where(table.Col(idColumn).In(ascestorIDs)). Union( dialect.From(groupRelationsJoinTable).InnerJoin( goqu.I(ascestors), goqu.On(goqu.I("parent_id").Eq(goqu.I("sub_id"))), ).Select("containing_id"), ), ).Select(parentID).Where(goqu.I(parentID).In(ids)) const single = false var ret []int if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { var id int if err := r.Scan(&id); err != nil { return err } ret = append(ret, id) return nil }); err != nil { return nil, err } return ret, nil } func (qb *GroupStore) sortByOCounter(direction string) string { // need to sum the o_counter from scenes and images return " ORDER BY (" + selectGroupOCountSQL + ") " + direction } ================================================ FILE: pkg/sqlite/group_filter.go ================================================ package sqlite import ( "context" "fmt" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) type groupFilterHandler struct { groupFilter *models.GroupFilterType } func (qb *groupFilterHandler) validate() error { groupFilter := qb.groupFilter if groupFilter == nil { return nil } if err := validateFilterCombination(groupFilter.OperatorFilter); err != nil { return err } if subFilter := groupFilter.SubFilter(); subFilter != nil { sqb := &groupFilterHandler{groupFilter: subFilter} if err := sqb.validate(); err != nil { return err } } return nil } func (qb *groupFilterHandler) handle(ctx context.Context, f *filterBuilder) { groupFilter := qb.groupFilter if groupFilter == nil { return } if err := qb.validate(); err != nil { f.setError(err) return } sf := groupFilter.SubFilter() if sf != nil { sub := &groupFilterHandler{sf} handleSubFilter(ctx, sub, f, groupFilter.OperatorFilter) } f.handleCriterion(ctx, qb.criterionHandler()) } var groupHierarchyHandler = hierarchicalRelationshipHandler{ primaryTable: groupTable, relationTable: groupRelationsTable, aliasPrefix: groupTable, parentIDCol: "containing_id", childIDCol: "sub_id", } func (qb *groupFilterHandler) criterionHandler() criterionHandler { groupFilter := qb.groupFilter return compoundHandler{ stringCriterionHandler(groupFilter.Name, "groups.name"), stringCriterionHandler(groupFilter.Director, "groups.director"), stringCriterionHandler(groupFilter.Synopsis, "groups.description"), intCriterionHandler(groupFilter.Rating100, "groups.rating", nil), floatIntCriterionHandler(groupFilter.Duration, "groups.duration", nil), qb.missingCriterionHandler(groupFilter.IsMissing), qb.urlsCriterionHandler(groupFilter.URL), studioCriterionHandler(groupTable, groupFilter.Studios), qb.performersCriterionHandler(groupFilter.Performers), qb.tagsCriterionHandler(groupFilter.Tags), qb.tagCountCriterionHandler(groupFilter.TagCount), qb.groupOCounterCriterionHandler(groupFilter.OCounter), qb.sceneCountCriterionHandler(groupFilter.SceneCount), &dateCriterionHandler{groupFilter.Date, "groups.date", nil}, groupHierarchyHandler.ParentsCriterionHandler(groupFilter.ContainingGroups), groupHierarchyHandler.ChildrenCriterionHandler(groupFilter.SubGroups), groupHierarchyHandler.ParentCountCriterionHandler(groupFilter.ContainingGroupCount), groupHierarchyHandler.ChildCountCriterionHandler(groupFilter.SubGroupCount), ×tampCriterionHandler{groupFilter.CreatedAt, "groups.created_at", nil}, ×tampCriterionHandler{groupFilter.UpdatedAt, "groups.updated_at", nil}, &customFieldsFilterHandler{ table: groupsCustomFieldsTable.GetTable(), fkCol: groupIDColumn, c: groupFilter.CustomFields, idCol: "groups.id", }, &relatedFilterHandler{ relatedIDCol: "groups_scenes.scene_id", relatedRepo: sceneRepository.repository, relatedHandler: &sceneFilterHandler{groupFilter.ScenesFilter}, joinFn: func(f *filterBuilder) { groupRepository.scenes.innerJoin(f, "", "groups.id") }, }, &relatedFilterHandler{ relatedIDCol: "groups.studio_id", relatedRepo: studioRepository.repository, relatedHandler: &studioFilterHandler{groupFilter.StudiosFilter}, }, } } func (qb *groupFilterHandler) missingCriterionHandler(isMissing *string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if isMissing != nil && *isMissing != "" { switch *isMissing { case "front_image": f.addWhere("groups.front_image_blob IS NULL") case "back_image": f.addWhere("groups.back_image_blob IS NULL") case "scenes": f.addLeftJoin("groups_scenes", "", "groups_scenes.group_id = groups.id") f.addWhere("groups_scenes.scene_id IS NULL") case "url": groupsURLsTableMgr.join(f, "", "groups.id") f.addWhere("group_urls.url IS NULL") case "studio": f.addWhere("groups.studio_id IS NULL") case "performers": f.addLeftJoin("groups_scenes", "gs_perf", "groups.id = gs_perf.group_id") f.addLeftJoin("performers_scenes", "ps_perf", "gs_perf.scene_id = ps_perf.scene_id") f.addWhere("ps_perf.performer_id IS NULL") case "tags": groupRepository.tags.join(f, "tags_join", "groups.id") f.addWhere("tags_join.group_id IS NULL") default: if err := validateIsMissing(*isMissing, []string{ "aliases", "description", "director", "date", "rating", }); err != nil { f.setError(err) return } f.addWhere("(groups." + *isMissing + " IS NULL OR TRIM(groups." + *isMissing + ") = '')") } } } } func (qb *groupFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ primaryTable: groupTable, primaryFK: groupIDColumn, joinTable: groupURLsTable, stringColumn: groupURLColumn, addJoinTable: func(f *filterBuilder) { groupsURLsTableMgr.join(f, "", "groups.id") }, } return h.handler(url) } func (qb *groupFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if performers != nil { if performers.Modifier == models.CriterionModifierIsNull || performers.Modifier == models.CriterionModifierNotNull { var notClause string if performers.Modifier == models.CriterionModifierNotNull { notClause = "NOT" } f.addLeftJoin("groups_scenes", "", "groups.id = groups_scenes.group_id") f.addLeftJoin("performers_scenes", "", "groups_scenes.scene_id = performers_scenes.scene_id") f.addWhere(fmt.Sprintf("performers_scenes.performer_id IS %s NULL", notClause)) return } if len(performers.Value) == 0 { return } var args []interface{} for _, arg := range performers.Value { args = append(args, arg) } // Hack, can't apply args to join, nor inner join on a left join, so use CTE instead f.addWith(`groups_performers AS ( SELECT groups_scenes.group_id, performers_scenes.performer_id FROM groups_scenes INNER JOIN performers_scenes ON groups_scenes.scene_id = performers_scenes.scene_id WHERE performers_scenes.performer_id IN`+getInBinding(len(performers.Value))+` )`, args...) f.addLeftJoin("groups_performers", "", "groups.id = groups_performers.group_id") switch performers.Modifier { case models.CriterionModifierIncludes: f.addWhere("groups_performers.performer_id IS NOT NULL") case models.CriterionModifierIncludesAll: f.addWhere("groups_performers.performer_id IS NOT NULL") f.addHaving("COUNT(DISTINCT groups_performers.performer_id) = ?", len(performers.Value)) case models.CriterionModifierExcludes: f.addWhere("groups_performers.performer_id IS NULL") } } } } func (qb *groupFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { h := joinedHierarchicalMultiCriterionHandlerBuilder{ primaryTable: groupTable, foreignTable: tagTable, foreignFK: "tag_id", relationsTable: "tags_relations", joinAs: "group_tag", joinTable: groupsTagsTable, primaryFK: groupIDColumn, } return h.handler(tags) } func (qb *groupFilterHandler) tagCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: groupTable, joinTable: groupsTagsTable, primaryFK: groupIDColumn, } return h.handler(count) } func (qb *groupFilterHandler) sceneCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: groupTable, joinTable: groupsScenesTable, primaryFK: groupIDColumn, } return h.handler(count) } // used for sorting and filtering on group o-count var selectGroupOCountSQL = utils.StrFormat( "SELECT SUM(o_counter) "+ "FROM ("+ "SELECT COUNT({scenes_o_dates}.{o_date}) as o_counter from {groups_scenes} s "+ "LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} "+ "LEFT JOIN {scenes_o_dates} ON {scenes_o_dates}.{scene_id} = {scenes}.id "+ "WHERE s.{group_id} = {group}.id "+ ")", map[string]interface{}{ "group": groupTable, "group_id": groupIDColumn, "groups_scenes": groupsScenesTable, "scenes": sceneTable, "scene_id": sceneIDColumn, "scenes_o_dates": scenesODatesTable, "o_date": sceneODateColumn, }, ) func (qb *groupFilterHandler) groupOCounterCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if count == nil { return } lhs := "(" + selectGroupOCountSQL + ")" clause, args := getIntCriterionWhereClause(lhs, *count) f.addWhere(clause, args...) } } ================================================ FILE: pkg/sqlite/group_relationships.go ================================================ package sqlite import ( "context" "fmt" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/models" "gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4/zero" ) type groupRelationshipRow struct { ContainingID int `db:"containing_id"` SubID int `db:"sub_id"` OrderIndex int `db:"order_index"` Description zero.String `db:"description"` } func (r groupRelationshipRow) resolve(useContainingID bool) models.GroupIDDescription { id := r.ContainingID if !useContainingID { id = r.SubID } return models.GroupIDDescription{ GroupID: id, Description: r.Description.String, } } type groupRelationshipStore struct { table *table } func (s *groupRelationshipStore) GetContainingGroupDescriptions(ctx context.Context, id int) ([]models.GroupIDDescription, error) { const idIsContaining = false return s.getGroupRelationships(ctx, id, idIsContaining) } func (s *groupRelationshipStore) GetSubGroupDescriptions(ctx context.Context, id int) ([]models.GroupIDDescription, error) { const idIsContaining = true return s.getGroupRelationships(ctx, id, idIsContaining) } func (s *groupRelationshipStore) getGroupRelationships(ctx context.Context, id int, idIsContaining bool) ([]models.GroupIDDescription, error) { col := "containing_id" if !idIsContaining { col = "sub_id" } table := s.table.table q := dialect.Select(table.All()). From(table). Where(table.Col(col).Eq(id)). Order(table.Col("order_index").Asc()) const single = false var ret []models.GroupIDDescription if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { var row groupRelationshipRow if err := rows.StructScan(&row); err != nil { return err } ret = append(ret, row.resolve(!idIsContaining)) return nil }); err != nil { return nil, fmt.Errorf("getting group relationships from %s: %w", table.GetTable(), err) } return ret, nil } // getMaxOrderIndex gets the maximum order index for the containing group with the given id func (s *groupRelationshipStore) getMaxOrderIndex(ctx context.Context, containingID int) (int, error) { idColumn := s.table.table.Col("containing_id") q := dialect.Select(goqu.MAX("order_index")). From(s.table.table). Where(idColumn.Eq(containingID)) var maxOrderIndex zero.Int if err := querySimple(ctx, q, &maxOrderIndex); err != nil { return 0, fmt.Errorf("getting max order index: %w", err) } return int(maxOrderIndex.Int64), nil } // createRelationships creates relationships between a group and other groups. // If idIsContaining is true, the provided id is the containing group. func (s *groupRelationshipStore) createRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions, idIsContaining bool) error { if d.Loaded() { for i, v := range d.List() { orderIndex := i + 1 r := groupRelationshipRow{ ContainingID: id, SubID: v.GroupID, OrderIndex: orderIndex, Description: zero.StringFrom(v.Description), } if !idIsContaining { // get the max order index of the containing groups sub groups containingID := v.GroupID maxOrderIndex, err := s.getMaxOrderIndex(ctx, containingID) if err != nil { return err } r.ContainingID = v.GroupID r.SubID = id r.OrderIndex = maxOrderIndex + 1 } _, err := s.table.insert(ctx, r) if err != nil { return fmt.Errorf("inserting into %s: %w", s.table.table.GetTable(), err) } } return nil } return nil } // createRelationships creates relationships between a group and other groups. // If idIsContaining is true, the provided id is the containing group. func (s *groupRelationshipStore) createContainingRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions) error { const idIsContaining = false return s.createRelationships(ctx, id, d, idIsContaining) } // createRelationships creates relationships between a group and other groups. // If idIsContaining is true, the provided id is the containing group. func (s *groupRelationshipStore) createSubRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions) error { const idIsContaining = true return s.createRelationships(ctx, id, d, idIsContaining) } func (s *groupRelationshipStore) replaceRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions, idIsContaining bool) error { // always destroy the existing relationships even if the new list is empty if err := s.destroyAllJoins(ctx, id, idIsContaining); err != nil { return err } return s.createRelationships(ctx, id, d, idIsContaining) } func (s *groupRelationshipStore) replaceContainingRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions) error { const idIsContaining = false return s.replaceRelationships(ctx, id, d, idIsContaining) } func (s *groupRelationshipStore) replaceSubRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions) error { const idIsContaining = true return s.replaceRelationships(ctx, id, d, idIsContaining) } func (s *groupRelationshipStore) modifyRelationships(ctx context.Context, id int, v *models.UpdateGroupDescriptions, idIsContaining bool) error { if v == nil { return nil } switch v.Mode { case models.RelationshipUpdateModeSet: return s.replaceJoins(ctx, id, *v, idIsContaining) case models.RelationshipUpdateModeAdd: return s.addJoins(ctx, id, v.Groups, idIsContaining) case models.RelationshipUpdateModeRemove: toRemove := make([]int, len(v.Groups)) for i, vv := range v.Groups { toRemove[i] = vv.GroupID } return s.destroyJoins(ctx, id, toRemove, idIsContaining) } return nil } func (s *groupRelationshipStore) modifyContainingRelationships(ctx context.Context, id int, v *models.UpdateGroupDescriptions) error { const idIsContaining = false return s.modifyRelationships(ctx, id, v, idIsContaining) } func (s *groupRelationshipStore) modifySubRelationships(ctx context.Context, id int, v *models.UpdateGroupDescriptions) error { const idIsContaining = true return s.modifyRelationships(ctx, id, v, idIsContaining) } func (s *groupRelationshipStore) addJoins(ctx context.Context, id int, groups []models.GroupIDDescription, idIsContaining bool) error { // if we're adding to a containing group, get the max order index first var maxOrderIndex int if idIsContaining { var err error maxOrderIndex, err = s.getMaxOrderIndex(ctx, id) if err != nil { return err } } for i, vv := range groups { r := groupRelationshipRow{ Description: zero.StringFrom(vv.Description), } if idIsContaining { r.ContainingID = id r.SubID = vv.GroupID r.OrderIndex = maxOrderIndex + (i + 1) } else { // get the max order index of the containing groups sub groups containingMaxOrderIndex, err := s.getMaxOrderIndex(ctx, vv.GroupID) if err != nil { return err } r.ContainingID = vv.GroupID r.SubID = id r.OrderIndex = containingMaxOrderIndex + 1 } _, err := s.table.insert(ctx, r) if err != nil { return fmt.Errorf("inserting into %s: %w", s.table.table.GetTable(), err) } } return nil } func (s *groupRelationshipStore) destroyAllJoins(ctx context.Context, id int, idIsContaining bool) error { table := s.table.table idColumn := table.Col("containing_id") if !idIsContaining { idColumn = table.Col("sub_id") } q := dialect.Delete(table).Where(idColumn.Eq(id)) if _, err := exec(ctx, q); err != nil { return fmt.Errorf("destroying %s: %w", table.GetTable(), err) } return nil } func (s *groupRelationshipStore) replaceJoins(ctx context.Context, id int, v models.UpdateGroupDescriptions, idIsContaining bool) error { if err := s.destroyAllJoins(ctx, id, idIsContaining); err != nil { return err } // convert to RelatedGroupDescriptions rgd := models.NewRelatedGroupDescriptions(v.Groups) return s.createRelationships(ctx, id, rgd, idIsContaining) } func (s *groupRelationshipStore) destroyJoins(ctx context.Context, id int, toRemove []int, idIsContaining bool) error { table := s.table.table idColumn := table.Col("containing_id") fkColumn := table.Col("sub_id") if !idIsContaining { idColumn = table.Col("sub_id") fkColumn = table.Col("containing_id") } q := dialect.Delete(table).Where(idColumn.Eq(id), fkColumn.In(toRemove)) if _, err := exec(ctx, q); err != nil { return fmt.Errorf("destroying %s: %w", table.GetTable(), err) } return nil } func (s *groupRelationshipStore) getOrderIndexOfSubGroup(ctx context.Context, containingGroupID int, subGroupID int) (int, error) { table := s.table.table q := dialect.Select("order_index"). From(table). Where( table.Col("containing_id").Eq(containingGroupID), table.Col("sub_id").Eq(subGroupID), ) var orderIndex null.Int if err := querySimple(ctx, q, &orderIndex); err != nil { return 0, fmt.Errorf("getting order index: %w", err) } if !orderIndex.Valid { return 0, fmt.Errorf("sub-group %d not found in containing group %d", subGroupID, containingGroupID) } return int(orderIndex.Int64), nil } func (s *groupRelationshipStore) getGroupIDAtOrderIndex(ctx context.Context, containingGroupID int, orderIndex int) (*int, error) { table := s.table.table q := dialect.Select(table.Col("sub_id")).From(table).Where( table.Col("containing_id").Eq(containingGroupID), table.Col("order_index").Eq(orderIndex), ) var ret null.Int if err := querySimple(ctx, q, &ret); err != nil { return nil, fmt.Errorf("getting sub id for order index: %w", err) } if !ret.Valid { return nil, nil } intRet := int(ret.Int64) return &intRet, nil } func (s *groupRelationshipStore) getOrderIndexAfterOrderIndex(ctx context.Context, containingGroupID int, orderIndex int) (int, error) { table := s.table.table q := dialect.Select(goqu.MIN("order_index")).From(table).Where( table.Col("containing_id").Eq(containingGroupID), table.Col("order_index").Gt(orderIndex), ) var ret null.Int if err := querySimple(ctx, q, &ret); err != nil { return 0, fmt.Errorf("getting order index: %w", err) } if !ret.Valid { return orderIndex + 1, nil } return int(ret.Int64), nil } // incrementOrderIndexes increments the order_index value of all sub-groups in the containing group at or after the given index func (s *groupRelationshipStore) incrementOrderIndexes(ctx context.Context, groupID int, indexBefore int) error { table := s.table.table // WORKAROUND - sqlite won't allow incrementing the value directly since it causes a // unique constraint violation. // Instead, we first set the order index to a negative value temporarily // see https://stackoverflow.com/a/7703239/695786 q := dialect.Update(table).Set(exp.Record{ "order_index": goqu.L("-order_index"), }).Where( table.Col("containing_id").Eq(groupID), table.Col("order_index").Gte(indexBefore), ) if _, err := exec(ctx, q); err != nil { return fmt.Errorf("updating %s: %w", table.GetTable(), err) } q = dialect.Update(table).Set(exp.Record{ "order_index": goqu.L("1-order_index"), }).Where( table.Col("containing_id").Eq(groupID), table.Col("order_index").Lt(0), ) if _, err := exec(ctx, q); err != nil { return fmt.Errorf("updating %s: %w", table.GetTable(), err) } return nil } func (s *groupRelationshipStore) reorderSubGroup(ctx context.Context, groupID int, subGroupID int, insertPointID int, insertAfter bool) error { insertPointIndex, err := s.getOrderIndexOfSubGroup(ctx, groupID, insertPointID) if err != nil { return err } // if we're setting before if insertAfter { insertPointIndex, err = s.getOrderIndexAfterOrderIndex(ctx, groupID, insertPointIndex) if err != nil { return err } } // increment the order index of all sub-groups after and including the insertion point if err := s.incrementOrderIndexes(ctx, groupID, int(insertPointIndex)); err != nil { return err } // set the order index of the sub-group to the insertion point table := s.table.table q := dialect.Update(table).Set(exp.Record{ "order_index": insertPointIndex, }).Where( table.Col("containing_id").Eq(groupID), table.Col("sub_id").Eq(subGroupID), ) if _, err := exec(ctx, q); err != nil { return fmt.Errorf("updating %s: %w", table.GetTable(), err) } return nil } func (s *groupRelationshipStore) AddSubGroups(ctx context.Context, groupID int, subGroups []models.GroupIDDescription, insertIndex *int) error { const idIsContaining = true if err := s.addJoins(ctx, groupID, subGroups, idIsContaining); err != nil { return err } ids := make([]int, len(subGroups)) for i, v := range subGroups { ids[i] = v.GroupID } if insertIndex != nil { // get the id of the sub-group at the insert index insertPointID, err := s.getGroupIDAtOrderIndex(ctx, groupID, *insertIndex) if err != nil { return err } if insertPointID == nil { // if the insert index is out of bounds, just assume adding to the end return nil } // reorder the sub-groups const insertAfter = false if err := s.ReorderSubGroups(ctx, groupID, ids, *insertPointID, insertAfter); err != nil { return err } } return nil } func (s *groupRelationshipStore) RemoveSubGroups(ctx context.Context, groupID int, subGroupIDs []int) error { const idIsContaining = true return s.destroyJoins(ctx, groupID, subGroupIDs, idIsContaining) } func (s *groupRelationshipStore) ReorderSubGroups(ctx context.Context, groupID int, subGroupIDs []int, insertPointID int, insertAfter bool) error { for _, id := range subGroupIDs { if err := s.reorderSubGroup(ctx, groupID, id, insertPointID, insertAfter); err != nil { return err } } return nil } ================================================ FILE: pkg/sqlite/group_test.go ================================================ //go:build integration // +build integration package sqlite_test import ( "context" "fmt" "slices" "strconv" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sliceutil/intslice" ) func loadGroupRelationships(ctx context.Context, expected models.Group, actual *models.Group) error { if expected.URLs.Loaded() { if err := actual.LoadURLs(ctx, db.Group); err != nil { return err } } if expected.TagIDs.Loaded() { if err := actual.LoadTagIDs(ctx, db.Group); err != nil { return err } } if expected.ContainingGroups.Loaded() { if err := actual.LoadContainingGroupIDs(ctx, db.Group); err != nil { return err } } if expected.SubGroups.Loaded() { if err := actual.LoadSubGroupIDs(ctx, db.Group); err != nil { return err } } return nil } func Test_GroupStore_Create(t *testing.T) { var ( name = "name" url = "url" aliases = "alias1, alias2" director = "director" rating = 60 duration = 34 synopsis = "synopsis" date, _ = models.ParseDate("2003-02-01") containingGroupDescription = "containingGroupDescription" subGroupDescription = "subGroupDescription" createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) ) tests := []struct { name string newObject models.Group wantErr bool }{ { "full", models.Group{ Name: name, Duration: &duration, Date: &date, Rating: &rating, StudioID: &studioIDs[studioIdxWithGroup], Director: director, Synopsis: synopsis, URLs: models.NewRelatedStrings([]string{url}), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGroup]}), ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ {GroupID: groupIDs[groupIdxWithScene], Description: containingGroupDescription}, }), SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ {GroupID: groupIDs[groupIdxWithStudio], Description: subGroupDescription}, }), Aliases: aliases, CreatedAt: createdAt, UpdatedAt: updatedAt, }, false, }, { "invalid tag id", models.Group{ Name: name, TagIDs: models.NewRelatedIDs([]int{invalidID}), }, true, }, { "invalid containing group id", models.Group{ Name: name, ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{{GroupID: invalidID}}), }, true, }, { "invalid sub group id", models.Group{ Name: name, SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{{GroupID: invalidID}}), }, true, }, } qb := db.Group for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) p := tt.newObject if err := qb.Create(ctx, &p); (err != nil) != tt.wantErr { t.Errorf("GroupStore.Create() error = %v, wantErr = %v", err, tt.wantErr) } if tt.wantErr { assert.Zero(p.ID) return } assert.NotZero(p.ID) copy := tt.newObject copy.ID = p.ID // load relationships if err := loadGroupRelationships(ctx, copy, &p); err != nil { t.Errorf("loadGroupRelationships() error = %v", err) return } assert.Equal(copy, p) // ensure can find the group found, err := qb.Find(ctx, p.ID) if err != nil { t.Errorf("GroupStore.Find() error = %v", err) } if !assert.NotNil(found) { return } // load relationships if err := loadGroupRelationships(ctx, copy, found); err != nil { t.Errorf("loadGroupRelationships() error = %v", err) return } assert.Equal(copy, *found) return }) } } func Test_groupQueryBuilder_Update(t *testing.T) { var ( name = "name" url = "url" aliases = "alias1, alias2" director = "director" rating = 60 duration = 34 synopsis = "synopsis" date, _ = models.ParseDate("2003-02-01") containingGroupDescription = "containingGroupDescription" subGroupDescription = "subGroupDescription" createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) ) tests := []struct { name string updatedObject models.Group wantErr bool }{ { "full", models.Group{ ID: groupIDs[groupIdxWithTag], Name: name, Duration: &duration, Date: &date, Rating: &rating, StudioID: &studioIDs[studioIdxWithGroup], Director: director, Synopsis: synopsis, URLs: models.NewRelatedStrings([]string{url}), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGroup]}), ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ {GroupID: groupIDs[groupIdxWithScene], Description: containingGroupDescription}, }), SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ {GroupID: groupIDs[groupIdxWithStudio], Description: subGroupDescription}, }), Aliases: aliases, CreatedAt: createdAt, UpdatedAt: updatedAt, }, false, }, { "clear tag ids", models.Group{ ID: groupIDs[groupIdxWithTag], Name: name, TagIDs: models.NewRelatedIDs([]int{}), }, false, }, { "clear containing ids", models.Group{ ID: groupIDs[groupIdxWithParent], Name: name, ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}), }, false, }, { "clear sub ids", models.Group{ ID: groupIDs[groupIdxWithChild], Name: name, SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}), }, false, }, { "invalid studio id", models.Group{ ID: groupIDs[groupIdxWithScene], Name: name, StudioID: &invalidID, }, true, }, { "invalid tag id", models.Group{ ID: groupIDs[groupIdxWithScene], Name: name, TagIDs: models.NewRelatedIDs([]int{invalidID}), }, true, }, { "invalid containing group id", models.Group{ ID: groupIDs[groupIdxWithScene], Name: name, ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{{GroupID: invalidID}}), }, true, }, { "invalid sub group id", models.Group{ ID: groupIDs[groupIdxWithScene], Name: name, SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{{GroupID: invalidID}}), }, true, }, } qb := db.Group for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) actual := tt.updatedObject expected := tt.updatedObject if err := qb.Update(ctx, &actual); (err != nil) != tt.wantErr { t.Errorf("groupQueryBuilder.Update() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { return } s, err := qb.Find(ctx, actual.ID) if err != nil { t.Errorf("groupQueryBuilder.Find() error = %v", err) } // load relationships if err := loadGroupRelationships(ctx, expected, s); err != nil { t.Errorf("loadGroupRelationships() error = %v", err) return } assert.Equal(expected, *s) }) } } var clearGroupPartial = models.GroupPartial{ // leave mandatory fields Aliases: models.OptionalString{Set: true, Null: true}, Synopsis: models.OptionalString{Set: true, Null: true}, Director: models.OptionalString{Set: true, Null: true}, Duration: models.OptionalInt{Set: true, Null: true}, URLs: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet}, Date: models.OptionalDate{Set: true, Null: true}, Rating: models.OptionalInt{Set: true, Null: true}, StudioID: models.OptionalInt{Set: true, Null: true}, TagIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet}, ContainingGroups: &models.UpdateGroupDescriptions{Mode: models.RelationshipUpdateModeSet}, SubGroups: &models.UpdateGroupDescriptions{Mode: models.RelationshipUpdateModeSet}, } func emptyGroup(idx int) models.Group { return models.Group{ ID: groupIDs[idx], Name: groupNames[idx], TagIDs: models.NewRelatedIDs([]int{}), ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}), SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}), } } func Test_groupQueryBuilder_UpdatePartial(t *testing.T) { var ( name = "name" url = "url" aliases = "alias1, alias2" director = "director" rating = 60 duration = 34 synopsis = "synopsis" date, _ = models.ParseDate("2003-02-01") containingGroupDescription = "containingGroupDescription" subGroupDescription = "subGroupDescription" createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) ) tests := []struct { name string id int partial models.GroupPartial want models.Group wantErr bool }{ { "full", groupIDs[groupIdxWithScene], models.GroupPartial{ Name: models.NewOptionalString(name), Director: models.NewOptionalString(director), Synopsis: models.NewOptionalString(synopsis), Aliases: models.NewOptionalString(aliases), URLs: &models.UpdateStrings{ Values: []string{url}, Mode: models.RelationshipUpdateModeSet, }, Date: models.NewOptionalDate(date), Duration: models.NewOptionalInt(duration), Rating: models.NewOptionalInt(rating), StudioID: models.NewOptionalInt(studioIDs[studioIdxWithGroup]), CreatedAt: models.NewOptionalTime(createdAt), UpdatedAt: models.NewOptionalTime(updatedAt), TagIDs: &models.UpdateIDs{ IDs: []int{tagIDs[tagIdx1WithGroup], tagIDs[tagIdx1WithDupName]}, Mode: models.RelationshipUpdateModeSet, }, ContainingGroups: &models.UpdateGroupDescriptions{ Groups: []models.GroupIDDescription{ {GroupID: groupIDs[groupIdxWithStudio], Description: containingGroupDescription}, {GroupID: groupIDs[groupIdxWithThreeTags], Description: containingGroupDescription}, }, Mode: models.RelationshipUpdateModeSet, }, SubGroups: &models.UpdateGroupDescriptions{ Groups: []models.GroupIDDescription{ {GroupID: groupIDs[groupIdxWithTag], Description: subGroupDescription}, {GroupID: groupIDs[groupIdxWithDupName], Description: subGroupDescription}, }, Mode: models.RelationshipUpdateModeSet, }, }, models.Group{ ID: groupIDs[groupIdxWithScene], Name: name, Director: director, Synopsis: synopsis, Aliases: aliases, URLs: models.NewRelatedStrings([]string{url}), Date: &date, Duration: &duration, Rating: &rating, StudioID: &studioIDs[studioIdxWithGroup], CreatedAt: createdAt, UpdatedAt: updatedAt, TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGroup]}), ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ {GroupID: groupIDs[groupIdxWithStudio], Description: containingGroupDescription}, {GroupID: groupIDs[groupIdxWithThreeTags], Description: containingGroupDescription}, }), SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ {GroupID: groupIDs[groupIdxWithTag], Description: subGroupDescription}, {GroupID: groupIDs[groupIdxWithDupName], Description: subGroupDescription}, }), }, false, }, { "clear all", groupIDs[groupIdxWithScene], clearGroupPartial, emptyGroup(groupIdxWithScene), false, }, { "clear tag ids", groupIDs[groupIdxWithTag], clearGroupPartial, emptyGroup(groupIdxWithTag), false, }, { "clear group relationships", groupIDs[groupIdxWithParentAndChild], clearGroupPartial, emptyGroup(groupIdxWithParentAndChild), false, }, { "add containing group", groupIDs[groupIdxWithParent], models.GroupPartial{ ContainingGroups: &models.UpdateGroupDescriptions{ Groups: []models.GroupIDDescription{ {GroupID: groupIDs[groupIdxWithScene], Description: containingGroupDescription}, }, Mode: models.RelationshipUpdateModeAdd, }, }, models.Group{ ID: groupIDs[groupIdxWithParent], Name: groupNames[groupIdxWithParent], ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ {GroupID: groupIDs[groupIdxWithChild]}, {GroupID: groupIDs[groupIdxWithScene], Description: containingGroupDescription}, }), }, false, }, { "add sub group", groupIDs[groupIdxWithChild], models.GroupPartial{ SubGroups: &models.UpdateGroupDescriptions{ Groups: []models.GroupIDDescription{ {GroupID: groupIDs[groupIdxWithScene], Description: subGroupDescription}, }, Mode: models.RelationshipUpdateModeAdd, }, }, models.Group{ ID: groupIDs[groupIdxWithChild], Name: groupNames[groupIdxWithChild], SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ {GroupID: groupIDs[groupIdxWithParent]}, {GroupID: groupIDs[groupIdxWithScene], Description: subGroupDescription}, }), }, false, }, { "remove containing group", groupIDs[groupIdxWithParent], models.GroupPartial{ ContainingGroups: &models.UpdateGroupDescriptions{ Groups: []models.GroupIDDescription{ {GroupID: groupIDs[groupIdxWithChild]}, }, Mode: models.RelationshipUpdateModeRemove, }, }, models.Group{ ID: groupIDs[groupIdxWithParent], Name: groupNames[groupIdxWithParent], ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}), }, false, }, { "remove sub group", groupIDs[groupIdxWithChild], models.GroupPartial{ SubGroups: &models.UpdateGroupDescriptions{ Groups: []models.GroupIDDescription{ {GroupID: groupIDs[groupIdxWithParent]}, }, Mode: models.RelationshipUpdateModeRemove, }, }, models.Group{ ID: groupIDs[groupIdxWithChild], Name: groupNames[groupIdxWithChild], SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}), }, false, }, { "invalid id", invalidID, models.GroupPartial{}, models.Group{}, true, }, } for _, tt := range tests { qb := db.Group runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.UpdatePartial(ctx, tt.id, tt.partial) if (err != nil) != tt.wantErr { t.Errorf("groupQueryBuilder.UpdatePartial() error = %v, wantErr %v", err, tt.wantErr) return } if tt.wantErr { return } // load relationships if err := loadGroupRelationships(ctx, tt.want, got); err != nil { t.Errorf("loadGroupRelationships() error = %v", err) return } assert.Equal(tt.want, *got) s, err := qb.Find(ctx, tt.id) if err != nil { t.Errorf("groupQueryBuilder.Find() error = %v", err) } // load relationships if err := loadGroupRelationships(ctx, tt.want, s); err != nil { t.Errorf("loadGroupRelationships() error = %v", err) return } assert.Equal(tt.want, *s) }) } } func Test_GroupStore_UpdatePartialCustomFields(t *testing.T) { tests := []struct { name string id int partial models.GroupPartial expected map[string]interface{} // nil to use the partial }{ { "set custom fields", groupIDs[groupIdxWithChild], models.GroupPartial{ CustomFields: models.CustomFieldsInput{ Full: testCustomFields, }, }, nil, }, { "clear custom fields", groupIDs[groupIdxWithChild], models.GroupPartial{ CustomFields: models.CustomFieldsInput{ Full: map[string]interface{}{}, }, }, nil, }, { "partial custom fields", groupIDs[groupIdxWithTwoTags], models.GroupPartial{ CustomFields: models.CustomFieldsInput{ Partial: map[string]interface{}{ "string": "bbb", "new_field": "new", }, }, }, map[string]interface{}{ "int": int64(3), "real": 0.3, "string": "bbb", "new_field": "new", }, }, } for _, tt := range tests { qb := db.Group runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) _, err := qb.UpdatePartial(ctx, tt.id, tt.partial) if err != nil { t.Errorf("GroupStore.UpdatePartial() error = %v", err) return } // ensure custom fields are correct cf, err := qb.GetCustomFields(ctx, tt.id) if err != nil { t.Errorf("GroupStore.GetCustomFields() error = %v", err) return } if tt.expected == nil { assert.Equal(tt.partial.CustomFields.Full, cf) } else { assert.Equal(tt.expected, cf) } }) } } func TestGroupFindByName(t *testing.T) { withTxn(func(ctx context.Context) error { mqb := db.Group name := groupNames[groupIdxWithScene] // find a group by name group, err := mqb.FindByName(ctx, name, false) if err != nil { t.Errorf("Error finding groups: %s", err.Error()) } assert.Equal(t, groupNames[groupIdxWithScene], group.Name) name = groupNames[groupIdxWithDupName] // find a group by name nocase group, err = mqb.FindByName(ctx, name, true) if err != nil { t.Errorf("Error finding groups: %s", err.Error()) } // groupIdxWithDupName and groupIdxWithScene should have similar names ( only diff should be Name vs NaMe) //group.Name should match with groupIdxWithScene since its ID is before moveIdxWithDupName assert.Equal(t, groupNames[groupIdxWithScene], group.Name) //group.Name should match with groupIdxWithDupName if the check is not case sensitive assert.Equal(t, strings.ToLower(groupNames[groupIdxWithDupName]), strings.ToLower(group.Name)) return nil }) } func TestGroupFindByNames(t *testing.T) { withTxn(func(ctx context.Context) error { var names []string mqb := db.Group names = append(names, groupNames[groupIdxWithScene]) // find groups by names groups, err := mqb.FindByNames(ctx, names, false) if err != nil { t.Errorf("Error finding groups: %s", err.Error()) } assert.Len(t, groups, 1) assert.Equal(t, groupNames[groupIdxWithScene], groups[0].Name) groups, err = mqb.FindByNames(ctx, names, true) // find groups by names nocase if err != nil { t.Errorf("Error finding groups: %s", err.Error()) } assert.Len(t, groups, 2) // groupIdxWithScene and groupIdxWithDupName assert.Equal(t, strings.ToLower(groupNames[groupIdxWithScene]), strings.ToLower(groups[0].Name)) assert.Equal(t, strings.ToLower(groupNames[groupIdxWithScene]), strings.ToLower(groups[1].Name)) return nil }) } func groupsToIDs(i []*models.Group) []int { ret := make([]int, len(i)) for i, v := range i { ret[i] = v.ID } return ret } func TestGroupQuery(t *testing.T) { var ( frontImage = "front_image" backImage = "back_image" ) tests := []struct { name string findFilter *models.FindFilterType filter *models.GroupFilterType includeIdxs []int excludeIdxs []int wantErr bool }{ { "is missing front image", nil, &models.GroupFilterType{ IsMissing: &frontImage, }, // just ensure that it doesn't error nil, nil, false, }, { "is missing back image", nil, &models.GroupFilterType{ IsMissing: &backImage, }, // just ensure that it doesn't error nil, nil, false, }, { "scene count equals 1", nil, &models.GroupFilterType{ SceneCount: &models.IntCriterionInput{ Value: 1, Modifier: models.CriterionModifierEquals, }, }, []int{groupIdxWithScene}, []int{groupIdxWithParentAndChild}, false, }, { "scene count less than 1", nil, &models.GroupFilterType{ SceneCount: &models.IntCriterionInput{ Value: 1, Modifier: models.CriterionModifierLessThan, }, }, []int{groupIdxWithParentAndChild}, []int{groupIdxWithScene}, false, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) results, _, err := db.Group.Query(ctx, tt.filter, tt.findFilter) if (err != nil) != tt.wantErr { t.Errorf("GroupQueryBuilder.Query() error = %v, wantErr %v", err, tt.wantErr) return } ids := groupsToIDs(results) include := indexesToIDs(performerIDs, tt.includeIdxs) exclude := indexesToIDs(performerIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(ids, i) } for _, e := range exclude { assert.NotContains(ids, e) } }) } } func TestGroupQueryStudio(t *testing.T) { withTxn(func(ctx context.Context) error { mqb := db.Group studioCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithGroup]), }, Modifier: models.CriterionModifierIncludes, } groupFilter := models.GroupFilterType{ Studios: &studioCriterion, } groups, _, err := mqb.Query(ctx, &groupFilter, nil) if err != nil { t.Errorf("Error querying group: %s", err.Error()) } assert.Len(t, groups, 1) // ensure id is correct assert.Equal(t, groupIDs[groupIdxWithStudio], groups[0].ID) studioCriterion = models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithGroup]), }, Modifier: models.CriterionModifierExcludes, } q := getGroupStringValue(groupIdxWithStudio, titleField) findFilter := models.FindFilterType{ Q: &q, } groups, _, err = mqb.Query(ctx, &groupFilter, &findFilter) if err != nil { t.Errorf("Error querying group: %s", err.Error()) } assert.Len(t, groups, 0) return nil }) } func TestGroupQueryURL(t *testing.T) { const sceneIdx = 1 groupURL := getGroupStringValue(sceneIdx, urlField) urlCriterion := models.StringCriterionInput{ Value: groupURL, Modifier: models.CriterionModifierEquals, } filter := models.GroupFilterType{ URL: &urlCriterion, } verifyFn := func(n *models.Group) { t.Helper() urls := n.URLs.List() var url string if len(urls) > 0 { url = urls[0] } verifyString(t, url, urlCriterion) } verifyGroupQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierNotEquals verifyGroupQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierMatchesRegex urlCriterion.Value = "group_.*1_URL" verifyGroupQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierNotMatchesRegex verifyGroupQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierIsNull urlCriterion.Value = "" verifyGroupQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierNotNull verifyGroupQuery(t, filter, verifyFn) } func TestGroupQueryURLExcludes(t *testing.T) { withRollbackTxn(func(ctx context.Context) error { mqb := db.Group // create group with two URLs group := models.Group{ Name: "TestGroupQueryURLExcludes", URLs: models.NewRelatedStrings([]string{ "aaa", "bbb", }), } err := mqb.Create(ctx, &group) if err != nil { return fmt.Errorf("Error creating group: %w", err) } // query for groups that exclude the URL "aaa" urlCriterion := models.StringCriterionInput{ Value: "aaa", Modifier: models.CriterionModifierExcludes, } nameCriterion := models.StringCriterionInput{ Value: group.Name, Modifier: models.CriterionModifierEquals, } filter := models.GroupFilterType{ URL: &urlCriterion, Name: &nameCriterion, } groups := queryGroups(ctx, t, &filter, nil) assert.Len(t, groups, 0, "Expected no groups to be found") // query for groups that exclude the URL "ccc" urlCriterion.Value = "ccc" groups = queryGroups(ctx, t, &filter, nil) if assert.Len(t, groups, 1, "Expected one group to be found") { assert.Equal(t, group.Name, groups[0].Name) } return nil }) } func verifyGroupQuery(t *testing.T, filter models.GroupFilterType, verifyFn func(s *models.Group)) { withTxn(func(ctx context.Context) error { t.Helper() sqb := db.Group groups := queryGroups(ctx, t, &filter, nil) for _, group := range groups { if err := group.LoadURLs(ctx, sqb); err != nil { t.Errorf("Error loading group relationships: %v", err) } } // assume it should find at least one assert.Greater(t, len(groups), 0) for _, m := range groups { verifyFn(m) } return nil }) } func queryGroups(ctx context.Context, t *testing.T, groupFilter *models.GroupFilterType, findFilter *models.FindFilterType) []*models.Group { sqb := db.Group groups, _, err := sqb.Query(ctx, groupFilter, findFilter) if err != nil { t.Errorf("Error querying group: %s", err.Error()) } return groups } func TestGroupQueryTags(t *testing.T) { withTxn(func(ctx context.Context) error { tagCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithGroup]), strconv.Itoa(tagIDs[tagIdx1WithGroup]), }, Modifier: models.CriterionModifierIncludes, } groupFilter := models.GroupFilterType{ Tags: &tagCriterion, } // ensure ids are correct groups := queryGroups(ctx, t, &groupFilter, nil) assert.Len(t, groups, 3) for _, group := range groups { assert.True(t, group.ID == groupIDs[groupIdxWithTag] || group.ID == groupIDs[groupIdxWithTwoTags] || group.ID == groupIDs[groupIdxWithThreeTags]) } tagCriterion = models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdx1WithGroup]), strconv.Itoa(tagIDs[tagIdx2WithGroup]), }, Modifier: models.CriterionModifierIncludesAll, } groups = queryGroups(ctx, t, &groupFilter, nil) if assert.Len(t, groups, 2) { assert.Equal(t, sceneIDs[groupIdxWithTwoTags], groups[0].ID) assert.Equal(t, sceneIDs[groupIdxWithThreeTags], groups[1].ID) } tagCriterion = models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdx1WithGroup]), }, Modifier: models.CriterionModifierExcludes, } q := getSceneStringValue(groupIdxWithTwoTags, titleField) findFilter := models.FindFilterType{ Q: &q, } groups = queryGroups(ctx, t, &groupFilter, &findFilter) assert.Len(t, groups, 0) return nil }) } func TestGroupQueryTagCount(t *testing.T) { const tagCount = 1 tagCountCriterion := models.IntCriterionInput{ Value: tagCount, Modifier: models.CriterionModifierEquals, } verifyGroupsTagCount(t, tagCountCriterion) tagCountCriterion.Modifier = models.CriterionModifierNotEquals verifyGroupsTagCount(t, tagCountCriterion) tagCountCriterion.Modifier = models.CriterionModifierGreaterThan verifyGroupsTagCount(t, tagCountCriterion) tagCountCriterion.Modifier = models.CriterionModifierLessThan verifyGroupsTagCount(t, tagCountCriterion) } func verifyGroupsTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Group groupFilter := models.GroupFilterType{ TagCount: &tagCountCriterion, } groups := queryGroups(ctx, t, &groupFilter, nil) assert.Greater(t, len(groups), 0) for _, group := range groups { ids, err := sqb.GetTagIDs(ctx, group.ID) if err != nil { return err } verifyInt(t, len(ids), tagCountCriterion) } return nil }) } func TestGroupQuerySorting(t *testing.T) { sort := "scenes_count" direction := models.SortDirectionEnumDesc findFilter := models.FindFilterType{ Sort: &sort, Direction: &direction, } withTxn(func(ctx context.Context) error { groups := queryGroups(ctx, t, nil, &findFilter) // scenes should be in same order as indexes firstGroup := groups[0] assert.Equal(t, groupIDs[groupIdxWithScene], firstGroup.ID) // sort in descending order direction = models.SortDirectionEnumAsc groups = queryGroups(ctx, t, nil, &findFilter) lastGroup := groups[len(groups)-1] assert.Equal(t, groupIDs[groupIdxWithParentAndScene], lastGroup.ID) return nil }) } func TestGroupQuerySortOrderIndex(t *testing.T) { sort := "sub_group_order" direction := models.SortDirectionEnumDesc findFilter := models.FindFilterType{ Sort: &sort, Direction: &direction, } groupFilter := models.GroupFilterType{ ContainingGroups: &models.HierarchicalMultiCriterionInput{ Value: intslice.IntSliceToStringSlice([]int{groupIdxWithChild}), Modifier: models.CriterionModifierIncludes, }, } withTxn(func(ctx context.Context) error { // just ensure there are no errors _, _, err := db.Group.Query(ctx, &groupFilter, &findFilter) if err != nil { t.Errorf("Error querying group: %s", err.Error()) } _, _, err = db.Group.Query(ctx, nil, &findFilter) if err != nil { t.Errorf("Error querying group: %s", err.Error()) } return nil }) } func TestGroupUpdateFrontImage(t *testing.T) { if err := withRollbackTxn(func(ctx context.Context) error { qb := db.Group // create group to test against const name = "TestGroupUpdateGroupImages" group := models.Group{ Name: name, } err := qb.Create(ctx, &group) if err != nil { return fmt.Errorf("Error creating group: %s", err.Error()) } return testUpdateImage(t, ctx, group.ID, qb.UpdateFrontImage, qb.GetFrontImage) }); err != nil { t.Error(err.Error()) } } func TestGroupUpdateBackImage(t *testing.T) { if err := withRollbackTxn(func(ctx context.Context) error { qb := db.Group // create group to test against const name = "TestGroupUpdateGroupImages" group := models.Group{ Name: name, } err := qb.Create(ctx, &group) if err != nil { return fmt.Errorf("Error creating group: %s", err.Error()) } return testUpdateImage(t, ctx, group.ID, qb.UpdateBackImage, qb.GetBackImage) }); err != nil { t.Error(err.Error()) } } func TestGroupQueryContainingGroups(t *testing.T) { const nameField = "Name" type criterion struct { valueIdxs []int modifier models.CriterionModifier depth int } tests := []struct { name string c criterion q string includeIdxs []int }{ { "includes", criterion{ []int{groupIdxWithChild}, models.CriterionModifierIncludes, 0, }, "", []int{groupIdxWithParent}, }, { "excludes", criterion{ []int{groupIdxWithChild}, models.CriterionModifierExcludes, 0, }, getGroupStringValue(groupIdxWithParent, nameField), nil, }, { "includes (all levels)", criterion{ []int{groupIdxWithGrandChild}, models.CriterionModifierIncludes, -1, }, "", []int{groupIdxWithParentAndChild, groupIdxWithGrandParent}, }, { "includes (1 level)", criterion{ []int{groupIdxWithGrandChild}, models.CriterionModifierIncludes, 1, }, "", []int{groupIdxWithParentAndChild, groupIdxWithGrandParent}, }, { "is null", criterion{ nil, models.CriterionModifierIsNull, 0, }, getGroupStringValue(groupIdxWithParent, nameField), nil, }, { "not null", criterion{ nil, models.CriterionModifierNotNull, 0, }, "", []int{groupIdxWithParentAndChild, groupIdxWithParent, groupIdxWithGrandParent, groupIdxWithParentAndScene}, }, } qb := db.Group for _, tt := range tests { valueIDs := indexesToIDs(groupIDs, tt.c.valueIdxs) expectedIDs := indexesToIDs(groupIDs, tt.includeIdxs) runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { groupFilter := &models.GroupFilterType{ ContainingGroups: &models.HierarchicalMultiCriterionInput{ Value: intslice.IntSliceToStringSlice(valueIDs), Modifier: tt.c.modifier, }, } if tt.c.depth != 0 { groupFilter.ContainingGroups.Depth = &tt.c.depth } findFilter := models.FindFilterType{} if tt.q != "" { findFilter.Q = &tt.q } groups, _, err := qb.Query(ctx, groupFilter, &findFilter) if err != nil { t.Errorf("GroupStore.Query() error = %v", err) return } // get ids of groups groupIDs := sliceutil.Map(groups, func(g *models.Group) int { return g.ID }) assert.ElementsMatch(t, expectedIDs, groupIDs) }) } } func TestGroupQuerySubGroups(t *testing.T) { const nameField = "Name" type criterion struct { valueIdxs []int modifier models.CriterionModifier depth int } tests := []struct { name string c criterion q string expectedIdxs []int }{ { "includes", criterion{ []int{groupIdxWithParent}, models.CriterionModifierIncludes, 0, }, "", []int{groupIdxWithChild}, }, { "excludes", criterion{ []int{groupIdxWithParent}, models.CriterionModifierExcludes, 0, }, getGroupStringValue(groupIdxWithChild, nameField), nil, }, { "includes (all levels)", criterion{ []int{groupIdxWithGrandParent}, models.CriterionModifierIncludes, -1, }, "", []int{groupIdxWithGrandChild, groupIdxWithParentAndChild}, }, { "includes (1 level)", criterion{ []int{groupIdxWithGrandParent}, models.CriterionModifierIncludes, 1, }, "", []int{groupIdxWithGrandChild, groupIdxWithParentAndChild}, }, { "is null", criterion{ nil, models.CriterionModifierIsNull, 0, }, getGroupStringValue(groupIdxWithChild, nameField), nil, }, { "not null", criterion{ nil, models.CriterionModifierNotNull, 0, }, "", []int{groupIdxWithGrandChild, groupIdxWithChild, groupIdxWithParentAndChild, groupIdxWithChildWithScene}, }, } qb := db.Group for _, tt := range tests { valueIDs := indexesToIDs(groupIDs, tt.c.valueIdxs) expectedIDs := indexesToIDs(groupIDs, tt.expectedIdxs) runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { groupFilter := &models.GroupFilterType{ SubGroups: &models.HierarchicalMultiCriterionInput{ Value: intslice.IntSliceToStringSlice(valueIDs), Modifier: tt.c.modifier, }, } if tt.c.depth != 0 { groupFilter.SubGroups.Depth = &tt.c.depth } findFilter := models.FindFilterType{} if tt.q != "" { findFilter.Q = &tt.q } groups, _, err := qb.Query(ctx, groupFilter, &findFilter) if err != nil { t.Errorf("GroupStore.Query() error = %v", err) return } // get ids of groups groupIDs := sliceutil.Map(groups, func(g *models.Group) int { return g.ID }) assert.ElementsMatch(t, expectedIDs, groupIDs) }) } } func TestGroupQueryContainingGroupCount(t *testing.T) { const nameField = "Name" tests := []struct { name string value int modifier models.CriterionModifier q string expectedIdxs []int }{ { "equals", 1, models.CriterionModifierEquals, "", []int{groupIdxWithParent, groupIdxWithGrandParent, groupIdxWithParentAndChild, groupIdxWithParentAndScene}, }, { "not equals", 1, models.CriterionModifierNotEquals, getGroupStringValue(groupIdxWithParent, nameField), nil, }, { "less than", 1, models.CriterionModifierLessThan, getGroupStringValue(groupIdxWithParent, nameField), nil, }, { "greater than", 0, models.CriterionModifierGreaterThan, "", []int{groupIdxWithParent, groupIdxWithGrandParent, groupIdxWithParentAndChild, groupIdxWithParentAndScene}, }, } qb := db.Group for _, tt := range tests { expectedIDs := indexesToIDs(groupIDs, tt.expectedIdxs) runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { groupFilter := &models.GroupFilterType{ ContainingGroupCount: &models.IntCriterionInput{ Value: tt.value, Modifier: tt.modifier, }, } findFilter := models.FindFilterType{} if tt.q != "" { findFilter.Q = &tt.q } groups, _, err := qb.Query(ctx, groupFilter, &findFilter) if err != nil { t.Errorf("GroupStore.Query() error = %v", err) return } // get ids of groups groupIDs := sliceutil.Map(groups, func(g *models.Group) int { return g.ID }) assert.ElementsMatch(t, expectedIDs, groupIDs) }) } } func TestGroupQuerySubGroupCount(t *testing.T) { const nameField = "Name" tests := []struct { name string value int modifier models.CriterionModifier q string expectedIdxs []int }{ { "equals", 1, models.CriterionModifierEquals, "", []int{groupIdxWithChild, groupIdxWithGrandChild, groupIdxWithParentAndChild, groupIdxWithChildWithScene}, }, { "not equals", 1, models.CriterionModifierNotEquals, getGroupStringValue(groupIdxWithChild, nameField), nil, }, { "less than", 1, models.CriterionModifierLessThan, getGroupStringValue(groupIdxWithChild, nameField), nil, }, { "greater than", 0, models.CriterionModifierGreaterThan, "", []int{groupIdxWithChild, groupIdxWithGrandChild, groupIdxWithParentAndChild, groupIdxWithChildWithScene}, }, } qb := db.Group for _, tt := range tests { expectedIDs := indexesToIDs(groupIDs, tt.expectedIdxs) runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { groupFilter := &models.GroupFilterType{ SubGroupCount: &models.IntCriterionInput{ Value: tt.value, Modifier: tt.modifier, }, } findFilter := models.FindFilterType{} if tt.q != "" { findFilter.Q = &tt.q } groups, _, err := qb.Query(ctx, groupFilter, &findFilter) if err != nil { t.Errorf("GroupStore.Query() error = %v", err) return } // get ids of groups groupIDs := sliceutil.Map(groups, func(g *models.Group) int { return g.ID }) assert.ElementsMatch(t, expectedIDs, groupIDs) }) } } func TestGroupFindInAncestors(t *testing.T) { tests := []struct { name string ancestorIdxs []int idxs []int expectedIdxs []int }{ { "basic", []int{groupIdxWithGrandParent}, []int{groupIdxWithGrandChild}, []int{groupIdxWithGrandChild}, }, { "same", []int{groupIdxWithScene}, []int{groupIdxWithScene}, []int{groupIdxWithScene}, }, { "no matches", []int{groupIdxWithGrandParent}, []int{groupIdxWithScene}, nil, }, } qb := db.Group for _, tt := range tests { ancestorIDs := indexesToIDs(groupIDs, tt.ancestorIdxs) ids := indexesToIDs(groupIDs, tt.idxs) expectedIDs := indexesToIDs(groupIDs, tt.expectedIdxs) runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { found, err := qb.FindInAncestors(ctx, ancestorIDs, ids) if err != nil { t.Errorf("GroupStore.FindInAncestors() error = %v", err) return } // get ids of groups assert.ElementsMatch(t, found, expectedIDs) }) } } func TestGroupReorderSubGroups(t *testing.T) { tests := []struct { name string subGroupLen int idxsToMove []int insertLoc int insertAfter bool // order of elements, using original indexes expectedIdxs []int }{ { "move single back before", 5, []int{2}, 1, false, []int{0, 2, 1, 3, 4}, }, { "move single forward before", 5, []int{2}, 4, false, []int{0, 1, 3, 2, 4}, }, { "move multiple back before", 5, []int{3, 2, 4}, 0, false, []int{3, 2, 4, 0, 1}, }, { "move multiple forward before", 5, []int{2, 1, 0}, 4, false, []int{3, 2, 1, 0, 4}, }, { "move single back after", 5, []int{2}, 0, true, []int{0, 2, 1, 3, 4}, }, { "move single forward after", 5, []int{2}, 4, true, []int{0, 1, 3, 4, 2}, }, { "move multiple back after", 5, []int{3, 2, 4}, 0, false, []int{0, 3, 2, 4, 1}, }, { "move multiple forward after", 5, []int{2, 1, 0}, 4, false, []int{3, 4, 2, 1, 0}, }, } qb := db.Group for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { // create the group group := models.Group{ Name: "TestGroupReorderSubGroups", } if err := qb.Create(ctx, &group); err != nil { t.Errorf("GroupStore.Create() error = %v", err) return } // and sub-groups idxToId := make([]int, tt.subGroupLen) for i := 0; i < tt.subGroupLen; i++ { subGroup := models.Group{ Name: fmt.Sprintf("SubGroup %d", i), ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ {GroupID: group.ID}, }), } if err := qb.Create(ctx, &subGroup); err != nil { t.Errorf("GroupStore.Create() error = %v", err) return } idxToId[i] = subGroup.ID } // reorder idsToMove := indexesToIDs(idxToId, tt.idxsToMove) insertID := idxToId[tt.insertLoc] if err := qb.ReorderSubGroups(ctx, group.ID, idsToMove, insertID, tt.insertAfter); err != nil { t.Errorf("GroupStore.ReorderSubGroups() error = %v", err) return } // validate the new order gd, err := qb.GetSubGroupDescriptions(ctx, group.ID) if err != nil { t.Errorf("GroupStore.GetSubGroupDescriptions() error = %v", err) return } // get ids of groups newIDs := sliceutil.Map(gd, func(gd models.GroupIDDescription) int { return gd.GroupID }) newIdxs := sliceutil.Map(newIDs, func(id int) int { return slices.Index(idxToId, id) }) assert.ElementsMatch(t, tt.expectedIdxs, newIdxs) }) } } func TestGroupAddSubGroups(t *testing.T) { tests := []struct { name string existingSubGroupLen int insertGroupsLen int insertLoc int // order of elements, using original indexes expectedIdxs []int }{ { "append single", 4, 1, 999, []int{0, 1, 2, 3, 4}, }, { "insert single middle", 4, 1, 2, []int{0, 1, 4, 2, 3}, }, { "insert single start", 4, 1, 0, []int{4, 0, 1, 2, 3}, }, { "append multiple", 4, 2, 999, []int{0, 1, 2, 3, 4, 5}, }, { "insert multiple middle", 4, 2, 2, []int{0, 1, 4, 5, 2, 3}, }, { "insert multiple start", 4, 2, 0, []int{4, 5, 0, 1, 2, 3}, }, } qb := db.Group for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { // create the group group := models.Group{ Name: "TestGroupReorderSubGroups", } if err := qb.Create(ctx, &group); err != nil { t.Errorf("GroupStore.Create() error = %v", err) return } // and sub-groups idxToId := make([]int, tt.existingSubGroupLen+tt.insertGroupsLen) for i := 0; i < tt.existingSubGroupLen; i++ { subGroup := models.Group{ Name: fmt.Sprintf("Existing SubGroup %d", i), ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ {GroupID: group.ID}, }), } if err := qb.Create(ctx, &subGroup); err != nil { t.Errorf("GroupStore.Create() error = %v", err) return } idxToId[i] = subGroup.ID } // and sub-groups to insert for i := 0; i < tt.insertGroupsLen; i++ { subGroup := models.Group{ Name: fmt.Sprintf("Inserted SubGroup %d", i), } if err := qb.Create(ctx, &subGroup); err != nil { t.Errorf("GroupStore.Create() error = %v", err) return } idxToId[i+tt.existingSubGroupLen] = subGroup.ID } // convert ids to description idDescriptions := make([]models.GroupIDDescription, tt.insertGroupsLen) for i, id := range idxToId[tt.existingSubGroupLen:] { idDescriptions[i] = models.GroupIDDescription{GroupID: id} } // add if err := qb.AddSubGroups(ctx, group.ID, idDescriptions, &tt.insertLoc); err != nil { t.Errorf("GroupStore.AddSubGroups() error = %v", err) return } // validate the new order gd, err := qb.GetSubGroupDescriptions(ctx, group.ID) if err != nil { t.Errorf("GroupStore.GetSubGroupDescriptions() error = %v", err) return } // get ids of groups newIDs := sliceutil.Map(gd, func(gd models.GroupIDDescription) int { return gd.GroupID }) newIdxs := sliceutil.Map(newIDs, func(id int) int { return slices.Index(idxToId, id) }) assert.ElementsMatch(t, tt.expectedIdxs, newIdxs) }) } } func TestGroupRemoveSubGroups(t *testing.T) { tests := []struct { name string subGroupLen int removeIdxs []int // order of elements, using original indexes expectedIdxs []int }{ { "remove last", 4, []int{3}, []int{0, 1, 2}, }, { "remove first", 4, []int{0}, []int{1, 2, 3}, }, { "remove middle", 4, []int{2}, []int{0, 1, 3}, }, { "remove multiple", 4, []int{1, 3}, []int{0, 2}, }, { "remove all", 4, []int{0, 1, 2, 3}, []int{}, }, } qb := db.Group for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { // create the group group := models.Group{ Name: "TestGroupReorderSubGroups", } if err := qb.Create(ctx, &group); err != nil { t.Errorf("GroupStore.Create() error = %v", err) return } // and sub-groups idxToId := make([]int, tt.subGroupLen) for i := 0; i < tt.subGroupLen; i++ { subGroup := models.Group{ Name: fmt.Sprintf("Existing SubGroup %d", i), ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ {GroupID: group.ID}, }), } if err := qb.Create(ctx, &subGroup); err != nil { t.Errorf("GroupStore.Create() error = %v", err) return } idxToId[i] = subGroup.ID } idsToRemove := indexesToIDs(idxToId, tt.removeIdxs) if err := qb.RemoveSubGroups(ctx, group.ID, idsToRemove); err != nil { t.Errorf("GroupStore.RemoveSubGroups() error = %v", err) return } // validate the new order gd, err := qb.GetSubGroupDescriptions(ctx, group.ID) if err != nil { t.Errorf("GroupStore.GetSubGroupDescriptions() error = %v", err) return } // get ids of groups newIDs := sliceutil.Map(gd, func(gd models.GroupIDDescription) int { return gd.GroupID }) newIdxs := sliceutil.Map(newIDs, func(id int) int { return slices.Index(idxToId, id) }) assert.ElementsMatch(t, tt.expectedIdxs, newIdxs) }) } } func TestGroupFindSubGroupIDs(t *testing.T) { tests := []struct { name string containingGroupIdx int subIdxs []int expectedIdxs []int }{ { "overlap", groupIdxWithGrandChild, []int{groupIdxWithParentAndChild, groupIdxWithGrandParent}, []int{groupIdxWithParentAndChild}, }, { "non-overlap", groupIdxWithGrandChild, []int{groupIdxWithGrandParent}, []int{}, }, { "none", groupIdxWithScene, []int{groupIdxWithDupName}, []int{}, }, { "invalid", invalidID, []int{invalidID}, []int{}, }, } qb := db.Group for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { subIDs := indexesToIDs(groupIDs, tt.subIdxs) id := indexToID(groupIDs, tt.containingGroupIdx) found, err := qb.FindSubGroupIDs(ctx, id, subIDs) if err != nil { t.Errorf("GroupStore.FindSubGroupIDs() error = %v", err) return } // get ids of groups foundIdxs := sliceutil.Map(found, func(id int) int { return slices.Index(groupIDs, id) }) assert.ElementsMatch(t, tt.expectedIdxs, foundIdxs) }) } } func TestGroupQueryCustomFields(t *testing.T) { tests := []struct { name string filter *models.GroupFilterType includeIdxs []int excludeIdxs []int wantErr bool }{ { "equals", &models.GroupFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierEquals, Value: []any{getGroupStringValue(groupIdxWithChild, "custom")}, }, }, }, []int{groupIdxWithChild}, nil, false, }, { "not equals", &models.GroupFilterType{ Name: &models.StringCriterionInput{ Value: getGroupStringValue(groupIdxWithChild, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotEquals, Value: []any{getGroupStringValue(groupIdxWithChild, "custom")}, }, }, }, nil, []int{groupIdxWithChild}, false, }, { "includes", &models.GroupFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierIncludes, Value: []any{getGroupStringValue(groupIdxWithChild, "custom")[9:]}, }, }, }, []int{groupIdxWithChild}, nil, false, }, { "excludes", &models.GroupFilterType{ Name: &models.StringCriterionInput{ Value: getGroupStringValue(groupIdxWithChild, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierExcludes, Value: []any{getGroupStringValue(groupIdxWithChild, "custom")[9:]}, }, }, }, nil, []int{groupIdxWithChild}, false, }, { "regex", &models.GroupFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierMatchesRegex, Value: []any{".*11_custom"}, }, }, }, []int{groupIdxWithChildWithScene}, nil, false, }, { "invalid regex", &models.GroupFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierMatchesRegex, Value: []any{"["}, }, }, }, nil, nil, true, }, { "not matches regex", &models.GroupFilterType{ Name: &models.StringCriterionInput{ Value: getGroupStringValue(groupIdxWithChildWithScene, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotMatchesRegex, Value: []any{".*11_custom"}, }, }, }, nil, []int{groupIdxWithChildWithScene}, false, }, { "invalid not matches regex", &models.GroupFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotMatchesRegex, Value: []any{"["}, }, }, }, nil, nil, true, }, { "null", &models.GroupFilterType{ Name: &models.StringCriterionInput{ Value: getGroupStringValue(groupIdxWithGrandParent, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "not existing", Modifier: models.CriterionModifierIsNull, }, }, }, []int{groupIdxWithGrandParent}, nil, false, }, { "not null", &models.GroupFilterType{ Name: &models.StringCriterionInput{ Value: getGroupStringValue(groupIdxWithGrandParent, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotNull, }, }, }, []int{groupIdxWithGrandParent}, nil, false, }, { "between", &models.GroupFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "real", Modifier: models.CriterionModifierBetween, Value: []any{0.15, 0.25}, }, }, }, []int{groupIdxWithTag}, nil, false, }, { "not between", &models.GroupFilterType{ Name: &models.StringCriterionInput{ Value: getGroupStringValue(groupIdxWithTag, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "real", Modifier: models.CriterionModifierNotBetween, Value: []any{0.15, 0.25}, }, }, }, nil, []int{groupIdxWithTag}, false, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) groups, _, err := db.Group.Query(ctx, tt.filter, nil) if (err != nil) != tt.wantErr { t.Errorf("GroupStore.Query() error = %v, wantErr %v", err, tt.wantErr) } if err != nil { return } ids := groupsToIDs(groups) include := indexesToIDs(groupIDs, tt.includeIdxs) exclude := indexesToIDs(groupIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(ids, i) } for _, e := range exclude { assert.NotContains(ids, e) } }) } } // TODO Update // TODO Destroy - ensure image is destroyed // TODO Find // TODO Count // TODO All // TODO Query ================================================ FILE: pkg/sqlite/history.go ================================================ package sqlite import ( "context" "time" ) type viewDateManager struct { tableMgr *viewHistoryTable } func (qb *viewDateManager) GetViewDates(ctx context.Context, id int) ([]time.Time, error) { return qb.tableMgr.getDates(ctx, id) } func (qb *viewDateManager) GetManyViewDates(ctx context.Context, ids []int) ([][]time.Time, error) { return qb.tableMgr.getManyDates(ctx, ids) } func (qb *viewDateManager) CountViews(ctx context.Context, id int) (int, error) { return qb.tableMgr.getCount(ctx, id) } func (qb *viewDateManager) GetManyViewCount(ctx context.Context, ids []int) ([]int, error) { return qb.tableMgr.getManyCount(ctx, ids) } func (qb *viewDateManager) CountAllViews(ctx context.Context) (int, error) { return qb.tableMgr.getAllCount(ctx) } func (qb *viewDateManager) CountUniqueViews(ctx context.Context) (int, error) { return qb.tableMgr.getUniqueCount(ctx) } func (qb *viewDateManager) LastView(ctx context.Context, id int) (*time.Time, error) { return qb.tableMgr.getLastDate(ctx, id) } func (qb *viewDateManager) GetManyLastViewed(ctx context.Context, ids []int) ([]*time.Time, error) { return qb.tableMgr.getManyLastDate(ctx, ids) } func (qb *viewDateManager) AddViews(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) { return qb.tableMgr.addDates(ctx, id, dates) } func (qb *viewDateManager) DeleteViews(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) { return qb.tableMgr.deleteDates(ctx, id, dates) } func (qb *viewDateManager) DeleteAllViews(ctx context.Context, id int) (int, error) { return qb.tableMgr.deleteAllDates(ctx, id) } type oDateManager struct { tableMgr *viewHistoryTable } func (qb *oDateManager) GetODates(ctx context.Context, id int) ([]time.Time, error) { return qb.tableMgr.getDates(ctx, id) } func (qb *oDateManager) GetManyODates(ctx context.Context, ids []int) ([][]time.Time, error) { return qb.tableMgr.getManyDates(ctx, ids) } func (qb *oDateManager) GetOCount(ctx context.Context, id int) (int, error) { return qb.tableMgr.getCount(ctx, id) } func (qb *oDateManager) GetManyOCount(ctx context.Context, ids []int) ([]int, error) { return qb.tableMgr.getManyCount(ctx, ids) } func (qb *oDateManager) GetAllOCount(ctx context.Context) (int, error) { return qb.tableMgr.getAllCount(ctx) } func (qb *oDateManager) GetUniqueOCount(ctx context.Context) (int, error) { return qb.tableMgr.getUniqueCount(ctx) } func (qb *oDateManager) AddO(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) { return qb.tableMgr.addDates(ctx, id, dates) } func (qb *oDateManager) DeleteO(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) { return qb.tableMgr.deleteDates(ctx, id, dates) } func (qb *oDateManager) ResetO(ctx context.Context, id int) (int, error) { return qb.tableMgr.deleteAllDates(ctx, id) } ================================================ FILE: pkg/sqlite/image.go ================================================ package sqlite import ( "context" "database/sql" "errors" "fmt" "path/filepath" "slices" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil" "gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4/zero" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" ) const imageTable = "images" const ( imageIDColumn = "image_id" performersImagesTable = "performers_images" imagesTagsTable = "images_tags" imagesFilesTable = "images_files" imagesURLsTable = "image_urls" imageURLColumn = "url" ) type imageRow struct { ID int `db:"id" goqu:"skipinsert"` Title zero.String `db:"title"` Code zero.String `db:"code"` // expressed as 1-100 Rating null.Int `db:"rating"` Date NullDate `db:"date"` DatePrecision null.Int `db:"date_precision"` Details zero.String `db:"details"` Photographer zero.String `db:"photographer"` Organized bool `db:"organized"` OCounter int `db:"o_counter"` StudioID null.Int `db:"studio_id,omitempty"` CreatedAt Timestamp `db:"created_at"` UpdatedAt Timestamp `db:"updated_at"` } func (r *imageRow) fromImage(i models.Image) { r.ID = i.ID r.Title = zero.StringFrom(i.Title) r.Code = zero.StringFrom(i.Code) r.Rating = intFromPtr(i.Rating) r.Date = NullDateFromDatePtr(i.Date) r.DatePrecision = datePrecisionFromDatePtr(i.Date) r.Details = zero.StringFrom(i.Details) r.Photographer = zero.StringFrom(i.Photographer) r.Organized = i.Organized r.OCounter = i.OCounter r.StudioID = intFromPtr(i.StudioID) r.CreatedAt = Timestamp{Timestamp: i.CreatedAt} r.UpdatedAt = Timestamp{Timestamp: i.UpdatedAt} } type imageQueryRow struct { imageRow PrimaryFileID null.Int `db:"primary_file_id"` PrimaryFileFolderPath zero.String `db:"primary_file_folder_path"` PrimaryFileBasename zero.String `db:"primary_file_basename"` PrimaryFileChecksum zero.String `db:"primary_file_checksum"` } func (r *imageQueryRow) resolve() *models.Image { ret := &models.Image{ ID: r.ID, Title: r.Title.String, Code: r.Code.String, Rating: nullIntPtr(r.Rating), Date: r.Date.DatePtr(r.DatePrecision), Details: r.Details.String, Photographer: r.Photographer.String, Organized: r.Organized, OCounter: r.OCounter, StudioID: nullIntPtr(r.StudioID), PrimaryFileID: nullIntFileIDPtr(r.PrimaryFileID), Checksum: r.PrimaryFileChecksum.String, CreatedAt: r.CreatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp, } if r.PrimaryFileFolderPath.Valid && r.PrimaryFileBasename.Valid { ret.Path = filepath.Join(r.PrimaryFileFolderPath.String, r.PrimaryFileBasename.String) } return ret } type imageRowRecord struct { updateRecord } func (r *imageRowRecord) fromPartial(i models.ImagePartial) { r.setNullString("title", i.Title) r.setNullString("code", i.Code) r.setNullInt("rating", i.Rating) r.setNullDate("date", "date_precision", i.Date) r.setNullString("details", i.Details) r.setNullString("photographer", i.Photographer) r.setBool("organized", i.Organized) r.setInt("o_counter", i.OCounter) r.setNullInt("studio_id", i.StudioID) r.setTimestamp("created_at", i.CreatedAt) r.setTimestamp("updated_at", i.UpdatedAt) } type imageRepositoryType struct { repository performers joinRepository galleries joinRepository tags joinRepository files filesRepository } func (r *imageRepositoryType) addImagesFilesTable(f *filterBuilder) { f.addLeftJoin(imagesFilesTable, "", "images_files.image_id = images.id") } func (r *imageRepositoryType) addFilesTable(f *filterBuilder) { r.addImagesFilesTable(f) f.addLeftJoin(fileTable, "", "images_files.file_id = files.id") } func (r *imageRepositoryType) addFoldersTable(f *filterBuilder) { r.addFilesTable(f) f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id") } func (r *imageRepositoryType) addImageFilesTable(f *filterBuilder) { r.addImagesFilesTable(f) f.addLeftJoin(imageFileTable, "", "image_files.file_id = images_files.file_id") } var ( imageRepository = imageRepositoryType{ repository: repository{ tableName: imageTable, idColumn: idColumn, }, performers: joinRepository{ repository: repository{ tableName: performersImagesTable, idColumn: imageIDColumn, }, fkColumn: performerIDColumn, }, galleries: joinRepository{ repository: repository{ tableName: galleriesImagesTable, idColumn: imageIDColumn, }, fkColumn: galleryIDColumn, }, files: filesRepository{ repository: repository{ tableName: imagesFilesTable, idColumn: imageIDColumn, }, }, tags: joinRepository{ repository: repository{ tableName: imagesTagsTable, idColumn: imageIDColumn, }, fkColumn: tagIDColumn, foreignTable: tagTable, orderBy: tagTableSortSQL, }, } ) type ImageStore struct { customFieldsStore tableMgr *table oCounterManager repo *storeRepository } func NewImageStore(r *storeRepository) *ImageStore { return &ImageStore{ customFieldsStore: customFieldsStore{ table: imagesCustomFieldsTable, fk: imagesCustomFieldsTable.Col(imageIDColumn), }, tableMgr: imageTableMgr, oCounterManager: oCounterManager{imageTableMgr}, repo: r, } } func (qb *ImageStore) table() exp.IdentifierExpression { return qb.tableMgr.table } func (qb *ImageStore) selectDataset() *goqu.SelectDataset { table := qb.table() files := fileTableMgr.table folders := folderTableMgr.table checksum := fingerprintTableMgr.table return dialect.From(table).LeftJoin( imagesFilesJoinTable, goqu.On( imagesFilesJoinTable.Col(imageIDColumn).Eq(table.Col(idColumn)), imagesFilesJoinTable.Col("primary").Eq(1), ), ).LeftJoin( files, goqu.On(files.Col(idColumn).Eq(imagesFilesJoinTable.Col(fileIDColumn))), ).LeftJoin( folders, goqu.On(folders.Col(idColumn).Eq(files.Col("parent_folder_id"))), ).LeftJoin( checksum, goqu.On( checksum.Col(fileIDColumn).Eq(imagesFilesJoinTable.Col(fileIDColumn)), checksum.Col("type").Eq(models.FingerprintTypeMD5), ), ).Select( qb.table().All(), imagesFilesJoinTable.Col(fileIDColumn).As("primary_file_id"), folders.Col("path").As("primary_file_folder_path"), files.Col("basename").As("primary_file_basename"), checksum.Col("fingerprint").As("primary_file_checksum"), ) } func (qb *ImageStore) Create(ctx context.Context, newObject *models.CreateImageInput) error { var r imageRow r.fromImage(*newObject.Image) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { return err } if len(newObject.FileIDs) > 0 { const firstPrimary = true if err := imagesFilesTableMgr.insertJoins(ctx, id, firstPrimary, newObject.FileIDs); err != nil { return err } } if newObject.URLs.Loaded() { const startPos = 0 if err := imagesURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { return err } } if newObject.PerformerIDs.Loaded() { if err := imagesPerformersTableMgr.insertJoins(ctx, id, newObject.PerformerIDs.List()); err != nil { return err } } if newObject.TagIDs.Loaded() { if err := imagesTagsTableMgr.insertJoins(ctx, id, newObject.TagIDs.List()); err != nil { return err } } if newObject.GalleryIDs.Loaded() { if err := imageGalleriesTableMgr.insertJoins(ctx, id, newObject.GalleryIDs.List()); err != nil { return err } } if err := qb.SetCustomFields(ctx, id, models.CustomFieldsInput{ Full: newObject.CustomFields, }); err != nil { return err } updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } *newObject.Image = *updated return nil } func (qb *ImageStore) UpdatePartial(ctx context.Context, id int, partial models.ImagePartial) (*models.Image, error) { r := imageRowRecord{ updateRecord{ Record: make(exp.Record), }, } r.fromPartial(partial) if len(r.Record) > 0 { if err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil { return nil, err } } if partial.GalleryIDs != nil { if err := imageGalleriesTableMgr.modifyJoins(ctx, id, partial.GalleryIDs.IDs, partial.GalleryIDs.Mode); err != nil { return nil, err } } if partial.URLs != nil { if err := imagesURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { return nil, err } } if partial.PerformerIDs != nil { if err := imagesPerformersTableMgr.modifyJoins(ctx, id, partial.PerformerIDs.IDs, partial.PerformerIDs.Mode); err != nil { return nil, err } } if partial.TagIDs != nil { if err := imagesTagsTableMgr.modifyJoins(ctx, id, partial.TagIDs.IDs, partial.TagIDs.Mode); err != nil { return nil, err } } if partial.PrimaryFileID != nil { if err := imagesFilesTableMgr.setPrimary(ctx, id, *partial.PrimaryFileID); err != nil { return nil, err } } if err := qb.SetCustomFields(ctx, id, partial.CustomFields); err != nil { return nil, err } return qb.find(ctx, id) } func (qb *ImageStore) Update(ctx context.Context, updatedObject *models.Image) error { var r imageRow r.fromImage(*updatedObject) if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { return err } if updatedObject.URLs.Loaded() { if err := imagesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { return err } } if updatedObject.PerformerIDs.Loaded() { if err := imagesPerformersTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.PerformerIDs.List()); err != nil { return err } } if updatedObject.TagIDs.Loaded() { if err := imagesTagsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.TagIDs.List()); err != nil { return err } } if updatedObject.GalleryIDs.Loaded() { if err := imageGalleriesTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.GalleryIDs.List()); err != nil { return err } } if updatedObject.Files.Loaded() { fileIDs := make([]models.FileID, len(updatedObject.Files.List())) for i, f := range updatedObject.Files.List() { fileIDs[i] = f.Base().ID } if err := imagesFilesTableMgr.replaceJoins(ctx, updatedObject.ID, fileIDs); err != nil { return err } } return nil } func (qb *ImageStore) Destroy(ctx context.Context, id int) error { return qb.tableMgr.destroyExisting(ctx, []int{id}) } // returns nil, nil if not found func (qb *ImageStore) Find(ctx context.Context, id int) (*models.Image, error) { ret, err := qb.find(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } return ret, err } func (qb *ImageStore) FindMany(ctx context.Context, ids []int) ([]*models.Image, error) { images := make([]*models.Image, len(ids)) if err := batchExec(ids, defaultBatchSize, func(batch []int) error { q := qb.selectDataset().Prepared(true).Where(qb.table().Col(idColumn).In(batch)) unsorted, err := qb.getMany(ctx, q) if err != nil { return err } for _, s := range unsorted { i := slices.Index(ids, s.ID) images[i] = s } return nil }); err != nil { return nil, err } for i := range images { if images[i] == nil { return nil, fmt.Errorf("image with id %d not found", ids[i]) } } return images, nil } // returns nil, sql.ErrNoRows if not found func (qb *ImageStore) find(ctx context.Context, id int) (*models.Image, error) { q := qb.selectDataset().Where(qb.tableMgr.byID(id)) ret, err := qb.get(ctx, q) if err != nil { return nil, err } return ret, nil } func (qb *ImageStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]*models.Image, error) { table := qb.table() q := qb.selectDataset().Prepared(true).Where( table.Col(idColumn).Eq( sq, ), ) return qb.getMany(ctx, q) } // returns nil, sql.ErrNoRows if not found func (qb *ImageStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Image, error) { ret, err := qb.getMany(ctx, q) if err != nil { return nil, err } if len(ret) == 0 { return nil, sql.ErrNoRows } return ret[0], nil } func (qb *ImageStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Image, error) { const single = false var ret []*models.Image var lastID int if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { var f imageQueryRow if err := r.StructScan(&f); err != nil { return err } i := f.resolve() if i.ID == lastID { return fmt.Errorf("internal error: multiple rows returned for single image id %d", i.ID) } lastID = i.ID ret = append(ret, i) return nil }); err != nil { return nil, err } return ret, nil } // Returns the custom cover for the gallery, if one has been set. func (qb *ImageStore) CoverByGalleryID(ctx context.Context, galleryID int) (*models.Image, error) { table := qb.table() sq := dialect.From(table). InnerJoin( galleriesImagesJoinTable, goqu.On(table.Col(idColumn).Eq(galleriesImagesJoinTable.Col(imageIDColumn))), ). Select(table.Col(idColumn)). Where(goqu.And( galleriesImagesJoinTable.Col("gallery_id").Eq(galleryID), galleriesImagesJoinTable.Col("cover").Eq(true), )) q := qb.selectDataset().Prepared(true).Where( table.Col(idColumn).Eq( sq, ), ) ret, err := qb.getMany(ctx, q) if err != nil { return nil, fmt.Errorf("getting cover for gallery %d: %w", galleryID, err) } switch { case len(ret) > 1: return nil, fmt.Errorf("internal error: multiple covers returned for gallery %d", galleryID) case len(ret) == 1: return ret[0], nil default: return nil, nil } } func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]models.File, error) { fileIDs, err := imageRepository.files.get(ctx, id) if err != nil { return nil, err } // use fileStore to load files files, err := qb.repo.File.Find(ctx, fileIDs...) if err != nil { return nil, err } ret := make([]models.File, len(files)) copy(ret, files) return ret, nil } func (qb *ImageStore) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) { const primaryOnly = false return imageRepository.files.getMany(ctx, ids, primaryOnly) } func (qb *ImageStore) FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Image, error) { table := qb.table() sq := dialect.From(table). InnerJoin( imagesFilesJoinTable, goqu.On(table.Col(idColumn).Eq(imagesFilesJoinTable.Col(imageIDColumn))), ). Select(table.Col(idColumn)).Where(imagesFilesJoinTable.Col(fileIDColumn).Eq(fileID)) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting image by file id %d: %w", fileID, err) } return ret, nil } func (qb *ImageStore) CountByFileID(ctx context.Context, fileID models.FileID) (int, error) { joinTable := imagesFilesJoinTable q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col(fileIDColumn).Eq(fileID)) return count(ctx, q) } func (qb *ImageStore) FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Image, error) { table := qb.table() fingerprintTable := fingerprintTableMgr.table var ex []exp.Expression for _, v := range fp { ex = append(ex, goqu.And( fingerprintTable.Col("type").Eq(v.Type), fingerprintTable.Col("fingerprint").Eq(v.Fingerprint), )) } sq := dialect.From(table). InnerJoin( imagesFilesJoinTable, goqu.On(table.Col(idColumn).Eq(imagesFilesJoinTable.Col(imageIDColumn))), ). InnerJoin( fingerprintTable, goqu.On(fingerprintTable.Col(fileIDColumn).Eq(imagesFilesJoinTable.Col(fileIDColumn))), ). Select(table.Col(idColumn)).Where(goqu.Or(ex...)) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting image by fingerprints: %w", err) } return ret, nil } func (qb *ImageStore) FindByChecksum(ctx context.Context, checksum string) ([]*models.Image, error) { return qb.FindByFingerprints(ctx, []models.Fingerprint{ { Type: models.FingerprintTypeMD5, Fingerprint: checksum, }, }) } var defaultGalleryOrder = []exp.OrderedExpression{ goqu.L("COALESCE(folders.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI").Asc(), goqu.L("COALESCE(images.title, images.id) COLLATE NATURAL_CI").Asc(), } func (qb *ImageStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Image, error) { table := qb.table() sq := dialect.From(table). InnerJoin( galleriesImagesJoinTable, goqu.On(table.Col(idColumn).Eq(galleriesImagesJoinTable.Col(imageIDColumn))), ). Select(table.Col(idColumn)).Where( galleriesImagesJoinTable.Col("gallery_id").Eq(galleryID), ) q := qb.selectDataset().Prepared(true).Where( table.Col(idColumn).Eq( sq, ), ).Order(defaultGalleryOrder...) ret, err := qb.getMany(ctx, q) if err != nil { return nil, fmt.Errorf("getting images for gallery %d: %w", galleryID, err) } return ret, nil } func (qb *ImageStore) FindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*models.Image, error) { table := qb.table() q := qb.selectDataset(). InnerJoin( galleriesImagesJoinTable, goqu.On(table.Col(idColumn).Eq(galleriesImagesJoinTable.Col(imageIDColumn))), ). Where(galleriesImagesJoinTable.Col(galleryIDColumn).Eq(galleryID)). Prepared(true). Order(defaultGalleryOrder...). Limit(1).Offset(index) ret, err := qb.getMany(ctx, q) if err != nil { return nil, fmt.Errorf("getting images for gallery %d: %w", galleryID, err) } if len(ret) == 0 { return nil, nil } return ret[0], nil } func (qb *ImageStore) CountByGalleryID(ctx context.Context, galleryID int) (int, error) { joinTable := goqu.T(galleriesImagesTable) q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col("gallery_id").Eq(galleryID)) return count(ctx, q) } func (qb *ImageStore) OCountByPerformerID(ctx context.Context, performerID int) (int, error) { table := qb.table() joinTable := performersImagesJoinTable q := dialect.Select(goqu.COALESCE(goqu.SUM("o_counter"), 0)).From(table).InnerJoin(joinTable, goqu.On(table.Col(idColumn).Eq(joinTable.Col(imageIDColumn)))).Where(joinTable.Col(performerIDColumn).Eq(performerID)) var ret int if err := querySimple(ctx, q, &ret); err != nil { return 0, err } return ret, nil } func (qb *ImageStore) OCountByStudioID(ctx context.Context, studioID int) (int, error) { table := qb.table() q := dialect.Select(goqu.COALESCE(goqu.SUM("o_counter"), 0)).From(table).Where( table.Col(studioIDColumn).Eq(studioID), ) var ret int if err := querySimple(ctx, q, &ret); err != nil { return 0, err } return ret, nil } func (qb *ImageStore) OCount(ctx context.Context) (int, error) { table := qb.table() q := dialect.Select(goqu.COALESCE(goqu.SUM("o_counter"), 0)).From(table) var ret int if err := querySimple(ctx, q, &ret); err != nil { return 0, err } return ret, nil } func (qb *ImageStore) FindByFolderID(ctx context.Context, folderID models.FolderID) ([]*models.Image, error) { table := qb.table() fileTable := goqu.T(fileTable) sq := dialect.From(table). InnerJoin( imagesFilesJoinTable, goqu.On(table.Col(idColumn).Eq(imagesFilesJoinTable.Col(imageIDColumn))), ). InnerJoin( fileTable, goqu.On(imagesFilesJoinTable.Col(fileIDColumn).Eq(fileTable.Col(idColumn))), ). Select(table.Col(idColumn)).Where( fileTable.Col("parent_folder_id").Eq(folderID), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting image by folder: %w", err) } return ret, nil } func (qb *ImageStore) FindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]*models.Image, error) { table := qb.table() fileTable := goqu.T(fileTable) sq := dialect.From(table). InnerJoin( imagesFilesJoinTable, goqu.On(table.Col(idColumn).Eq(imagesFilesJoinTable.Col(imageIDColumn))), ). InnerJoin( fileTable, goqu.On(imagesFilesJoinTable.Col(fileIDColumn).Eq(fileTable.Col(idColumn))), ). Select(table.Col(idColumn)).Where( fileTable.Col("zip_file_id").Eq(zipFileID), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting image by zip file: %w", err) } return ret, nil } func (qb *ImageStore) Count(ctx context.Context) (int, error) { q := dialect.Select(goqu.COUNT("*")).From(qb.table()) return count(ctx, q) } func (qb *ImageStore) Size(ctx context.Context) (float64, error) { table := qb.table() fileTable := fileTableMgr.table q := dialect.Select( goqu.COALESCE(goqu.SUM(fileTableMgr.table.Col("size")), 0), ).From(table).InnerJoin( imagesFilesJoinTable, goqu.On(table.Col(idColumn).Eq(imagesFilesJoinTable.Col(imageIDColumn))), ).InnerJoin( fileTable, goqu.On(imagesFilesJoinTable.Col(fileIDColumn).Eq(fileTable.Col(idColumn))), ) var ret float64 if err := querySimple(ctx, q, &ret); err != nil { return 0, err } return ret, nil } func (qb *ImageStore) All(ctx context.Context) ([]*models.Image, error) { return qb.getMany(ctx, qb.selectDataset()) } func (qb *ImageStore) makeQuery(ctx context.Context, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if imageFilter == nil { imageFilter = &models.ImageFilterType{} } if findFilter == nil { findFilter = &models.FindFilterType{} } query := imageRepository.newQuery() distinctIDs(&query, imageTable) if q := findFilter.Q; q != nil && *q != "" { query.addJoins( join{ table: imagesFilesTable, onClause: "images_files.image_id = images.id", }, join{ table: fileTable, onClause: "images_files.file_id = files.id", }, join{ table: folderTable, onClause: "files.parent_folder_id = folders.id", }, join{ table: fingerprintTable, onClause: "files_fingerprints.file_id = images_files.file_id", }, ) filepathColumn := "folders.path || '" + string(filepath.Separator) + "' || files.basename" searchColumns := []string{"images.title", "images.details", filepathColumn, "files_fingerprints.fingerprint"} query.parseQueryString(searchColumns, *q) } filter := filterBuilderFromHandler(ctx, &imageFilterHandler{ imageFilter: imageFilter, }) if err := query.addFilter(filter); err != nil { return nil, err } if err := qb.setImageSortAndPagination(&query, findFilter); err != nil { return nil, err } return &query, nil } func (qb *ImageStore) Query(ctx context.Context, options models.ImageQueryOptions) (*models.ImageQueryResult, error) { query, err := qb.makeQuery(ctx, options.ImageFilter, options.FindFilter) if err != nil { return nil, err } result, err := qb.queryGroupedFields(ctx, options, *query) if err != nil { return nil, fmt.Errorf("error querying aggregate fields: %w", err) } idsResult, err := query.findIDs(ctx) if err != nil { return nil, fmt.Errorf("error finding IDs: %w", err) } result.IDs = idsResult return result, nil } func (qb *ImageStore) queryGroupedFields(ctx context.Context, options models.ImageQueryOptions, query queryBuilder) (*models.ImageQueryResult, error) { if !options.Count && !options.Megapixels && !options.TotalSize { // nothing to do - return empty result return models.NewImageQueryResult(qb), nil } aggregateQuery := imageRepository.newQuery() if options.Count { aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total") } if options.Megapixels { query.addJoins( join{ table: imagesFilesTable, onClause: "images_files.image_id = images.id", }, join{ table: imageFileTable, onClause: "images_files.file_id = image_files.file_id", }, ) query.addColumn("COALESCE(image_files.width, 0) * COALESCE(image_files.height, 0) as megapixels") aggregateQuery.addColumn("COALESCE(SUM(temp.megapixels), 0) / 1000000 as megapixels") } if options.TotalSize { query.addJoins( join{ table: imagesFilesTable, onClause: "images_files.image_id = images.id", }, join{ table: fileTable, onClause: "images_files.file_id = files.id", }, ) query.addColumn("COALESCE(files.size, 0) as size") aggregateQuery.addColumn("SUM(temp.size) as size") } const includeSortPagination = false aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination)) out := struct { Total int Megapixels null.Float Size null.Float }{} if err := imageRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil { return nil, err } ret := models.NewImageQueryResult(qb) ret.Count = out.Total ret.Megapixels = out.Megapixels.Float64 ret.TotalSize = out.Size.Float64 return ret, nil } func (qb *ImageStore) QueryCount(ctx context.Context, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (int, error) { query, err := qb.makeQuery(ctx, imageFilter, findFilter) if err != nil { return 0, err } return query.executeCount(ctx) } var imageSortOptions = sortOptions{ "created_at", "date", "file_count", "file_mod_time", "filesize", "id", "o_counter", "path", "performer_count", "random", "rating", "resolution", "tag_count", "title", "updated_at", } func (qb *ImageStore) setImageSortAndPagination(q *queryBuilder, findFilter *models.FindFilterType) error { sortClause := "" if findFilter != nil && findFilter.Sort != nil && *findFilter.Sort != "" { sort := findFilter.GetSort("title") direction := findFilter.GetDirection() // CVE-2024-32231 - ensure sort is in the list of allowed sorts if err := imageSortOptions.validateSort(sort); err != nil { return err } // translate sort field if sort == "file_mod_time" { sort = "mod_time" } addFilesJoin := func() { q.addJoins( join{ sort: true, table: imagesFilesTable, onClause: "images_files.image_id = images.id", }, join{ sort: true, table: fileTable, onClause: "images_files.file_id = files.id", }, ) } addFolderJoin := func() { q.addJoins(join{ sort: true, table: folderTable, onClause: "files.parent_folder_id = folders.id", }) } switch sort { case "path": addFilesJoin() addFolderJoin() sortClause = " ORDER BY COALESCE(folders.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI " + direction case "file_count": sortClause = getCountSort(imageTable, imagesFilesTable, imageIDColumn, direction) case "tag_count": sortClause = getCountSort(imageTable, imagesTagsTable, imageIDColumn, direction) case "performer_count": sortClause = getCountSort(imageTable, performersImagesTable, imageIDColumn, direction) case "mod_time", "filesize": addFilesJoin() sortClause = getSort(sort, direction, "files") case "resolution": addFilesJoin() q.addJoins(join{ sort: true, table: imageFileTable, onClause: "images_files.file_id = image_files.file_id", }) sortClause = " ORDER BY MIN(image_files.width, image_files.height) " + direction case "title": addFilesJoin() addFolderJoin() sortClause = " ORDER BY COALESCE(images.title, files.basename) COLLATE NATURAL_CI " + direction + ", folders.path COLLATE NATURAL_CI " + direction default: sortClause = getSort(sort, direction, "images") } // Whatever the sorting, always use title/id as a final sort sortClause += ", COALESCE(images.title, images.id) COLLATE NATURAL_CI ASC" } q.sortAndPagination = sortClause + getPagination(findFilter) return nil } func (qb *ImageStore) AddFileID(ctx context.Context, id int, fileID models.FileID) error { const firstPrimary = false return imagesFilesTableMgr.insertJoins(ctx, id, firstPrimary, []models.FileID{fileID}) } // RemoveFileID removes the file ID from the image. // If the file ID is the primary file, then the next file in the list is set as the primary file. func (qb *ImageStore) RemoveFileID(ctx context.Context, id int, fileID models.FileID) error { fileIDs, err := imagesFilesTableMgr.get(ctx, id) if err != nil { return fmt.Errorf("getting file IDs for image %d: %w", id, err) } fileIDs = sliceutil.Filter(fileIDs, func(f models.FileID) bool { return f != fileID }) return imagesFilesTableMgr.replaceJoins(ctx, id, fileIDs) } func (qb *ImageStore) GetGalleryIDs(ctx context.Context, imageID int) ([]int, error) { return imageRepository.galleries.getIDs(ctx, imageID) } // func (qb *imageQueryBuilder) UpdateGalleries(ctx context.Context, imageID int, galleryIDs []int) error { // // Delete the existing joins and then create new ones // return qb.galleriesRepository().replace(ctx, imageID, galleryIDs) // } func (qb *ImageStore) GetPerformerIDs(ctx context.Context, imageID int) ([]int, error) { return imageRepository.performers.getIDs(ctx, imageID) } func (qb *ImageStore) UpdatePerformers(ctx context.Context, imageID int, performerIDs []int) error { // Delete the existing joins and then create new ones return imageRepository.performers.replace(ctx, imageID, performerIDs) } func (qb *ImageStore) GetTagIDs(ctx context.Context, imageID int) ([]int, error) { return imageRepository.tags.getIDs(ctx, imageID) } func (qb *ImageStore) UpdateTags(ctx context.Context, imageID int, tagIDs []int) error { // Delete the existing joins and then create new ones return imageRepository.tags.replace(ctx, imageID, tagIDs) } func (qb *ImageStore) GetURLs(ctx context.Context, imageID int) ([]string, error) { return imagesURLsTableMgr.get(ctx, imageID) } ================================================ FILE: pkg/sqlite/image_filter.go ================================================ package sqlite import ( "context" "github.com/stashapp/stash/pkg/models" ) type imageFilterHandler struct { imageFilter *models.ImageFilterType } func (qb *imageFilterHandler) validate() error { imageFilter := qb.imageFilter if imageFilter == nil { return nil } if err := validateFilterCombination(imageFilter.OperatorFilter); err != nil { return err } if subFilter := imageFilter.SubFilter(); subFilter != nil { sqb := &imageFilterHandler{imageFilter: subFilter} if err := sqb.validate(); err != nil { return err } } return nil } func (qb *imageFilterHandler) handle(ctx context.Context, f *filterBuilder) { imageFilter := qb.imageFilter if imageFilter == nil { return } if err := qb.validate(); err != nil { f.setError(err) return } sf := imageFilter.SubFilter() if sf != nil { sub := &imageFilterHandler{sf} handleSubFilter(ctx, sub, f, imageFilter.OperatorFilter) } f.handleCriterion(ctx, qb.criterionHandler()) } func (qb *imageFilterHandler) criterionHandler() criterionHandler { imageFilter := qb.imageFilter return compoundHandler{ intCriterionHandler(imageFilter.ID, "images.id", nil), criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if imageFilter.Checksum != nil { imageRepository.addImagesFilesTable(f) f.addInnerJoin(fingerprintTable, "fingerprints_md5", "images_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") } stringCriterionHandler(imageFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f) }), &phashDistanceCriterionHandler{ joinFn: func(f *filterBuilder) { imageRepository.addImagesFilesTable(f) f.addLeftJoin(fingerprintTable, "fingerprints_phash", "images_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") }, criterion: imageFilter.PhashDistance, }, stringCriterionHandler(imageFilter.Title, "images.title"), stringCriterionHandler(imageFilter.Code, "images.code"), stringCriterionHandler(imageFilter.Details, "images.details"), stringCriterionHandler(imageFilter.Photographer, "images.photographer"), pathCriterionHandler(imageFilter.Path, "folders.path", "files.basename", imageRepository.addFoldersTable), qb.fileCountCriterionHandler(imageFilter.FileCount), intCriterionHandler(imageFilter.Rating100, "images.rating", nil), intCriterionHandler(imageFilter.OCounter, "images.o_counter", nil), boolCriterionHandler(imageFilter.Organized, "images.organized", nil), &dateCriterionHandler{imageFilter.Date, "images.date", nil}, qb.urlsCriterionHandler(imageFilter.URL), resolutionCriterionHandler(imageFilter.Resolution, "image_files.height", "image_files.width", imageRepository.addImageFilesTable), orientationCriterionHandler(imageFilter.Orientation, "image_files.height", "image_files.width", imageRepository.addImageFilesTable), qb.missingCriterionHandler(imageFilter.IsMissing), qb.tagsCriterionHandler(imageFilter.Tags), qb.tagCountCriterionHandler(imageFilter.TagCount), qb.galleriesCriterionHandler(imageFilter.Galleries), qb.performersCriterionHandler(imageFilter.Performers), qb.performerCountCriterionHandler(imageFilter.PerformerCount), studioCriterionHandler(imageTable, imageFilter.Studios), qb.performerTagsCriterionHandler(imageFilter.PerformerTags), qb.performerFavoriteCriterionHandler(imageFilter.PerformerFavorite), qb.performerAgeCriterionHandler(imageFilter.PerformerAge), ×tampCriterionHandler{imageFilter.CreatedAt, "images.created_at", nil}, ×tampCriterionHandler{imageFilter.UpdatedAt, "images.updated_at", nil}, &customFieldsFilterHandler{ table: imagesCustomFieldsTable.GetTable(), fkCol: imageIDColumn, c: imageFilter.CustomFields, idCol: "images.id", }, &relatedFilterHandler{ relatedIDCol: "galleries_images.gallery_id", relatedRepo: galleryRepository.repository, relatedHandler: &galleryFilterHandler{imageFilter.GalleriesFilter}, joinFn: func(f *filterBuilder) { imageRepository.galleries.innerJoin(f, "", "images.id") }, }, &relatedFilterHandler{ relatedIDCol: "performers_join.performer_id", relatedRepo: performerRepository.repository, relatedHandler: &performerFilterHandler{imageFilter.PerformersFilter}, joinFn: func(f *filterBuilder) { imageRepository.performers.innerJoin(f, "performers_join", "images.id") }, }, &relatedFilterHandler{ relatedIDCol: "images.studio_id", relatedRepo: studioRepository.repository, relatedHandler: &studioFilterHandler{imageFilter.StudiosFilter}, }, &relatedFilterHandler{ relatedIDCol: "image_tag.tag_id", relatedRepo: tagRepository.repository, relatedHandler: &tagFilterHandler{imageFilter.TagsFilter}, joinFn: func(f *filterBuilder) { imageRepository.tags.innerJoin(f, "image_tag", "images.id") }, }, &relatedFilterHandler{ relatedIDCol: "files.id", relatedRepo: fileRepository.repository, relatedHandler: &fileFilterHandler{ fileFilter: imageFilter.FilesFilter, isRelated: true, }, joinFn: func(f *filterBuilder) { imageRepository.addFilesTable(f) imageRepository.addFoldersTable(f) }, // don't use a subquery; join directly directJoin: true, }, } } func (qb *imageFilterHandler) fileCountCriterionHandler(fileCount *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: imageTable, joinTable: imagesFilesTable, primaryFK: imageIDColumn, } return h.handler(fileCount) } func (qb *imageFilterHandler) missingCriterionHandler(isMissing *string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if isMissing != nil && *isMissing != "" { switch *isMissing { case "url": imagesURLsTableMgr.join(f, "", "images.id") f.addWhere("image_urls.url IS NULL") case "studio": f.addWhere("images.studio_id IS NULL") case "performers": imageRepository.performers.join(f, "performers_join", "images.id") f.addWhere("performers_join.image_id IS NULL") case "galleries": imageRepository.galleries.join(f, "galleries_join", "images.id") f.addWhere("galleries_join.image_id IS NULL") case "tags": imageRepository.tags.join(f, "tags_join", "images.id") f.addWhere("tags_join.image_id IS NULL") default: if err := validateIsMissing(*isMissing, []string{ "title", "details", "photographer", "date", "code", "rating", }); err != nil { f.setError(err) return } f.addWhere("(images." + *isMissing + " IS NULL OR TRIM(images." + *isMissing + ") = '')") } } } } func (qb *imageFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ primaryTable: imageTable, primaryFK: imageIDColumn, joinTable: imagesURLsTable, stringColumn: imageURLColumn, addJoinTable: func(f *filterBuilder) { imagesURLsTableMgr.join(f, "", "images.id") }, } return h.handler(url) } func (qb *imageFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { return multiCriterionHandlerBuilder{ primaryTable: imageTable, foreignTable: foreignTable, joinTable: joinTable, primaryFK: imageIDColumn, foreignFK: foreignFK, addJoinsFunc: addJoinsFunc, } } func (qb *imageFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { h := joinedHierarchicalMultiCriterionHandlerBuilder{ primaryTable: imageTable, foreignTable: tagTable, foreignFK: "tag_id", relationsTable: "tags_relations", joinAs: "image_tag", joinTable: imagesTagsTable, primaryFK: imageIDColumn, } return h.handler(tags) } func (qb *imageFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: imageTable, joinTable: imagesTagsTable, primaryFK: imageIDColumn, } return h.handler(tagCount) } func (qb *imageFilterHandler) galleriesCriterionHandler(galleries *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { if galleries.Modifier == models.CriterionModifierIncludes || galleries.Modifier == models.CriterionModifierIncludesAll { f.addInnerJoin(galleriesImagesTable, "", "galleries_images.image_id = images.id") f.addInnerJoin(galleryTable, "", "galleries_images.gallery_id = galleries.id") } } h := qb.getMultiCriterionHandlerBuilder(galleryTable, galleriesImagesTable, galleryIDColumn, addJoinsFunc) return h.handler(galleries) } func (qb *imageFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { h := joinedMultiCriterionHandlerBuilder{ primaryTable: imageTable, joinTable: performersImagesTable, joinAs: "performers_join", primaryFK: imageIDColumn, foreignFK: performerIDColumn, addJoinTable: func(f *filterBuilder) { imageRepository.performers.join(f, "performers_join", "images.id") }, } return h.handler(performers) } func (qb *imageFilterHandler) performerCountCriterionHandler(performerCount *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: imageTable, joinTable: performersImagesTable, primaryFK: imageIDColumn, } return h.handler(performerCount) } func (qb *imageFilterHandler) performerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if performerfavorite != nil { f.addLeftJoin("performers_images", "", "images.id = performers_images.image_id") if *performerfavorite { // contains at least one favorite f.addLeftJoin("performers", "", "performers.id = performers_images.performer_id") f.addWhere("performers.favorite = 1") } else { // contains zero favorites f.addLeftJoin(`(SELECT performers_images.image_id as id FROM performers_images JOIN performers ON performers.id = performers_images.performer_id GROUP BY performers_images.image_id HAVING SUM(performers.favorite) = 0)`, "nofaves", "images.id = nofaves.id") f.addWhere("performers_images.image_id IS NULL OR nofaves.id IS NOT NULL") } } } } func (qb *imageFilterHandler) performerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if performerAge != nil { f.addInnerJoin("performers_images", "", "images.id = performers_images.image_id") f.addInnerJoin("performers", "", "performers_images.performer_id = performers.id") f.addWhere("images.date != '' AND performers.birthdate != ''") f.addWhere("images.date IS NOT NULL AND performers.birthdate IS NOT NULL") ageCalc := "cast(strftime('%Y.%m%d', images.date) - strftime('%Y.%m%d', performers.birthdate) as int)" whereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2) f.addWhere(whereClause, args...) } } } func (qb *imageFilterHandler) performerTagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandler { return &joinedPerformerTagsHandler{ criterion: tags, primaryTable: imageTable, joinTable: performersImagesTable, joinPrimaryKey: imageIDColumn, } } ================================================ FILE: pkg/sqlite/image_test.go ================================================ //go:build integration // +build integration package sqlite_test import ( "context" "reflect" "strconv" "testing" "time" "github.com/stashapp/stash/pkg/models" "github.com/stretchr/testify/assert" ) func loadImageRelationships(ctx context.Context, expected models.Image, actual *models.Image) error { if expected.URLs.Loaded() { if err := actual.LoadURLs(ctx, db.Image); err != nil { return err } } if expected.GalleryIDs.Loaded() { if err := actual.LoadGalleryIDs(ctx, db.Image); err != nil { return err } } if expected.TagIDs.Loaded() { if err := actual.LoadTagIDs(ctx, db.Image); err != nil { return err } } if expected.PerformerIDs.Loaded() { if err := actual.LoadPerformerIDs(ctx, db.Image); err != nil { return err } } if expected.Files.Loaded() { if err := actual.LoadFiles(ctx, db.Image); err != nil { return err } } // clear Path, Checksum, PrimaryFileID if expected.Path == "" { actual.Path = "" } if expected.Checksum == "" { actual.Checksum = "" } if expected.PrimaryFileID == nil { actual.PrimaryFileID = nil } return nil } func Test_imageQueryBuilder_Create(t *testing.T) { var ( title = "title" code = "code" rating = 60 details = "details" photographer = "photographer" ocounter = 5 url = "url" date, _ = models.ParseDate("2003-02-01") createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) imageFile = makeFileWithID(fileIdxStartImageFiles) ) tests := []struct { name string newObject models.CreateImageInput wantErr bool }{ { "full", models.CreateImageInput{ Image: &models.Image{ Title: title, Code: code, Rating: &rating, Date: &date, Details: details, Photographer: photographer, URLs: models.NewRelatedStrings([]string{url}), Organized: true, OCounter: ocounter, StudioID: &studioIDs[studioIdxWithImage], CreatedAt: createdAt, UpdatedAt: updatedAt, GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]}), PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]}), }, CustomFields: testCustomFields, }, false, }, { "with file", models.CreateImageInput{ Image: &models.Image{ Title: title, Code: code, Rating: &rating, Date: &date, Details: details, Photographer: photographer, URLs: models.NewRelatedStrings([]string{url}), Organized: true, OCounter: ocounter, StudioID: &studioIDs[studioIdxWithImage], Files: models.NewRelatedFiles([]models.File{ imageFile.(*models.ImageFile), }), PrimaryFileID: &imageFile.Base().ID, Path: imageFile.Base().Path, CreatedAt: createdAt, UpdatedAt: updatedAt, GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]}), PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]}), }, }, false, }, { "invalid studio id", models.CreateImageInput{ Image: &models.Image{ StudioID: &invalidID, }, }, true, }, { "invalid gallery id", models.CreateImageInput{ Image: &models.Image{ GalleryIDs: models.NewRelatedIDs([]int{invalidID}), }, }, true, }, { "invalid tag id", models.CreateImageInput{ Image: &models.Image{ TagIDs: models.NewRelatedIDs([]int{invalidID}), }, }, true, }, { "invalid performer id", models.CreateImageInput{ Image: &models.Image{ PerformerIDs: models.NewRelatedIDs([]int{invalidID}), }, }, true, }, } qb := db.Image for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) var fileIDs []models.FileID if tt.newObject.Files.Loaded() { for _, f := range tt.newObject.Files.List() { fileIDs = append(fileIDs, f.Base().ID) } } s := *tt.newObject.Image if err := qb.Create(ctx, &models.CreateImageInput{ Image: &s, FileIDs: fileIDs, }); (err != nil) != tt.wantErr { t.Errorf("imageQueryBuilder.Create() error = %v, wantErr = %v", err, tt.wantErr) } if tt.wantErr { assert.Zero(s.ID) return } assert.NotZero(s.ID) copy := *tt.newObject.Image copy.ID = s.ID // load relationships if err := loadImageRelationships(ctx, copy, &s); err != nil { t.Errorf("loadImageRelationships() error = %v", err) return } assert.Equal(copy, s) // ensure can find the image found, err := qb.Find(ctx, s.ID) if err != nil { t.Errorf("imageQueryBuilder.Find() error = %v", err) } // load relationships if err := loadImageRelationships(ctx, copy, found); err != nil { t.Errorf("loadImageRelationships() error = %v", err) return } assert.Equal(copy, *found) }) } } func clearImageFileIDs(image *models.Image) { if image.Files.Loaded() { for _, f := range image.Files.List() { f.Base().ID = 0 } } } func makeImageFileWithID(i int) *models.ImageFile { ret := makeImageFile(i) ret.ID = imageFileIDs[i] return ret } func Test_imageQueryBuilder_Update(t *testing.T) { var ( title = "title" code = "code" rating = 60 url = "url" details = "details" photographer = "photographer" date, _ = models.ParseDate("2003-02-01") ocounter = 5 createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) ) tests := []struct { name string updatedObject *models.Image wantErr bool }{ { "full", &models.Image{ ID: imageIDs[imageIdxWithGallery], Title: title, Code: code, Rating: &rating, URLs: models.NewRelatedStrings([]string{url}), Date: &date, Details: details, Photographer: photographer, Organized: true, OCounter: ocounter, StudioID: &studioIDs[studioIdxWithImage], CreatedAt: createdAt, UpdatedAt: updatedAt, GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]}), PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]}), }, false, }, { "clear nullables", &models.Image{ ID: imageIDs[imageIdxWithGallery], GalleryIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), PerformerIDs: models.NewRelatedIDs([]int{}), Organized: true, CreatedAt: createdAt, UpdatedAt: updatedAt, }, false, }, { "clear gallery ids", &models.Image{ ID: imageIDs[imageIdxWithGallery], GalleryIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), PerformerIDs: models.NewRelatedIDs([]int{}), Organized: true, CreatedAt: createdAt, UpdatedAt: updatedAt, }, false, }, { "clear tag ids", &models.Image{ ID: imageIDs[imageIdxWithTag], GalleryIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), PerformerIDs: models.NewRelatedIDs([]int{}), Organized: true, CreatedAt: createdAt, UpdatedAt: updatedAt, }, false, }, { "clear performer ids", &models.Image{ ID: imageIDs[imageIdxWithPerformer], GalleryIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), PerformerIDs: models.NewRelatedIDs([]int{}), Organized: true, CreatedAt: createdAt, UpdatedAt: updatedAt, }, false, }, { "invalid studio id", &models.Image{ ID: imageIDs[imageIdxWithGallery], Organized: true, StudioID: &invalidID, CreatedAt: createdAt, UpdatedAt: updatedAt, }, true, }, { "invalid gallery id", &models.Image{ ID: imageIDs[imageIdxWithGallery], Organized: true, GalleryIDs: models.NewRelatedIDs([]int{invalidID}), CreatedAt: createdAt, UpdatedAt: updatedAt, }, true, }, { "invalid tag id", &models.Image{ ID: imageIDs[imageIdxWithGallery], Organized: true, TagIDs: models.NewRelatedIDs([]int{invalidID}), CreatedAt: createdAt, UpdatedAt: updatedAt, }, true, }, { "invalid performer id", &models.Image{ ID: imageIDs[imageIdxWithGallery], Organized: true, PerformerIDs: models.NewRelatedIDs([]int{invalidID}), CreatedAt: createdAt, UpdatedAt: updatedAt, }, true, }, } qb := db.Image for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) copy := *tt.updatedObject if err := qb.Update(ctx, tt.updatedObject); (err != nil) != tt.wantErr { t.Errorf("imageQueryBuilder.Update() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { return } s, err := qb.Find(ctx, tt.updatedObject.ID) if err != nil { t.Errorf("imageQueryBuilder.Find() error = %v", err) } // load relationships if err := loadImageRelationships(ctx, copy, s); err != nil { t.Errorf("loadImageRelationships() error = %v", err) return } assert.Equal(copy, *s) }) } } func clearImagePartial() models.ImagePartial { // leave mandatory fields return models.ImagePartial{ Title: models.OptionalString{Set: true, Null: true}, Code: models.OptionalString{Set: true, Null: true}, Details: models.OptionalString{Set: true, Null: true}, Photographer: models.OptionalString{Set: true, Null: true}, Rating: models.OptionalInt{Set: true, Null: true}, URLs: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet}, Date: models.OptionalDate{Set: true, Null: true}, StudioID: models.OptionalInt{Set: true, Null: true}, GalleryIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet}, TagIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet}, PerformerIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet}, } } func Test_imageQueryBuilder_UpdatePartial(t *testing.T) { var ( title = "title" code = "code" details = "details" photographer = "photographer" rating = 60 url = "url" date, _ = models.ParseDate("2003-02-01") ocounter = 5 createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) ) tests := []struct { name string id int partial models.ImagePartial want models.Image wantErr bool }{ { "full", imageIDs[imageIdx1WithGallery], models.ImagePartial{ Title: models.NewOptionalString(title), Code: models.NewOptionalString(code), Details: models.NewOptionalString(details), Photographer: models.NewOptionalString(photographer), Rating: models.NewOptionalInt(rating), URLs: &models.UpdateStrings{ Values: []string{url}, Mode: models.RelationshipUpdateModeSet, }, Date: models.NewOptionalDate(date), Organized: models.NewOptionalBool(true), OCounter: models.NewOptionalInt(ocounter), StudioID: models.NewOptionalInt(studioIDs[studioIdxWithImage]), CreatedAt: models.NewOptionalTime(createdAt), UpdatedAt: models.NewOptionalTime(updatedAt), GalleryIDs: &models.UpdateIDs{ IDs: []int{galleryIDs[galleryIdxWithImage]}, Mode: models.RelationshipUpdateModeSet, }, TagIDs: &models.UpdateIDs{ IDs: []int{tagIDs[tagIdx1WithImage], tagIDs[tagIdx1WithDupName]}, Mode: models.RelationshipUpdateModeSet, }, PerformerIDs: &models.UpdateIDs{ IDs: []int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]}, Mode: models.RelationshipUpdateModeSet, }, }, models.Image{ ID: imageIDs[imageIdx1WithGallery], Title: title, Code: code, Details: details, Photographer: photographer, Rating: &rating, URLs: models.NewRelatedStrings([]string{url}), Date: &date, Organized: true, OCounter: ocounter, StudioID: &studioIDs[studioIdxWithImage], Files: models.NewRelatedFiles([]models.File{ makeImageFile(imageIdx1WithGallery), }), CreatedAt: createdAt, UpdatedAt: updatedAt, GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]}), PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]}), }, false, }, { "clear all", imageIDs[imageIdx1WithGallery], clearImagePartial(), models.Image{ ID: imageIDs[imageIdx1WithGallery], OCounter: getOCounter(imageIdx1WithGallery), Files: models.NewRelatedFiles([]models.File{ makeImageFile(imageIdx1WithGallery), }), GalleryIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), PerformerIDs: models.NewRelatedIDs([]int{}), }, false, }, { "invalid id", invalidID, models.ImagePartial{}, models.Image{}, true, }, } for _, tt := range tests { qb := db.Image runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.UpdatePartial(ctx, tt.id, tt.partial) if (err != nil) != tt.wantErr { t.Errorf("imageQueryBuilder.UpdatePartial() error = %v, wantErr %v", err, tt.wantErr) return } if tt.wantErr { return } // load relationships if err := loadImageRelationships(ctx, tt.want, got); err != nil { t.Errorf("loadImageRelationships() error = %v", err) return } clearImageFileIDs(got) assert.Equal(tt.want, *got) s, err := qb.Find(ctx, tt.id) if err != nil { t.Errorf("imageQueryBuilder.Find() error = %v", err) } // load relationships if err := loadImageRelationships(ctx, tt.want, s); err != nil { t.Errorf("loadImageRelationships() error = %v", err) return } clearImageFileIDs(s) assert.Equal(tt.want, *s) }) } } func Test_imageQueryBuilder_UpdatePartialRelationships(t *testing.T) { tests := []struct { name string id int partial models.ImagePartial want models.Image wantErr bool }{ { "add galleries", imageIDs[imageIdxWithGallery], models.ImagePartial{ GalleryIDs: &models.UpdateIDs{ IDs: []int{galleryIDs[galleryIdx1WithImage], galleryIDs[galleryIdx1WithPerformer]}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Image{ GalleryIDs: models.NewRelatedIDs(append(indexesToIDs(galleryIDs, imageGalleries[imageIdxWithGallery]), galleryIDs[galleryIdx1WithImage], galleryIDs[galleryIdx1WithPerformer], )), }, false, }, { "add tags", imageIDs[imageIdxWithTwoTags], models.ImagePartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGallery]}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Image{ TagIDs: models.NewRelatedIDs(append( []int{ tagIDs[tagIdx1WithGallery], tagIDs[tagIdx1WithDupName], }, indexesToIDs(tagIDs, imageTags[imageIdxWithTwoTags])..., )), }, false, }, { "add performers", imageIDs[imageIdxWithTwoPerformers], models.ImagePartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerIDs[performerIdx1WithDupName], performerIDs[performerIdx1WithGallery]}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Image{ PerformerIDs: models.NewRelatedIDs(append(indexesToIDs(performerIDs, imagePerformers[imageIdxWithTwoPerformers]), performerIDs[performerIdx1WithDupName], performerIDs[performerIdx1WithGallery], )), }, false, }, { "add duplicate galleries", imageIDs[imageIdxWithGallery], models.ImagePartial{ GalleryIDs: &models.UpdateIDs{ IDs: []int{galleryIDs[galleryIdxWithImage], galleryIDs[galleryIdx1WithPerformer]}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Image{ GalleryIDs: models.NewRelatedIDs(append(indexesToIDs(galleryIDs, imageGalleries[imageIdxWithGallery]), galleryIDs[galleryIdx1WithPerformer], )), }, false, }, { "add duplicate tags", imageIDs[imageIdxWithTwoTags], models.ImagePartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagIDs[tagIdx1WithImage], tagIDs[tagIdx1WithGallery]}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Image{ TagIDs: models.NewRelatedIDs(append( []int{tagIDs[tagIdx1WithGallery]}, indexesToIDs(tagIDs, imageTags[imageIdxWithTwoTags])..., )), }, false, }, { "add duplicate performers", imageIDs[imageIdxWithTwoPerformers], models.ImagePartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithGallery]}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Image{ PerformerIDs: models.NewRelatedIDs(append(indexesToIDs(performerIDs, imagePerformers[imageIdxWithTwoPerformers]), performerIDs[performerIdx1WithGallery], )), }, false, }, { "add invalid galleries", imageIDs[imageIdxWithGallery], models.ImagePartial{ GalleryIDs: &models.UpdateIDs{ IDs: []int{invalidID}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Image{}, true, }, { "add invalid tags", imageIDs[imageIdxWithTwoTags], models.ImagePartial{ TagIDs: &models.UpdateIDs{ IDs: []int{invalidID}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Image{}, true, }, { "add invalid performers", imageIDs[imageIdxWithTwoPerformers], models.ImagePartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{invalidID}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Image{}, true, }, { "remove galleries", imageIDs[imageIdxWithGallery], models.ImagePartial{ GalleryIDs: &models.UpdateIDs{ IDs: []int{galleryIDs[galleryIdxWithImage]}, Mode: models.RelationshipUpdateModeRemove, }, }, models.Image{ GalleryIDs: models.NewRelatedIDs([]int{}), }, false, }, { "remove tags", imageIDs[imageIdxWithTwoTags], models.ImagePartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagIDs[tagIdx1WithImage]}, Mode: models.RelationshipUpdateModeRemove, }, }, models.Image{ TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx2WithImage]}), }, false, }, { "remove performers", imageIDs[imageIdxWithTwoPerformers], models.ImagePartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerIDs[performerIdx1WithImage]}, Mode: models.RelationshipUpdateModeRemove, }, }, models.Image{ PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx2WithImage]}), }, false, }, { "remove unrelated galleries", imageIDs[imageIdxWithGallery], models.ImagePartial{ GalleryIDs: &models.UpdateIDs{ IDs: []int{galleryIDs[galleryIdx1WithImage]}, Mode: models.RelationshipUpdateModeRemove, }, }, models.Image{ GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}), }, false, }, { "remove unrelated tags", imageIDs[imageIdxWithTwoTags], models.ImagePartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagIDs[tagIdx1WithPerformer]}, Mode: models.RelationshipUpdateModeRemove, }, }, models.Image{ TagIDs: models.NewRelatedIDs(indexesToIDs(tagIDs, imageTags[imageIdxWithTwoTags])), }, false, }, { "remove unrelated performers", imageIDs[imageIdxWithTwoPerformers], models.ImagePartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerIDs[performerIdx1WithDupName]}, Mode: models.RelationshipUpdateModeRemove, }, }, models.Image{ PerformerIDs: models.NewRelatedIDs(indexesToIDs(performerIDs, imagePerformers[imageIdxWithTwoPerformers])), }, false, }, } for _, tt := range tests { qb := db.Image runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.UpdatePartial(ctx, tt.id, tt.partial) if (err != nil) != tt.wantErr { t.Errorf("imageQueryBuilder.UpdatePartial() error = %v, wantErr %v", err, tt.wantErr) return } if tt.wantErr { return } s, err := qb.Find(ctx, tt.id) if err != nil { t.Errorf("imageQueryBuilder.Find() error = %v", err) } // load relationships if err := loadImageRelationships(ctx, tt.want, got); err != nil { t.Errorf("loadImageRelationships() error = %v", err) return } if err := loadImageRelationships(ctx, tt.want, s); err != nil { t.Errorf("loadImageRelationships() error = %v", err) return } // only compare fields that were in the partial if tt.partial.PerformerIDs != nil { assert.ElementsMatch(tt.want.PerformerIDs.List(), got.PerformerIDs.List()) assert.ElementsMatch(tt.want.PerformerIDs.List(), s.PerformerIDs.List()) } if tt.partial.TagIDs != nil { assert.ElementsMatch(tt.want.TagIDs.List(), got.TagIDs.List()) assert.ElementsMatch(tt.want.TagIDs.List(), s.TagIDs.List()) } if tt.partial.GalleryIDs != nil { assert.ElementsMatch(tt.want.GalleryIDs.List(), got.GalleryIDs.List()) assert.ElementsMatch(tt.want.GalleryIDs.List(), s.GalleryIDs.List()) } }) } } func Test_ImageStore_UpdatePartialCustomFields(t *testing.T) { tests := []struct { name string id int partial models.ImagePartial expected map[string]interface{} // nil to use the partial }{ { "set custom fields", imageIDs[imageIdx1WithGallery], models.ImagePartial{ CustomFields: models.CustomFieldsInput{ Full: testCustomFields, }, }, nil, }, { "clear custom fields", imageIDs[imageIdx1WithGallery], models.ImagePartial{ CustomFields: models.CustomFieldsInput{ Full: map[string]interface{}{}, }, }, nil, }, { "partial custom fields", imageIDs[imageIdxWithStudio], models.ImagePartial{ CustomFields: models.CustomFieldsInput{ Partial: map[string]interface{}{ "string": "bbb", "new_field": "new", }, }, }, map[string]interface{}{ "int": int64(2), "real": 1.2, "string": "bbb", "new_field": "new", }, }, } for _, tt := range tests { qb := db.Image runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) _, err := qb.UpdatePartial(ctx, tt.id, tt.partial) if err != nil { t.Errorf("ImageStore.UpdatePartial() error = %v", err) return } // ensure custom fields are correct cf, err := qb.GetCustomFields(ctx, tt.id) if err != nil { t.Errorf("ImageStore.GetCustomFields() error = %v", err) return } if tt.expected == nil { assert.Equal(tt.partial.CustomFields.Full, cf) } else { assert.Equal(tt.expected, cf) } }) } } func Test_imageQueryBuilder_IncrementOCounter(t *testing.T) { tests := []struct { name string id int want int wantErr bool }{ { "increment", imageIDs[1], 2, false, }, { "invalid", invalidID, 0, true, }, } qb := db.Image for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { got, err := qb.IncrementOCounter(ctx, tt.id) if (err != nil) != tt.wantErr { t.Errorf("imageQueryBuilder.IncrementOCounter() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { t.Errorf("imageQueryBuilder.IncrementOCounter() = %v, want %v", got, tt.want) } }) } } func Test_imageQueryBuilder_DecrementOCounter(t *testing.T) { tests := []struct { name string id int want int wantErr bool }{ { "decrement", imageIDs[2], 1, false, }, { "zero", imageIDs[0], 0, false, }, { "invalid", invalidID, 0, true, }, } qb := db.Image for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { got, err := qb.DecrementOCounter(ctx, tt.id) if (err != nil) != tt.wantErr { t.Errorf("imageQueryBuilder.DecrementOCounter() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { t.Errorf("imageQueryBuilder.DecrementOCounter() = %v, want %v", got, tt.want) } }) } } func Test_imageQueryBuilder_ResetOCounter(t *testing.T) { tests := []struct { name string id int want int wantErr bool }{ { "decrement", imageIDs[2], 0, false, }, { "zero", imageIDs[0], 0, false, }, { "invalid", invalidID, 0, true, }, } qb := db.Image for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { got, err := qb.ResetOCounter(ctx, tt.id) if (err != nil) != tt.wantErr { t.Errorf("imageQueryBuilder.ResetOCounter() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { t.Errorf("imageQueryBuilder.ResetOCounter() = %v, want %v", got, tt.want) } }) } } func Test_imageQueryBuilder_Destroy(t *testing.T) { tests := []struct { name string id int wantErr bool }{ { "valid", imageIDs[imageIdxWithGallery], false, }, { "invalid", invalidID, true, }, } qb := db.Image for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) if err := qb.Destroy(ctx, tt.id); (err != nil) != tt.wantErr { t.Errorf("imageQueryBuilder.Destroy() error = %v, wantErr %v", err, tt.wantErr) } // ensure cannot be found i, err := qb.Find(ctx, tt.id) assert.Nil(err) assert.Nil(i) }) } } func makeImageWithID(index int) *models.Image { const fromDB = true ret := makeImage(index) ret.ID = imageIDs[index] ret.Files = models.NewRelatedFiles([]models.File{makeImageFile(index)}) return ret } func Test_imageQueryBuilder_Find(t *testing.T) { tests := []struct { name string id int want *models.Image wantErr bool }{ { "valid", imageIDs[imageIdxWithGallery], makeImageWithID(imageIdxWithGallery), false, }, { "invalid", invalidID, nil, false, }, { "with performers", imageIDs[imageIdxWithTwoPerformers], makeImageWithID(imageIdxWithTwoPerformers), false, }, { "with tags", imageIDs[imageIdxWithTwoTags], makeImageWithID(imageIdxWithTwoTags), false, }, } qb := db.Image for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.Find(ctx, tt.id) if (err != nil) != tt.wantErr { t.Errorf("imageQueryBuilder.Find() error = %v, wantErr %v", err, tt.wantErr) return } if got != nil { // load relationships if err := loadImageRelationships(ctx, *tt.want, got); err != nil { t.Errorf("loadImageRelationships() error = %v", err) return } clearImageFileIDs(got) } assert.Equal(tt.want, got) }) } } func postFindImages(ctx context.Context, want []*models.Image, got []*models.Image) error { for i, s := range got { // load relationships if i < len(want) { if err := loadImageRelationships(ctx, *want[i], s); err != nil { return err } } clearImageFileIDs(s) } return nil } func Test_imageQueryBuilder_FindMany(t *testing.T) { tests := []struct { name string ids []int want []*models.Image wantErr bool }{ { "valid with relationships", []int{imageIDs[imageIdxWithGallery], imageIDs[imageIdxWithTwoPerformers], imageIDs[imageIdxWithTwoTags]}, []*models.Image{ makeImageWithID(imageIdxWithGallery), makeImageWithID(imageIdxWithTwoPerformers), makeImageWithID(imageIdxWithTwoTags), }, false, }, { "invalid", []int{imageIDs[imageIdxWithGallery], imageIDs[imageIdxWithTwoPerformers], invalidID}, nil, true, }, } qb := db.Image for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { got, err := qb.FindMany(ctx, tt.ids) if (err != nil) != tt.wantErr { t.Errorf("imageQueryBuilder.FindMany() error = %v, wantErr %v", err, tt.wantErr) return } if err := postFindImages(ctx, tt.want, got); err != nil { t.Errorf("loadImageRelationships() error = %v", err) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("imageQueryBuilder.FindMany() = %v, want %v", got, tt.want) } }) } } func Test_imageQueryBuilder_FindByChecksum(t *testing.T) { getChecksum := func(index int) string { return getImageStringValue(index, checksumField) } tests := []struct { name string checksum string want []*models.Image wantErr bool }{ { "valid", getChecksum(imageIdxWithGallery), []*models.Image{makeImageWithID(imageIdxWithGallery)}, false, }, { "invalid", "invalid checksum", nil, false, }, { "with performers", getChecksum(imageIdxWithTwoPerformers), []*models.Image{makeImageWithID(imageIdxWithTwoPerformers)}, false, }, { "with tags", getChecksum(imageIdxWithTwoTags), []*models.Image{makeImageWithID(imageIdxWithTwoTags)}, false, }, } qb := db.Image for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.FindByChecksum(ctx, tt.checksum) if (err != nil) != tt.wantErr { t.Errorf("imageQueryBuilder.FindByChecksum() error = %v, wantErr %v", err, tt.wantErr) return } if err := postFindImages(ctx, tt.want, got); err != nil { t.Errorf("loadImageRelationships() error = %v", err) return } assert.Equal(tt.want, got) }) } } func Test_imageQueryBuilder_FindByFingerprints(t *testing.T) { getChecksum := func(index int) string { return getImageStringValue(index, checksumField) } tests := []struct { name string fingerprints []models.Fingerprint want []*models.Image wantErr bool }{ { "valid", []models.Fingerprint{ { Type: models.FingerprintTypeMD5, Fingerprint: getChecksum(imageIdxWithGallery), }, }, []*models.Image{makeImageWithID(imageIdxWithGallery)}, false, }, { "invalid", []models.Fingerprint{ { Type: models.FingerprintTypeMD5, Fingerprint: "invalid checksum", }, }, nil, false, }, { "with performers", []models.Fingerprint{ { Type: models.FingerprintTypeMD5, Fingerprint: getChecksum(imageIdxWithTwoPerformers), }, }, []*models.Image{makeImageWithID(imageIdxWithTwoPerformers)}, false, }, { "with tags", []models.Fingerprint{ { Type: models.FingerprintTypeMD5, Fingerprint: getChecksum(imageIdxWithTwoTags), }, }, []*models.Image{makeImageWithID(imageIdxWithTwoTags)}, false, }, } qb := db.Image for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.FindByFingerprints(ctx, tt.fingerprints) if (err != nil) != tt.wantErr { t.Errorf("imageQueryBuilder.FindByChecksum() error = %v, wantErr %v", err, tt.wantErr) return } if err := postFindImages(ctx, tt.want, got); err != nil { t.Errorf("loadImageRelationships() error = %v", err) return } assert.Equal(tt.want, got) }) } } func Test_imageQueryBuilder_FindByGalleryID(t *testing.T) { tests := []struct { name string galleryID int want []*models.Image wantErr bool }{ { "valid", galleryIDs[galleryIdxWithTwoImages], []*models.Image{makeImageWithID(imageIdx1WithGallery), makeImageWithID(imageIdx2WithGallery)}, false, }, { "none", galleryIDs[galleryIdx1WithPerformer], nil, false, }, } qb := db.Image for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.FindByGalleryID(ctx, tt.galleryID) if (err != nil) != tt.wantErr { t.Errorf("imageQueryBuilder.FindByGalleryID() error = %v, wantErr %v", err, tt.wantErr) return } if err := postFindImages(ctx, tt.want, got); err != nil { t.Errorf("loadImageRelationships() error = %v", err) return } assert.Equal(tt.want, got) return }) } } func Test_imageQueryBuilder_CountByGalleryID(t *testing.T) { tests := []struct { name string galleryID int want int wantErr bool }{ { "valid", galleryIDs[galleryIdxWithTwoImages], 2, false, }, { "none", galleryIDs[galleryIdx1WithPerformer], 0, false, }, } qb := db.Image for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { got, err := qb.CountByGalleryID(ctx, tt.galleryID) if (err != nil) != tt.wantErr { t.Errorf("imageQueryBuilder.CountByGalleryID() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { t.Errorf("imageQueryBuilder.CountByGalleryID() = %v, want %v", got, tt.want) } }) } } func imagesToIDs(i []*models.Image) []int { var ret []int for _, ii := range i { ret = append(ret, ii.ID) } return ret } func Test_imageStore_FindByFileID(t *testing.T) { tests := []struct { name string fileID models.FileID include []int exclude []int }{ { "valid", imageFileIDs[imageIdxWithGallery], []int{imageIdxWithGallery}, nil, }, { "invalid", invalidFileID, nil, []int{imageIdxWithGallery}, }, } qb := db.Image for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.FindByFileID(ctx, tt.fileID) if err != nil { t.Errorf("ImageStore.FindByFileID() error = %v", err) return } for _, f := range got { clearImageFileIDs(f) } ids := imagesToIDs(got) include := indexesToIDs(imageIDs, tt.include) exclude := indexesToIDs(imageIDs, tt.exclude) for _, i := range include { assert.Contains(ids, i) } for _, e := range exclude { assert.NotContains(ids, e) } }) } } func Test_imageStore_FindByFolderID(t *testing.T) { tests := []struct { name string folderID models.FolderID include []int exclude []int }{ { "valid", folderIDs[folderIdxWithImageFiles], []int{imageIdxWithGallery}, nil, }, { "invalid", invalidFolderID, nil, []int{imageIdxWithGallery}, }, { "parent folder", folderIDs[folderIdxForObjectFiles], nil, []int{imageIdxWithGallery}, }, } qb := db.Image for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.FindByFolderID(ctx, tt.folderID) if err != nil { t.Errorf("ImageStore.FindByFolderID() error = %v", err) return } for _, f := range got { clearImageFileIDs(f) } ids := imagesToIDs(got) include := indexesToIDs(imageIDs, tt.include) exclude := indexesToIDs(imageIDs, tt.exclude) for _, i := range include { assert.Contains(ids, i) } for _, e := range exclude { assert.NotContains(ids, e) } }) } } func Test_imageStore_FindByZipFileID(t *testing.T) { tests := []struct { name string zipFileID models.FileID include []int exclude []int }{ { "valid", fileIDs[fileIdxZip], []int{imageIdxInZip}, nil, }, { "invalid", invalidFileID, nil, []int{imageIdxInZip}, }, } qb := db.Image for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.FindByZipFileID(ctx, tt.zipFileID) if err != nil { t.Errorf("ImageStore.FindByZipFileID() error = %v", err) return } for _, f := range got { clearImageFileIDs(f) } ids := imagesToIDs(got) include := indexesToIDs(imageIDs, tt.include) exclude := indexesToIDs(imageIDs, tt.exclude) for _, i := range include { assert.Contains(ids, i) } for _, e := range exclude { assert.NotContains(ids, e) } }) } } func TestImageQueryQ(t *testing.T) { withTxn(func(ctx context.Context) error { const imageIdx = 2 q := getImageStringValue(imageIdx, titleField) sqb := db.Image imageQueryQ(ctx, t, sqb, q, imageIdx) return nil }) } func TestImageQueryQ_Details(t *testing.T) { withTxn(func(ctx context.Context) error { const imageIdx = 3 q := getImageStringValue(imageIdx, detailsField) sqb := db.Image imageQueryQ(ctx, t, sqb, q, imageIdx) return nil }) } func queryImagesWithCount(ctx context.Context, sqb models.ImageReader, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) ([]*models.Image, int, error) { result, err := sqb.Query(ctx, models.ImageQueryOptions{ QueryOptions: models.QueryOptions{ FindFilter: findFilter, Count: true, }, ImageFilter: imageFilter, }) if err != nil { return nil, 0, err } images, err := result.Resolve(ctx) if err != nil { return nil, 0, err } return images, result.Count, nil } func imageQueryQ(ctx context.Context, t *testing.T, sqb models.ImageReader, q string, expectedImageIdx int) { filter := models.FindFilterType{ Q: &q, } images := queryImages(ctx, t, sqb, nil, &filter) assert.Len(t, images, 1) image := images[0] assert.Equal(t, imageIDs[expectedImageIdx], image.ID) count, err := sqb.QueryCount(ctx, nil, &filter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } assert.Equal(t, len(images), count) // no Q should return all results filter.Q = nil images = queryImages(ctx, t, sqb, nil, &filter) assert.Len(t, images, totalImages) } func verifyImageQuery(t *testing.T, filter models.ImageFilterType, verifyFn func(ctx context.Context, s *models.Image)) { t.Helper() withTxn(func(ctx context.Context) error { t.Helper() sqb := db.Image images := queryImages(ctx, t, sqb, &filter, nil) // assume it should find at least one assert.Greater(t, len(images), 0) for _, image := range images { verifyFn(ctx, image) } return nil }) } func TestImageQueryURL(t *testing.T) { const imageIdx = 1 imageURL := getImageStringValue(imageIdx, urlField) urlCriterion := models.StringCriterionInput{ Value: imageURL, Modifier: models.CriterionModifierEquals, } filter := models.ImageFilterType{ URL: &urlCriterion, } verifyFn := func(ctx context.Context, o *models.Image) { t.Helper() if err := o.LoadURLs(ctx, db.Image); err != nil { t.Errorf("Error loading scene URLs: %v", err) } urls := o.URLs.List() var url string if len(urls) > 0 { url = urls[0] } verifyString(t, url, urlCriterion) } verifyImageQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierNotEquals verifyImageQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierMatchesRegex urlCriterion.Value = "image_.*1_URL" verifyImageQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierNotMatchesRegex verifyImageQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierIsNull urlCriterion.Value = "" verifyImageQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierNotNull verifyImageQuery(t, filter, verifyFn) } func TestImageQueryPath(t *testing.T) { const imageIdx = 1 imagePath := getFilePath(folderIdxWithImageFiles, getImageBasename(imageIdx)) pathCriterion := models.StringCriterionInput{ Value: imagePath, Modifier: models.CriterionModifierEquals, } verifyImagePath(t, pathCriterion, 1) pathCriterion.Modifier = models.CriterionModifierNotEquals verifyImagePath(t, pathCriterion, totalImages-1) pathCriterion.Modifier = models.CriterionModifierMatchesRegex pathCriterion.Value = "image_.*01_Path" verifyImagePath(t, pathCriterion, 1) // TODO - 2 if zip path is included pathCriterion.Modifier = models.CriterionModifierNotMatchesRegex verifyImagePath(t, pathCriterion, totalImages-1) // TODO - -2 if zip path is included } func verifyImagePath(t *testing.T, pathCriterion models.StringCriterionInput, expected int) { withTxn(func(ctx context.Context) error { sqb := db.Image imageFilter := models.ImageFilterType{ Path: &pathCriterion, } images := queryImages(ctx, t, sqb, &imageFilter, nil) assert.Equal(t, expected, len(images), "number of returned images") for _, image := range images { verifyString(t, image.Path, pathCriterion) } return nil }) } func TestImageQueryPathOr(t *testing.T) { const image1Idx = 1 const image2Idx = 2 image1Path := getFilePath(folderIdxWithImageFiles, getImageBasename(image1Idx)) image2Path := getFilePath(folderIdxWithImageFiles, getImageBasename(image2Idx)) imageFilter := models.ImageFilterType{ Path: &models.StringCriterionInput{ Value: image1Path, Modifier: models.CriterionModifierEquals, }, OperatorFilter: models.OperatorFilter[models.ImageFilterType]{ Or: &models.ImageFilterType{ Path: &models.StringCriterionInput{ Value: image2Path, Modifier: models.CriterionModifierEquals, }, }, }, } withTxn(func(ctx context.Context) error { sqb := db.Image images := queryImages(ctx, t, sqb, &imageFilter, nil) if !assert.Len(t, images, 2) { return nil } assert.Equal(t, image1Path, images[0].Path) assert.Equal(t, image2Path, images[1].Path) return nil }) } func TestImageQueryPathAndRating(t *testing.T) { const imageIdx = 1 imagePath := getFilePath(folderIdxWithImageFiles, getImageBasename(imageIdx)) imageRating := getRating(imageIdx) imageFilter := models.ImageFilterType{ Path: &models.StringCriterionInput{ Value: imagePath, Modifier: models.CriterionModifierEquals, }, OperatorFilter: models.OperatorFilter[models.ImageFilterType]{ And: &models.ImageFilterType{ Rating100: &models.IntCriterionInput{ Value: int(imageRating.Int64), Modifier: models.CriterionModifierEquals, }, }, }, } withTxn(func(ctx context.Context) error { sqb := db.Image images := queryImages(ctx, t, sqb, &imageFilter, nil) if !assert.Len(t, images, 1) { return nil } assert.Equal(t, imagePath, images[0].Path) assert.Equal(t, int(imageRating.Int64), *images[0].Rating) return nil }) } func TestImageQueryPathNotRating(t *testing.T) { const imageIdx = 1 imageRating := getRating(imageIdx) pathCriterion := models.StringCriterionInput{ Value: "image_.*1_Path", Modifier: models.CriterionModifierMatchesRegex, } ratingCriterion := models.IntCriterionInput{ Value: int(imageRating.Int64), Modifier: models.CriterionModifierEquals, } imageFilter := models.ImageFilterType{ Path: &pathCriterion, OperatorFilter: models.OperatorFilter[models.ImageFilterType]{ Not: &models.ImageFilterType{ Rating100: &ratingCriterion, }, }, } withTxn(func(ctx context.Context) error { sqb := db.Image images := queryImages(ctx, t, sqb, &imageFilter, nil) for _, image := range images { verifyString(t, image.Path, pathCriterion) ratingCriterion.Modifier = models.CriterionModifierNotEquals verifyIntPtr(t, image.Rating, ratingCriterion) } return nil }) } func TestImageIllegalQuery(t *testing.T) { assert := assert.New(t) const imageIdx = 1 subFilter := models.ImageFilterType{ Path: &models.StringCriterionInput{ Value: getImageStringValue(imageIdx, "Path"), Modifier: models.CriterionModifierEquals, }, } imageFilter := &models.ImageFilterType{ OperatorFilter: models.OperatorFilter[models.ImageFilterType]{ And: &subFilter, Or: &subFilter, }, } withTxn(func(ctx context.Context) error { sqb := db.Image _, _, err := queryImagesWithCount(ctx, sqb, imageFilter, nil) assert.NotNil(err) imageFilter.Or = nil imageFilter.Not = &subFilter _, _, err = queryImagesWithCount(ctx, sqb, imageFilter, nil) assert.NotNil(err) imageFilter.And = nil imageFilter.Or = &subFilter _, _, err = queryImagesWithCount(ctx, sqb, imageFilter, nil) assert.NotNil(err) return nil }) } func TestImageQueryRating100(t *testing.T) { const rating = 60 ratingCriterion := models.IntCriterionInput{ Value: rating, Modifier: models.CriterionModifierEquals, } verifyImagesRating100(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierNotEquals verifyImagesRating100(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierGreaterThan verifyImagesRating100(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierLessThan verifyImagesRating100(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierIsNull verifyImagesRating100(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierNotNull verifyImagesRating100(t, ratingCriterion) } func verifyImagesRating100(t *testing.T, ratingCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Image imageFilter := models.ImageFilterType{ Rating100: &ratingCriterion, } images, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, nil) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } for _, image := range images { verifyIntPtr(t, image.Rating, ratingCriterion) } return nil }) } func TestImageQueryOCounter(t *testing.T) { const oCounter = 1 oCounterCriterion := models.IntCriterionInput{ Value: oCounter, Modifier: models.CriterionModifierEquals, } verifyImagesOCounter(t, oCounterCriterion) oCounterCriterion.Modifier = models.CriterionModifierNotEquals verifyImagesOCounter(t, oCounterCriterion) oCounterCriterion.Modifier = models.CriterionModifierGreaterThan verifyImagesOCounter(t, oCounterCriterion) oCounterCriterion.Modifier = models.CriterionModifierLessThan verifyImagesOCounter(t, oCounterCriterion) } func verifyImagesOCounter(t *testing.T, oCounterCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Image imageFilter := models.ImageFilterType{ OCounter: &oCounterCriterion, } images, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, nil) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } for _, image := range images { verifyInt(t, image.OCounter, oCounterCriterion) } return nil }) } func TestImageQueryResolution(t *testing.T) { verifyImagesResolution(t, models.ResolutionEnumLow) verifyImagesResolution(t, models.ResolutionEnumStandard) verifyImagesResolution(t, models.ResolutionEnumStandardHd) verifyImagesResolution(t, models.ResolutionEnumFullHd) verifyImagesResolution(t, models.ResolutionEnumFourK) verifyImagesResolution(t, models.ResolutionEnum("unknown")) } func verifyImagesResolution(t *testing.T, resolution models.ResolutionEnum) { withTxn(func(ctx context.Context) error { sqb := db.Image imageFilter := models.ImageFilterType{ Resolution: &models.ResolutionCriterionInput{ Value: resolution, Modifier: models.CriterionModifierEquals, }, } images, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, nil) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } for _, image := range images { if err := image.LoadPrimaryFile(ctx, db.File); err != nil { t.Errorf("Error loading primary file: %s", err.Error()) return nil } f := image.Files.Primary() vf, ok := f.(models.VisualFile) if !ok { t.Errorf("Error: image primary file is not a visual file (is type %T)", f) } verifyImageResolution(t, vf.GetHeight(), resolution) } return nil }) } func verifyImageResolution(t *testing.T, height int, resolution models.ResolutionEnum) { if !resolution.IsValid() { return } assert := assert.New(t) switch resolution { case models.ResolutionEnumLow: assert.True(height < 480) case models.ResolutionEnumStandard: assert.True(height >= 480 && height < 720) case models.ResolutionEnumStandardHd: assert.True(height >= 720 && height < 1080) case models.ResolutionEnumFullHd: assert.True(height >= 1080 && height < 2160) case models.ResolutionEnumFourK: assert.True(height >= 2160) } } func TestImageQueryIsMissingGalleries(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Image isMissing := "galleries" imageFilter := models.ImageFilterType{ IsMissing: &isMissing, } q := getImageStringValue(imageIdxWithGallery, titleField) findFilter := models.FindFilterType{ Q: &q, } images, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } assert.Len(t, images, 0) findFilter.Q = nil images, _, err = queryImagesWithCount(ctx, sqb, &imageFilter, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } assert.Greater(t, len(images), 0) // ensure non of the ids equal the one with gallery for _, image := range images { assert.NotEqual(t, imageIDs[imageIdxWithGallery], image.ID) } return nil }) } func TestImageQueryIsMissingStudio(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Image isMissing := "studio" imageFilter := models.ImageFilterType{ IsMissing: &isMissing, } q := getImageStringValue(imageIdxWithStudio, titleField) findFilter := models.FindFilterType{ Q: &q, } images, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } assert.Len(t, images, 0) findFilter.Q = nil images, _, err = queryImagesWithCount(ctx, sqb, &imageFilter, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } // ensure non of the ids equal the one with studio for _, image := range images { assert.NotEqual(t, imageIDs[imageIdxWithStudio], image.ID) } return nil }) } func TestImageQueryIsMissingPerformers(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Image isMissing := "performers" imageFilter := models.ImageFilterType{ IsMissing: &isMissing, } q := getImageStringValue(imageIdxWithPerformer, titleField) findFilter := models.FindFilterType{ Q: &q, } images, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } assert.Len(t, images, 0) findFilter.Q = nil images, _, err = queryImagesWithCount(ctx, sqb, &imageFilter, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } assert.True(t, len(images) > 0) // ensure non of the ids equal the one with performers for _, image := range images { assert.NotEqual(t, imageIDs[imageIdxWithPerformer], image.ID) } return nil }) } func TestImageQueryIsMissingTags(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Image isMissing := "tags" imageFilter := models.ImageFilterType{ IsMissing: &isMissing, } q := getImageStringValue(imageIdxWithTwoTags, titleField) findFilter := models.FindFilterType{ Q: &q, } images, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } assert.Len(t, images, 0) findFilter.Q = nil images, _, err = queryImagesWithCount(ctx, sqb, &imageFilter, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } assert.True(t, len(images) > 0) return nil }) } func TestImageQueryIsMissingRating(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Image isMissing := "rating" imageFilter := models.ImageFilterType{ IsMissing: &isMissing, } images, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, nil) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } assert.True(t, len(images) > 0) // ensure rating is null for _, image := range images { assert.Nil(t, image.Rating) } return nil }) } func TestImageQueryGallery(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Image galleryCriterion := models.MultiCriterionInput{ Value: []string{ strconv.Itoa(galleryIDs[galleryIdxWithImage]), }, Modifier: models.CriterionModifierIncludes, } imageFilter := models.ImageFilterType{ Galleries: &galleryCriterion, } images := queryImages(ctx, t, sqb, &imageFilter, nil) assert.Len(t, images, 1) // ensure ids are correct for _, image := range images { assert.True(t, image.ID == imageIDs[imageIdxWithGallery]) } galleryCriterion = models.MultiCriterionInput{ Value: []string{ strconv.Itoa(galleryIDs[galleryIdx1WithImage]), strconv.Itoa(galleryIDs[galleryIdx2WithImage]), }, Modifier: models.CriterionModifierIncludesAll, } images = queryImages(ctx, t, sqb, &imageFilter, nil) assert.Len(t, images, 1) assert.Equal(t, imageIDs[imageIdxWithTwoGalleries], images[0].ID) galleryCriterion = models.MultiCriterionInput{ Value: []string{ strconv.Itoa(performerIDs[galleryIdx1WithImage]), }, Modifier: models.CriterionModifierExcludes, } q := getImageStringValue(imageIdxWithTwoGalleries, titleField) findFilter := models.FindFilterType{ Q: &q, } images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) assert.Len(t, images, 0) q = getImageStringValue(imageIdxWithPerformer, titleField) images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) assert.Len(t, images, 1) return nil }) } func TestImageQueryPerformers(t *testing.T) { tests := []struct { name string filter models.MultiCriterionInput includeIdxs []int excludeIdxs []int wantErr bool }{ { "includes", models.MultiCriterionInput{ Value: []string{ strconv.Itoa(performerIDs[performerIdxWithImage]), strconv.Itoa(performerIDs[performerIdx1WithImage]), }, Modifier: models.CriterionModifierIncludes, }, []int{ imageIdxWithPerformer, imageIdxWithTwoPerformers, }, []int{ imageIdxWithGallery, }, false, }, { "includes all", models.MultiCriterionInput{ Value: []string{ strconv.Itoa(performerIDs[performerIdx1WithImage]), strconv.Itoa(performerIDs[performerIdx2WithImage]), }, Modifier: models.CriterionModifierIncludesAll, }, []int{ imageIdxWithTwoPerformers, }, []int{ imageIdxWithPerformer, }, false, }, { "excludes", models.MultiCriterionInput{ Modifier: models.CriterionModifierExcludes, Value: []string{strconv.Itoa(tagIDs[performerIdx1WithImage])}, }, nil, []int{imageIdxWithTwoPerformers}, false, }, { "is null", models.MultiCriterionInput{ Modifier: models.CriterionModifierIsNull, }, []int{imageIdxWithTag}, []int{ imageIdxWithPerformer, imageIdxWithTwoPerformers, imageIdxWithPerformerTwoTags, }, false, }, { "not null", models.MultiCriterionInput{ Modifier: models.CriterionModifierNotNull, }, []int{ imageIdxWithPerformer, imageIdxWithTwoPerformers, imageIdxWithPerformerTwoTags, }, []int{imageIdxWithTag}, false, }, { "equals", models.MultiCriterionInput{ Modifier: models.CriterionModifierEquals, Value: []string{ strconv.Itoa(tagIDs[performerIdx1WithImage]), strconv.Itoa(tagIDs[performerIdx2WithImage]), }, }, []int{imageIdxWithTwoPerformers}, []int{ imageIdxWithThreePerformers, }, false, }, { "not equals", models.MultiCriterionInput{ Modifier: models.CriterionModifierNotEquals, Value: []string{ strconv.Itoa(tagIDs[performerIdx1WithImage]), strconv.Itoa(tagIDs[performerIdx2WithImage]), }, }, nil, nil, true, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) results, err := db.Image.Query(ctx, models.ImageQueryOptions{ ImageFilter: &models.ImageFilterType{ Performers: &tt.filter, }, }) if (err != nil) != tt.wantErr { t.Errorf("ImageStore.Query() error = %v, wantErr %v", err, tt.wantErr) return } include := indexesToIDs(imageIDs, tt.includeIdxs) exclude := indexesToIDs(imageIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(results.IDs, i) } for _, e := range exclude { assert.NotContains(results.IDs, e) } }) } } func TestImageQueryTags(t *testing.T) { tests := []struct { name string filter models.HierarchicalMultiCriterionInput includeIdxs []int excludeIdxs []int wantErr bool }{ { "includes", models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithImage]), strconv.Itoa(tagIDs[tagIdx1WithImage]), }, Modifier: models.CriterionModifierIncludes, }, []int{ imageIdxWithTag, imageIdxWithTwoTags, }, []int{ imageIdxWithGallery, }, false, }, { "includes all", models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdx1WithImage]), strconv.Itoa(tagIDs[tagIdx2WithImage]), }, Modifier: models.CriterionModifierIncludesAll, }, []int{ imageIdxWithTwoTags, }, []int{ imageIdxWithTag, }, false, }, { "excludes", models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierExcludes, Value: []string{strconv.Itoa(tagIDs[tagIdx1WithImage])}, }, nil, []int{imageIdxWithTwoTags}, false, }, { "is null", models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIsNull, }, []int{imageIdx1WithPerformer}, []int{ imageIdxWithTag, imageIdxWithTwoTags, imageIdxWithThreeTags, }, false, }, { "not null", models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierNotNull, }, []int{ imageIdxWithTag, imageIdxWithTwoTags, imageIdxWithThreeTags, }, []int{imageIdx1WithPerformer}, false, }, { "equals", models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierEquals, Value: []string{ strconv.Itoa(tagIDs[tagIdx1WithImage]), strconv.Itoa(tagIDs[tagIdx2WithImage]), }, }, []int{imageIdxWithTwoTags}, []int{ imageIdxWithThreeTags, }, false, }, { "not equals", models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierNotEquals, Value: []string{ strconv.Itoa(tagIDs[tagIdx1WithImage]), strconv.Itoa(tagIDs[tagIdx2WithImage]), }, }, nil, nil, true, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) results, err := db.Image.Query(ctx, models.ImageQueryOptions{ ImageFilter: &models.ImageFilterType{ Tags: &tt.filter, }, }) if (err != nil) != tt.wantErr { t.Errorf("ImageStore.Query() error = %v, wantErr %v", err, tt.wantErr) return } include := indexesToIDs(imageIDs, tt.includeIdxs) exclude := indexesToIDs(imageIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(results.IDs, i) } for _, e := range exclude { assert.NotContains(results.IDs, e) } }) } } func TestImageQueryStudio(t *testing.T) { tests := []struct { name string q string studioCriterion models.HierarchicalMultiCriterionInput expectedIDs []int wantErr bool }{ { "includes", "", models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithImage]), }, Modifier: models.CriterionModifierIncludes, }, []int{imageIDs[imageIdxWithStudio]}, false, }, { "excludes", getImageStringValue(imageIdxWithStudio, titleField), models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithImage]), }, Modifier: models.CriterionModifierExcludes, }, []int{}, false, }, { "excludes includes null", getImageStringValue(imageIdxWithGallery, titleField), models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithImage]), }, Modifier: models.CriterionModifierExcludes, }, []int{imageIDs[imageIdxWithGallery]}, false, }, { "equals", "", models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithImage]), }, Modifier: models.CriterionModifierEquals, }, []int{imageIDs[imageIdxWithStudio]}, false, }, { "not equals", getImageStringValue(imageIdxWithStudio, titleField), models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithImage]), }, Modifier: models.CriterionModifierNotEquals, }, []int{}, false, }, } qb := db.Image for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { studioCriterion := tt.studioCriterion imageFilter := models.ImageFilterType{ Studios: &studioCriterion, } var findFilter *models.FindFilterType if tt.q != "" { findFilter = &models.FindFilterType{ Q: &tt.q, } } images := queryImages(ctx, t, qb, &imageFilter, findFilter) assert.ElementsMatch(t, imagesToIDs(images), tt.expectedIDs) }) } } func TestImageQueryStudioDepth(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Image depth := 2 studioCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithGrandChild]), }, Modifier: models.CriterionModifierIncludes, Depth: &depth, } imageFilter := models.ImageFilterType{ Studios: &studioCriterion, } images := queryImages(ctx, t, sqb, &imageFilter, nil) assert.Len(t, images, 1) depth = 1 images = queryImages(ctx, t, sqb, &imageFilter, nil) assert.Len(t, images, 0) studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])} images = queryImages(ctx, t, sqb, &imageFilter, nil) assert.Len(t, images, 1) // ensure id is correct assert.Equal(t, imageIDs[imageIdxWithGrandChildStudio], images[0].ID) depth = 2 studioCriterion = models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithGrandChild]), }, Modifier: models.CriterionModifierExcludes, Depth: &depth, } q := getImageStringValue(imageIdxWithGrandChildStudio, titleField) findFilter := models.FindFilterType{ Q: &q, } images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) assert.Len(t, images, 0) depth = 1 images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) assert.Len(t, images, 1) studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])} images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) assert.Len(t, images, 0) return nil }) } func queryImages(ctx context.Context, t *testing.T, sqb models.ImageReader, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) []*models.Image { images, _, err := queryImagesWithCount(ctx, sqb, imageFilter, findFilter) if err != nil { t.Errorf("Error querying images: %s", err.Error()) } return images } func TestImageQueryPerformerTags(t *testing.T) { allDepth := -1 tests := []struct { name string findFilter *models.FindFilterType filter *models.ImageFilterType includeIdxs []int excludeIdxs []int wantErr bool }{ { "includes", nil, &models.ImageFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithPerformer]), strconv.Itoa(tagIDs[tagIdx1WithPerformer]), }, Modifier: models.CriterionModifierIncludes, }, }, []int{ imageIdxWithPerformerTag, imageIdxWithPerformerTwoTags, imageIdxWithTwoPerformerTag, }, []int{ imageIdxWithPerformer, }, false, }, { "includes sub-tags", nil, &models.ImageFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), }, Depth: &allDepth, Modifier: models.CriterionModifierIncludes, }, }, []int{ imageIdxWithPerformerParentTag, }, []int{ imageIdxWithPerformer, imageIdxWithPerformerTag, imageIdxWithPerformerTwoTags, imageIdxWithTwoPerformerTag, }, false, }, { "includes all", nil, &models.ImageFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdx1WithPerformer]), strconv.Itoa(tagIDs[tagIdx2WithPerformer]), }, Modifier: models.CriterionModifierIncludesAll, }, }, []int{ imageIdxWithPerformerTwoTags, }, []int{ imageIdxWithPerformer, imageIdxWithPerformerTag, imageIdxWithTwoPerformerTag, }, false, }, { "excludes performer tag tagIdx2WithPerformer", nil, &models.ImageFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierExcludes, Value: []string{strconv.Itoa(tagIDs[tagIdx2WithPerformer])}, }, }, nil, []int{imageIdxWithTwoPerformerTag}, false, }, { "excludes sub-tags", nil, &models.ImageFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), }, Depth: &allDepth, Modifier: models.CriterionModifierExcludes, }, }, []int{ imageIdxWithPerformer, imageIdxWithPerformerTag, imageIdxWithPerformerTwoTags, imageIdxWithTwoPerformerTag, }, []int{ imageIdxWithPerformerParentTag, }, false, }, { "is null", nil, &models.ImageFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIsNull, }, }, []int{imageIdxWithGallery}, []int{imageIdxWithPerformerTag}, false, }, { "not null", nil, &models.ImageFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierNotNull, }, }, []int{imageIdxWithPerformerTag}, []int{imageIdxWithGallery}, false, }, { "equals", nil, &models.ImageFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierEquals, Value: []string{ strconv.Itoa(tagIDs[tagIdx2WithPerformer]), }, }, }, nil, nil, true, }, { "not equals", nil, &models.ImageFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierNotEquals, Value: []string{ strconv.Itoa(tagIDs[tagIdx2WithPerformer]), }, }, }, nil, nil, true, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) results, err := db.Image.Query(ctx, models.ImageQueryOptions{ ImageFilter: tt.filter, QueryOptions: models.QueryOptions{ FindFilter: tt.findFilter, }, }) if (err != nil) != tt.wantErr { t.Errorf("ImageStore.Query() error = %v, wantErr %v", err, tt.wantErr) return } include := indexesToIDs(imageIDs, tt.includeIdxs) exclude := indexesToIDs(imageIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(results.IDs, i) } for _, e := range exclude { assert.NotContains(results.IDs, e) } }) } } func TestImageQueryTagCount(t *testing.T) { const tagCount = 1 tagCountCriterion := models.IntCriterionInput{ Value: tagCount, Modifier: models.CriterionModifierEquals, } verifyImagesTagCount(t, tagCountCriterion) tagCountCriterion.Modifier = models.CriterionModifierNotEquals verifyImagesTagCount(t, tagCountCriterion) tagCountCriterion.Modifier = models.CriterionModifierGreaterThan verifyImagesTagCount(t, tagCountCriterion) tagCountCriterion.Modifier = models.CriterionModifierLessThan verifyImagesTagCount(t, tagCountCriterion) } func verifyImagesTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Image imageFilter := models.ImageFilterType{ TagCount: &tagCountCriterion, } images := queryImages(ctx, t, sqb, &imageFilter, nil) assert.Greater(t, len(images), 0) for _, image := range images { ids, err := sqb.GetTagIDs(ctx, image.ID) if err != nil { return err } verifyInt(t, len(ids), tagCountCriterion) } return nil }) } func TestImageQueryPerformerCount(t *testing.T) { const performerCount = 1 performerCountCriterion := models.IntCriterionInput{ Value: performerCount, Modifier: models.CriterionModifierEquals, } verifyImagesPerformerCount(t, performerCountCriterion) performerCountCriterion.Modifier = models.CriterionModifierNotEquals verifyImagesPerformerCount(t, performerCountCriterion) performerCountCriterion.Modifier = models.CriterionModifierGreaterThan verifyImagesPerformerCount(t, performerCountCriterion) performerCountCriterion.Modifier = models.CriterionModifierLessThan verifyImagesPerformerCount(t, performerCountCriterion) } func verifyImagesPerformerCount(t *testing.T, performerCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Image imageFilter := models.ImageFilterType{ PerformerCount: &performerCountCriterion, } images := queryImages(ctx, t, sqb, &imageFilter, nil) assert.Greater(t, len(images), 0) for _, image := range images { ids, err := sqb.GetPerformerIDs(ctx, image.ID) if err != nil { return err } verifyInt(t, len(ids), performerCountCriterion) } return nil }) } func TestImageQuerySorting(t *testing.T) { tests := []struct { name string sortBy string dir models.SortDirectionEnum firstIdx int // -1 to ignore lastIdx int }{ { "file mod time", "file_mod_time", models.SortDirectionEnumDesc, -1, -1, }, { "file size", "filesize", models.SortDirectionEnumDesc, -1, -1, }, { "path", "path", models.SortDirectionEnumDesc, -1, -1, }, { "date", "date", models.SortDirectionEnumDesc, imageIdxWithTwoGalleries, imageIdxWithGrandChildStudio, }, } qb := db.Image for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.Query(ctx, models.ImageQueryOptions{ QueryOptions: models.QueryOptions{ FindFilter: &models.FindFilterType{ Sort: &tt.sortBy, Direction: &tt.dir, }, }, }) if err != nil { t.Errorf("ImageStore.TestImageQuerySorting() error = %v", err) return } images, err := got.Resolve(ctx) if err != nil { t.Errorf("ImageStore.TestImageQuerySorting() error = %v", err) return } if !assert.Greater(len(images), 0) { return } // image should be in same order as indexes first := images[0] last := images[len(images)-1] if tt.firstIdx != -1 { firstID := sceneIDs[tt.firstIdx] assert.Equal(firstID, first.ID) } if tt.lastIdx != -1 { lastID := sceneIDs[tt.lastIdx] assert.Equal(lastID, last.ID) } }) } } func TestImageQueryPagination(t *testing.T) { withTxn(func(ctx context.Context) error { perPage := 1 findFilter := models.FindFilterType{ PerPage: &perPage, } sqb := db.Image images, _, err := queryImagesWithCount(ctx, sqb, nil, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } assert.Len(t, images, 1) firstID := images[0].ID page := 2 findFilter.Page = &page images, _, err = queryImagesWithCount(ctx, sqb, nil, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } assert.Len(t, images, 1) secondID := images[0].ID assert.NotEqual(t, firstID, secondID) perPage = 2 page = 1 images, _, err = queryImagesWithCount(ctx, sqb, nil, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } assert.Len(t, images, 2) assert.Equal(t, firstID, images[0].ID) assert.Equal(t, secondID, images[1].ID) return nil }) } func TestImageQueryCustomFields(t *testing.T) { tests := []struct { name string filter *models.ImageFilterType includeIdxs []int excludeIdxs []int wantErr bool }{ { "equals", &models.ImageFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierEquals, Value: []any{getImageStringValue(imageIdx1WithGallery, "custom")}, }, }, }, []int{imageIdx1WithGallery}, nil, false, }, { "not equals", &models.ImageFilterType{ Title: &models.StringCriterionInput{ Value: getImageStringValue(imageIdx1WithGallery, titleField), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotEquals, Value: []any{getImageStringValue(imageIdx1WithGallery, "custom")}, }, }, }, nil, []int{imageIdx1WithGallery}, false, }, { "includes", &models.ImageFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierIncludes, Value: []any{getImageStringValue(imageIdx1WithGallery, "custom")[9:]}, }, }, }, []int{imageIdx1WithGallery}, nil, false, }, { "excludes", &models.ImageFilterType{ Title: &models.StringCriterionInput{ Value: getImageStringValue(imageIdx1WithGallery, titleField), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierExcludes, Value: []any{getImageStringValue(imageIdx1WithGallery, "custom")[9:]}, }, }, }, nil, []int{imageIdx1WithGallery}, false, }, { "regex", &models.ImageFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierMatchesRegex, Value: []any{".*17_custom"}, }, }, }, []int{imageIdxWithPerformerTag}, nil, false, }, { "invalid regex", &models.ImageFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierMatchesRegex, Value: []any{"["}, }, }, }, nil, nil, true, }, { "not matches regex", &models.ImageFilterType{ Title: &models.StringCriterionInput{ Value: getImageStringValue(imageIdxWithPerformerTag, titleField), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotMatchesRegex, Value: []any{".*17_custom"}, }, }, }, nil, []int{imageIdxWithPerformerTag}, false, }, { "invalid not matches regex", &models.ImageFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotMatchesRegex, Value: []any{"["}, }, }, }, nil, nil, true, }, { "null", &models.ImageFilterType{ Title: &models.StringCriterionInput{ Value: getImageStringValue(imageIdx1WithGallery, titleField), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "not existing", Modifier: models.CriterionModifierIsNull, }, }, }, []int{imageIdx1WithGallery}, nil, false, }, { "not null", &models.ImageFilterType{ Title: &models.StringCriterionInput{ Value: getImageStringValue(imageIdx1WithGallery, titleField), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotNull, }, }, }, []int{imageIdx1WithGallery}, nil, false, }, { "between", &models.ImageFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "real", Modifier: models.CriterionModifierBetween, Value: []any{0.15, 0.25}, }, }, }, []int{imageIdx2WithGallery}, nil, false, }, { "not between", &models.ImageFilterType{ Title: &models.StringCriterionInput{ Value: getImageStringValue(imageIdx2WithGallery, titleField), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "real", Modifier: models.CriterionModifierNotBetween, Value: []any{0.15, 0.25}, }, }, }, nil, []int{imageIdx2WithGallery}, false, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) result, err := db.Image.Query(ctx, models.ImageQueryOptions{ ImageFilter: tt.filter, }) if (err != nil) != tt.wantErr { t.Errorf("ImageStore.Query() error = %v, wantErr %v", err, tt.wantErr) } if err != nil { return } images, err := result.Resolve(ctx) if err != nil { t.Errorf("ImageStore.Query().Resolve() error = %v", err) } ids := imagesToIDs(images) include := indexesToIDs(imageIDs, tt.includeIdxs) exclude := indexesToIDs(imageIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(ids, i) } for _, e := range exclude { assert.NotContains(ids, e) } }) } } // TODO Count // TODO SizeCount // TODO All ================================================ FILE: pkg/sqlite/migrate.go ================================================ package sqlite import ( "context" "fmt" "github.com/golang-migrate/migrate/v4" sqlite3mig "github.com/golang-migrate/migrate/v4/database/sqlite3" "github.com/golang-migrate/migrate/v4/source/iofs" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/logger" ) func (db *Database) needsMigration() bool { return db.schemaVersion != appSchemaVersion } type Migrator struct { db *Database conn *sqlx.DB m *migrate.Migrate } func NewMigrator(db *Database) (*Migrator, error) { m := &Migrator{ db: db, } const disableForeignKeys = true const writable = true var err error m.conn, err = m.db.open(disableForeignKeys, writable) if err != nil { return nil, err } m.conn.SetMaxOpenConns(maxReadConnections) m.conn.SetMaxIdleConns(maxReadConnections) m.conn.SetConnMaxIdleTime(dbConnTimeout) m.m, err = m.getMigrate() // if error encountered, close the connection if err != nil { m.Close() } return m, err } func (m *Migrator) Close() { if m.m != nil { m.m.Close() m.m = nil } } func (m *Migrator) CurrentSchemaVersion() uint { databaseSchemaVersion, _, _ := m.m.Version() return databaseSchemaVersion } func (m *Migrator) RequiredSchemaVersion() uint { return appSchemaVersion } func (m *Migrator) getMigrate() (*migrate.Migrate, error) { migrations, err := iofs.New(migrationsBox, "migrations") if err != nil { return nil, err } driver, err := sqlite3mig.WithInstance(m.conn.DB, &sqlite3mig.Config{}) if err != nil { return nil, err } // use sqlite3Driver so that migration has access to durationToTinyInt return migrate.NewWithInstance( "iofs", migrations, m.db.dbPath, driver, ) } func (m *Migrator) RunMigration(ctx context.Context, newVersion uint) error { databaseSchemaVersion, _, _ := m.m.Version() if newVersion != databaseSchemaVersion+1 { return fmt.Errorf("invalid migration version %d, expected %d", newVersion, databaseSchemaVersion+1) } // run pre migrations as needed if err := m.runCustomMigrations(ctx, preMigrations[newVersion]); err != nil { return fmt.Errorf("running pre migrations for schema version %d: %w", newVersion, err) } if err := m.m.Steps(1); err != nil { // migration failed return err } // run post migrations as needed if err := m.runCustomMigrations(ctx, postMigrations[newVersion]); err != nil { return fmt.Errorf("running post migrations for schema version %d: %w", newVersion, err) } // update the schema version m.db.schemaVersion, _, _ = m.m.Version() return nil } func (m *Migrator) runCustomMigrations(ctx context.Context, fns []customMigrationFunc) error { for _, fn := range fns { if err := m.runCustomMigration(ctx, fn); err != nil { return err } } return nil } func (m *Migrator) runCustomMigration(ctx context.Context, fn customMigrationFunc) error { if err := fn(ctx, m.conn); err != nil { return err } return nil } func (m *Migrator) PostMigrate(ctx context.Context) error { // optimise the database var err error logger.Info("Running database analyze") // don't use Optimize/vacuum as this adds a significant amount of time // to the migration err = analyze(ctx, m.conn) if err == nil { logger.Debug("Flushing WAL") err = flushWAL(ctx, m.conn) } if err != nil { return fmt.Errorf("error optimising database: %s", err) } return nil } func (db *Database) getDatabaseSchemaVersion() (uint, error) { m, err := NewMigrator(db) if err != nil { return 0, err } defer m.Close() ret, _, _ := m.m.Version() return ret, nil } func (db *Database) ReInitialise() error { return db.initialise() } // RunAllMigrations runs all migrations to bring the database up to the current schema version func (db *Database) RunAllMigrations() error { ctx := context.Background() m, err := NewMigrator(db) if err != nil { return err } defer m.Close() databaseSchemaVersion, _, _ := m.m.Version() stepNumber := appSchemaVersion - databaseSchemaVersion if stepNumber != 0 { logger.Infof("Migrating database from version %d to %d", databaseSchemaVersion, appSchemaVersion) // run each migration individually, and run custom migrations as needed var i uint = 1 for ; i <= stepNumber; i++ { newVersion := databaseSchemaVersion + i if err := m.RunMigration(ctx, newVersion); err != nil { return err } } } return nil } ================================================ FILE: pkg/sqlite/migrations/10_image_tables.up.sql ================================================ -- recreate scenes, studios and performers tables ALTER TABLE `studios` rename to `_studios_old`; ALTER TABLE `scenes` rename to `_scenes_old`; ALTER TABLE `performers` RENAME TO `_performers_old`; ALTER TABLE `movies` rename to `_movies_old`; -- remove studio image CREATE TABLE `studios` ( `id` integer not null primary key autoincrement, `checksum` varchar(255) not null, `name` varchar(255), `url` varchar(255), `parent_id` integer DEFAULT NULL CHECK ( id IS NOT parent_id ) REFERENCES studios(id) on delete set null, `created_at` datetime not null, `updated_at` datetime not null ); DROP INDEX `studios_checksum_unique`; DROP INDEX `index_studios_on_name`; DROP INDEX `index_studios_on_checksum`; CREATE UNIQUE INDEX `studios_checksum_unique` on `studios` (`checksum`); CREATE INDEX `index_studios_on_name` on `studios` (`name`); CREATE INDEX `index_studios_on_checksum` on `studios` (`checksum`); -- remove scene cover CREATE TABLE `scenes` ( `id` integer not null primary key autoincrement, `path` varchar(510) not null, `checksum` varchar(255) not null, `title` varchar(255), `details` text, `url` varchar(255), `date` date, `rating` tinyint, `size` varchar(255), `duration` float, `video_codec` varchar(255), `audio_codec` varchar(255), `width` tinyint, `height` tinyint, `framerate` float, `bitrate` integer, `studio_id` integer, `o_counter` tinyint not null default 0, `format` varchar(255), `created_at` datetime not null, `updated_at` datetime not null, -- changed from cascade delete foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL ); DROP INDEX IF EXISTS `scenes_path_unique`; DROP INDEX IF EXISTS `scenes_checksum_unique`; DROP INDEX IF EXISTS `index_scenes_on_studio_id`; CREATE UNIQUE INDEX `scenes_path_unique` on `scenes` (`path`); CREATE UNIQUE INDEX `scenes_checksum_unique` on `scenes` (`checksum`); CREATE INDEX `index_scenes_on_studio_id` on `scenes` (`studio_id`); -- remove performer image CREATE TABLE `performers` ( `id` integer not null primary key autoincrement, `checksum` varchar(255) not null, `name` varchar(255), `gender` varchar(20), `url` varchar(255), `twitter` varchar(255), `instagram` varchar(255), `birthdate` date, `ethnicity` varchar(255), `country` varchar(255), `eye_color` varchar(255), `height` varchar(255), `measurements` varchar(255), `fake_tits` varchar(255), `career_length` varchar(255), `tattoos` varchar(255), `piercings` varchar(255), `aliases` varchar(255), `favorite` boolean not null default '0', `created_at` datetime not null, `updated_at` datetime not null ); DROP INDEX `performers_checksum_unique`; DROP INDEX `index_performers_on_name`; CREATE UNIQUE INDEX `performers_checksum_unique` on `performers` (`checksum`); CREATE INDEX `index_performers_on_name` on `performers` (`name`); -- remove front_image and back_image CREATE TABLE `movies` ( `id` integer not null primary key autoincrement, `name` varchar(255) not null, `aliases` varchar(255), `duration` integer, `date` date, `rating` tinyint, `studio_id` integer, `director` varchar(255), `synopsis` text, `checksum` varchar(255) not null, `url` varchar(255), `created_at` datetime not null, `updated_at` datetime not null, foreign key(`studio_id`) references `studios`(`id`) on delete set null ); DROP INDEX `movies_name_unique`; DROP INDEX `movies_checksum_unique`; DROP INDEX `index_movies_on_studio_id`; CREATE UNIQUE INDEX `movies_name_unique` on `movies` (`name`); CREATE UNIQUE INDEX `movies_checksum_unique` on `movies` (`checksum`); CREATE INDEX `index_movies_on_studio_id` on `movies` (`studio_id`); -- recreate the tables referencing the above tables to correct their references ALTER TABLE `galleries` rename to `_galleries_old`; ALTER TABLE `performers_scenes` rename to `_performers_scenes_old`; ALTER TABLE `scene_markers` rename to `_scene_markers_old`; ALTER TABLE `scene_markers_tags` rename to `_scene_markers_tags_old`; ALTER TABLE `scenes_tags` rename to `_scenes_tags_old`; ALTER TABLE `movies_scenes` rename to `_movies_scenes_old`; ALTER TABLE `scraped_items` rename to `_scraped_items_old`; CREATE TABLE `galleries` ( `id` integer not null primary key autoincrement, `path` varchar(510) not null, `checksum` varchar(255) not null, `scene_id` integer, `created_at` datetime not null, `updated_at` datetime not null, foreign key(`scene_id`) references `scenes`(`id`) ); DROP INDEX IF EXISTS `index_galleries_on_scene_id`; DROP INDEX IF EXISTS `galleries_path_unique`; DROP INDEX IF EXISTS `galleries_checksum_unique`; CREATE INDEX `index_galleries_on_scene_id` on `galleries` (`scene_id`); CREATE UNIQUE INDEX `galleries_path_unique` on `galleries` (`path`); CREATE UNIQUE INDEX `galleries_checksum_unique` on `galleries` (`checksum`); CREATE TABLE `performers_scenes` ( `performer_id` integer, `scene_id` integer, foreign key(`performer_id`) references `performers`(`id`), foreign key(`scene_id`) references `scenes`(`id`) ); DROP INDEX `index_performers_scenes_on_scene_id`; DROP INDEX `index_performers_scenes_on_performer_id`; CREATE INDEX `index_performers_scenes_on_scene_id` on `performers_scenes` (`scene_id`); CREATE INDEX `index_performers_scenes_on_performer_id` on `performers_scenes` (`performer_id`); CREATE TABLE `scene_markers` ( `id` integer not null primary key autoincrement, `title` varchar(255) not null, `seconds` float not null, `primary_tag_id` integer not null, `scene_id` integer, `created_at` datetime not null, `updated_at` datetime not null, foreign key(`primary_tag_id`) references `tags`(`id`), foreign key(`scene_id`) references `scenes`(`id`) ); DROP INDEX `index_scene_markers_on_scene_id`; DROP INDEX `index_scene_markers_on_primary_tag_id`; CREATE INDEX `index_scene_markers_on_scene_id` on `scene_markers` (`scene_id`); CREATE INDEX `index_scene_markers_on_primary_tag_id` on `scene_markers` (`primary_tag_id`); CREATE TABLE `scene_markers_tags` ( `scene_marker_id` integer, `tag_id` integer, foreign key(`scene_marker_id`) references `scene_markers`(`id`) on delete CASCADE, foreign key(`tag_id`) references `tags`(`id`) ); DROP INDEX `index_scene_markers_tags_on_tag_id`; DROP INDEX `index_scene_markers_tags_on_scene_marker_id`; CREATE INDEX `index_scene_markers_tags_on_tag_id` on `scene_markers_tags` (`tag_id`); CREATE INDEX `index_scene_markers_tags_on_scene_marker_id` on `scene_markers_tags` (`scene_marker_id`); CREATE TABLE `scenes_tags` ( `scene_id` integer, `tag_id` integer, foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE, foreign key(`tag_id`) references `tags`(`id`) ); DROP INDEX `index_scenes_tags_on_tag_id`; DROP INDEX `index_scenes_tags_on_scene_id`; CREATE INDEX `index_scenes_tags_on_tag_id` on `scenes_tags` (`tag_id`); CREATE INDEX `index_scenes_tags_on_scene_id` on `scenes_tags` (`scene_id`); CREATE TABLE `movies_scenes` ( `movie_id` integer, `scene_id` integer, `scene_index` tinyint, foreign key(`movie_id`) references `movies`(`id`) on delete cascade, foreign key(`scene_id`) references `scenes`(`id`) on delete cascade ); DROP INDEX `index_movies_scenes_on_movie_id`; DROP INDEX `index_movies_scenes_on_scene_id`; CREATE INDEX `index_movies_scenes_on_movie_id` on `movies_scenes` (`movie_id`); CREATE INDEX `index_movies_scenes_on_scene_id` on `movies_scenes` (`scene_id`); -- remove movie_id since doesn't appear to be used CREATE TABLE `scraped_items` ( `id` integer not null primary key autoincrement, `title` varchar(255), `description` text, `url` varchar(255), `date` date, `rating` varchar(255), `tags` varchar(510), `models` varchar(510), `episode` integer, `gallery_filename` varchar(255), `gallery_url` varchar(510), `video_filename` varchar(255), `video_url` varchar(255), `studio_id` integer, `created_at` datetime not null, `updated_at` datetime not null, foreign key(`studio_id`) references `studios`(`id`) ); DROP INDEX `index_scraped_items_on_studio_id`; CREATE INDEX `index_scraped_items_on_studio_id` on `scraped_items` (`studio_id`); -- now populate from the old tables -- these tables are changed so require the full column def INSERT INTO `studios` ( `id`, `checksum`, `name`, `url`, `parent_id`, `created_at`, `updated_at` ) SELECT `id`, `checksum`, `name`, `url`, `parent_id`, `created_at`, `updated_at` FROM `_studios_old`; INSERT INTO `scenes` ( `id`, `path`, `checksum`, `title`, `details`, `url`, `date`, `rating`, `size`, `duration`, `video_codec`, `audio_codec`, `width`, `height`, `framerate`, `bitrate`, `studio_id`, `o_counter`, `format`, `created_at`, `updated_at` ) SELECT `id`, `path`, `checksum`, `title`, `details`, `url`, `date`, `rating`, `size`, `duration`, `video_codec`, `audio_codec`, `width`, `height`, `framerate`, `bitrate`, `studio_id`, `o_counter`, `format`, `created_at`, `updated_at` FROM `_scenes_old`; INSERT INTO `performers` ( `id`, `checksum`, `name`, `gender`, `url`, `twitter`, `instagram`, `birthdate`, `ethnicity`, `country`, `eye_color`, `height`, `measurements`, `fake_tits`, `career_length`, `tattoos`, `piercings`, `aliases`, `favorite`, `created_at`, `updated_at` ) SELECT `id`, `checksum`, `name`, `gender`, `url`, `twitter`, `instagram`, `birthdate`, `ethnicity`, `country`, `eye_color`, `height`, `measurements`, `fake_tits`, `career_length`, `tattoos`, `piercings`, `aliases`, `favorite`, `created_at`, `updated_at` FROM `_performers_old`; INSERT INTO `movies` ( `id`, `name`, `aliases`, `duration`, `date`, `rating`, `studio_id`, `director`, `synopsis`, `checksum`, `url`, `created_at`, `updated_at` ) SELECT `id`, `name`, `aliases`, `duration`, `date`, `rating`, `studio_id`, `director`, `synopsis`, `checksum`, `url`, `created_at`, `updated_at` FROM `_movies_old`; INSERT INTO `scraped_items` ( `id`, `title`, `description`, `url`, `date`, `rating`, `tags`, `models`, `episode`, `gallery_filename`, `gallery_url`, `video_filename`, `video_url`, `studio_id`, `created_at`, `updated_at` ) SELECT `id`, `title`, `description`, `url`, `date`, `rating`, `tags`, `models`, `episode`, `gallery_filename`, `gallery_url`, `video_filename`, `video_url`, `studio_id`, `created_at`, `updated_at` FROM `_scraped_items_old`; -- these tables are a direct copy INSERT INTO `galleries` SELECT * from `_galleries_old`; INSERT INTO `performers_scenes` SELECT * from `_performers_scenes_old`; INSERT INTO `scene_markers` SELECT * from `_scene_markers_old`; INSERT INTO `scene_markers_tags` SELECT * from `_scene_markers_tags_old`; INSERT INTO `scenes_tags` SELECT * from `_scenes_tags_old`; INSERT INTO `movies_scenes` SELECT * from `_movies_scenes_old`; -- populate covers in separate table CREATE TABLE `scenes_cover` ( `scene_id` integer, `cover` blob not null, foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE ); CREATE UNIQUE INDEX `index_scene_covers_on_scene_id` on `scenes_cover` (`scene_id`); INSERT INTO `scenes_cover` ( `scene_id`, `cover` ) SELECT `id`, `cover` from `_scenes_old` where `cover` is not null; -- put performer images in separate table CREATE TABLE `performers_image` ( `performer_id` integer, `image` blob not null, foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE ); CREATE UNIQUE INDEX `index_performer_image_on_performer_id` on `performers_image` (`performer_id`); INSERT INTO `performers_image` ( `performer_id`, `image` ) SELECT `id`, `image` from `_performers_old` where `image` is not null; -- put studio images in separate table CREATE TABLE `studios_image` ( `studio_id` integer, `image` blob not null, foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE ); CREATE UNIQUE INDEX `index_studio_image_on_studio_id` on `studios_image` (`studio_id`); INSERT INTO `studios_image` ( `studio_id`, `image` ) SELECT `id`, `image` from `_studios_old` where `image` is not null; -- put movie images in separate table CREATE TABLE `movies_images` ( `movie_id` integer, `front_image` blob not null, `back_image` blob, foreign key(`movie_id`) references `movies`(`id`) on delete CASCADE ); CREATE UNIQUE INDEX `index_movie_images_on_movie_id` on `movies_images` (`movie_id`); INSERT INTO `movies_images` ( `movie_id`, `front_image`, `back_image` ) SELECT `id`, `front_image`, `back_image` from `_movies_old` where `front_image` is not null; -- drop old tables DROP TABLE `_scenes_old`; DROP TABLE `_studios_old`; DROP TABLE `_performers_old`; DROP TABLE `_movies_old`; DROP TABLE `_galleries_old`; DROP TABLE `_performers_scenes_old`; DROP TABLE `_scene_markers_old`; DROP TABLE `_scene_markers_tags_old`; DROP TABLE `_scenes_tags_old`; DROP TABLE `_movies_scenes_old`; DROP TABLE `_scraped_items_old`; ================================================ FILE: pkg/sqlite/migrations/11_tag_image.up.sql ================================================ CREATE TABLE `tags_image` ( `tag_id` integer, `image` blob not null, foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE ); CREATE UNIQUE INDEX `index_tag_image_on_tag_id` on `tags_image` (`tag_id`); ================================================ FILE: pkg/sqlite/migrations/12_oshash.up.sql ================================================ -- need to change scenes.checksum to be nullable ALTER TABLE `scenes` rename to `_scenes_old`; CREATE TABLE `scenes` ( `id` integer not null primary key autoincrement, `path` varchar(510) not null, -- nullable `checksum` varchar(255), -- add oshash `oshash` varchar(255), `title` varchar(255), `details` text, `url` varchar(255), `date` date, `rating` tinyint, `size` varchar(255), `duration` float, `video_codec` varchar(255), `audio_codec` varchar(255), `width` tinyint, `height` tinyint, `framerate` float, `bitrate` integer, `studio_id` integer, `o_counter` tinyint not null default 0, `format` varchar(255), `created_at` datetime not null, `updated_at` datetime not null, foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL, -- add check to ensure at least one hash is set CHECK (`checksum` is not null or `oshash` is not null) ); DROP INDEX IF EXISTS `scenes_path_unique`; DROP INDEX IF EXISTS `scenes_checksum_unique`; DROP INDEX IF EXISTS `index_scenes_on_studio_id`; CREATE UNIQUE INDEX `scenes_path_unique` on `scenes` (`path`); CREATE UNIQUE INDEX `scenes_checksum_unique` on `scenes` (`checksum`); CREATE UNIQUE INDEX `scenes_oshash_unique` on `scenes` (`oshash`); CREATE INDEX `index_scenes_on_studio_id` on `scenes` (`studio_id`); -- recreate the tables referencing scenes to correct their references ALTER TABLE `galleries` rename to `_galleries_old`; ALTER TABLE `performers_scenes` rename to `_performers_scenes_old`; ALTER TABLE `scene_markers` rename to `_scene_markers_old`; ALTER TABLE `scene_markers_tags` rename to `_scene_markers_tags_old`; ALTER TABLE `scenes_tags` rename to `_scenes_tags_old`; ALTER TABLE `movies_scenes` rename to `_movies_scenes_old`; ALTER TABLE `scenes_cover` rename to `_scenes_cover_old`; CREATE TABLE `galleries` ( `id` integer not null primary key autoincrement, `path` varchar(510) not null, `checksum` varchar(255) not null, `scene_id` integer, `created_at` datetime not null, `updated_at` datetime not null, foreign key(`scene_id`) references `scenes`(`id`) ); DROP INDEX IF EXISTS `index_galleries_on_scene_id`; DROP INDEX IF EXISTS `galleries_path_unique`; DROP INDEX IF EXISTS `galleries_checksum_unique`; CREATE INDEX `index_galleries_on_scene_id` on `galleries` (`scene_id`); CREATE UNIQUE INDEX `galleries_path_unique` on `galleries` (`path`); CREATE UNIQUE INDEX `galleries_checksum_unique` on `galleries` (`checksum`); CREATE TABLE `performers_scenes` ( `performer_id` integer, `scene_id` integer, foreign key(`performer_id`) references `performers`(`id`), foreign key(`scene_id`) references `scenes`(`id`) ); DROP INDEX `index_performers_scenes_on_scene_id`; DROP INDEX `index_performers_scenes_on_performer_id`; CREATE INDEX `index_performers_scenes_on_scene_id` on `performers_scenes` (`scene_id`); CREATE INDEX `index_performers_scenes_on_performer_id` on `performers_scenes` (`performer_id`); CREATE TABLE `scene_markers` ( `id` integer not null primary key autoincrement, `title` varchar(255) not null, `seconds` float not null, `primary_tag_id` integer not null, `scene_id` integer, `created_at` datetime not null, `updated_at` datetime not null, foreign key(`primary_tag_id`) references `tags`(`id`), foreign key(`scene_id`) references `scenes`(`id`) ); DROP INDEX `index_scene_markers_on_scene_id`; DROP INDEX `index_scene_markers_on_primary_tag_id`; CREATE INDEX `index_scene_markers_on_scene_id` on `scene_markers` (`scene_id`); CREATE INDEX `index_scene_markers_on_primary_tag_id` on `scene_markers` (`primary_tag_id`); CREATE TABLE `scene_markers_tags` ( `scene_marker_id` integer, `tag_id` integer, foreign key(`scene_marker_id`) references `scene_markers`(`id`) on delete CASCADE, foreign key(`tag_id`) references `tags`(`id`) ); DROP INDEX `index_scene_markers_tags_on_tag_id`; DROP INDEX `index_scene_markers_tags_on_scene_marker_id`; CREATE INDEX `index_scene_markers_tags_on_tag_id` on `scene_markers_tags` (`tag_id`); CREATE INDEX `index_scene_markers_tags_on_scene_marker_id` on `scene_markers_tags` (`scene_marker_id`); CREATE TABLE `scenes_tags` ( `scene_id` integer, `tag_id` integer, foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE, foreign key(`tag_id`) references `tags`(`id`) ); DROP INDEX `index_scenes_tags_on_tag_id`; DROP INDEX `index_scenes_tags_on_scene_id`; CREATE INDEX `index_scenes_tags_on_tag_id` on `scenes_tags` (`tag_id`); CREATE INDEX `index_scenes_tags_on_scene_id` on `scenes_tags` (`scene_id`); CREATE TABLE `movies_scenes` ( `movie_id` integer, `scene_id` integer, `scene_index` tinyint, foreign key(`movie_id`) references `movies`(`id`) on delete cascade, foreign key(`scene_id`) references `scenes`(`id`) on delete cascade ); DROP INDEX `index_movies_scenes_on_movie_id`; DROP INDEX `index_movies_scenes_on_scene_id`; CREATE INDEX `index_movies_scenes_on_movie_id` on `movies_scenes` (`movie_id`); CREATE INDEX `index_movies_scenes_on_scene_id` on `movies_scenes` (`scene_id`); CREATE TABLE `scenes_cover` ( `scene_id` integer, `cover` blob not null, foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE ); DROP INDEX `index_scene_covers_on_scene_id`; CREATE UNIQUE INDEX `index_scene_covers_on_scene_id` on `scenes_cover` (`scene_id`); -- now populate from the old tables -- these tables are changed so require the full column def INSERT INTO `scenes` ( `id`, `path`, `checksum`, `title`, `details`, `url`, `date`, `rating`, `size`, `duration`, `video_codec`, `audio_codec`, `width`, `height`, `framerate`, `bitrate`, `studio_id`, `o_counter`, `format`, `created_at`, `updated_at` ) SELECT `id`, `path`, `checksum`, `title`, `details`, `url`, `date`, `rating`, `size`, `duration`, `video_codec`, `audio_codec`, `width`, `height`, `framerate`, `bitrate`, `studio_id`, `o_counter`, `format`, `created_at`, `updated_at` FROM `_scenes_old`; -- these tables are a direct copy INSERT INTO `galleries` SELECT * from `_galleries_old`; INSERT INTO `performers_scenes` SELECT * from `_performers_scenes_old`; INSERT INTO `scene_markers` SELECT * from `_scene_markers_old`; INSERT INTO `scene_markers_tags` SELECT * from `_scene_markers_tags_old`; INSERT INTO `scenes_tags` SELECT * from `_scenes_tags_old`; INSERT INTO `movies_scenes` SELECT * from `_movies_scenes_old`; INSERT INTO `scenes_cover` SELECT * from `_scenes_cover_old`; -- drop old tables DROP TABLE `_scenes_old`; DROP TABLE `_galleries_old`; DROP TABLE `_performers_scenes_old`; DROP TABLE `_scene_markers_old`; DROP TABLE `_scene_markers_tags_old`; DROP TABLE `_scenes_tags_old`; DROP TABLE `_movies_scenes_old`; DROP TABLE `_scenes_cover_old`; ================================================ FILE: pkg/sqlite/migrations/12_postmigrate.go ================================================ package migrations import ( "context" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sqlite" ) func post12(ctx context.Context, db *sqlx.DB) error { m := schema12Migrator{ migrator: migrator{ db: db, }, } return m.migrateConfig(ctx) } type schema12Migrator struct { migrator } func (m *schema12Migrator) migrateConfig(ctx context.Context) error { // if there are no scene files in the database, then default the // VideoFileNamingAlgorithm config setting to oshash and calculateMD5 to // false, otherwise set them to true for backwards compatibility purposes var count int if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { query := "SELECT COUNT(*) from `scenes`" return tx.Get(&count, query) }); err != nil { return err } usingMD5 := count != 0 defaultAlgorithm := models.HashAlgorithmOshash if usingMD5 { logger.Infof("Defaulting video file naming algorithm to %s", models.HashAlgorithmMd5) defaultAlgorithm = models.HashAlgorithmMd5 } c := config.GetInstance() c.SetDefault(config.VideoFileNamingAlgorithm, defaultAlgorithm) c.SetDefault(config.CalculateMD5, usingMD5) if err := c.Write(); err != nil { logger.Errorf("Error while writing configuration file: %s", err.Error()) } return nil } func init() { sqlite.RegisterPostMigration(12, post12) } ================================================ FILE: pkg/sqlite/migrations/13_images.up.sql ================================================ CREATE TABLE `images` ( `id` integer not null primary key autoincrement, `path` varchar(510) not null, `checksum` varchar(255) not null, `title` varchar(255), `rating` tinyint, `size` integer, `width` tinyint, `height` tinyint, `studio_id` integer, `o_counter` tinyint not null default 0, `created_at` datetime not null, `updated_at` datetime not null, foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL ); CREATE INDEX `index_images_on_studio_id` on `images` (`studio_id`); CREATE TABLE `performers_images` ( `performer_id` integer, `image_id` integer, foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE, foreign key(`image_id`) references `images`(`id`) on delete CASCADE ); CREATE INDEX `index_performers_images_on_image_id` on `performers_images` (`image_id`); CREATE INDEX `index_performers_images_on_performer_id` on `performers_images` (`performer_id`); CREATE TABLE `images_tags` ( `image_id` integer, `tag_id` integer, foreign key(`image_id`) references `images`(`id`) on delete CASCADE, foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE ); CREATE INDEX `index_images_tags_on_tag_id` on `images_tags` (`tag_id`); CREATE INDEX `index_images_tags_on_image_id` on `images_tags` (`image_id`); -- need to recreate galleries to add foreign key ALTER TABLE `galleries` rename to `_galleries_old`; CREATE TABLE `galleries` ( `id` integer not null primary key autoincrement, `path` varchar(510), `checksum` varchar(255) not null, `zip` boolean not null default '0', `title` varchar(255), `url` varchar(255), `date` date, `details` text, `studio_id` integer, `rating` tinyint, `scene_id` integer, `created_at` datetime not null, `updated_at` datetime not null, foreign key(`scene_id`) references `scenes`(`id`) on delete SET NULL, foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL ); DROP INDEX IF EXISTS `index_galleries_on_scene_id`; DROP INDEX IF EXISTS `galleries_path_unique`; DROP INDEX IF EXISTS `galleries_checksum_unique`; CREATE INDEX `index_galleries_on_scene_id` on `galleries` (`scene_id`); CREATE UNIQUE INDEX `galleries_path_unique` on `galleries` (`path`); CREATE UNIQUE INDEX `galleries_checksum_unique` on `galleries` (`checksum`); CREATE INDEX `index_galleries_on_studio_id` on `galleries` (`studio_id`); CREATE TABLE `galleries_images` ( `gallery_id` integer, `image_id` integer, foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE, foreign key(`image_id`) references `images`(`id`) on delete CASCADE ); CREATE INDEX `index_galleries_images_on_image_id` on `galleries_images` (`image_id`); CREATE INDEX `index_galleries_images_on_gallery_id` on `galleries_images` (`gallery_id`); CREATE TABLE `performers_galleries` ( `performer_id` integer, `gallery_id` integer, foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE, foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE ); CREATE INDEX `index_performers_galleries_on_gallery_id` on `performers_galleries` (`gallery_id`); CREATE INDEX `index_performers_galleries_on_performer_id` on `performers_galleries` (`performer_id`); CREATE TABLE `galleries_tags` ( `gallery_id` integer, `tag_id` integer, foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE, foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE ); CREATE INDEX `index_galleries_tags_on_tag_id` on `galleries_tags` (`tag_id`); CREATE INDEX `index_galleries_tags_on_gallery_id` on `galleries_tags` (`gallery_id`); INSERT INTO `galleries` ( `id`, `path`, `checksum`, `scene_id`, `created_at`, `updated_at` ) SELECT `id`, `path`, `checksum`, `scene_id`, `created_at`, `updated_at` FROM `_galleries_old`; DROP TABLE `_galleries_old`; ================================================ FILE: pkg/sqlite/migrations/14_stash_box_ids.up.sql ================================================ CREATE TABLE `scene_stash_ids` ( `scene_id` integer, `endpoint` varchar(255), `stash_id` varchar(36), foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE ); CREATE TABLE `performer_stash_ids` ( `performer_id` integer, `endpoint` varchar(255), `stash_id` varchar(36), foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE ); CREATE TABLE `studio_stash_ids` ( `studio_id` integer, `endpoint` varchar(255), `stash_id` varchar(36), foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE ); ================================================ FILE: pkg/sqlite/migrations/15_file_mod_time.up.sql ================================================ ALTER TABLE `scenes` ADD COLUMN `file_mod_time` datetime; ALTER TABLE `images` ADD COLUMN `file_mod_time` datetime; ALTER TABLE `galleries` ADD COLUMN `file_mod_time` datetime; ================================================ FILE: pkg/sqlite/migrations/16_organized_flag.up.sql ================================================ ALTER TABLE `scenes` ADD COLUMN `organized` boolean not null default '0'; ALTER TABLE `images` ADD COLUMN `organized` boolean not null default '0'; ALTER TABLE `galleries` ADD COLUMN `organized` boolean not null default '0'; ================================================ FILE: pkg/sqlite/migrations/17_reset_scene_size.up.sql ================================================ UPDATE `scenes` SET `size` = NULL; ================================================ FILE: pkg/sqlite/migrations/18_scene_galleries.up.sql ================================================ -- recreate the tables referencing galleries to correct their references ALTER TABLE `galleries` rename to `_galleries_old`; ALTER TABLE `galleries_images` rename to `_galleries_images_old`; ALTER TABLE `galleries_tags` rename to `_galleries_tags_old`; ALTER TABLE `performers_galleries` rename to `_performers_galleries_old`; CREATE TABLE `galleries` ( `id` integer not null primary key autoincrement, `path` varchar(510), `checksum` varchar(255) not null, `zip` boolean not null default '0', `title` varchar(255), `url` varchar(255), `date` date, `details` text, `studio_id` integer, `rating` tinyint, `file_mod_time` datetime, `organized` boolean not null default '0', `created_at` datetime not null, `updated_at` datetime not null, foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL ); DROP INDEX IF EXISTS `index_galleries_on_scene_id`; DROP INDEX IF EXISTS `galleries_path_unique`; DROP INDEX IF EXISTS `galleries_checksum_unique`; DROP INDEX IF EXISTS `index_galleries_on_studio_id`; CREATE UNIQUE INDEX `galleries_path_unique` on `galleries` (`path`); CREATE UNIQUE INDEX `galleries_checksum_unique` on `galleries` (`checksum`); CREATE INDEX `index_galleries_on_studio_id` on `galleries` (`studio_id`); CREATE TABLE `scenes_galleries` ( `scene_id` integer, `gallery_id` integer, foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE, foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE ); CREATE INDEX `index_scenes_galleries_on_scene_id` on `scenes_galleries` (`scene_id`); CREATE INDEX `index_scenes_galleries_on_gallery_id` on `scenes_galleries` (`gallery_id`); CREATE TABLE `galleries_images` ( `gallery_id` integer, `image_id` integer, foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE, foreign key(`image_id`) references `images`(`id`) on delete CASCADE ); DROP INDEX IF EXISTS `index_galleries_images_on_image_id`; DROP INDEX IF EXISTS `index_galleries_images_on_gallery_id`; CREATE INDEX `index_galleries_images_on_image_id` on `galleries_images` (`image_id`); CREATE INDEX `index_galleries_images_on_gallery_id` on `galleries_images` (`gallery_id`); CREATE TABLE `performers_galleries` ( `performer_id` integer, `gallery_id` integer, foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE, foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE ); DROP INDEX IF EXISTS `index_performers_galleries_on_gallery_id`; DROP INDEX IF EXISTS `index_performers_galleries_on_performer_id`; CREATE INDEX `index_performers_galleries_on_gallery_id` on `performers_galleries` (`gallery_id`); CREATE INDEX `index_performers_galleries_on_performer_id` on `performers_galleries` (`performer_id`); CREATE TABLE `galleries_tags` ( `gallery_id` integer, `tag_id` integer, foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE, foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE ); DROP INDEX IF EXISTS `index_galleries_tags_on_tag_id`; DROP INDEX IF EXISTS `index_galleries_tags_on_gallery_id`; CREATE INDEX `index_galleries_tags_on_tag_id` on `galleries_tags` (`tag_id`); CREATE INDEX `index_galleries_tags_on_gallery_id` on `galleries_tags` (`gallery_id`); -- populate from the old tables INSERT INTO `galleries` ( `id`, `path`, `checksum`, `zip`, `title`, `url`, `date`, `details`, `studio_id`, `rating`, `file_mod_time`, `organized`, `created_at`, `updated_at` ) SELECT `id`, `path`, `checksum`, `zip`, `title`, `url`, `date`, `details`, `studio_id`, `rating`, `file_mod_time`, `organized`, `created_at`, `updated_at` FROM `_galleries_old`; INSERT INTO `scenes_galleries` ( `scene_id`, `gallery_id` ) SELECT `scene_id`, `id` FROM `_galleries_old` WHERE scene_id IS NOT NULL; -- these tables are a direct copy INSERT INTO `galleries_images` SELECT * from `_galleries_images_old`; INSERT INTO `galleries_tags` SELECT * from `_galleries_tags_old`; INSERT INTO `performers_galleries` SELECT * from `_performers_galleries_old`; -- drop old tables DROP TABLE `_galleries_old`; DROP TABLE `_galleries_images_old`; DROP TABLE `_galleries_tags_old`; DROP TABLE `_performers_galleries_old`; ================================================ FILE: pkg/sqlite/migrations/19_performer_tags.up.sql ================================================ CREATE TABLE `performers_tags` ( `performer_id` integer NOT NULL, `tag_id` integer NOT NULL, foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE, foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE ); CREATE INDEX `index_performers_tags_on_tag_id` on `performers_tags` (`tag_id`); CREATE INDEX `index_performers_tags_on_performer_id` on `performers_tags` (`performer_id`); ================================================ FILE: pkg/sqlite/migrations/1_initial.down.sql ================================================ DROP TABLE IF EXISTS scenes; ================================================ FILE: pkg/sqlite/migrations/1_initial.up.sql ================================================ CREATE TABLE `tags` ( `id` integer not null primary key autoincrement, `name` varchar(255), `created_at` datetime not null, `updated_at` datetime not null ); CREATE TABLE `studios` ( `id` integer not null primary key autoincrement, `image` blob not null, `checksum` varchar(255) not null, `name` varchar(255), `url` varchar(255), `created_at` datetime not null, `updated_at` datetime not null ); CREATE TABLE `scraped_items` ( `id` integer not null primary key autoincrement, `title` varchar(255), `description` text, `url` varchar(255), `date` date, `rating` varchar(255), `tags` varchar(510), `models` varchar(510), `episode` integer, `gallery_filename` varchar(255), `gallery_url` varchar(510), `video_filename` varchar(255), `video_url` varchar(255), `studio_id` integer, `created_at` datetime not null, `updated_at` datetime not null, foreign key(`studio_id`) references `studios`(`id`) ); CREATE TABLE `scenes_tags` ( `scene_id` integer, `tag_id` integer, foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE, foreign key(`tag_id`) references `tags`(`id`) ); CREATE TABLE `scenes` ( `id` integer not null primary key autoincrement, `path` varchar(510) not null, `checksum` varchar(255) not null, `title` varchar(255), `details` text, `url` varchar(255), `date` date, `rating` tinyint, `size` varchar(255), `duration` float, `video_codec` varchar(255), `audio_codec` varchar(255), `width` tinyint, `height` tinyint, `framerate` float, `bitrate` integer, `studio_id` integer, `created_at` datetime not null, `updated_at` datetime not null, foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE ); CREATE TABLE `scene_markers_tags` ( `scene_marker_id` integer, `tag_id` integer, foreign key(`scene_marker_id`) references `scene_markers`(`id`) on delete CASCADE, foreign key(`tag_id`) references `tags`(`id`) ); CREATE TABLE `scene_markers` ( `id` integer not null primary key autoincrement, `title` varchar(255) not null, `seconds` float not null, `primary_tag_id` integer not null, `scene_id` integer, `created_at` datetime not null, `updated_at` datetime not null, foreign key(`primary_tag_id`) references `tags`(`id`), foreign key(`scene_id`) references `scenes`(`id`) ); CREATE TABLE `performers_scenes` ( `performer_id` integer, `scene_id` integer, foreign key(`performer_id`) references `performers`(`id`), foreign key(`scene_id`) references `scenes`(`id`) ); CREATE TABLE `performers` ( `id` integer not null primary key autoincrement, `image` blob not null, `checksum` varchar(255) not null, `name` varchar(255), `url` varchar(255), `twitter` varchar(255), `instagram` varchar(255), `birthdate` date, `ethnicity` varchar(255), `country` varchar(255), `eye_color` varchar(255), `height` varchar(255), `measurements` varchar(255), `fake_tits` varchar(255), `career_length` varchar(255), `tattoos` varchar(255), `piercings` varchar(255), `aliases` varchar(255), `favorite` boolean not null default '0', `created_at` datetime not null, `updated_at` datetime not null ); CREATE TABLE `galleries` ( `id` integer not null primary key autoincrement, `path` varchar(510) not null, `checksum` varchar(255) not null, `scene_id` integer, `created_at` datetime not null, `updated_at` datetime not null, foreign key(`scene_id`) references `scenes`(`id`) ); CREATE UNIQUE INDEX `studios_checksum_unique` on `studios` (`checksum`); CREATE UNIQUE INDEX `scenes_path_unique` on `scenes` (`path`); CREATE UNIQUE INDEX `scenes_checksum_unique` on `scenes` (`checksum`); CREATE UNIQUE INDEX `performers_checksum_unique` on `performers` (`checksum`); CREATE INDEX `index_tags_on_name` on `tags` (`name`); CREATE INDEX `index_studios_on_name` on `studios` (`name`); CREATE INDEX `index_studios_on_checksum` on `studios` (`checksum`); CREATE INDEX `index_scraped_items_on_studio_id` on `scraped_items` (`studio_id`); CREATE INDEX `index_scenes_tags_on_tag_id` on `scenes_tags` (`tag_id`); CREATE INDEX `index_scenes_tags_on_scene_id` on `scenes_tags` (`scene_id`); CREATE INDEX `index_scenes_on_studio_id` on `scenes` (`studio_id`); CREATE INDEX `index_scene_markers_tags_on_tag_id` on `scene_markers_tags` (`tag_id`); CREATE INDEX `index_scene_markers_tags_on_scene_marker_id` on `scene_markers_tags` (`scene_marker_id`); CREATE INDEX `index_scene_markers_on_scene_id` on `scene_markers` (`scene_id`); CREATE INDEX `index_scene_markers_on_primary_tag_id` on `scene_markers` (`primary_tag_id`); CREATE INDEX `index_performers_scenes_on_scene_id` on `performers_scenes` (`scene_id`); CREATE INDEX `index_performers_scenes_on_performer_id` on `performers_scenes` (`performer_id`); CREATE INDEX `index_performers_on_name` on `performers` (`name`); CREATE INDEX `index_performers_on_checksum` on `performers` (`checksum`); CREATE INDEX `index_galleries_on_scene_id` on `galleries` (`scene_id`); CREATE UNIQUE INDEX `galleries_path_unique` on `galleries` (`path`); CREATE UNIQUE INDEX `galleries_checksum_unique` on `galleries` (`checksum`); ================================================ FILE: pkg/sqlite/migrations/20_phash.up.sql ================================================ ALTER TABLE `scenes` ADD COLUMN `phash` blob; ================================================ FILE: pkg/sqlite/migrations/21_performers_studios_details.up.sql ================================================ ALTER TABLE `performers` ADD COLUMN `details` text; ALTER TABLE `performers` ADD COLUMN `death_date` date; ALTER TABLE `performers` ADD COLUMN `hair_color` varchar(255); ALTER TABLE `performers` ADD COLUMN `weight` integer; ALTER TABLE `studios` ADD COLUMN `details` text; ================================================ FILE: pkg/sqlite/migrations/22_performers_studios_rating.up.sql ================================================ ALTER TABLE `performers` ADD COLUMN `rating` tinyint; ALTER TABLE `studios` ADD COLUMN `rating` tinyint; ================================================ FILE: pkg/sqlite/migrations/23_scenes_interactive.up.sql ================================================ ALTER TABLE `scenes` ADD COLUMN `interactive` boolean not null default '0'; ================================================ FILE: pkg/sqlite/migrations/24_tag_aliases.up.sql ================================================ CREATE TABLE `tag_aliases` ( `tag_id` integer, `alias` varchar(255) NOT NULL, foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE ); CREATE UNIQUE INDEX `tag_aliases_alias_unique` on `tag_aliases` (`alias`); ================================================ FILE: pkg/sqlite/migrations/25_saved_filters.up.sql ================================================ CREATE TABLE `saved_filters` ( `id` integer not null primary key autoincrement, `name` varchar(510) not null, `mode` varchar(255) not null, `filter` blob not null ); CREATE UNIQUE INDEX `index_saved_filters_on_mode_name_unique` on `saved_filters` (`mode`, `name`); ================================================ FILE: pkg/sqlite/migrations/26_tag_hierarchy.up.sql ================================================ CREATE TABLE tags_relations ( parent_id integer, child_id integer, primary key (parent_id, child_id), foreign key (parent_id) references tags(id) on delete cascade, foreign key (child_id) references tags(id) on delete cascade ); ================================================ FILE: pkg/sqlite/migrations/27_studio_aliases.up.sql ================================================ CREATE TABLE `studio_aliases` ( `studio_id` integer, `alias` varchar(255) NOT NULL, foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE ); CREATE UNIQUE INDEX `studio_aliases_alias_unique` on `studio_aliases` (`alias`); ================================================ FILE: pkg/sqlite/migrations/28_images_indexes.up.sql ================================================ DROP INDEX IF EXISTS `images_path_unique`; CREATE UNIQUE INDEX `images_path_unique` ON `images` (`path`); ================================================ FILE: pkg/sqlite/migrations/29_interactive_speed.up.sql ================================================ ALTER TABLE `scenes` ADD COLUMN `interactive_speed` int ================================================ FILE: pkg/sqlite/migrations/2_cover_image.up.sql ================================================ ALTER TABLE `scenes` ADD COLUMN `cover` blob; ================================================ FILE: pkg/sqlite/migrations/30_ignore_autotag.up..sql ================================================ ALTER TABLE `performers` ADD COLUMN `ignore_auto_tag` boolean not null default '0'; ALTER TABLE `studios` ADD COLUMN `ignore_auto_tag` boolean not null default '0'; ALTER TABLE `tags` ADD COLUMN `ignore_auto_tag` boolean not null default '0'; ================================================ FILE: pkg/sqlite/migrations/31_scenes_captions.up.sql ================================================ CREATE TABLE `scene_captions` ( `scene_id` integer, `language_code` varchar(255) NOT NULL, `filename` varchar(255) NOT NULL, `caption_type` varchar(255) NOT NULL, primary key (`scene_id`, `language_code`, `caption_type`), foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE ); ================================================ FILE: pkg/sqlite/migrations/32_files.up.sql ================================================ -- folders may be deleted independently. Don't cascade CREATE TABLE `folders` ( `id` integer not null primary key autoincrement, `path` varchar(255) NOT NULL, `parent_folder_id` integer, `mod_time` datetime not null, `created_at` datetime not null, `updated_at` datetime not null, foreign key(`parent_folder_id`) references `folders`(`id`) on delete SET NULL ); CREATE INDEX `index_folders_on_parent_folder_id` on `folders` (`parent_folder_id`); CREATE UNIQUE INDEX `index_folders_on_path_unique` on `folders` (`path`); -- require reference folders/zip files to be deleted manually first CREATE TABLE `files` ( `id` integer not null primary key autoincrement, `basename` varchar(255) NOT NULL, `zip_file_id` integer, `parent_folder_id` integer not null, `size` integer NOT NULL, `mod_time` datetime not null, `created_at` datetime not null, `updated_at` datetime not null, foreign key(`parent_folder_id`) references `folders`(`id`), foreign key(`zip_file_id`) references `files`(`id`), CHECK (`basename` != '') ); CREATE UNIQUE INDEX `index_files_zip_basename_unique` ON `files` (`zip_file_id`, `parent_folder_id`, `basename`) WHERE `zip_file_id` IS NOT NULL; CREATE UNIQUE INDEX `index_files_on_parent_folder_id_basename_unique` on `files` (`parent_folder_id`, `basename`); CREATE INDEX `index_files_on_basename` on `files` (`basename`); ALTER TABLE `folders` ADD COLUMN `zip_file_id` integer REFERENCES `files`(`id`); CREATE INDEX `index_folders_on_zip_file_id` on `folders` (`zip_file_id`) WHERE `zip_file_id` IS NOT NULL; CREATE TABLE `files_fingerprints` ( `file_id` integer NOT NULL, `type` varchar(255) NOT NULL, `fingerprint` blob NOT NULL, foreign key(`file_id`) references `files`(`id`) on delete CASCADE, PRIMARY KEY (`file_id`, `type`, `fingerprint`) ); CREATE INDEX `index_fingerprint_type_fingerprint` ON `files_fingerprints` (`type`, `fingerprint`); CREATE TABLE `video_files` ( `file_id` integer NOT NULL primary key, `duration` float NOT NULL, `video_codec` varchar(255) NOT NULL, `format` varchar(255) NOT NULL, `audio_codec` varchar(255) NOT NULL, `width` tinyint NOT NULL, `height` tinyint NOT NULL, `frame_rate` float NOT NULL, `bit_rate` integer NOT NULL, `interactive` boolean not null default '0', `interactive_speed` int, foreign key(`file_id`) references `files`(`id`) on delete CASCADE ); CREATE TABLE `video_captions` ( `file_id` integer NOT NULL, `language_code` varchar(255) NOT NULL, `filename` varchar(255) NOT NULL, `caption_type` varchar(255) NOT NULL, primary key (`file_id`, `language_code`, `caption_type`), foreign key(`file_id`) references `video_files`(`file_id`) on delete CASCADE ); CREATE TABLE `image_files` ( `file_id` integer NOT NULL primary key, `format` varchar(255) NOT NULL, `width` tinyint NOT NULL, `height` tinyint NOT NULL, foreign key(`file_id`) references `files`(`id`) on delete CASCADE ); CREATE TABLE `images_files` ( `image_id` integer NOT NULL, `file_id` integer NOT NULL, `primary` boolean NOT NULL, foreign key(`image_id`) references `images`(`id`) on delete CASCADE, foreign key(`file_id`) references `files`(`id`) on delete CASCADE, PRIMARY KEY(`image_id`, `file_id`) ); CREATE INDEX `index_images_files_on_file_id` on `images_files` (`file_id`); CREATE UNIQUE INDEX `unique_index_images_files_on_primary` on `images_files` (`image_id`) WHERE `primary` = 1; CREATE TABLE `galleries_files` ( `gallery_id` integer NOT NULL, `file_id` integer NOT NULL, `primary` boolean NOT NULL, foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE, foreign key(`file_id`) references `files`(`id`) on delete CASCADE, PRIMARY KEY(`gallery_id`, `file_id`) ); CREATE INDEX `index_galleries_files_file_id` ON `galleries_files` (`file_id`); CREATE UNIQUE INDEX `unique_index_galleries_files_on_primary` on `galleries_files` (`gallery_id`) WHERE `primary` = 1; CREATE TABLE `scenes_files` ( `scene_id` integer NOT NULL, `file_id` integer NOT NULL, `primary` boolean NOT NULL, foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE, foreign key(`file_id`) references `files`(`id`) on delete CASCADE, PRIMARY KEY(`scene_id`, `file_id`) ); CREATE INDEX `index_scenes_files_file_id` ON `scenes_files` (`file_id`); CREATE UNIQUE INDEX `unique_index_scenes_files_on_primary` on `scenes_files` (`scene_id`) WHERE `primary` = 1; PRAGMA foreign_keys=OFF; CREATE TABLE `images_new` ( `id` integer not null primary key autoincrement, -- REMOVED: `path` varchar(510) not null, -- REMOVED: `checksum` varchar(255) not null, `title` varchar(255), `rating` tinyint, -- REMOVED: `size` integer, -- REMOVED: `width` tinyint, -- REMOVED: `height` tinyint, `studio_id` integer, `o_counter` tinyint not null default 0, `organized` boolean not null default '0', -- REMOVED: `file_mod_time` datetime, `created_at` datetime not null, `updated_at` datetime not null, foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL ); INSERT INTO `images_new` ( `id`, `title`, `rating`, `studio_id`, `o_counter`, `organized`, `created_at`, `updated_at` ) SELECT `id`, `title`, `rating`, `studio_id`, `o_counter`, `organized`, `created_at`, `updated_at` FROM `images`; -- create temporary placeholder folder INSERT INTO `folders` (`path`, `mod_time`, `created_at`, `updated_at`) VALUES ('', '1970-01-01 00:00:00', '1970-01-01 00:00:00', '1970-01-01 00:00:00'); -- insert image files - we will fix these up in the post-migration INSERT INTO `files` ( `basename`, `parent_folder_id`, `size`, `mod_time`, `created_at`, `updated_at` ) SELECT `path`, 1, -- special value if null so that it is recalculated COALESCE(`size`, -1), COALESCE(`file_mod_time`, '1970-01-01 00:00:00'), `created_at`, `updated_at` FROM `images`; INSERT INTO `image_files` ( `file_id`, `format`, `width`, `height` ) SELECT `files`.`id`, -- special values so that they are recalculated 'unset', COALESCE(`images`.`width`, -1), COALESCE(`images`.`height`, -1) FROM `images` INNER JOIN `files` ON `images`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1; INSERT INTO `images_files` ( `image_id`, `file_id`, `primary` ) SELECT `images`.`id`, `files`.`id`, 1 FROM `images` INNER JOIN `files` ON `images`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1; INSERT INTO `files_fingerprints` ( `file_id`, `type`, `fingerprint` ) SELECT `files`.`id`, 'md5', `images`.`checksum` FROM `images` INNER JOIN `files` ON `images`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1; DROP TABLE `images`; ALTER TABLE `images_new` rename to `images`; CREATE INDEX `index_images_on_studio_id` on `images` (`studio_id`); CREATE TABLE `galleries_new` ( `id` integer not null primary key autoincrement, -- REMOVED: `path` varchar(510), -- REMOVED: `checksum` varchar(255) not null, -- REMOVED: `zip` boolean not null default '0', `folder_id` integer, `title` varchar(255), `url` varchar(255), `date` date, `details` text, `studio_id` integer, `rating` tinyint, -- REMOVED: `file_mod_time` datetime, `organized` boolean not null default '0', `created_at` datetime not null, `updated_at` datetime not null, foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL, foreign key(`folder_id`) references `folders`(`id`) on delete SET NULL ); INSERT INTO `galleries_new` ( `id`, `title`, `url`, `date`, `details`, `studio_id`, `rating`, `organized`, `created_at`, `updated_at` ) SELECT `id`, `title`, `url`, `date`, `details`, `studio_id`, `rating`, `organized`, `created_at`, `updated_at` FROM `galleries`; -- insert gallery files - we will fix these up in the post-migration INSERT INTO `files` ( `basename`, `parent_folder_id`, `size`, `mod_time`, `created_at`, `updated_at` ) SELECT `path`, 1, -- special value so that it is recalculated -1, COALESCE(`file_mod_time`, '1970-01-01 00:00:00'), `created_at`, `updated_at` FROM `galleries` WHERE `galleries`.`path` IS NOT NULL AND `galleries`.`zip` = '1'; -- insert gallery zip folders - we will fix these up in the post-migration INSERT INTO `folders` ( `path`, `zip_file_id`, `mod_time`, `created_at`, `updated_at` ) SELECT `galleries`.`path`, `files`.`id`, '1970-01-01 00:00:00', `galleries`.`created_at`, `galleries`.`updated_at` FROM `galleries` INNER JOIN `files` ON `galleries`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1 WHERE `galleries`.`path` IS NOT NULL AND `galleries`.`zip` = '1'; -- set the zip file id of the zip folders UPDATE `folders` SET `zip_file_id` = (SELECT `files`.`id` FROM `files` WHERE `folders`.`path` = `files`.`basename`); -- insert gallery folders - we will fix these up in the post-migration INSERT INTO `folders` ( `path`, `mod_time`, `created_at`, `updated_at` ) SELECT `path`, '1970-01-01 00:00:00', `created_at`, `updated_at` FROM `galleries` WHERE `galleries`.`path` IS NOT NULL AND `galleries`.`zip` = '0'; UPDATE `galleries_new` SET `folder_id` = ( SELECT `folders`.`id` FROM `folders` INNER JOIN `galleries` ON `galleries_new`.`id` = `galleries`.`id` WHERE `folders`.`path` = `galleries`.`path` AND `galleries`.`zip` = '0' ); INSERT INTO `galleries_files` ( `gallery_id`, `file_id`, `primary` ) SELECT `galleries`.`id`, `files`.`id`, 1 FROM `galleries` INNER JOIN `files` ON `galleries`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1; INSERT INTO `files_fingerprints` ( `file_id`, `type`, `fingerprint` ) SELECT `files`.`id`, 'md5', `galleries`.`checksum` FROM `galleries` INNER JOIN `files` ON `galleries`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1; DROP TABLE `galleries`; ALTER TABLE `galleries_new` rename to `galleries`; CREATE INDEX `index_galleries_on_studio_id` on `galleries` (`studio_id`); -- should only be possible to create a single gallery per folder CREATE UNIQUE INDEX `index_galleries_on_folder_id_unique` on `galleries` (`folder_id`); CREATE TABLE `scenes_new` ( `id` integer not null primary key autoincrement, -- REMOVED: `path` varchar(510) not null, -- REMOVED: `checksum` varchar(255), -- REMOVED: `oshash` varchar(255), `title` varchar(255), `details` text, `url` varchar(255), `date` date, `rating` tinyint, -- REMOVED: `size` varchar(255), -- REMOVED: `duration` float, -- REMOVED: `video_codec` varchar(255), -- REMOVED: `audio_codec` varchar(255), -- REMOVED: `width` tinyint, -- REMOVED: `height` tinyint, -- REMOVED: `framerate` float, -- REMOVED: `bitrate` integer, `studio_id` integer, `o_counter` tinyint not null default 0, -- REMOVED: `format` varchar(255), `organized` boolean not null default '0', -- REMOVED: `interactive` boolean not null default '0', -- REMOVED: `interactive_speed` int, `created_at` datetime not null, `updated_at` datetime not null, -- REMOVED: `file_mod_time` datetime, -- REMOVED: `phash` blob, foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL -- REMOVED: CHECK (`checksum` is not null or `oshash` is not null) ); INSERT INTO `scenes_new` ( `id`, `title`, `details`, `url`, `date`, `rating`, `studio_id`, `o_counter`, `organized`, `created_at`, `updated_at` ) SELECT `id`, `title`, `details`, `url`, `date`, `rating`, `studio_id`, `o_counter`, `organized`, `created_at`, `updated_at` FROM `scenes`; -- insert scene files - we will fix these up in the post-migration INSERT INTO `files` ( `basename`, `parent_folder_id`, `size`, `mod_time`, `created_at`, `updated_at` ) SELECT `path`, 1, -- special value if null so that it is recalculated COALESCE(`size`, -1), COALESCE(`file_mod_time`, '1970-01-01 00:00:00'), `created_at`, `updated_at` FROM `scenes`; INSERT INTO `video_files` ( `file_id`, `duration`, `video_codec`, `format`, `audio_codec`, `width`, `height`, `frame_rate`, `bit_rate`, `interactive`, `interactive_speed` ) SELECT `files`.`id`, COALESCE(`scenes`.`duration`, -1), -- special values for unset to be updated during scan COALESCE(`scenes`.`video_codec`, 'unset'), COALESCE(`scenes`.`format`, 'unset'), COALESCE(`scenes`.`audio_codec`, 'unset'), COALESCE(`scenes`.`width`, -1), COALESCE(`scenes`.`height`, -1), COALESCE(`scenes`.`framerate`, -1), COALESCE(`scenes`.`bitrate`, -1), `scenes`.`interactive`, `scenes`.`interactive_speed` FROM `scenes` INNER JOIN `files` ON `scenes`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1; INSERT INTO `scenes_files` ( `scene_id`, `file_id`, `primary` ) SELECT `scenes`.`id`, `files`.`id`, 1 FROM `scenes` INNER JOIN `files` ON `scenes`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1; INSERT INTO `files_fingerprints` ( `file_id`, `type`, `fingerprint` ) SELECT `files`.`id`, 'md5', `scenes`.`checksum` FROM `scenes` INNER JOIN `files` ON `scenes`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1 WHERE `scenes`.`checksum` is not null; INSERT INTO `files_fingerprints` ( `file_id`, `type`, `fingerprint` ) SELECT `files`.`id`, 'oshash', `scenes`.`oshash` FROM `scenes` INNER JOIN `files` ON `scenes`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1 WHERE `scenes`.`oshash` is not null; INSERT INTO `files_fingerprints` ( `file_id`, `type`, `fingerprint` ) SELECT `files`.`id`, 'phash', `scenes`.`phash` FROM `scenes` INNER JOIN `files` ON `scenes`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1 WHERE `scenes`.`phash` is not null; INSERT INTO `video_captions` ( `file_id`, `language_code`, `filename`, `caption_type` ) SELECT `files`.`id`, `scene_captions`.`language_code`, `scene_captions`.`filename`, `scene_captions`.`caption_type` FROM `scene_captions` INNER JOIN `scenes` ON `scene_captions`.`scene_id` = `scenes`.`id` INNER JOIN `files` ON `scenes`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1; DROP TABLE `scenes`; DROP TABLE `scene_captions`; ALTER TABLE `scenes_new` rename to `scenes`; CREATE INDEX `index_scenes_on_studio_id` on `scenes` (`studio_id`); PRAGMA foreign_keys=ON; ================================================ FILE: pkg/sqlite/migrations/32_postmigrate.go ================================================ package migrations import ( "context" "database/sql" "fmt" "path/filepath" "strings" "time" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/sqlite" "gopkg.in/guregu/null.v4" ) const legacyZipSeparator = "\x00" func post32(ctx context.Context, db *sqlx.DB) error { logger.Info("Running post-migration for schema version 32") m := schema32Migrator{ migrator: migrator{ db: db, }, folderCache: make(map[string]folderInfo), } if err := m.migrateFolders(ctx); err != nil { return fmt.Errorf("migrating folders: %w", err) } if err := m.migrateFiles(ctx); err != nil { return fmt.Errorf("migrating files: %w", err) } if err := m.deletePlaceholderFolder(ctx); err != nil { return fmt.Errorf("deleting placeholder folder: %w", err) } return nil } type folderInfo struct { id int zipID sql.NullInt64 } type schema32Migrator struct { migrator folderCache map[string]folderInfo } func (m *schema32Migrator) migrateFolders(ctx context.Context) error { logger.Infof("Migrating folders") const ( limit = 1000 logEvery = 10000 ) lastID := 0 count := 0 for { gotSome := false if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { query := "SELECT `folders`.`id`, `folders`.`path` FROM `folders` INNER JOIN `galleries` ON `galleries`.`folder_id` = `folders`.`id`" if lastID != 0 { query += fmt.Sprintf("AND `folders`.`id` > %d ", lastID) } query += fmt.Sprintf("ORDER BY `folders`.`id` LIMIT %d", limit) rows, err := tx.Query(query) if err != nil { return err } defer rows.Close() for rows.Next() { var id int var p string err := rows.Scan(&id, &p) if err != nil { return err } lastID = id gotSome = true count++ parent := filepath.Dir(p) parentID, zipFileID, err := m.createFolderHierarchy(tx, parent) if err != nil { return err } _, err = tx.Exec("UPDATE `folders` SET `parent_folder_id` = ?, `zip_file_id` = ? WHERE `id` = ?", parentID, zipFileID, id) if err != nil { return err } } return rows.Err() }); err != nil { return err } if !gotSome { break } if count%logEvery == 0 { logger.Infof("Migrated %d folders", count) } } return nil } func (m *schema32Migrator) migrateFiles(ctx context.Context) error { const ( limit = 1000 logEvery = 10000 ) result := struct { Count int `db:"count"` }{0} if err := m.db.Get(&result, "SELECT COUNT(*) AS count FROM `files`"); err != nil { return err } logger.Infof("Migrating %d files...", result.Count) lastID := 0 count := 0 for { gotSome := false // using offset for this is slow. Save the last id and filter by that instead query := "SELECT `id`, `basename` FROM `files` " if lastID != 0 { query += fmt.Sprintf("WHERE `id` > %d ", lastID) } query += fmt.Sprintf("ORDER BY `id` LIMIT %d", limit) if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { rows, err := tx.Query(query) if err != nil { return err } defer rows.Close() for rows.Next() { gotSome = true var id int var p string err := rows.Scan(&id, &p) if err != nil { return err } if strings.Contains(p, legacyZipSeparator) { // remove any null characters from the path p = strings.ReplaceAll(p, legacyZipSeparator, string(filepath.Separator)) } parent := filepath.Dir(p) basename := filepath.Base(p) if parent != "." { parentID, zipFileID, err := m.createFolderHierarchy(tx, parent) if err != nil { return err } _, err = tx.Exec("UPDATE `files` SET `parent_folder_id` = ?, `zip_file_id` = ?, `basename` = ? WHERE `id` = ?", parentID, zipFileID, basename, id) if err != nil { return fmt.Errorf("migrating file %s: %w", p, err) } } else { // if we don't reassign from the placeholder, it will fail // so log a warning at least here logger.Warnf("Unable to migrate invalid path: %s", p) } lastID = id count++ } return rows.Err() }); err != nil { return err } if !gotSome { break } if count%logEvery == 0 { logger.Infof("Migrated %d files", count) // manual checkpoint to flush wal file if _, err := m.db.Exec("PRAGMA wal_checkpoint(FULL)"); err != nil { return fmt.Errorf("running wal checkpoint: %w", err) } } } logger.Infof("Finished migrating files") return nil } func (m *schema32Migrator) deletePlaceholderFolder(ctx context.Context) error { // only delete the placeholder folder if no files/folders are attached to it result := struct { Count int `db:"count"` }{0} if err := m.db.Get(&result, "SELECT COUNT(*) AS count FROM `files` WHERE `parent_folder_id` = 1"); err != nil { return err } if result.Count > 0 { return fmt.Errorf("not deleting placeholder folder because it has %d files", result.Count) } result.Count = 0 if err := m.db.Get(&result, "SELECT COUNT(*) AS count FROM `folders` WHERE `parent_folder_id` = 1"); err != nil { return err } if result.Count > 0 { return fmt.Errorf("not deleting placeholder folder because it has %d folders", result.Count) } return m.withTxn(ctx, func(tx *sqlx.Tx) error { _, err := tx.Exec("DELETE FROM `folders` WHERE `id` = 1") return err }) } func (m *schema32Migrator) createFolderHierarchy(tx *sqlx.Tx, p string) (*int, sql.NullInt64, error) { parent := filepath.Dir(p) if parent == p { // get or create this folder return m.getOrCreateFolder(tx, p, nil, sql.NullInt64{}) } var ( parentID *int zipFileID sql.NullInt64 err error ) // try to find parent folder in cache first foundEntry, ok := m.folderCache[parent] if ok { parentID = &foundEntry.id zipFileID = foundEntry.zipID } else { parentID, zipFileID, err = m.createFolderHierarchy(tx, parent) if err != nil { return nil, sql.NullInt64{}, err } } return m.getOrCreateFolder(tx, p, parentID, zipFileID) } func (m *schema32Migrator) getOrCreateFolder(tx *sqlx.Tx, path string, parentID *int, zipFileID sql.NullInt64) (*int, sql.NullInt64, error) { foundEntry, ok := m.folderCache[path] if ok { return &foundEntry.id, foundEntry.zipID, nil } const query = "SELECT `id`, `zip_file_id` FROM `folders` WHERE `path` = ?" rows, err := tx.Query(query, path) if err != nil { return nil, sql.NullInt64{}, err } defer rows.Close() if rows.Next() { var id int var zfid sql.NullInt64 err := rows.Scan(&id, &zfid) if err != nil { return nil, sql.NullInt64{}, err } return &id, zfid, nil } if err := rows.Err(); err != nil { return nil, sql.NullInt64{}, err } const insertSQL = "INSERT INTO `folders` (`path`,`parent_folder_id`,`zip_file_id`,`mod_time`,`created_at`,`updated_at`) VALUES (?,?,?,?,?,?)" var parentFolderID null.Int if parentID != nil { parentFolderID = null.IntFrom(int64(*parentID)) } now := time.Now() result, err := tx.Exec(insertSQL, path, parentFolderID, zipFileID, time.Time{}, now, now) if err != nil { return nil, sql.NullInt64{}, fmt.Errorf("creating folder %s: %w", path, err) } id, err := result.LastInsertId() if err != nil { return nil, sql.NullInt64{}, fmt.Errorf("creating folder %s: %w", path, err) } idInt := int(id) m.folderCache[path] = folderInfo{id: idInt, zipID: zipFileID} return &idInt, zipFileID, nil } func init() { sqlite.RegisterPostMigration(32, post32) } ================================================ FILE: pkg/sqlite/migrations/32_premigrate.go ================================================ package migrations import ( "context" "fmt" "os" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/sqlite" ) func pre32(ctx context.Context, db *sqlx.DB) error { // verify that folder-based galleries (those with zip = 0 and path is not null) are // not zip-based. If they are zip based then set zip to 1 // we could still miss some if the path does not exist, but this is the best we can do logger.Info("Running pre-migration for schema version 32") mm := schema32PreMigrator{ migrator: migrator{ db: db, }, } return mm.migrate(ctx) } type schema32PreMigrator struct { migrator } func (m *schema32PreMigrator) migrate(ctx context.Context) error { const ( limit = 1000 logEvery = 10000 ) // query for galleries with zip = 0 and path not null result := struct { Count int `db:"count"` }{0} if err := m.db.Get(&result, "SELECT COUNT(*) AS count FROM `galleries` WHERE `zip` = '0' AND `path` IS NOT NULL"); err != nil { return err } if result.Count == 0 { return nil } logger.Infof("Checking %d galleries for incorrect zip value...", result.Count) lastID := 0 count := 0 for { gotSome := false if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { query := "SELECT `id`, `path` FROM `galleries` WHERE `zip` = '0' AND `path` IS NOT NULL " if lastID != 0 { query += fmt.Sprintf("AND `id` > %d ", lastID) } query += fmt.Sprintf("ORDER BY `id` LIMIT %d", limit) rows, err := tx.Query(query) if err != nil { return err } defer rows.Close() for rows.Next() { var id int var p string err := rows.Scan(&id, &p) if err != nil { return err } gotSome = true lastID = id count++ // if path does not exist, make no changes // if it does exist and is a folder, then we ignore it // otherwise set zip to 1 info, err := os.Stat(p) if err != nil { logger.Warnf("unable to verify if %q is a folder due to error %v. Assuming folder-based.", p, err) continue } if info.IsDir() { // ignore it continue } logger.Infof("Correcting %q gallery to be zip-based.", p) _, err = tx.Exec("UPDATE `galleries` SET `zip` = '1' WHERE `id` = ?", id) if err != nil { return err } } return rows.Err() }); err != nil { return err } if !gotSome { break } if count%logEvery == 0 { logger.Infof("Checked %d galleries", count) } } return nil } func init() { sqlite.RegisterPreMigration(32, pre32) } ================================================ FILE: pkg/sqlite/migrations/33_noop.up.sql ================================================ -- no schema changes ================================================ FILE: pkg/sqlite/migrations/34_indexes.up.sql ================================================ CREATE INDEX `index_performer_stash_ids_on_performer_id` ON `performer_stash_ids` (`performer_id`); CREATE INDEX `index_scene_stash_ids_on_scene_id` ON `scene_stash_ids` (`scene_id`); CREATE INDEX `index_studio_stash_ids_on_studio_id` ON `studio_stash_ids` (`studio_id`); ================================================ FILE: pkg/sqlite/migrations/34_postmigrate.go ================================================ package migrations import ( "context" "fmt" "strings" "time" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/sqlite" ) type schema34Migrator struct { migrator } func post34(ctx context.Context, db *sqlx.DB) error { logger.Info("Running post-migration for schema version 34") m := schema34Migrator{ migrator: migrator{ db: db, }, } objectCols := []string{ "created_at", "updated_at", } filesystemCols := objectCols filesystemCols = append(filesystemCols, "mod_time") if err := m.migrateObjects(ctx, "scenes", objectCols); err != nil { return fmt.Errorf("migrating scenes: %w", err) } if err := m.migrateObjects(ctx, "images", objectCols); err != nil { return fmt.Errorf("migrating images: %w", err) } if err := m.migrateObjects(ctx, "galleries", objectCols); err != nil { return fmt.Errorf("migrating galleries: %w", err) } if err := m.migrateObjects(ctx, "files", filesystemCols); err != nil { return fmt.Errorf("migrating files: %w", err) } if err := m.migrateObjects(ctx, "folders", filesystemCols); err != nil { return fmt.Errorf("migrating folders: %w", err) } return nil } func (m *schema34Migrator) migrateObjects(ctx context.Context, table string, cols []string) error { logger.Infof("Migrating %s table", table) quotedCols := make([]string, len(cols)+1) quotedCols[0] = "`id`" whereClauses := make([]string, len(cols)) updateClauses := make([]string, len(cols)) for i, v := range cols { quotedCols[i+1] = "`" + v + "`" whereClauses[i] = "`" + v + "` like '% %'" updateClauses[i] = "`" + v + "` = ?" } colList := strings.Join(quotedCols, ", ") clauseList := strings.Join(whereClauses, " OR ") updateList := strings.Join(updateClauses, ", ") const ( limit = 1000 logEvery = 10000 ) lastID := 0 count := 0 for { gotSome := false if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { query := fmt.Sprintf("SELECT %s FROM `%s` WHERE (%s)", colList, table, clauseList) if lastID != 0 { query += fmt.Sprintf(" AND `id` > %d ", lastID) } query += fmt.Sprintf(" ORDER BY `id` LIMIT %d", limit) rows, err := tx.Query(query) if err != nil { return err } defer rows.Close() for rows.Next() { var ( id int ) timeValues := make([]interface{}, len(cols)+1) timeValues[0] = &id for i := range cols { v := time.Time{} timeValues[i+1] = &v } err := rows.Scan(timeValues...) if err != nil { return err } lastID = id gotSome = true count++ // convert incorrect timestamp string to correct one // based on models.SQLTimestamp args := make([]interface{}, len(cols)+1) for i := range cols { tv := timeValues[i+1].(*time.Time) args[i] = tv.Format(time.RFC3339) } args[len(cols)] = id updateSQL := fmt.Sprintf("UPDATE `%s` SET %s WHERE `id` = ?", table, updateList) _, err = tx.Exec(updateSQL, args...) if err != nil { return err } } return rows.Err() }); err != nil { return err } if !gotSome { break } if count%logEvery == 0 { logger.Infof("Migrated %d rows", count) } } return nil } func init() { sqlite.RegisterPostMigration(34, post34) } ================================================ FILE: pkg/sqlite/migrations/35_assoc_tables.up.sql ================================================ -- add primary keys to association tables that are missing them PRAGMA foreign_keys=OFF; CREATE TABLE `performers_image_new` ( `performer_id` integer primary key, `image` blob not null, foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE ); INSERT INTO `performers_image_new` ( `performer_id`, `image` ) SELECT `performer_id`, `image` FROM `performers_image` WHERE `performer_id` IS NOT NULL; DROP TABLE `performers_image`; ALTER TABLE `performers_image_new` rename to `performers_image`; -- the following index is removed in favour of primary key -- CREATE UNIQUE INDEX `index_performer_image_on_performer_id` on `performers_image` (`performer_id`); CREATE TABLE `studios_image_new` ( `studio_id` integer primary key, `image` blob not null, foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE ); INSERT INTO `studios_image_new` ( `studio_id`, `image` ) SELECT `studio_id`, `image` FROM `studios_image` WHERE `studio_id` IS NOT NULL; DROP TABLE `studios_image`; ALTER TABLE `studios_image_new` rename to `studios_image`; -- the following index is removed in favour of primary key -- CREATE UNIQUE INDEX `index_studio_image_on_studio_id` on `studios_image` (`studio_id`); CREATE TABLE `movies_images_new` ( `movie_id` integer primary key, `front_image` blob not null, `back_image` blob, foreign key(`movie_id`) references `movies`(`id`) on delete CASCADE ); INSERT INTO `movies_images_new` ( `movie_id`, `front_image`, `back_image` ) SELECT `movie_id`, `front_image`, `back_image` FROM `movies_images` WHERE `movie_id` IS NOT NULL; DROP TABLE `movies_images`; ALTER TABLE `movies_images_new` rename to `movies_images`; -- the following index is removed in favour of primary key -- CREATE UNIQUE INDEX `index_movie_images_on_movie_id` on `movies_images` (`movie_id`); CREATE TABLE `tags_image_new` ( `tag_id` integer primary key, `image` blob not null, foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE ); INSERT INTO `tags_image_new` ( `tag_id`, `image` ) SELECT `tag_id`, `image` FROM `tags_image` WHERE `tag_id` IS NOT NULL; DROP TABLE `tags_image`; ALTER TABLE `tags_image_new` rename to `tags_image`; -- the following index is removed in favour of primary key -- CREATE UNIQUE INDEX `index_tag_image_on_tag_id` on `tags_image` (`tag_id`); -- add on delete cascade to foreign keys CREATE TABLE `performers_scenes_new` ( `performer_id` integer, `scene_id` integer, foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE, foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE, PRIMARY KEY (`scene_id`, `performer_id`) ); INSERT INTO `performers_scenes_new` ( `performer_id`, `scene_id` ) SELECT `performer_id`, `scene_id` FROM `performers_scenes` WHERE `performer_id` IS NOT NULL AND `scene_id` IS NOT NULL ON CONFLICT (`scene_id`, `performer_id`) DO NOTHING; DROP TABLE `performers_scenes`; ALTER TABLE `performers_scenes_new` rename to `performers_scenes`; CREATE INDEX `index_performers_scenes_on_performer_id` on `performers_scenes` (`performer_id`); -- the following index is removed in favour of primary key -- CREATE INDEX `index_performers_scenes_on_scene_id` on `performers_scenes` (`scene_id`); CREATE TABLE `scene_markers_tags_new` ( `scene_marker_id` integer, `tag_id` integer, foreign key(`scene_marker_id`) references `scene_markers`(`id`) on delete CASCADE, foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE, PRIMARY KEY(`scene_marker_id`, `tag_id`) ); INSERT INTO `scene_markers_tags_new` ( `scene_marker_id`, `tag_id` ) SELECT `scene_marker_id`, `tag_id` FROM `scene_markers_tags` WHERE `scene_marker_id` IS NOT NULL AND `tag_id` IS NOT NULL ON CONFLICT (`scene_marker_id`, `tag_id`) DO NOTHING; DROP TABLE `scene_markers_tags`; ALTER TABLE `scene_markers_tags_new` rename to `scene_markers_tags`; CREATE INDEX `index_scene_markers_tags_on_tag_id` on `scene_markers_tags` (`tag_id`); -- the following index is removed in favour of primary key -- CREATE INDEX `index_scene_markers_tags_on_scene_marker_id` on `scene_markers_tags` (`scene_marker_id`); -- add delete cascade to tag_id CREATE TABLE `scenes_tags_new` ( `scene_id` integer, `tag_id` integer, foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE, foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE, PRIMARY KEY(`scene_id`, `tag_id`) ); INSERT INTO `scenes_tags_new` ( `scene_id`, `tag_id` ) SELECT `scene_id`, `tag_id` FROM `scenes_tags` WHERE `scene_id` IS NOT NULL AND `tag_id` IS NOT NULL ON CONFLICT (`scene_id`, `tag_id`) DO NOTHING; DROP TABLE `scenes_tags`; ALTER TABLE `scenes_tags_new` rename to `scenes_tags`; CREATE INDEX `index_scenes_tags_on_tag_id` on `scenes_tags` (`tag_id`); -- the following index is removed in favour of primary key -- CREATE INDEX `index_scenes_tags_on_scene_id` on `scenes_tags` (`scene_id`); CREATE TABLE `movies_scenes_new` ( `movie_id` integer, `scene_id` integer, `scene_index` tinyint, foreign key(`movie_id`) references `movies`(`id`) on delete cascade, foreign key(`scene_id`) references `scenes`(`id`) on delete cascade, PRIMARY KEY(`movie_id`, `scene_id`) ); INSERT INTO `movies_scenes_new` ( `movie_id`, `scene_id`, `scene_index` ) SELECT `movie_id`, `scene_id`, `scene_index` FROM `movies_scenes` WHERE `movie_id` IS NOT NULL AND `scene_id` IS NOT NULL ON CONFLICT (`movie_id`, `scene_id`) DO NOTHING; DROP TABLE `movies_scenes`; ALTER TABLE `movies_scenes_new` rename to `movies_scenes`; CREATE INDEX `index_movies_scenes_on_movie_id` on `movies_scenes` (`movie_id`); -- the following index is removed in favour of primary key -- CREATE INDEX `index_movies_scenes_on_scene_id` on `movies_scenes` (`scene_id`); CREATE TABLE `scenes_cover_new` ( `scene_id` integer primary key, `cover` blob not null, foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE ); INSERT INTO `scenes_cover_new` ( `scene_id`, `cover` ) SELECT `scene_id`, `cover` FROM `scenes_cover` WHERE `scene_id` IS NOT NULL; DROP TABLE `scenes_cover`; ALTER TABLE `scenes_cover_new` rename to `scenes_cover`; -- the following index is removed in favour of primary key -- CREATE UNIQUE INDEX `index_scene_covers_on_scene_id` on `scenes_cover` (`scene_id`); CREATE TABLE `performers_images_new` ( `performer_id` integer, `image_id` integer, foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE, foreign key(`image_id`) references `images`(`id`) on delete CASCADE, PRIMARY KEY(`image_id`, `performer_id`) ); INSERT INTO `performers_images_new` ( `performer_id`, `image_id` ) SELECT `performer_id`, `image_id` FROM `performers_images` WHERE `performer_id` IS NOT NULL AND `image_id` IS NOT NULL ON CONFLICT (`image_id`, `performer_id`) DO NOTHING; DROP TABLE `performers_images`; ALTER TABLE `performers_images_new` rename to `performers_images`; CREATE INDEX `index_performers_images_on_performer_id` on `performers_images` (`performer_id`); -- the following index is removed in favour of primary key -- CREATE INDEX `index_performers_images_on_image_id` on `performers_images` (`image_id`); CREATE TABLE `images_tags_new` ( `image_id` integer, `tag_id` integer, foreign key(`image_id`) references `images`(`id`) on delete CASCADE, foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE, PRIMARY KEY(`image_id`, `tag_id`) ); INSERT INTO `images_tags_new` ( `image_id`, `tag_id` ) SELECT `image_id`, `tag_id` FROM `images_tags` WHERE `image_id` IS NOT NULL AND `tag_id` IS NOT NULL ON CONFLICT (`image_id`, `tag_id`) DO NOTHING; DROP TABLE `images_tags`; ALTER TABLE `images_tags_new` rename to `images_tags`; CREATE INDEX `index_images_tags_on_tag_id` on `images_tags` (`tag_id`); -- the following index is removed in favour of primary key -- CREATE INDEX `index_images_tags_on_image_id` on `images_tags` (`image_id`); CREATE TABLE `scene_stash_ids_new` ( `scene_id` integer NOT NULL, `endpoint` varchar(255) NOT NULL, `stash_id` varchar(36) NOT NULL, foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE, PRIMARY KEY(`scene_id`, `endpoint`) ); INSERT INTO `scene_stash_ids_new` ( `scene_id`, `endpoint`, `stash_id` ) SELECT `scene_id`, `endpoint`, `stash_id` FROM `scene_stash_ids` WHERE `scene_id` IS NOT NULL AND `endpoint` IS NOT NULL AND `stash_id` IS NOT NULL; DROP TABLE `scene_stash_ids`; ALTER TABLE `scene_stash_ids_new` rename to `scene_stash_ids`; -- the following index is removed in favour of primary key -- CREATE INDEX `index_scene_stash_ids_on_scene_id` ON `scene_stash_ids` (`scene_id`); CREATE TABLE `scenes_galleries_new` ( `scene_id` integer NOT NULL, `gallery_id` integer NOT NULL, foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE, foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE, PRIMARY KEY(`scene_id`, `gallery_id`) ); INSERT INTO `scenes_galleries_new` ( `scene_id`, `gallery_id` ) SELECT `scene_id`, `gallery_id` FROM `scenes_galleries` WHERE `scene_id` IS NOT NULL AND `gallery_id` IS NOT NULL ON CONFLICT (`scene_id`, `gallery_id`) DO NOTHING; DROP TABLE `scenes_galleries`; ALTER TABLE `scenes_galleries_new` rename to `scenes_galleries`; CREATE INDEX `index_scenes_galleries_on_gallery_id` on `scenes_galleries` (`gallery_id`); -- the following index is removed in favour of primary key -- CREATE INDEX `index_scenes_galleries_on_scene_id` on `scenes_galleries` (`scene_id`); CREATE TABLE `galleries_images_new` ( `gallery_id` integer NOT NULL, `image_id` integer NOT NULL, foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE, foreign key(`image_id`) references `images`(`id`) on delete CASCADE, PRIMARY KEY(`gallery_id`, `image_id`) ); INSERT INTO `galleries_images_new` ( `gallery_id`, `image_id` ) SELECT `gallery_id`, `image_id` FROM `galleries_images` WHERE `image_id` IS NOT NULL AND `gallery_id` IS NOT NULL ON CONFLICT (`gallery_id`, `image_id`) DO NOTHING; DROP TABLE `galleries_images`; ALTER TABLE `galleries_images_new` rename to `galleries_images`; CREATE INDEX `index_galleries_images_on_image_id` on `galleries_images` (`image_id`); -- the following index is removed in favour of primary key -- CREATE INDEX `index_galleries_images_on_gallery_id` on `galleries_images` (`gallery_id`); CREATE TABLE `performers_galleries_new` ( `performer_id` integer NOT NULL, `gallery_id` integer NOT NULL, foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE, foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE, PRIMARY KEY(`gallery_id`, `performer_id`) ); INSERT INTO `performers_galleries_new` ( `performer_id`, `gallery_id` ) SELECT `performer_id`, `gallery_id` FROM `performers_galleries` WHERE `performer_id` IS NOT NULL AND `gallery_id` IS NOT NULL ON CONFLICT (`gallery_id`, `performer_id`) DO NOTHING; DROP TABLE `performers_galleries`; ALTER TABLE `performers_galleries_new` rename to `performers_galleries`; CREATE INDEX `index_performers_galleries_on_performer_id` on `performers_galleries` (`performer_id`); -- the following index is removed in favour of primary key -- CREATE INDEX `index_performers_galleries_on_gallery_id` on `performers_galleries` (`gallery_id`); CREATE TABLE `galleries_tags_new` ( `gallery_id` integer NOT NULL, `tag_id` integer NOT NULL, foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE, foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE, PRIMARY KEY(`gallery_id`, `tag_id`) ); INSERT INTO `galleries_tags_new` ( `gallery_id`, `tag_id` ) SELECT `gallery_id`, `tag_id` FROM `galleries_tags` WHERE `tag_id` IS NOT NULL AND `gallery_id` IS NOT NULL ON CONFLICT (`gallery_id`, `tag_id`) DO NOTHING; DROP TABLE `galleries_tags`; ALTER TABLE `galleries_tags_new` rename to `galleries_tags`; CREATE INDEX `index_galleries_tags_on_tag_id` on `galleries_tags` (`tag_id`); -- the following index is removed in favour of primary key -- CREATE INDEX `index_galleries_tags_on_gallery_id` on `galleries_tags` (`gallery_id`); CREATE TABLE `performers_tags_new` ( `performer_id` integer NOT NULL, `tag_id` integer NOT NULL, foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE, foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE, PRIMARY KEY(`performer_id`, `tag_id`) ); INSERT INTO `performers_tags_new` ( `performer_id`, `tag_id` ) SELECT `performer_id`, `tag_id` FROM `performers_tags` WHERE true ON CONFLICT (`performer_id`, `tag_id`) DO NOTHING; DROP TABLE `performers_tags`; ALTER TABLE `performers_tags_new` rename to `performers_tags`; CREATE INDEX `index_performers_tags_on_tag_id` on `performers_tags` (`tag_id`); -- the following index is removed in favour of primary key -- CREATE INDEX `index_performers_tags_on_performer_id` on `performers_tags` (`performer_id`); CREATE TABLE `tag_aliases_new` ( `tag_id` integer NOT NULL, `alias` varchar(255) NOT NULL, foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE, PRIMARY KEY(`tag_id`, `alias`) ); INSERT INTO `tag_aliases_new` ( `tag_id`, `alias` ) SELECT `tag_id`, `alias` FROM `tag_aliases`; DROP TABLE `tag_aliases`; ALTER TABLE `tag_aliases_new` rename to `tag_aliases`; CREATE UNIQUE INDEX `tag_aliases_alias_unique` on `tag_aliases` (`alias`); CREATE TABLE `studio_aliases_new` ( `studio_id` integer NOT NULL, `alias` varchar(255) NOT NULL, foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE, PRIMARY KEY(`studio_id`, `alias`) ); INSERT INTO `studio_aliases_new` ( `studio_id`, `alias` ) SELECT `studio_id`, `alias` FROM `studio_aliases`; DROP TABLE `studio_aliases`; ALTER TABLE `studio_aliases_new` rename to `studio_aliases`; CREATE UNIQUE INDEX `studio_aliases_alias_unique` on `studio_aliases` (`alias`); PRAGMA foreign_keys=ON; ================================================ FILE: pkg/sqlite/migrations/36_tags_description.up.sql ================================================ ALTER TABLE `tags` ADD COLUMN `description` text; ================================================ FILE: pkg/sqlite/migrations/37_iso_country_names.up.sql ================================================ UPDATE `performers` SET `country` = CASE WHEN LENGTH(TRIM(`country`)) == 2 THEN TRIM(`country`) ELSE CASE `country` WHEN 'Afghanistan' THEN 'AF' WHEN 'Albania' THEN 'AL' WHEN 'Algeria' THEN 'DZ' WHEN 'America' THEN 'US' WHEN 'American' THEN 'US' WHEN 'American Samoa' THEN 'AS' WHEN 'Andorra' THEN 'AD' WHEN 'Angola' THEN 'AO' WHEN 'Anguilla' THEN 'AI' WHEN 'Antarctica' THEN 'AQ' WHEN 'Antigua and Barbuda' THEN 'AG' WHEN 'Argentina' THEN 'AR' WHEN 'Armenia' THEN 'AM' WHEN 'Aruba' THEN 'AW' WHEN 'Australia' THEN 'AU' WHEN 'Austria' THEN 'AT' WHEN 'Azerbaijan' THEN 'AZ' WHEN 'Bahamas' THEN 'BS' WHEN 'Bahrain' THEN 'BH' WHEN 'Bangladesh' THEN 'BD' WHEN 'Barbados' THEN 'BB' WHEN 'Belarus' THEN 'BY' WHEN 'Belgium' THEN 'BE' WHEN 'Belize' THEN 'BZ' WHEN 'Benin' THEN 'BJ' WHEN 'Bermuda' THEN 'BM' WHEN 'Bhutan' THEN 'BT' WHEN 'Bolivia' THEN 'BO' WHEN 'Bosnia and Herzegovina' THEN 'BA' WHEN 'Botswana' THEN 'BW' WHEN 'Bouvet Island' THEN 'BV' WHEN 'Brazil' THEN 'BR' WHEN 'British Indian Ocean Territory' THEN 'IO' WHEN 'Brunei Darussalam' THEN 'BN' WHEN 'Bulgaria' THEN 'BG' WHEN 'Burkina Faso' THEN 'BF' WHEN 'Burundi' THEN 'BI' WHEN 'Cambodia' THEN 'KH' WHEN 'Cameroon' THEN 'CM' WHEN 'Canada' THEN 'CA' WHEN 'Cape Verde' THEN 'CV' WHEN 'Cayman Islands' THEN 'KY' WHEN 'Central African Republic' THEN 'CF' WHEN 'Chad' THEN 'TD' WHEN 'Chile' THEN 'CL' WHEN 'China' THEN 'CN' WHEN 'Christmas Island' THEN 'CX' WHEN 'Cocos (Keeling) Islands' THEN 'CC' WHEN 'Colombia' THEN 'CO' WHEN 'Comoros' THEN 'KM' WHEN 'Congo' THEN 'CG' WHEN 'Congo the Democratic Republic of the' THEN 'CD' WHEN 'Cook Islands' THEN 'CK' WHEN 'Costa Rica' THEN 'CR' WHEN 'Cote D''Ivoire' THEN 'CI' WHEN 'Croatia' THEN 'HR' WHEN 'Cuba' THEN 'CU' WHEN 'Cyprus' THEN 'CY' WHEN 'Czech Republic' THEN 'CZ' WHEN 'Czechia' THEN 'CZ' WHEN 'Denmark' THEN 'DK' WHEN 'Djibouti' THEN 'DJ' WHEN 'Dominica' THEN 'DM' WHEN 'Dominican Republic' THEN 'DO' WHEN 'Ecuador' THEN 'EC' WHEN 'Egypt' THEN 'EG' WHEN 'El Salvador' THEN 'SV' WHEN 'Equatorial Guinea' THEN 'GQ' WHEN 'Eritrea' THEN 'ER' WHEN 'Estonia' THEN 'EE' WHEN 'Ethiopia' THEN 'ET' WHEN 'Falkland Islands (Malvinas)' THEN 'FK' WHEN 'Faroe Islands' THEN 'FO' WHEN 'Fiji' THEN 'FJ' WHEN 'Finland' THEN 'FI' WHEN 'France' THEN 'FR' WHEN 'French Guiana' THEN 'GF' WHEN 'French Polynesia' THEN 'PF' WHEN 'French Southern Territories' THEN 'TF' WHEN 'Gabon' THEN 'GA' WHEN 'Gambia' THEN 'GM' WHEN 'Georgia' THEN 'GE' WHEN 'Germany' THEN 'DE' WHEN 'Ghana' THEN 'GH' WHEN 'Gibraltar' THEN 'GI' WHEN 'Greece' THEN 'GR' WHEN 'Greenland' THEN 'GL' WHEN 'Grenada' THEN 'GD' WHEN 'Guadeloupe' THEN 'GP' WHEN 'Guam' THEN 'GU' WHEN 'Guatemala' THEN 'GT' WHEN 'Guinea' THEN 'GN' WHEN 'Guinea-Bissau' THEN 'GW' WHEN 'Guyana' THEN 'GY' WHEN 'Haiti' THEN 'HT' WHEN 'Heard Island and McDonald Islands' THEN 'HM' WHEN 'Holy See (Vatican City State)' THEN 'VA' WHEN 'Honduras' THEN 'HN' WHEN 'Hong Kong' THEN 'HK' WHEN 'Hungary' THEN 'HU' WHEN 'Iceland' THEN 'IS' WHEN 'India' THEN 'IN' WHEN 'Indonesia' THEN 'ID' WHEN 'Iran' THEN 'IR' WHEN 'Iran Islamic Republic of' THEN 'IR' WHEN 'Iraq' THEN 'IQ' WHEN 'Ireland' THEN 'IE' WHEN 'Israel' THEN 'IL' WHEN 'Italy' THEN 'IT' WHEN 'Jamaica' THEN 'JM' WHEN 'Japan' THEN 'JP' WHEN 'Jordan' THEN 'JO' WHEN 'Kazakhstan' THEN 'KZ' WHEN 'Kenya' THEN 'KE' WHEN 'Kiribati' THEN 'KI' WHEN 'North Korea' THEN 'KP' WHEN 'South Korea' THEN 'KR' WHEN 'Kuwait' THEN 'KW' WHEN 'Kyrgyzstan' THEN 'KG' WHEN 'Lao People''s Democratic Republic' THEN 'LA' WHEN 'Latvia' THEN 'LV' WHEN 'Lebanon' THEN 'LB' WHEN 'Lesotho' THEN 'LS' WHEN 'Liberia' THEN 'LR' WHEN 'Libya' THEN 'LY' WHEN 'Liechtenstein' THEN 'LI' WHEN 'Lithuania' THEN 'LT' WHEN 'Luxembourg' THEN 'LU' WHEN 'Macao' THEN 'MO' WHEN 'Madagascar' THEN 'MG' WHEN 'Malawi' THEN 'MW' WHEN 'Malaysia' THEN 'MY' WHEN 'Maldives' THEN 'MV' WHEN 'Mali' THEN 'ML' WHEN 'Malta' THEN 'MT' WHEN 'Marshall Islands' THEN 'MH' WHEN 'Martinique' THEN 'MQ' WHEN 'Mauritania' THEN 'MR' WHEN 'Mauritius' THEN 'MU' WHEN 'Mayotte' THEN 'YT' WHEN 'Mexico' THEN 'MX' WHEN 'Micronesia Federated States of' THEN 'FM' WHEN 'Moldova' THEN 'MD' WHEN 'Moldova Republic of' THEN 'MD' WHEN 'Moldova, Republic of' THEN 'MD' WHEN 'Monaco' THEN 'MC' WHEN 'Mongolia' THEN 'MN' WHEN 'Montserrat' THEN 'MS' WHEN 'Morocco' THEN 'MA' WHEN 'Mozambique' THEN 'MZ' WHEN 'Myanmar' THEN 'MM' WHEN 'Namibia' THEN 'NA' WHEN 'Nauru' THEN 'NR' WHEN 'Nepal' THEN 'NP' WHEN 'Netherlands' THEN 'NL' WHEN 'New Caledonia' THEN 'NC' WHEN 'New Zealand' THEN 'NZ' WHEN 'Nicaragua' THEN 'NI' WHEN 'Niger' THEN 'NE' WHEN 'Nigeria' THEN 'NG' WHEN 'Niue' THEN 'NU' WHEN 'Norfolk Island' THEN 'NF' WHEN 'North Macedonia Republic of' THEN 'MK' WHEN 'Northern Mariana Islands' THEN 'MP' WHEN 'Norway' THEN 'NO' WHEN 'Oman' THEN 'OM' WHEN 'Pakistan' THEN 'PK' WHEN 'Palau' THEN 'PW' WHEN 'Palestinian Territory Occupied' THEN 'PS' WHEN 'Panama' THEN 'PA' WHEN 'Papua New Guinea' THEN 'PG' WHEN 'Paraguay' THEN 'PY' WHEN 'Peru' THEN 'PE' WHEN 'Philippines' THEN 'PH' WHEN 'Pitcairn' THEN 'PN' WHEN 'Poland' THEN 'PL' WHEN 'Portugal' THEN 'PT' WHEN 'Puerto Rico' THEN 'PR' WHEN 'Qatar' THEN 'QA' WHEN 'Reunion' THEN 'RE' WHEN 'Romania' THEN 'RO' WHEN 'Russia' THEN 'RU' WHEN 'Russian Federation' THEN 'RU' WHEN 'Rwanda' THEN 'RW' WHEN 'Saint Helena' THEN 'SH' WHEN 'Saint Kitts and Nevis' THEN 'KN' WHEN 'Saint Lucia' THEN 'LC' WHEN 'Saint Pierre and Miquelon' THEN 'PM' WHEN 'Saint Vincent and the Grenadines' THEN 'VC' WHEN 'Samoa' THEN 'WS' WHEN 'San Marino' THEN 'SM' WHEN 'Sao Tome and Principe' THEN 'ST' WHEN 'Saudi Arabia' THEN 'SA' WHEN 'Senegal' THEN 'SN' WHEN 'Seychelles' THEN 'SC' WHEN 'Sierra Leone' THEN 'SL' WHEN 'Singapore' THEN 'SG' WHEN 'Slovakia' THEN 'SK' WHEN 'Slovak Republic' THEN 'SK' WHEN 'Slovenia' THEN 'SI' WHEN 'Solomon Islands' THEN 'SB' WHEN 'Somalia' THEN 'SO' WHEN 'South Africa' THEN 'ZA' WHEN 'South Georgia and the South Sandwich Islands' THEN 'GS' WHEN 'Spain' THEN 'ES' WHEN 'Sri Lanka' THEN 'LK' WHEN 'Sudan' THEN 'SD' WHEN 'Suriname' THEN 'SR' WHEN 'Svalbard and Jan Mayen' THEN 'SJ' WHEN 'Eswatini' THEN 'SZ' WHEN 'Sweden' THEN 'SE' WHEN 'Switzerland' THEN 'CH' WHEN 'Syrian Arab Republic' THEN 'SY' WHEN 'Taiwan' THEN 'TW' WHEN 'Tajikistan' THEN 'TJ' WHEN 'Tanzania United Republic of' THEN 'TZ' WHEN 'Thailand' THEN 'TH' WHEN 'Timor-Leste' THEN 'TL' WHEN 'Togo' THEN 'TG' WHEN 'Tokelau' THEN 'TK' WHEN 'Tonga' THEN 'TO' WHEN 'Trinidad and Tobago' THEN 'TT' WHEN 'Tunisia' THEN 'TN' WHEN 'Turkey' THEN 'TR' WHEN 'Turkmenistan' THEN 'TM' WHEN 'Turks and Caicos Islands' THEN 'TC' WHEN 'Tuvalu' THEN 'TV' WHEN 'Uganda' THEN 'UG' WHEN 'Ukraine' THEN 'UA' WHEN 'United Arab Emirates' THEN 'AE' WHEN 'England' THEN 'GB' WHEN 'Great Britain' THEN 'GB' WHEN 'United Kingdom' THEN 'GB' WHEN 'USA' THEN 'US' WHEN 'United States' THEN 'US' WHEN 'United States of America' THEN 'US' WHEN 'United States Minor Outlying Islands' THEN 'UM' WHEN 'Uruguay' THEN 'UY' WHEN 'Uzbekistan' THEN 'UZ' WHEN 'Vanuatu' THEN 'VU' WHEN 'Venezuela' THEN 'VE' WHEN 'Vietnam' THEN 'VN' WHEN 'Virgin Islands British' THEN 'VG' WHEN 'Virgin Islands U.S.' THEN 'VI' WHEN 'Wallis and Futuna' THEN 'WF' WHEN 'Western Sahara' THEN 'EH' WHEN 'Yemen' THEN 'YE' WHEN 'Zambia' THEN 'ZM' WHEN 'Zimbabwe' THEN 'ZW' WHEN 'Åland Islands' THEN 'AX' WHEN 'Bonaire Sint Eustatius and Saba' THEN 'BQ' WHEN 'Curaçao' THEN 'CW' WHEN 'Guernsey' THEN 'GG' WHEN 'Isle of Man' THEN 'IM' WHEN 'Jersey' THEN 'JE' WHEN 'Montenegro' THEN 'ME' WHEN 'Saint Barthélemy' THEN 'BL' WHEN 'Saint Martin (French part)' THEN 'MF' WHEN 'Serbia' THEN 'RS' WHEN 'Sint Maarten (Dutch part)' THEN 'SX' WHEN 'South Sudan' THEN 'SS' WHEN 'Kosovo' THEN 'XK' ELSE `country` END END; ================================================ FILE: pkg/sqlite/migrations/38_scenes_director_code.up.sql ================================================ ALTER TABLE `scenes` ADD COLUMN `code` text; ALTER TABLE `scenes` ADD COLUMN `director` text; ================================================ FILE: pkg/sqlite/migrations/39_performer_height.up.sql ================================================ -- add primary keys to association tables that are missing them PRAGMA foreign_keys=OFF; CREATE TABLE `performers_new` ( `id` integer not null primary key autoincrement, `checksum` varchar(255) not null, `name` varchar(255), `gender` varchar(20), `url` varchar(255), `twitter` varchar(255), `instagram` varchar(255), `birthdate` date, `ethnicity` varchar(255), `country` varchar(255), `eye_color` varchar(255), -- changed from varchar(255) `height` int, `measurements` varchar(255), `fake_tits` varchar(255), `career_length` varchar(255), `tattoos` varchar(255), `piercings` varchar(255), `aliases` varchar(255), `favorite` boolean not null default '0', `created_at` datetime not null, `updated_at` datetime not null, `details` text, `death_date` date, `hair_color` varchar(255), `weight` integer, `rating` tinyint, `ignore_auto_tag` boolean not null default '0' ); INSERT INTO `performers_new` ( `id`, `checksum`, `name`, `gender`, `url`, `twitter`, `instagram`, `birthdate`, `ethnicity`, `country`, `eye_color`, `height`, `measurements`, `fake_tits`, `career_length`, `tattoos`, `piercings`, `aliases`, `favorite`, `created_at`, `updated_at`, `details`, `death_date`, `hair_color`, `weight`, `rating`, `ignore_auto_tag` ) SELECT `id`, `checksum`, `name`, `gender`, `url`, `twitter`, `instagram`, `birthdate`, `ethnicity`, `country`, `eye_color`, CASE `height` WHEN '' THEN NULL WHEN NULL THEN NULL ELSE CAST(`height` as int) END, `measurements`, `fake_tits`, `career_length`, `tattoos`, `piercings`, `aliases`, `favorite`, `created_at`, `updated_at`, `details`, `death_date`, `hair_color`, `weight`, `rating`, `ignore_auto_tag` FROM `performers`; DROP TABLE `performers`; ALTER TABLE `performers_new` rename to `performers`; CREATE UNIQUE INDEX `performers_checksum_unique` on `performers` (`checksum`); CREATE INDEX `index_performers_on_name` on `performers` (`name`); ================================================ FILE: pkg/sqlite/migrations/3_o_counter.up.sql ================================================ ALTER TABLE `scenes` ADD COLUMN `o_counter` tinyint not null default 0; ================================================ FILE: pkg/sqlite/migrations/40_newratings.up.sql ================================================ UPDATE `scenes` SET `rating` = (`rating` * 20) WHERE `rating` < 6; UPDATE `galleries` SET `rating` = (`rating` * 20) WHERE `rating` < 6; UPDATE `images` SET `rating` = (`rating` * 20) WHERE `rating` < 6; UPDATE `movies` SET `rating` = (`rating` * 20) WHERE `rating` < 6; UPDATE `performers` SET `rating` = (`rating` * 20) WHERE `rating` < 6; UPDATE `studios` SET `rating` = (`rating` * 20) WHERE `rating` < 6; ================================================ FILE: pkg/sqlite/migrations/41_scene_activity.up.sql ================================================ ALTER TABLE `scenes` ADD COLUMN `resume_time` float not null default 0; ALTER TABLE `scenes` ADD COLUMN `last_played_at` datetime default null; ALTER TABLE `scenes` ADD COLUMN `play_count` tinyint not null default 0; ALTER TABLE `scenes` ADD COLUMN `play_duration` float not null default 0; ================================================ FILE: pkg/sqlite/migrations/42_performer_disambig_aliases.up.sql ================================================ PRAGMA foreign_keys=OFF; CREATE TABLE `performer_aliases` ( `performer_id` integer NOT NULL, `alias` varchar(255) NOT NULL, foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE, PRIMARY KEY(`performer_id`, `alias`) ); CREATE INDEX `performer_aliases_alias` on `performer_aliases` (`alias`); DROP INDEX `performers_checksum_unique`; -- drop aliases and checksum -- add disambiguation CREATE TABLE `performers_new` ( `id` integer not null primary key autoincrement, `name` varchar(255), `disambiguation` varchar(255), `gender` varchar(20), `url` varchar(255), `twitter` varchar(255), `instagram` varchar(255), `birthdate` date, `ethnicity` varchar(255), `country` varchar(255), `eye_color` varchar(255), `height` int, `measurements` varchar(255), `fake_tits` varchar(255), `career_length` varchar(255), `tattoos` varchar(255), `piercings` varchar(255), `favorite` boolean not null default '0', `created_at` datetime not null, `updated_at` datetime not null, `details` text, `death_date` date, `hair_color` varchar(255), `weight` integer, `rating` tinyint, `ignore_auto_tag` boolean not null default '0' ); INSERT INTO `performers_new` ( `id`, `name`, `gender`, `url`, `twitter`, `instagram`, `birthdate`, `ethnicity`, `country`, `eye_color`, `height`, `measurements`, `fake_tits`, `career_length`, `tattoos`, `piercings`, `favorite`, `created_at`, `updated_at`, `details`, `death_date`, `hair_color`, `weight`, `rating`, `ignore_auto_tag` ) SELECT `id`, `name`, `gender`, `url`, `twitter`, `instagram`, `birthdate`, `ethnicity`, `country`, `eye_color`, `height`, `measurements`, `fake_tits`, `career_length`, `tattoos`, `piercings`, `favorite`, `created_at`, `updated_at`, `details`, `death_date`, `hair_color`, `weight`, `rating`, `ignore_auto_tag` FROM `performers`; INSERT INTO `performer_aliases` ( `performer_id`, `alias` ) SELECT `id`, `aliases` FROM `performers` WHERE `performers`.`aliases` IS NOT NULL AND `performers`.`aliases` != ''; DROP TABLE `performers`; ALTER TABLE `performers_new` rename to `performers`; -- these will be executed in the post-migration -- CREATE UNIQUE INDEX `performers_name_disambiguation_unique` on `performers` (`name`, `disambiguation`) WHERE `disambiguation` IS NOT NULL; -- CREATE UNIQUE INDEX `performers_name_unique` on `performers` (`name`) WHERE `disambiguation` IS NULL; PRAGMA foreign_keys=ON; ================================================ FILE: pkg/sqlite/migrations/42_postmigrate.go ================================================ package migrations import ( "context" "database/sql" "fmt" "regexp" "strconv" "strings" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sqlite" ) type schema42Migrator struct { migrator } func post42(ctx context.Context, db *sqlx.DB) error { logger.Info("Running post-migration for schema version 42") m := schema42Migrator{ migrator: migrator{ db: db, }, } if err := m.migrate(ctx); err != nil { return fmt.Errorf("migrating performer aliases: %w", err) } if err := m.migrateDuplicatePerformers(ctx); err != nil { return fmt.Errorf("migrating duplicate performers: %w", err) } // do this after duplicate performer detection, since setting disambiguation // breaks the duplicate disambiguation setting code if err := m.migratePerformersDisam(ctx); err != nil { return fmt.Errorf("migrating performer names: %w", err) } if err := m.executeSchemaChanges(); err != nil { return fmt.Errorf("executing schema changes: %w", err) } return nil } func (m *schema42Migrator) migrate(ctx context.Context) error { logger.Info("Migrating performer aliases") const ( limit = 1000 logEvery = 10000 ) lastID := 0 count := 0 for { gotSome := false if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { query := "SELECT `performer_id`, `alias` FROM `performer_aliases`" if lastID != 0 { query += fmt.Sprintf(" WHERE `performer_id` > %d ", lastID) } query += fmt.Sprintf(" ORDER BY `performer_id` LIMIT %d", limit) rows, err := tx.Query(query) if err != nil { return err } defer rows.Close() for rows.Next() { var ( id int aliases string ) err := rows.Scan(&id, &aliases) if err != nil { return err } lastID = id gotSome = true count++ if err := m.migratePerformerAliases(tx, id, aliases); err != nil { return err } } return rows.Err() }); err != nil { return err } if !gotSome { break } if count%logEvery == 0 { logger.Infof("Migrated %d rows", count) } } return nil } func (m *schema42Migrator) migratePerformerAliases(tx *sqlx.Tx, id int, aliases string) error { // split aliases by , or / aliasList := strings.FieldsFunc(aliases, func(r rune) bool { return strings.ContainsRune(",/", r) }) if len(aliasList) < 2 { // existing value is fine return nil } // delete the existing row if _, err := tx.Exec("DELETE FROM `performer_aliases` WHERE `performer_id` = ?", id); err != nil { return err } // trim whitespace from each alias for i, alias := range aliasList { aliasList[i] = strings.TrimSpace(alias) } // remove duplicates aliasList = sliceutil.AppendUniques(nil, aliasList) // insert aliases into table for _, alias := range aliasList { _, err := tx.Exec("INSERT INTO `performer_aliases` (`performer_id`, `alias`) VALUES (?, ?)", id, alias) if err != nil { return err } } return nil } func (m *schema42Migrator) migratePerformersDisam(ctx context.Context) error { logger.Info("Migrating performer disambiguation") const ( limit = 1 logEvery = 10000 ) count := 0 lastID := 0 for { gotSome := false if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { query := ` SELECT id, name FROM performers WHERE performers.name like '% (%)'` if lastID != 0 { query += fmt.Sprintf(" AND `id` > %d ", lastID) } query += fmt.Sprintf(" ORDER BY `id` LIMIT %d", limit) rows, err := tx.Query(query) if err != nil { return err } defer rows.Close() for rows.Next() { var ( id int name string ) err := rows.Scan(&id, &name) if err != nil { return err } gotSome = true lastID = id count++ if err := m.massagePerformerName(tx, id, name); err != nil { return err } } return rows.Err() }); err != nil { return err } if !gotSome { break } if count%logEvery == 0 { logger.Infof("Migrated %d performers", count) } } return nil } // extracts the performer name and disambiguation from the name field based on // the format "name (disambiguation)". var performerDisRE = regexp.MustCompile(`^((?:[^(\s]+\s)+)\(([^)]+)\)$`) func (m *schema42Migrator) massagePerformerName(tx *sqlx.Tx, performerID int, name string) error { r := performerDisRE.FindStringSubmatch(name) if len(r) != 3 { // ignore corner case invalid names return nil } // get the performer name and disambiguation from the capturing groups // trim the trailing whitespace (single only) from the name newName := strings.TrimSuffix(r[1], " ") newDis := r[2] logger.Infof("Separating %q into %q and disambiguation %q", name, newName, newDis) _, err := tx.Exec("UPDATE performers SET name = ?, disambiguation = ? WHERE id = ?", newName, newDis, performerID) if err != nil { return err } return nil } func (m *schema42Migrator) migrateDuplicatePerformers(ctx context.Context) error { logger.Info("Migrating duplicate performers") const ( limit = 1000 logEvery = 10000 ) count := 0 for { gotSome := false if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { query := ` SELECT id, name FROM performers WHERE performers.disambiguation IS NULL AND EXISTS ( SELECT 1 FROM performers p2 WHERE performers.name = p2.name AND performers.rowid > p2.rowid )` query += fmt.Sprintf(" ORDER BY `id` LIMIT %d", limit) rows, err := tx.Query(query) if err != nil { return err } defer rows.Close() for rows.Next() { var ( id int name string ) err := rows.Scan(&id, &name) if err != nil { return err } gotSome = true count++ if err := m.migrateDuplicatePerformer(tx, id, name); err != nil { return err } } return rows.Err() }); err != nil { return err } if !gotSome { break } if count%logEvery == 0 { logger.Infof("Migrated %d performers", count) } } return nil } func (m *schema42Migrator) migrateDuplicatePerformer(tx *sqlx.Tx, performerID int, name string) error { // get the highest value of disambiguation for this performer name query := ` SELECT disambiguation FROM performers WHERE name = ? ORDER BY disambiguation DESC LIMIT 1` var disambiguation sql.NullString if err := tx.Get(&disambiguation, query, name); err != nil { return err } newDisambiguation := 1 // if there is no disambiguation, set it to 1 if disambiguation.Valid { numericDis, err := strconv.Atoi(disambiguation.String) if err != nil { // shouldn't happen return err } newDisambiguation = numericDis + 1 } logger.Infof("Adding disambiguation '%d' for performer %q", newDisambiguation, name) _, err := tx.Exec("UPDATE performers SET disambiguation = ? WHERE id = ?", strconv.Itoa(newDisambiguation), performerID) if err != nil { return err } return nil } func (m *schema42Migrator) executeSchemaChanges() error { return m.execAll([]string{ "CREATE UNIQUE INDEX `performers_name_disambiguation_unique` on `performers` (`name`, `disambiguation`) WHERE `disambiguation` IS NOT NULL", "CREATE UNIQUE INDEX `performers_name_unique` on `performers` (`name`) WHERE `disambiguation` IS NULL", }) } func init() { sqlite.RegisterPostMigration(42, post42) } ================================================ FILE: pkg/sqlite/migrations/43_image_date_url.up.sql ================================================ ALTER TABLE `images` ADD COLUMN `url` varchar(255); ALTER TABLE `images` ADD COLUMN `date` date; ================================================ FILE: pkg/sqlite/migrations/44_gallery_chapters.up.sql ================================================ CREATE TABLE `galleries_chapters` ( `id` integer not null primary key autoincrement, `title` varchar(255) not null, `image_index` integer not null, `gallery_id` integer not null, `created_at` datetime not null, `updated_at` datetime not null, foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE ); CREATE INDEX `index_galleries_chapters_on_gallery_id` on `galleries_chapters` (`gallery_id`); ================================================ FILE: pkg/sqlite/migrations/45_blobs.up.sql ================================================ CREATE TABLE `blobs` ( `checksum` varchar(255) NOT NULL PRIMARY KEY, `blob` blob ); ALTER TABLE `tags` ADD COLUMN `image_blob` varchar(255) REFERENCES `blobs`(`checksum`); ALTER TABLE `studios` ADD COLUMN `image_blob` varchar(255) REFERENCES `blobs`(`checksum`); ALTER TABLE `performers` ADD COLUMN `image_blob` varchar(255) REFERENCES `blobs`(`checksum`); ALTER TABLE `scenes` ADD COLUMN `cover_blob` varchar(255) REFERENCES `blobs`(`checksum`); ALTER TABLE `movies` ADD COLUMN `front_image_blob` varchar(255) REFERENCES `blobs`(`checksum`); ALTER TABLE `movies` ADD COLUMN `back_image_blob` varchar(255) REFERENCES `blobs`(`checksum`); -- performed in the post-migration -- DROP TABLE `tags_image`; -- DROP TABLE `studios_image`; -- DROP TABLE `performers_image`; -- DROP TABLE `scenes_cover`; -- DROP TABLE `movies_images`; ================================================ FILE: pkg/sqlite/migrations/45_postmigrate.go ================================================ package migrations import ( "context" "fmt" "strings" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/hash/md5" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/sqlite" "github.com/stashapp/stash/pkg/utils" ) type schema45Migrator struct { migrator hasBlobs bool } func post45(ctx context.Context, db *sqlx.DB) error { logger.Info("Running post-migration for schema version 45") m := schema45Migrator{ migrator: migrator{ db: db, }, } if err := m.migrateImagesTable(ctx, migrateImagesTableOptions{ joinTable: "tags_image", joinIDCol: "tag_id", destTable: "tags", cols: []migrateImageToBlobOptions{ { joinImageCol: "image", destCol: "image_blob", }, }, }); err != nil { return fmt.Errorf("failed to migrate images table for tags: %w", err) } if err := m.migrateImagesTable(ctx, migrateImagesTableOptions{ joinTable: "studios_image", joinIDCol: "studio_id", destTable: "studios", cols: []migrateImageToBlobOptions{ { joinImageCol: "image", destCol: "image_blob", }, }, }); err != nil { return fmt.Errorf("failed to migrate images table for studios: %w", err) } if err := m.migrateImagesTable(ctx, migrateImagesTableOptions{ joinTable: "performers_image", joinIDCol: "performer_id", destTable: "performers", cols: []migrateImageToBlobOptions{ { joinImageCol: "image", destCol: "image_blob", }, }, }); err != nil { return fmt.Errorf("failed to migrate images table for performers: %w", err) } if err := m.migrateImagesTable(ctx, migrateImagesTableOptions{ joinTable: "scenes_cover", joinIDCol: "scene_id", destTable: "scenes", cols: []migrateImageToBlobOptions{ { joinImageCol: "cover", destCol: "cover_blob", }, }, }); err != nil { return fmt.Errorf("failed to migrate images table for scenes: %w", err) } if err := m.migrateImagesTable(ctx, migrateImagesTableOptions{ joinTable: "movies_images", joinIDCol: "movie_id", destTable: "movies", cols: []migrateImageToBlobOptions{ { joinImageCol: "front_image", destCol: "front_image_blob", }, { joinImageCol: "back_image", destCol: "back_image_blob", }, }, }); err != nil { return fmt.Errorf("failed to migrate images table for movies: %w", err) } tablesToDrop := []string{ "tags_image", "studios_image", "performers_image", "scenes_cover", "movies_images", } for _, table := range tablesToDrop { if err := m.dropTable(ctx, table); err != nil { return fmt.Errorf("failed to drop table %s: %w", table, err) } } if err := m.migrateConfig(ctx); err != nil { return fmt.Errorf("failed to migrate config: %w", err) } return nil } type migrateImageToBlobOptions struct { joinImageCol string destCol string } type migrateImagesTableOptions struct { joinTable string joinIDCol string destTable string cols []migrateImageToBlobOptions } func (o migrateImagesTableOptions) selectColumns() string { var cols []string for _, c := range o.cols { cols = append(cols, "`"+c.joinImageCol+"`") } return strings.Join(cols, ", ") } func (m *schema45Migrator) migrateImagesTable(ctx context.Context, options migrateImagesTableOptions) error { logger.Infof("Moving %s to blobs table", options.joinTable) const ( limit = 1000 logEvery = 10000 ) count := 0 for { gotSome := false if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { query := fmt.Sprintf("SELECT %s, %s FROM `%s`", options.joinIDCol, options.selectColumns(), options.joinTable) query += fmt.Sprintf(" LIMIT %d", limit) rows, err := tx.Query(query) if err != nil { return err } defer rows.Close() for rows.Next() { m.hasBlobs = true var id int result := make([]interface{}, len(options.cols)+1) result[0] = &id for i := range options.cols { v := []byte{} result[i+1] = &v } err := rows.Scan(result...) if err != nil { return err } gotSome = true count++ for i, col := range options.cols { image := result[i+1].(*[]byte) if len(*image) > 0 { if err := m.insertImage(tx, *image, id, options.destTable, col.destCol); err != nil { return err } } } // delete the row from the join table so we don't process it again deleteSQL := utils.StrFormat("DELETE FROM `{joinTable}` WHERE `{joinIDCol}` = ?", utils.StrFormatMap{ "joinTable": options.joinTable, "joinIDCol": options.joinIDCol, }) if _, err := tx.Exec(deleteSQL, id); err != nil { return err } } return rows.Err() }); err != nil { return err } if !gotSome { break } if count%logEvery == 0 { logger.Infof("Migrated %d images", count) } } return nil } func (m *schema45Migrator) insertImage(tx *sqlx.Tx, data []byte, id int, destTable string, destCol string) error { // calculate checksum and insert into blobs table checksum := md5.FromBytes(data) if _, err := tx.Exec("INSERT INTO `blobs` (`checksum`, `blob`) VALUES (?, ?) ON CONFLICT DO NOTHING", checksum, data); err != nil { return err } // set the tag image checksum updateSQL := utils.StrFormat("UPDATE `{destTable}` SET `{destCol}` = ? WHERE `id` = ?", utils.StrFormatMap{ "destTable": destTable, "destCol": destCol, }) if _, err := tx.Exec(updateSQL, checksum, id); err != nil { return err } return nil } func (m *schema45Migrator) dropTable(ctx context.Context, table string) error { if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { logger.Debugf("Dropping %s", table) _, err := tx.Exec(fmt.Sprintf("DROP TABLE `%s`", table)) return err }); err != nil { return err } return nil } func (m *schema45Migrator) migrateConfig(ctx context.Context) error { c := config.GetInstance() // if we don't have blobs, and storage is already set, then don't overwrite if !m.hasBlobs && c.GetBlobsStorage().IsValid() { logger.Infof("Blobs storage already set, not overwriting") return nil } // if we have blobs in the database, then default to database storage // otherwise default to filesystem storage defaultStorage := config.BlobStorageTypeFilesystem if m.hasBlobs || c.GetBlobsPath() == "" { defaultStorage = config.BlobStorageTypeDatabase } logger.Infof("Setting blobs storage to %s", defaultStorage.String()) c.SetInterface(config.BlobsStorage, defaultStorage) if err := c.Write(); err != nil { logger.Errorf("Error while writing configuration file: %s", err.Error()) } // if default scan settings are set, then set to generate scene covers by default scanDefaults := c.GetDefaultScanSettings() if scanDefaults != nil { scanDefaults.ScanGenerateCovers = true c.SetInterface(config.DefaultScanSettings, scanDefaults) if err := c.Write(); err != nil { logger.Errorf("Error while writing configuration file: %s", err.Error()) } } return nil } func init() { sqlite.RegisterPostMigration(45, post45) } ================================================ FILE: pkg/sqlite/migrations/46_penis_stats.up.sql ================================================ ALTER TABLE `performers` ADD COLUMN `penis_length` float; ALTER TABLE `performers` ADD COLUMN `circumcised` varchar[10]; ================================================ FILE: pkg/sqlite/migrations/47_scene_urls.up.sql ================================================ PRAGMA foreign_keys=OFF; CREATE TABLE `scene_urls` ( `scene_id` integer NOT NULL, `position` integer NOT NULL, `url` varchar(255) NOT NULL, foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE, PRIMARY KEY(`scene_id`, `position`, `url`) ); CREATE INDEX `scene_urls_url` on `scene_urls` (`url`); -- drop url CREATE TABLE "scenes_new" ( `id` integer not null primary key autoincrement, `title` varchar(255), `details` text, `date` date, `rating` tinyint, `studio_id` integer, `o_counter` tinyint not null default 0, `organized` boolean not null default '0', `created_at` datetime not null, `updated_at` datetime not null, `code` text, `director` text, `resume_time` float not null default 0, `last_played_at` datetime default null, `play_count` tinyint not null default 0, `play_duration` float not null default 0, `cover_blob` varchar(255) REFERENCES `blobs`(`checksum`), foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL ); INSERT INTO `scenes_new` ( `id`, `title`, `details`, `date`, `rating`, `studio_id`, `o_counter`, `organized`, `created_at`, `updated_at`, `code`, `director`, `resume_time`, `last_played_at`, `play_count`, `play_duration`, `cover_blob` ) SELECT `id`, `title`, `details`, `date`, `rating`, `studio_id`, `o_counter`, `organized`, `created_at`, `updated_at`, `code`, `director`, `resume_time`, `last_played_at`, `play_count`, `play_duration`, `cover_blob` FROM `scenes`; INSERT INTO `scene_urls` ( `scene_id`, `position`, `url` ) SELECT `id`, '0', `url` FROM `scenes` WHERE `scenes`.`url` IS NOT NULL AND `scenes`.`url` != ''; DROP INDEX `index_scenes_on_studio_id`; DROP TABLE `scenes`; ALTER TABLE `scenes_new` rename to `scenes`; CREATE INDEX `index_scenes_on_studio_id` on `scenes` (`studio_id`); PRAGMA foreign_keys=ON; ================================================ FILE: pkg/sqlite/migrations/48_cleanup.up.sql ================================================ PRAGMA foreign_keys=OFF; -- Cleanup old invalid dates UPDATE `scenes` SET `date` = NULL WHERE `date` = '0001-01-01' OR `date` = ''; UPDATE `galleries` SET `date` = NULL WHERE `date` = '0001-01-01' OR `date` = ''; UPDATE `performers` SET `birthdate` = NULL WHERE `birthdate` = '0001-01-01' OR `birthdate` = ''; UPDATE `performers` SET `death_date` = NULL WHERE `death_date` = '0001-01-01' OR `death_date` = ''; -- Delete scene markers with missing scenes DELETE FROM `scene_markers` WHERE `scene_id` IS NULL; -- make scene_id not null DROP INDEX `index_scene_markers_on_scene_id`; DROP INDEX `index_scene_markers_on_primary_tag_id`; CREATE TABLE `scene_markers_new` ( `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, `title` VARCHAR(255) NOT NULL, `seconds` FLOAT NOT NULL, `primary_tag_id` INTEGER NOT NULL, `scene_id` INTEGER NOT NULL, `created_at` DATETIME NOT NULL, `updated_at` DATETIME NOT NULL, FOREIGN KEY(`primary_tag_id`) REFERENCES `tags`(`id`), FOREIGN KEY(`scene_id`) REFERENCES `scenes`(`id`) ); INSERT INTO `scene_markers_new` SELECT * FROM `scene_markers`; DROP TABLE `scene_markers`; ALTER TABLE `scene_markers_new` RENAME TO `scene_markers`; CREATE INDEX `index_scene_markers_on_primary_tag_id` ON `scene_markers`(`primary_tag_id`); CREATE INDEX `index_scene_markers_on_scene_id` ON `scene_markers`(`scene_id`); -- drop unused scraped items table DROP TABLE IF EXISTS `scraped_items`; -- remove checksum from movies DROP INDEX `movies_checksum_unique`; DROP INDEX `movies_name_unique`; CREATE TABLE `movies_new` ( `id` integer not null primary key autoincrement, `name` varchar(255) not null, `aliases` varchar(255), `duration` integer, `date` date, `rating` tinyint, `studio_id` integer REFERENCES `studios`(`id`) ON DELETE SET NULL, `director` varchar(255), `synopsis` text, `url` varchar(255), `created_at` datetime not null, `updated_at` datetime not null, `front_image_blob` varchar(255) REFERENCES `blobs`(`checksum`), `back_image_blob` varchar(255) REFERENCES `blobs`(`checksum`) ); INSERT INTO `movies_new` SELECT `id`, `name`, `aliases`, `duration`, `date`, `rating`, `studio_id`, `director`, `synopsis`, `url`, `created_at`, `updated_at`, `front_image_blob`, `back_image_blob` FROM `movies`; DROP TABLE `movies`; ALTER TABLE `movies_new` RENAME TO `movies`; CREATE UNIQUE INDEX `index_movies_on_name_unique` ON `movies`(`name`); CREATE INDEX `index_movies_on_studio_id` on `movies` (`studio_id`); -- remove checksum from studios DROP INDEX `index_studios_on_checksum`; DROP INDEX `index_studios_on_name`; DROP INDEX `studios_checksum_unique`; CREATE TABLE `studios_new` ( `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, `name` VARCHAR(255) NOT NULL, `url` VARCHAR(255), `parent_id` INTEGER DEFAULT NULL CHECK (`id` IS NOT `parent_id`) REFERENCES `studios`(`id`) ON DELETE SET NULL, `created_at` DATETIME NOT NULL, `updated_at` DATETIME NOT NULL, `details` TEXT, `rating` TINYINT, `ignore_auto_tag` BOOLEAN NOT NULL DEFAULT FALSE, `image_blob` VARCHAR(255) REFERENCES `blobs`(`checksum`) ); INSERT INTO `studios_new` SELECT `id`, `name`, `url`, `parent_id`, `created_at`, `updated_at`, `details`, `rating`, `ignore_auto_tag`, `image_blob` FROM `studios`; DROP TABLE `studios`; ALTER TABLE `studios_new` RENAME TO `studios`; CREATE UNIQUE INDEX `index_studios_on_name_unique` ON `studios`(`name`); PRAGMA foreign_keys=ON; ================================================ FILE: pkg/sqlite/migrations/48_premigrate.go ================================================ package migrations import ( "context" "fmt" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/sqlite" ) func pre48(ctx context.Context, db *sqlx.DB) error { logger.Info("Running pre-migration for schema version 48") m := schema48PreMigrator{ migrator: migrator{ db: db, }, } if err := m.validateScrapedItems(ctx); err != nil { return err } if err := m.fixStudioNames(ctx); err != nil { return err } return nil } type schema48PreMigrator struct { migrator } func (m *schema48PreMigrator) validateScrapedItems(ctx context.Context) error { var count int row := m.db.QueryRowx("SELECT COUNT(*) FROM scraped_items") err := row.Scan(&count) if err != nil { return err } if count == 0 { return nil } return fmt.Errorf("found %d row(s) in scraped_items table, cannot migrate", count) } func (m *schema48PreMigrator) fixStudioNames(ctx context.Context) error { // First remove NULL names if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { _, err := tx.Exec("UPDATE studios SET name = 'NULL' WHERE name IS NULL") return err }); err != nil { return err } // Then remove duplicate names dupes := make(map[string][]int) // collect names if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { rows, err := tx.Query("SELECT id, name FROM studios ORDER BY name, id") if err != nil { return err } defer rows.Close() first := true var lastName string for rows.Next() { var ( id int name string ) err := rows.Scan(&id, &name) if err != nil { return err } if first { first = false lastName = name continue } if lastName == name { dupes[name] = append(dupes[name], id) } else { lastName = name } } return rows.Err() }); err != nil { return err } // rename them if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { for name, ids := range dupes { i := 0 for _, id := range ids { var newName string for j := 0; ; j++ { i++ newName = fmt.Sprintf("%s (%d)", name, i) var count int row := tx.QueryRowx("SELECT COUNT(*) FROM studios WHERE name = ?", newName) err := row.Scan(&count) if err != nil { return err } if count == 0 { break } // try up to 100 times to find a unique name if j == 100 { return fmt.Errorf("cannot make unique studio name for %s", name) } } logger.Infof("Renaming duplicate studio id %d to %s", id, newName) _, err := tx.Exec("UPDATE studios SET name = ? WHERE id = ?", newName, id) if err != nil { return err } } } return nil }); err != nil { return err } return nil } func init() { sqlite.RegisterPreMigration(48, pre48) } ================================================ FILE: pkg/sqlite/migrations/49_postmigrate.go ================================================ package migrations import ( "context" "encoding/json" "fmt" "reflect" "strconv" "strings" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sqlite" ) var migrate49TypeResolution = map[string][]string{ "Boolean": { /* "organized", "interactive", "ignore_auto_tag", "performer_favorite", "filter_favorites", */ }, "Int": { "id", "rating", "rating100", "o_counter", "duration", "tag_count", "age", "height", "height_cm", "weight", "scene_count", "marker_count", "image_count", "gallery_count", "performer_count", "interactive_speed", "resume_time", "play_count", "play_duration", "parent_count", "child_count", "performer_age", "file_count", }, "Float": { "penis_length", }, "Object": { "tags", "performers", "studios", "movies", "galleries", "parents", "children", "scene_tags", "performer_tags", }, } var migrate49NameChanges = map[string]string{ "rating": "rating100", "parent_studios": "parents", "child_studios": "children", "parent_tags": "parents", "child_tags": "children", "child_tag_count": "child_count", "parent_tag_count": "parent_count", "height": "height_cm", "imageIsMissing": "is_missing", "sceneIsMissing": "is_missing", "galleryIsMissing": "is_missing", "performerIsMissing": "is_missing", "tagIsMissing": "is_missing", "studioIsMissing": "is_missing", "movieIsMissing": "is_missing", "favorite": "filter_favorites", "hasMarkers": "has_markers", "parentTags": "parents", "childTags": "children", "phash": "phash_distance", "scene_code": "code", "hasChapters": "has_chapters", "sceneChecksum": "checksum", "galleryChecksum": "checksum", "sceneTags": "scene_tags", "performerTags": "performer_tags", "stash_id": "stash_id_endpoint", } func post49(ctx context.Context, db *sqlx.DB) error { logger.Info("Running post-migration for schema version 49") m := schema49Migrator{ migrator: migrator{ db: db, }, } return m.migrateSavedFilters(ctx) } type schema49Migrator struct { migrator } func (m *schema49Migrator) migrateSavedFilters(ctx context.Context) error { if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { rows, err := tx.Query("SELECT id, mode, find_filter FROM saved_filters ORDER BY id") if err != nil { return err } defer rows.Close() for rows.Next() { var ( id int mode models.FilterMode findFilter string ) err := rows.Scan(&id, &mode, &findFilter) if err != nil { return err } asRawMessage := json.RawMessage(findFilter) newFindFilter, err := m.getFindFilter(asRawMessage) if err != nil { return fmt.Errorf("failed to get find filter for saved filter %s : %w", findFilter, err) } objectFilter, err := m.getObjectFilter(mode, asRawMessage) if err != nil { return fmt.Errorf("failed to get object filter for saved filter %s : %w", findFilter, err) } uiOptions, err := m.getDisplayOptions(asRawMessage) if err != nil { return fmt.Errorf("failed to get display options for saved filter %s : %w", findFilter, err) } _, err = tx.Exec("UPDATE saved_filters SET find_filter = ?, object_filter = ?, ui_options = ? WHERE id = ?", newFindFilter, objectFilter, uiOptions, id) if err != nil { return fmt.Errorf("failed to update saved filter %d: %w", id, err) } } return rows.Err() }); err != nil { return err } return nil } func (m *schema49Migrator) getDisplayOptions(data json.RawMessage) (json.RawMessage, error) { type displayOptions struct { DisplayMode *int `json:"disp"` ZoomIndex *int `json:"z"` } var opts displayOptions if err := json.Unmarshal(data, &opts); err != nil { return nil, fmt.Errorf("failed to unmarshal display options: %w", err) } ret := make(map[string]interface{}) if opts.DisplayMode != nil { ret["display_mode"] = *opts.DisplayMode } if opts.ZoomIndex != nil { ret["zoom_index"] = *opts.ZoomIndex } return json.Marshal(ret) } func (m *schema49Migrator) getFindFilter(data json.RawMessage) (json.RawMessage, error) { type findFilterJson struct { Q *string `json:"q"` Page *int `json:"page"` PerPage *int `json:"perPage"` Sort *string `json:"sortby"` Direction *string `json:"sortdir"` } ppDefault := 40 pageDefault := 1 qDefault := "" sortDefault := "date" asc := "asc" ff := findFilterJson{Q: &qDefault, Page: &pageDefault, PerPage: &ppDefault, Sort: &sortDefault, Direction: &asc} if err := json.Unmarshal(data, &ff); err != nil { return nil, fmt.Errorf("failed to unmarshal find filter: %w", err) } newDir := strings.ToUpper(*ff.Direction) ff.Direction = &newDir type findFilterRewrite struct { Q *string `json:"q"` Page *int `json:"page"` PerPage *int `json:"per_page"` Sort *string `json:"sort"` Direction *string `json:"direction"` } fr := findFilterRewrite(ff) return json.Marshal(fr) } func (m *schema49Migrator) getObjectFilter(mode models.FilterMode, data json.RawMessage) (json.RawMessage, error) { type criteriaJson struct { Criteria []string `json:"c"` } var c criteriaJson if err := json.Unmarshal(data, &c); err != nil { return nil, fmt.Errorf("failed to unmarshal object filter: %w", err) } ret := make(map[string]interface{}) for _, raw := range c.Criteria { if err := m.convertCriterion(mode, ret, raw); err != nil { return nil, err } } return json.Marshal(ret) } func (m *schema49Migrator) convertCriterion(mode models.FilterMode, out map[string]interface{}, criterion string) error { // convert to a map ret := make(map[string]interface{}) if err := json.Unmarshal([]byte(criterion), &ret); err != nil { return fmt.Errorf("failed to unmarshal criterion: %w", err) } field := ret["type"].(string) // Some names are deprecated if newFieldName, ok := migrate49NameChanges[field]; ok { field = newFieldName } delete(ret, "type") // unset the value for IS_NULL or NOT_NULL modifiers modifier := models.CriterionModifier(ret["modifier"].(string)) if modifier == models.CriterionModifierIsNull || modifier == models.CriterionModifierNotNull { delete(ret, "value") } else { // Find out whether the object needs some adjustment/has non-string content attached // Only adjust if value is present if v, ok := ret["value"]; ok && v != nil { var err error switch { case arrayContains(migrate49TypeResolution["Boolean"], field): ret["value"], err = m.adjustCriterionValue(ret["value"], "bool") case arrayContains(migrate49TypeResolution["Int"], field): ret["value"], err = m.adjustCriterionValue(ret["value"], "int") case arrayContains(migrate49TypeResolution["Float"], field): ret["value"], err = m.adjustCriterionValue(ret["value"], "float64") case arrayContains(migrate49TypeResolution["Object"], field): ret["value"], err = m.adjustCriterionValue(ret["value"], "object") } if err != nil { return fmt.Errorf("failed to adjust criterion value for %q: %w", field, err) } } } out[field] = ret return nil } func arrayContains(sl []string, name string) bool { for _, value := range sl { if value == name { return true } } return false } // General Function for converting the types inside a criterion func (m *schema49Migrator) adjustCriterionValue(value interface{}, typ string) (interface{}, error) { if mapvalue, ok := value.(map[string]interface{}); ok { // Primitive values and lists of them var err error for _, next := range []string{"value", "value2"} { if valmap, ok := mapvalue[next].([]string); ok { var valNewMap []interface{} for index, v := range valmap { valNewMap[index], err = m.convertValue(v, typ) if err != nil { return nil, err } } mapvalue[next] = valNewMap } else if _, ok := mapvalue[next]; ok { mapvalue[next], err = m.convertValue(mapvalue[next], typ) if err != nil { return nil, err } } } // Items for _, next := range []string{"items", "excluded"} { if _, ok := mapvalue[next]; ok { mapvalue[next], err = m.adjustCriterionItem(mapvalue[next]) if err != nil { return nil, err } } } // Those Values are always Int for _, next := range []string{"Distance", "Depth"} { if _, ok := mapvalue[next]; ok { mapvalue[next], err = strconv.ParseInt(mapvalue[next].(string), 10, 64) if err != nil { return nil, err } } } return mapvalue, nil } else if _, ok := value.(string); ok { // Singular Primitive Values return m.convertValue(value, typ) } else if listvalue, ok := value.([]interface{}); ok { // Items as a singular value, as well as singular lists var err error if typ == "object" { value, err = m.adjustCriterionItem(value) if err != nil { return nil, err } } else { for index, val := range listvalue { listvalue[index], err = m.convertValue(val, typ) if err != nil { return nil, err } } value = listvalue } return value, nil } else if _, ok := value.(int); ok { return value, nil } else if _, ok := value.(float64); ok { return value, nil } return nil, fmt.Errorf("could not recognize format of value %v", value) } // Converts values inside a criterion that represent some objects, like performer or studio. func (m *schema49Migrator) adjustCriterionItem(value interface{}) (interface{}, error) { // Basically, this first converts step by step the value, after that it adjusts id and Depth (of parent/child studios) to int if itemlist, ok := value.([]interface{}); ok { var itemNewList []interface{} for _, val := range itemlist { if val, ok := val.(map[string]interface{}); ok { newItem := make(map[string]interface{}) for index, v := range val { if v, ok := v.(string); ok { switch index { case "id": if formattedOut, ok := strconv.ParseInt(v, 10, 64); ok == nil { newItem["id"] = formattedOut } case "Depth": if formattedOut, ok := strconv.ParseInt(v, 10, 64); ok == nil { newItem["Depth"] = formattedOut } default: newItem[index] = v } } } itemNewList = append(itemNewList, newItem) } } return itemNewList, nil } return nil, fmt.Errorf("could not recognize %v as an item list", value) } // Converts a value of type string to its according type, given by string func (m *schema49Migrator) convertValue(value interface{}, typ string) (interface{}, error) { valueType := reflect.TypeOf(value).Name() if typ == valueType || (typ == "int" && valueType == "float64") || (typ == "float64" && valueType == "int") || value == "" { return value, nil } if val, ok := value.(string); ok { switch typ { case "float64": return strconv.ParseFloat(val, 64) case "int": return strconv.ParseInt(val, 10, 64) case "bool": return strconv.ParseBool(val) default: return nil, fmt.Errorf("no valid conversion type for %v, need bool, int or float64", typ) } } return nil, fmt.Errorf("cannot convert %v (%T) to %s", value, value, typ) } func init() { sqlite.RegisterPostMigration(49, post49) } ================================================ FILE: pkg/sqlite/migrations/49_saved_filter_refactor.up.sql ================================================ PRAGMA foreign_keys=OFF; -- remove filter column CREATE TABLE `saved_filters_new` ( `id` integer not null primary key autoincrement, `name` varchar(510) not null, `mode` varchar(255) not null, `find_filter` blob, `object_filter` blob, `ui_options` blob ); -- move filter data into find_filter to be migrated in the post-migration INSERT INTO `saved_filters_new` ( `id`, `name`, `mode`, `find_filter` ) SELECT `id`, `name`, `mode`, `filter` FROM `saved_filters`; DROP INDEX `index_saved_filters_on_mode_name_unique`; DROP TABLE `saved_filters`; ALTER TABLE `saved_filters_new` rename to `saved_filters`; CREATE UNIQUE INDEX `index_saved_filters_on_mode_name_unique` on `saved_filters` (`mode`, `name`); PRAGMA foreign_keys=ON; ================================================ FILE: pkg/sqlite/migrations/4_movie.up.sql ================================================ CREATE TABLE `movies` ( `id` integer not null primary key autoincrement, `name` varchar(255), `aliases` varchar(255), `duration` varchar(6), `date` date, `rating` varchar(1), `director` varchar(255), `synopsis` text, `front_image` blob not null, `back_image` blob, `checksum` varchar(255) not null, `url` varchar(255), `created_at` datetime not null, `updated_at` datetime not null ); CREATE TABLE `movies_scenes` ( `movie_id` integer, `scene_id` integer, `scene_index` varchar(2), foreign key(`movie_id`) references `movies`(`id`), foreign key(`scene_id`) references `scenes`(`id`) ); ALTER TABLE `scraped_items` ADD COLUMN `movie_id` integer; CREATE UNIQUE INDEX `movies_checksum_unique` on `movies` (`checksum`); CREATE UNIQUE INDEX `index_movie_id_scene_index_unique` ON `movies_scenes` ( `movie_id`, `scene_index` ); CREATE INDEX `index_movies_scenes_on_movie_id` on `movies_scenes` (`movie_id`); CREATE INDEX `index_movies_scenes_on_scene_id` on `movies_scenes` (`scene_id`); ================================================ FILE: pkg/sqlite/migrations/50_image_urls.up.sql ================================================ PRAGMA foreign_keys=OFF; CREATE TABLE `image_urls` ( `image_id` integer NOT NULL, `position` integer NOT NULL, `url` varchar(255) NOT NULL, foreign key(`image_id`) references `images`(`id`) on delete CASCADE, PRIMARY KEY(`image_id`, `position`, `url`) ); CREATE INDEX `image_urls_url` on `image_urls` (`url`); -- drop url CREATE TABLE "images_new" ( `id` integer not null primary key autoincrement, `title` varchar(255), `rating` tinyint, `studio_id` integer, `o_counter` tinyint not null default 0, `organized` boolean not null default '0', `created_at` datetime not null, `updated_at` datetime not null, `date` date, foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL ); INSERT INTO `images_new` ( `id`, `title`, `rating`, `studio_id`, `o_counter`, `organized`, `created_at`, `updated_at`, `date` ) SELECT `id`, `title`, `rating`, `studio_id`, `o_counter`, `organized`, `created_at`, `updated_at`, `date` FROM `images`; INSERT INTO `image_urls` ( `image_id`, `position`, `url` ) SELECT `id`, '0', `url` FROM `images` WHERE `images`.`url` IS NOT NULL AND `images`.`url` != ''; DROP INDEX `index_images_on_studio_id`; DROP TABLE `images`; ALTER TABLE `images_new` rename to `images`; CREATE INDEX `index_images_on_studio_id` on `images` (`studio_id`); PRAGMA foreign_keys=ON; ================================================ FILE: pkg/sqlite/migrations/51_gallery_urls.up.sql ================================================ PRAGMA foreign_keys=OFF; CREATE TABLE `gallery_urls` ( `gallery_id` integer NOT NULL, `position` integer NOT NULL, `url` varchar(255) NOT NULL, foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE, PRIMARY KEY(`gallery_id`, `position`, `url`) ); CREATE INDEX `gallery_urls_url` on `gallery_urls` (`url`); -- drop url CREATE TABLE `galleries_new` ( `id` integer not null primary key autoincrement, `folder_id` integer, `title` varchar(255), `date` date, `details` text, `studio_id` integer, `rating` tinyint, `organized` boolean not null default '0', `created_at` datetime not null, `updated_at` datetime not null, foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL, foreign key(`folder_id`) references `folders`(`id`) on delete SET NULL ); INSERT INTO `galleries_new` ( `id`, `folder_id`, `title`, `date`, `details`, `studio_id`, `rating`, `organized`, `created_at`, `updated_at` ) SELECT `id`, `folder_id`, `title`, `date`, `details`, `studio_id`, `rating`, `organized`, `created_at`, `updated_at` FROM `galleries`; INSERT INTO `gallery_urls` ( `gallery_id`, `position`, `url` ) SELECT `id`, '0', `url` FROM `galleries` WHERE `galleries`.`url` IS NOT NULL AND `galleries`.`url` != ''; DROP INDEX `index_galleries_on_studio_id`; DROP INDEX `index_galleries_on_folder_id_unique`; DROP TABLE `galleries`; ALTER TABLE `galleries_new` rename to `galleries`; CREATE INDEX `index_galleries_on_studio_id` on `galleries` (`studio_id`); CREATE UNIQUE INDEX `index_galleries_on_folder_id_unique` on `galleries` (`folder_id`); PRAGMA foreign_keys=ON; ================================================ FILE: pkg/sqlite/migrations/52_postmigrate.go ================================================ package migrations import ( "context" "database/sql" "errors" "fmt" "path/filepath" "strings" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/sqlite" ) type schema52Migrator struct { migrator } func post52(ctx context.Context, db *sqlx.DB) error { logger.Info("Running post-migration for schema version 52") m := schema52Migrator{ migrator: migrator{ db: db, }, } return m.migrate(ctx) } func (m *schema52Migrator) migrate(ctx context.Context) error { if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { query := "SELECT `folders`.`id`, `folders`.`path`, `parent_folder`.`path` FROM `folders` " + "INNER JOIN `folders` AS `parent_folder` ON `parent_folder`.`id` = `folders`.`parent_folder_id`" rows, err := tx.Query(query) if err != nil { return err } defer rows.Close() for rows.Next() { var ( id int folderPath string parentFolderPath string ) err := rows.Scan(&id, &folderPath, &parentFolderPath) if err != nil { return err } // ensure folder path is correct if !strings.HasPrefix(folderPath, parentFolderPath) { logger.Debugf("folder path %s does not have prefix %s. Correcting...", folderPath, parentFolderPath) // get the basename of the zip folder path and append it to the correct path folderBasename := filepath.Base(folderPath) correctPath := filepath.Join(parentFolderPath, folderBasename) logger.Infof("correcting folder path %s to %s", folderPath, correctPath) // ensure the correct path is unique var v int isEmptyErr := tx.Get(&v, "SELECT 1 FROM folders WHERE path = ?", correctPath) if isEmptyErr != nil && !errors.Is(isEmptyErr, sql.ErrNoRows) { return fmt.Errorf("error checking if correct path %s is unique: %w", correctPath, isEmptyErr) } if isEmptyErr == nil { // correct path is not unique, log and skip logger.Warnf("correct path %s already exists, skipping...", correctPath) continue } if _, err := tx.Exec("UPDATE folders SET path = ? WHERE id = ?", correctPath, id); err != nil { return fmt.Errorf("error updating folder path %s to %s: %w", folderPath, correctPath, err) } } } return rows.Err() }); err != nil { return err } return nil } func init() { sqlite.RegisterPostMigration(52, post52) } ================================================ FILE: pkg/sqlite/migrations/52_zip_folder_data_correct.up.sql ================================================ -- no schema changes ================================================ FILE: pkg/sqlite/migrations/53_gallery_photographer_code.up.sql ================================================ ALTER TABLE `galleries` ADD COLUMN `code` text; ALTER TABLE `galleries` ADD COLUMN `photographer` text; ================================================ FILE: pkg/sqlite/migrations/54_image_code_details_photographer.up.sql ================================================ ALTER TABLE `images` ADD COLUMN `code` text; ALTER TABLE `images` ADD COLUMN `photographer` text; ALTER TABLE `images` ADD COLUMN `details` text; ================================================ FILE: pkg/sqlite/migrations/55_manual_history.up.sql ================================================ PRAGMA foreign_keys=OFF; CREATE TABLE `scenes_view_dates` ( `scene_id` integer, `view_date` datetime not null, foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE ); CREATE TABLE `scenes_o_dates` ( `scene_id` integer, `o_date` datetime not null, foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE ); -- drop o_counter, play_count and last_played_at CREATE TABLE "scenes_new" ( `id` integer not null primary key autoincrement, `title` varchar(255), `details` text, `date` date, `rating` tinyint, `studio_id` integer, `organized` boolean not null default '0', `created_at` datetime not null, `updated_at` datetime not null, `code` text, `director` text, `resume_time` float not null default 0, `play_duration` float not null default 0, `cover_blob` varchar(255) REFERENCES `blobs`(`checksum`), foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL ); INSERT INTO `scenes_new` ( `id`, `title`, `details`, `date`, `rating`, `studio_id`, `organized`, `created_at`, `updated_at`, `code`, `director`, `resume_time`, `play_duration`, `cover_blob` ) SELECT `id`, `title`, `details`, `date`, `rating`, `studio_id`, `organized`, `created_at`, `updated_at`, `code`, `director`, `resume_time`, `play_duration`, `cover_blob` FROM `scenes`; WITH max_view_count AS ( SELECT MAX(play_count) AS max_count FROM scenes ), numbers AS ( SELECT 1 AS n FROM max_view_count UNION ALL SELECT n + 1 FROM numbers WHERE n < (SELECT max_count FROM max_view_count) ) INSERT INTO scenes_view_dates (scene_id, view_date) SELECT scenes.id, CASE WHEN numbers.n = scenes.play_count THEN COALESCE(scenes.last_played_at, scenes.created_at) ELSE scenes.created_at END AS view_date FROM scenes JOIN numbers WHERE numbers.n <= scenes.play_count; WITH numbers AS ( SELECT 1 AS n UNION ALL SELECT n + 1 FROM numbers WHERE n < (SELECT MAX(o_counter) FROM scenes) ) INSERT INTO scenes_o_dates (scene_id, o_date) SELECT scenes.id, CASE WHEN numbers.n <= scenes.o_counter THEN scenes.created_at END AS o_date FROM scenes CROSS JOIN numbers WHERE numbers.n <= scenes.o_counter; DROP INDEX `index_scenes_on_studio_id`; DROP TABLE `scenes`; ALTER TABLE `scenes_new` rename to `scenes`; CREATE INDEX `index_scenes_on_studio_id` on `scenes` (`studio_id`); PRAGMA foreign_keys=ON; ================================================ FILE: pkg/sqlite/migrations/55_postmigrate.go ================================================ package migrations import ( "context" "fmt" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/sqlite" ) type schema55Migrator struct { migrator } func post55(ctx context.Context, db *sqlx.DB) error { logger.Info("Running post-migration for schema version 55") m := schema55Migrator{ migrator: migrator{ db: db, }, } return m.migrate(ctx) } func (m *schema55Migrator) migrate(ctx context.Context) error { // the last_played_at column was storing in a different format than the rest of the timestamps // convert the play history date to the correct format if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { query := "SELECT DISTINCT `scene_id`, `view_date` FROM `scenes_view_dates`" rows, err := tx.Query(query) if err != nil { return err } defer rows.Close() for rows.Next() { var ( id int viewDate sqlite.Timestamp ) err := rows.Scan(&id, &viewDate) if err != nil { return err } utcTimestamp := sqlite.UTCTimestamp{ Timestamp: viewDate, } // convert the timestamp to the correct format if _, err := tx.Exec("UPDATE scenes_view_dates SET view_date = ? WHERE view_date = ?", utcTimestamp, viewDate.Timestamp); err != nil { return fmt.Errorf("error correcting view date %s to %s: %w", viewDate.Timestamp, viewDate, err) } } return rows.Err() }); err != nil { return err } return nil } func init() { sqlite.RegisterPostMigration(55, post55) } ================================================ FILE: pkg/sqlite/migrations/56_studio_favorite.up.sql ================================================ ALTER TABLE `studios` ADD COLUMN `favorite` boolean not null default '0'; ================================================ FILE: pkg/sqlite/migrations/57_tag_favorite.up.sql ================================================ ALTER TABLE `tags` ADD COLUMN `favorite` boolean not null default '0'; ================================================ FILE: pkg/sqlite/migrations/58_config_correct.up.sql ================================================ -- no schema changes ================================================ FILE: pkg/sqlite/migrations/58_postmigrate.go ================================================ package migrations import ( "bytes" "context" "fmt" "os" "time" "unicode" "github.com/jmoiron/sqlx" "github.com/spf13/cast" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/sqlite" "github.com/stashapp/stash/pkg/utils" ) type schema58Migrator struct { migrator } func post58(ctx context.Context, db *sqlx.DB) error { logger.Info("Running post-migration for schema version 58") m := schema58Migrator{ migrator: migrator{ db: db, }, } return m.migrate() } func (m *schema58Migrator) migrate() error { if err := m.migrateConfig(); err != nil { return fmt.Errorf("failed to migrate config: %w", err) } return nil } // fromSnakeCase converts a string from snake_case to camelCase func (m *schema58Migrator) fromSnakeCase(v string) string { var buf bytes.Buffer leadingUnderscore := true capvar := false for i, c := range v { switch { case c == '_' && !leadingUnderscore && i > 0: capvar = true case c == '_' && leadingUnderscore: buf.WriteRune(c) case capvar: buf.WriteRune(unicode.ToUpper(c)) capvar = false default: leadingUnderscore = false buf.WriteRune(c) } } return buf.String() } // fromSnakeCaseMap recursively converts a map using snake_case keys to camelCase keys func (m *schema58Migrator) fromSnakeCaseMap(mm map[string]interface{}) map[string]interface{} { return m.fromSnakeCaseValue(mm).(map[string]interface{}) } func (m *schema58Migrator) fromSnakeCaseValue(val interface{}) interface{} { switch v := val.(type) { case map[interface{}]interface{}: ret := cast.ToStringMap(v) for k, vv := range ret { adjKey := m.fromSnakeCase(k) ret[adjKey] = m.fromSnakeCaseValue(vv) } return ret case map[string]interface{}: ret := make(map[string]interface{}) for k, vv := range v { adjKey := m.fromSnakeCase(k) ret[adjKey] = m.fromSnakeCaseValue(vv) } return ret case []interface{}: ret := make([]interface{}, len(v)) for i, vv := range v { ret[i] = m.fromSnakeCaseValue(vv) } return ret default: return v } } // renameKey renames a fully qualified key name in a map func (m *schema58Migrator) renameKey(mm map[string]interface{}, from, to string) { nm := utils.NestedMap(mm) v, found := nm.Get(from) if !found { return } nm.Delete(from) nm.Set(to, v) } func (m *schema58Migrator) renameFrontPageContentKeys(ui map[string]interface{}) { frontPageContent, found := ui["frontPageContent"].([]interface{}) if !found { return } for _, v := range frontPageContent { vm := v.(map[string]interface{}) m.renameKey(vm, "savedfilterid", "savedFilterId") m.renameKey(vm, "sortby", "sortBy") } } func (m *schema58Migrator) migrateConfig() error { c := config.GetInstance() orgPath := c.GetConfigFile() if orgPath == "" { // no config file to migrate (usually in a test) return nil } ui := c.GetUIConfiguration() if len(ui) == 0 { // no UI config to migrate return nil } // save a backup of the original config file backupPath := fmt.Sprintf("%s.57.%s", orgPath, time.Now().Format("20060102_150405")) data, err := c.Marshal() if err != nil { return fmt.Errorf("failed to marshal backup config: %w", err) } logger.Infof("Backing up config to %s", backupPath) if err := os.WriteFile(backupPath, data, 0644); err != nil { return fmt.Errorf("failed to write backup config: %w", err) } // migrate the plugin and UI configs from snake_case to camelCase if ui != nil { ui = m.fromSnakeCaseMap(ui) // find and rename specific frontEndPage keys m.renameFrontPageContentKeys(ui) c.SetUIConfiguration(ui) } plugins := c.GetAllPluginConfiguration() newPlugins := make(map[string]interface{}) for key, value := range plugins { key = m.fromSnakeCase(key) newPlugins[key] = m.fromSnakeCaseMap(value) } c.SetInterface(config.PluginsSetting, newPlugins) if err := c.Write(); err != nil { return fmt.Errorf("failed to write config: %w", err) } return nil } func init() { sqlite.RegisterPostMigration(58, post58) } ================================================ FILE: pkg/sqlite/migrations/59_movie_urls.up.sql ================================================ PRAGMA foreign_keys=OFF; CREATE TABLE `movie_urls` ( `movie_id` integer NOT NULL, `position` integer NOT NULL, `url` varchar(255) NOT NULL, foreign key(`movie_id`) references `movies`(`id`) on delete CASCADE, PRIMARY KEY(`movie_id`, `position`, `url`) ); CREATE INDEX `movie_urls_url` on `movie_urls` (`url`); -- drop url CREATE TABLE `movies_new` ( `id` integer not null primary key autoincrement, `name` varchar(255) not null, `aliases` varchar(255), `duration` integer, `date` date, `rating` tinyint, `studio_id` integer REFERENCES `studios`(`id`) ON DELETE SET NULL, `director` varchar(255), `synopsis` text, `created_at` datetime not null, `updated_at` datetime not null, `front_image_blob` varchar(255) REFERENCES `blobs`(`checksum`), `back_image_blob` varchar(255) REFERENCES `blobs`(`checksum`) ); INSERT INTO `movies_new` ( `id`, `name`, `aliases`, `duration`, `date`, `rating`, `studio_id`, `director`, `synopsis`, `created_at`, `updated_at`, `front_image_blob`, `back_image_blob` ) SELECT `id`, `name`, `aliases`, `duration`, `date`, `rating`, `studio_id`, `director`, `synopsis`, `created_at`, `updated_at`, `front_image_blob`, `back_image_blob` FROM `movies`; INSERT INTO `movie_urls` ( `movie_id`, `position`, `url` ) SELECT `id`, '0', `url` FROM `movies` WHERE `movies`.`url` IS NOT NULL AND `movies`.`url` != ''; DROP INDEX `index_movies_on_name_unique`; DROP INDEX `index_movies_on_studio_id`; DROP TABLE `movies`; ALTER TABLE `movies_new` rename to `movies`; CREATE INDEX `index_movies_on_name` ON `movies`(`name`); CREATE INDEX `index_movies_on_studio_id` on `movies` (`studio_id`); PRAGMA foreign_keys=ON; ================================================ FILE: pkg/sqlite/migrations/5_performer_gender.down.sql ================================================ PRAGMA foreign_keys=off; -- need to re-create the performers table without the added column. -- also need re-create the performers_scenes table due to the foreign key -- rename existing performers table ALTER TABLE `performers` RENAME TO `performers_old`; ALTER TABLE `performers_scenes` RENAME TO `performers_scenes_old`; -- drop the indexes DROP INDEX IF EXISTS `index_performers_on_name`; DROP INDEX IF EXISTS `index_performers_on_checksum`; DROP INDEX IF EXISTS `index_performers_scenes_on_scene_id`; DROP INDEX IF EXISTS `index_performers_scenes_on_performer_id`; -- recreate the tables CREATE TABLE `performers` ( `id` integer not null primary key autoincrement, `image` blob not null, `checksum` varchar(255) not null, `name` varchar(255), `url` varchar(255), `twitter` varchar(255), `instagram` varchar(255), `birthdate` date, `ethnicity` varchar(255), `country` varchar(255), `eye_color` varchar(255), `height` varchar(255), `measurements` varchar(255), `fake_tits` varchar(255), `career_length` varchar(255), `tattoos` varchar(255), `piercings` varchar(255), `aliases` varchar(255), `favorite` boolean not null default '0', `created_at` datetime not null, `updated_at` datetime not null ); CREATE TABLE `performers_scenes` ( `performer_id` integer, `scene_id` integer, foreign key(`performer_id`) references `performers`(`id`), foreign key(`scene_id`) references `scenes`(`id`) ); INSERT INTO `performers` SELECT `id`, `image`, `checksum`, `name`, `url`, `twitter`, `instagram`, `birthdate`, `ethnicity`, `country`, `eye_color`, `height`, `measurements`, `fake_tits`, `career_length`, `tattoos`, `piercings`, `aliases`, `favorite`, `created_at`, `updated_at` FROM `performers_old`; INSERT INTO `performers_scenes` SELECT `performer_id`, `scene_id` FROM `performers_scenes_old`; DROP TABLE `performers_scenes_old`; DROP TABLE `performers_old`; -- re-create the indexes after removing the old tables CREATE INDEX `index_performers_on_name` on `performers` (`name`); CREATE INDEX `index_performers_on_checksum` on `performers` (`checksum`); CREATE INDEX `index_performers_scenes_on_scene_id` on `performers_scenes` (`scene_id`); CREATE INDEX `index_performers_scenes_on_performer_id` on `performers_scenes` (`performer_id`); PRAGMA foreign_keys=on; ================================================ FILE: pkg/sqlite/migrations/5_performer_gender.up.sql ================================================ ALTER TABLE `performers` ADD COLUMN `gender` varchar(20); ================================================ FILE: pkg/sqlite/migrations/60_default_filter_move.up.sql ================================================ -- no schema changes -- default filters will be removed in post-migration ================================================ FILE: pkg/sqlite/migrations/60_postmigrate.go ================================================ package migrations import ( "context" "encoding/json" "fmt" "os" "strings" "time" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/sqlite" ) type schema60Migrator struct { migrator } func post60(ctx context.Context, db *sqlx.DB) error { logger.Info("Running post-migration for schema version 60") m := schema60Migrator{ migrator: migrator{ db: db, }, } return m.migrate(ctx) } func (m *schema60Migrator) decodeJSON(s string, v interface{}) { if s == "" { return } if err := json.Unmarshal([]byte(s), v); err != nil { logger.Errorf("error decoding json %q: %v", s, err) } } type schema60DefaultFilters map[string]interface{} func (m *schema60Migrator) migrate(ctx context.Context) error { // save default filters into the UI config if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { query := "SELECT id, mode, find_filter, object_filter, ui_options FROM `saved_filters` WHERE `name` = ''" rows, err := tx.Query(query) if err != nil { return err } defer rows.Close() defaultFilters := make(schema60DefaultFilters) for rows.Next() { var ( id int mode string findFilterStr string objectFilterStr string uiOptionsStr string ) if err := rows.Scan(&id, &mode, &findFilterStr, &objectFilterStr, &uiOptionsStr); err != nil { return err } // convert the filters to the correct format findFilter := make(map[string]interface{}) objectFilter := make(map[string]interface{}) uiOptions := make(map[string]interface{}) m.decodeJSON(findFilterStr, &findFilter) m.decodeJSON(objectFilterStr, &objectFilter) m.decodeJSON(uiOptionsStr, &uiOptions) o := map[string]interface{}{ "mode": mode, "find_filter": findFilter, "object_filter": objectFilter, "ui_options": uiOptions, } defaultFilters[strings.ToLower(mode)] = o } if err := rows.Err(); err != nil { return err } if err := m.saveDefaultFilters(defaultFilters); err != nil { return fmt.Errorf("saving default filters: %w", err) } // remove the default filters from the database query = "DELETE FROM `saved_filters` WHERE `name` = ''" if _, err := tx.Exec(query); err != nil { return fmt.Errorf("deleting default filters: %w", err) } return nil }); err != nil { return err } return nil } func (m *schema60Migrator) saveDefaultFilters(defaultFilters schema60DefaultFilters) error { if len(defaultFilters) == 0 { logger.Debugf("no default filters to save") return nil } // save the default filters into the UI config config := config.GetInstance() orgPath := config.GetConfigFile() if orgPath == "" { // no config file to migrate (usually in a test or new system) logger.Debugf("no config file to migrate") return nil } uiConfig := config.GetUIConfiguration() if uiConfig == nil { uiConfig = make(map[string]interface{}) } // if the defaultFilters key already exists, don't overwrite them if _, found := uiConfig["defaultFilters"]; found { logger.Warn("defaultFilters already exists in the UI config, skipping migration") return nil } if err := m.backupConfig(orgPath); err != nil { return fmt.Errorf("backing up config: %w", err) } uiConfig["defaultFilters"] = map[string]interface{}(defaultFilters) config.SetUIConfiguration(uiConfig) if err := config.Write(); err != nil { return fmt.Errorf("failed to write config: %w", err) } return nil } func (m *schema60Migrator) backupConfig(orgPath string) error { c := config.GetInstance() // save a backup of the original config file backupPath := fmt.Sprintf("%s.59.%s", orgPath, time.Now().Format("20060102_150405")) data, err := c.Marshal() if err != nil { return fmt.Errorf("failed to marshal backup config: %w", err) } logger.Infof("Backing up config to %s", backupPath) if err := os.WriteFile(backupPath, data, 0644); err != nil { return fmt.Errorf("failed to write backup config: %w", err) } return nil } func init() { sqlite.RegisterPostMigration(60, post60) } ================================================ FILE: pkg/sqlite/migrations/61_movie_tags.up.sql ================================================ CREATE TABLE `movies_tags` ( `movie_id` integer NOT NULL, `tag_id` integer NOT NULL, foreign key(`movie_id`) references `movies`(`id`) on delete CASCADE, foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE, PRIMARY KEY(`movie_id`, `tag_id`) ); CREATE INDEX `index_movies_tags_on_tag_id` on `movies_tags` (`tag_id`); CREATE INDEX `index_movies_tags_on_movie_id` on `movies_tags` (`movie_id`); ================================================ FILE: pkg/sqlite/migrations/62_performer_urls.up.sql ================================================ PRAGMA foreign_keys=OFF; CREATE TABLE `performer_urls` ( `performer_id` integer NOT NULL, `position` integer NOT NULL, `url` varchar(255) NOT NULL, foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE, PRIMARY KEY(`performer_id`, `position`, `url`) ); CREATE INDEX `performers_urls_url` on `performer_urls` (`url`); -- drop url, twitter and instagram -- make name not null CREATE TABLE `performers_new` ( `id` integer not null primary key autoincrement, `name` varchar(255) not null, `disambiguation` varchar(255), `gender` varchar(20), `birthdate` date, `ethnicity` varchar(255), `country` varchar(255), `eye_color` varchar(255), `height` int, `measurements` varchar(255), `fake_tits` varchar(255), `career_length` varchar(255), `tattoos` varchar(255), `piercings` varchar(255), `favorite` boolean not null default '0', `created_at` datetime not null, `updated_at` datetime not null, `details` text, `death_date` date, `hair_color` varchar(255), `weight` integer, `rating` tinyint, `ignore_auto_tag` boolean not null default '0', `image_blob` varchar(255) REFERENCES `blobs`(`checksum`), `penis_length` float, `circumcised` varchar[10] ); INSERT INTO `performers_new` ( `id`, `name`, `disambiguation`, `gender`, `birthdate`, `ethnicity`, `country`, `eye_color`, `height`, `measurements`, `fake_tits`, `career_length`, `tattoos`, `piercings`, `favorite`, `created_at`, `updated_at`, `details`, `death_date`, `hair_color`, `weight`, `rating`, `ignore_auto_tag`, `image_blob`, `penis_length`, `circumcised` ) SELECT `id`, `name`, `disambiguation`, `gender`, `birthdate`, `ethnicity`, `country`, `eye_color`, `height`, `measurements`, `fake_tits`, `career_length`, `tattoos`, `piercings`, `favorite`, `created_at`, `updated_at`, `details`, `death_date`, `hair_color`, `weight`, `rating`, `ignore_auto_tag`, `image_blob`, `penis_length`, `circumcised` FROM `performers`; INSERT INTO `performer_urls` ( `performer_id`, `position`, `url` ) SELECT `id`, '0', `url` FROM `performers` WHERE `performers`.`url` IS NOT NULL AND `performers`.`url` != ''; INSERT INTO `performer_urls` ( `performer_id`, `position`, `url` ) SELECT `id`, (SELECT count(*) FROM `performer_urls` WHERE `performer_id` = `performers`.`id`)+1, CASE WHEN `twitter` LIKE 'http%://%' THEN `twitter` ELSE 'https://www.twitter.com/' || `twitter` END FROM `performers` WHERE `performers`.`twitter` IS NOT NULL AND `performers`.`twitter` != ''; INSERT INTO `performer_urls` ( `performer_id`, `position`, `url` ) SELECT `id`, (SELECT count(*) FROM `performer_urls` WHERE `performer_id` = `performers`.`id`)+1, CASE WHEN `instagram` LIKE 'http%://%' THEN `instagram` ELSE 'https://www.instagram.com/' || `instagram` END FROM `performers` WHERE `performers`.`instagram` IS NOT NULL AND `performers`.`instagram` != ''; DROP INDEX IF EXISTS `performers_name_disambiguation_unique`; DROP INDEX IF EXISTS `performers_name_unique`; DROP TABLE IF EXISTS `performers`; ALTER TABLE `performers_new` rename to `performers`; CREATE UNIQUE INDEX `performers_name_disambiguation_unique` on `performers` (`name`, `disambiguation`) WHERE `disambiguation` IS NOT NULL; CREATE UNIQUE INDEX `performers_name_unique` on `performers` (`name`) WHERE `disambiguation` IS NULL; PRAGMA foreign_keys=ON; ================================================ FILE: pkg/sqlite/migrations/63_studio_tags.up.sql ================================================ CREATE TABLE `studios_tags` ( `studio_id` integer NOT NULL, `tag_id` integer NOT NULL, foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE, foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE, PRIMARY KEY(`studio_id`, `tag_id`) ); CREATE INDEX `index_studios_tags_on_tag_id` on `studios_tags` (`tag_id`); ================================================ FILE: pkg/sqlite/migrations/64_fixes.up.sql ================================================ PRAGMA foreign_keys=OFF; -- recreate scenes_view_dates adding not null to scene_id and adding indexes CREATE TABLE `scenes_view_dates_new` ( `scene_id` integer not null, `view_date` datetime not null, foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE ); INSERT INTO `scenes_view_dates_new` ( `scene_id`, `view_date` ) SELECT `scene_id`, `view_date` FROM `scenes_view_dates` WHERE `scenes_view_dates`.`scene_id` IS NOT NULL; DROP INDEX IF EXISTS `index_scenes_view_dates`; DROP TABLE `scenes_view_dates`; ALTER TABLE `scenes_view_dates_new` rename to `scenes_view_dates`; CREATE INDEX `index_scenes_view_dates` ON `scenes_view_dates` (`scene_id`); -- recreate scenes_o_dates adding not null to scene_id and adding indexes CREATE TABLE `scenes_o_dates_new` ( `scene_id` integer not null, `o_date` datetime not null, foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE ); INSERT INTO `scenes_o_dates_new` ( `scene_id`, `o_date` ) SELECT `scene_id`, `o_date` FROM `scenes_o_dates` WHERE `scenes_o_dates`.`scene_id` IS NOT NULL; DROP INDEX IF EXISTS `index_scenes_o_dates`; DROP TABLE `scenes_o_dates`; ALTER TABLE `scenes_o_dates_new` rename to `scenes_o_dates`; CREATE INDEX `index_scenes_o_dates` ON `scenes_o_dates` (`scene_id`); PRAGMA foreign_keys=ON; ================================================ FILE: pkg/sqlite/migrations/64_postmigrate.go ================================================ package migrations import ( "context" "fmt" "time" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/sqlite" ) // this is a copy of the 55 post migration // some non-UTC dates were missed, so we need to correct them type schema64Migrator struct { migrator } func post64(ctx context.Context, db *sqlx.DB) error { logger.Info("Running post-migration for schema version 64") m := schema64Migrator{ migrator: migrator{ db: db, }, } return m.migrate(ctx) } func (m *schema64Migrator) migrate(ctx context.Context) error { // the last_played_at column was storing in a different format than the rest of the timestamps // convert the play history date to the correct format if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { query := "SELECT DISTINCT `scene_id`, `view_date` FROM `scenes_view_dates`" rows, err := tx.Query(query) if err != nil { return err } defer rows.Close() for rows.Next() { var ( id int viewDate sqlite.Timestamp ) err := rows.Scan(&id, &viewDate) if err != nil { return err } // skip if already in the correct format if viewDate.Timestamp.Location() == time.UTC { logger.Debugf("view date %s is already in the correct format", viewDate.Timestamp) continue } utcTimestamp := sqlite.UTCTimestamp{ Timestamp: viewDate, } // convert the timestamp to the correct format logger.Debugf("correcting view date %q to UTC date %q for scene %d", viewDate.Timestamp, viewDate.Timestamp.UTC(), id) r, err := tx.Exec("UPDATE scenes_view_dates SET view_date = ? WHERE scene_id = ? AND (view_date = ? OR view_date = ?)", utcTimestamp, id, viewDate.Timestamp, viewDate) if err != nil { return fmt.Errorf("error correcting view date %s to %s: %w", viewDate.Timestamp, viewDate, err) } rowsAffected, err := r.RowsAffected() if err != nil { return err } if rowsAffected == 0 { return fmt.Errorf("no rows affected when updating view date %s to %s for scene %d", viewDate.Timestamp, viewDate.Timestamp.UTC(), id) } } return rows.Err() }); err != nil { return err } return nil } func init() { sqlite.RegisterPostMigration(64, post64) } ================================================ FILE: pkg/sqlite/migrations/65_movie_group_rename.up.sql ================================================ ALTER TABLE `movies` RENAME TO `groups`; ALTER TABLE `groups` RENAME COLUMN `synopsis` TO `description`; DROP INDEX `index_movies_on_name`; CREATE INDEX `index_groups_on_name` ON `groups`(`name`); DROP INDEX `index_movies_on_studio_id`; CREATE INDEX `index_groups_on_studio_id` on `groups` (`studio_id`); ALTER TABLE `movie_urls` RENAME TO `group_urls`; ALTER TABLE `group_urls` RENAME COLUMN `movie_id` TO `group_id`; DROP INDEX `movie_urls_url`; CREATE INDEX `group_urls_url` on `group_urls` (`url`); ALTER TABLE `movies_tags` RENAME TO `groups_tags`; ALTER TABLE `groups_tags` RENAME COLUMN `movie_id` TO `group_id`; DROP INDEX `index_movies_tags_on_tag_id`; CREATE INDEX `index_groups_tags_on_tag_id` on `groups_tags` (`tag_id`); DROP INDEX `index_movies_tags_on_movie_id`; CREATE INDEX `index_groups_tags_on_movie_id` on `groups_tags` (`group_id`); ALTER TABLE `movies_scenes` RENAME TO `groups_scenes`; ALTER TABLE `groups_scenes` RENAME COLUMN `movie_id` TO `group_id`; ================================================ FILE: pkg/sqlite/migrations/65_postmigrate.go ================================================ package migrations import ( "context" "fmt" "os" "time" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/sqlite" ) type schema65Migrator struct { migrator } func post65(ctx context.Context, db *sqlx.DB) error { logger.Info("Running post-migration for schema version 65") m := schema65Migrator{ migrator: migrator{ db: db, }, } return m.migrate() } func (m *schema65Migrator) migrate() error { if err := m.migrateConfig(); err != nil { return fmt.Errorf("failed to migrate config: %w", err) } return nil } func (m *schema65Migrator) migrateConfig() error { c := config.GetInstance() orgPath := c.GetConfigFile() if orgPath == "" { // no config file to migrate (usually in a test) return nil } items := c.GetMenuItems() replaced := false // replace "movies" with "groups" in the menu items for i, item := range items { if item == "movies" { items[i] = "groups" replaced = true } } if !replaced { return nil } // save a backup of the original config file backupPath := fmt.Sprintf("%s.64.%s", orgPath, time.Now().Format("20060102_150405")) data, err := c.Marshal() if err != nil { return fmt.Errorf("failed to marshal backup config: %w", err) } logger.Infof("Backing up config to %s", backupPath) if err := os.WriteFile(backupPath, data, 0644); err != nil { return fmt.Errorf("failed to write backup config: %w", err) } c.SetInterface(config.MenuItems, items) if err := c.Write(); err != nil { return fmt.Errorf("failed to write config: %w", err) } return nil } func init() { sqlite.RegisterPostMigration(65, post65) } ================================================ FILE: pkg/sqlite/migrations/66_gallery_cover.up.sql ================================================ ALTER TABLE `galleries_images` ADD COLUMN `cover` BOOLEAN NOT NULL DEFAULT 0; CREATE UNIQUE INDEX `index_galleries_images_gallery_id_cover` on `galleries_images` (`gallery_id`, `cover`) WHERE `cover` = 1; ================================================ FILE: pkg/sqlite/migrations/67_group_relationships.up.sql ================================================ CREATE TABLE `groups_relations` ( `containing_id` integer not null, `sub_id` integer not null, `order_index` integer not null, `description` varchar(255), primary key (`containing_id`, `sub_id`), foreign key (`containing_id`) references `groups`(`id`) on delete cascade, foreign key (`sub_id`) references `groups`(`id`) on delete cascade, check (`containing_id` != `sub_id`) ); CREATE INDEX `index_groups_relations_sub_id` ON `groups_relations` (`sub_id`); CREATE UNIQUE INDEX `index_groups_relations_order_index_unique` ON `groups_relations` (`containing_id`, `order_index`); ================================================ FILE: pkg/sqlite/migrations/68_image_studio_index.up.sql ================================================ -- with the existing index, if no images have a studio id, then the index is -- not used when filtering by studio id. The assumption with this change is that -- most images don't have a studio id, so filtering by non-null studio id should -- be faster with this index. This is a tradeoff, as filtering by null studio id -- will be slower. DROP INDEX index_images_on_studio_id; CREATE INDEX `index_images_on_studio_id` on `images` (`studio_id`) WHERE `studio_id` IS NOT NULL; ================================================ FILE: pkg/sqlite/migrations/69_stash_id_updated_at.up.sql ================================================ ALTER TABLE `performer_stash_ids` ADD COLUMN `updated_at` datetime not null default '1970-01-01T00:00:00Z'; ALTER TABLE `scene_stash_ids` ADD COLUMN `updated_at` datetime not null default '1970-01-01T00:00:00Z'; ALTER TABLE `studio_stash_ids` ADD COLUMN `updated_at` datetime not null default '1970-01-01T00:00:00Z'; ================================================ FILE: pkg/sqlite/migrations/6_scenes_format.up.sql ================================================ ALTER TABLE `scenes` ADD COLUMN `format` varchar(255); ================================================ FILE: pkg/sqlite/migrations/70_markers_end.up.sql ================================================ ALTER TABLE scene_markers ADD COLUMN end_seconds FLOAT; ================================================ FILE: pkg/sqlite/migrations/71_custom_fields.up.sql ================================================ CREATE TABLE `performer_custom_fields` ( `performer_id` integer NOT NULL, `field` varchar(64) NOT NULL, `value` BLOB NOT NULL, PRIMARY KEY (`performer_id`, `field`), foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE ); CREATE INDEX `index_performer_custom_fields_field_value` ON `performer_custom_fields` (`field`, `value`); ================================================ FILE: pkg/sqlite/migrations/72_tag_sort_name.up.sql ================================================ ALTER TABLE `tags` ADD COLUMN `sort_name` varchar(255); ================================================ FILE: pkg/sqlite/migrations/73_studio_urls.up.sql ================================================ CREATE TABLE `studio_urls` ( `studio_id` integer NOT NULL, `position` integer NOT NULL, `url` varchar(255) NOT NULL, foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE, PRIMARY KEY(`studio_id`, `position`, `url`) ); CREATE INDEX `studio_urls_url` on `studio_urls` (`url`); INSERT INTO `studio_urls` ( `studio_id`, `position`, `url` ) SELECT `id`, '0', `url` FROM `studios` WHERE `studios`.`url` IS NOT NULL AND `studios`.`url` != ''; ALTER TABLE `studios` DROP COLUMN `url`; ================================================ FILE: pkg/sqlite/migrations/74_tag_stash_ids.up.sql ================================================ CREATE TABLE `tag_stash_ids` ( `tag_id` integer, `endpoint` varchar(255), `stash_id` varchar(36), `updated_at` datetime not null default '1970-01-01T00:00:00Z', foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE ); ================================================ FILE: pkg/sqlite/migrations/75_date_precision.up.sql ================================================ ALTER TABLE "scenes" ADD COLUMN "date_precision" TINYINT; ALTER TABLE "images" ADD COLUMN "date_precision" TINYINT; ALTER TABLE "galleries" ADD COLUMN "date_precision" TINYINT; ALTER TABLE "groups" ADD COLUMN "date_precision" TINYINT; ALTER TABLE "performers" ADD COLUMN "birthdate_precision" TINYINT; ALTER TABLE "performers" ADD COLUMN "death_date_precision" TINYINT; UPDATE "scenes" SET "date_precision" = 0 WHERE "date" IS NOT NULL; UPDATE "images" SET "date_precision" = 0 WHERE "date" IS NOT NULL; UPDATE "galleries" SET "date_precision" = 0 WHERE "date" IS NOT NULL; UPDATE "groups" SET "date_precision" = 0 WHERE "date" IS NOT NULL; UPDATE "performers" SET "birthdate_precision" = 0 WHERE "birthdate" IS NOT NULL; UPDATE "performers" SET "death_date_precision" = 0 WHERE "death_date" IS NOT NULL; ================================================ FILE: pkg/sqlite/migrations/76_studio_custom_fields.up.sql ================================================ CREATE TABLE `studio_custom_fields` ( `studio_id` integer NOT NULL, `field` varchar(64) NOT NULL, `value` BLOB NOT NULL, PRIMARY KEY (`studio_id`, `field`), foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE ); CREATE INDEX `index_studio_custom_fields_field_value` ON `studio_custom_fields` (`field`, `value`); ================================================ FILE: pkg/sqlite/migrations/77_tag_custom_fields.up.sql ================================================ CREATE TABLE `tag_custom_fields` ( `tag_id` integer NOT NULL, `field` varchar(64) NOT NULL, `value` BLOB NOT NULL, PRIMARY KEY (`tag_id`, `field`), foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE ); CREATE INDEX `index_tag_custom_fields_field_value` ON `tag_custom_fields` (`field`, `value`); ================================================ FILE: pkg/sqlite/migrations/78_performer_career_dates.up.sql ================================================ ALTER TABLE "performers" ADD COLUMN "career_start" integer; ALTER TABLE "performers" ADD COLUMN "career_end" integer; ================================================ FILE: pkg/sqlite/migrations/78_postmigrate.go ================================================ package migrations import ( "context" "database/sql" "fmt" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sqlite" ) type schema78Migrator struct { migrator } func post78(ctx context.Context, db *sqlx.DB) error { logger.Info("Running post-migration for schema version 78") m := schema78Migrator{ migrator: migrator{ db: db, }, } if err := m.migrateCareerLength(ctx); err != nil { return fmt.Errorf("migrating career_length: %w", err) } if err := m.dropCareerLength(); err != nil { return fmt.Errorf("dropping career_length column: %w", err) } return nil } func (m *schema78Migrator) migrateCareerLength(ctx context.Context) error { logger.Info("Migrating career_length to career_start/career_end") const limit = 1000 lastID := 0 parsed := 0 unparseable := 0 for { gotSome := false if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { query := `SELECT id, career_length FROM performers WHERE career_length IS NOT NULL AND career_length != ''` if lastID != 0 { query += fmt.Sprintf(" AND id > %d", lastID) } query += fmt.Sprintf(" ORDER BY id LIMIT %d", limit) rows, err := tx.Query(query) if err != nil { return err } defer rows.Close() for rows.Next() { var ( id int careerLength string ) if err := rows.Scan(&id, &careerLength); err != nil { return err } lastID = id gotSome = true start, end, err := models.ParseYearRangeString(careerLength) if err != nil { logger.Warnf("Could not parse career_length %q for performer %d: %v — preserving as custom field", careerLength, id, err) if err := m.preserveAsCustomField(tx, id, careerLength); err != nil { return fmt.Errorf("preserving career_length for performer %d: %w", id, err) } unparseable++ continue } if err := m.updateCareerFields(tx, id, start, end); err != nil { return fmt.Errorf("updating career fields for performer %d: %w", id, err) } parsed++ } return rows.Err() }); err != nil { return err } if !gotSome { break } } logger.Infof("Career length migration complete: %d parsed, %d unparseable (preserved as custom fields)", parsed, unparseable) return nil } func (m *schema78Migrator) updateCareerFields(tx *sqlx.Tx, id int, start *models.Date, end *models.Date) error { var ( startYear, endYear *int ) if start != nil { year := start.Year() startYear = &year } if end != nil { year := end.Year() endYear = &year } _, err := tx.Exec( "UPDATE performers SET career_start = ?, career_end = ? WHERE id = ?", startYear, endYear, id, ) return err } func (m *schema78Migrator) preserveAsCustomField(tx *sqlx.Tx, id int, value string) error { // check if a career_length custom field already exists var existing sql.NullString err := tx.Get(&existing, "SELECT value FROM performer_custom_fields WHERE performer_id = ? AND field = 'career_length'", id) if err == nil { logger.Debugf("career_length custom field already exists for performer %d, skipping", id) return nil } _, err = tx.Exec( "INSERT INTO performer_custom_fields (performer_id, field, value) VALUES (?, 'career_length', ?)", id, value, ) return err } func (m *schema78Migrator) dropCareerLength() error { logger.Info("Dropping career_length column from performers table") return m.execAll([]string{ "ALTER TABLE performers DROP COLUMN career_length", }) } func init() { sqlite.RegisterPostMigration(78, post78) } ================================================ FILE: pkg/sqlite/migrations/79_scene_custom_fields.up.sql ================================================ CREATE TABLE `scene_custom_fields` ( `scene_id` integer NOT NULL, `field` varchar(64) NOT NULL, `value` BLOB NOT NULL, PRIMARY KEY (`scene_id`, `field`), foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE ); CREATE INDEX `index_scene_custom_fields_field_value` ON `scene_custom_fields` (`field`, `value`); ================================================ FILE: pkg/sqlite/migrations/7_performer_optimization.up.sql ================================================ DROP INDEX `performers_checksum_unique`; DROP INDEX `index_performers_on_name`; DROP INDEX `index_performers_on_checksum`; ALTER TABLE `performers` RENAME TO `temp_old_performers`; CREATE TABLE `performers` ( `id` integer not null primary key autoincrement, `checksum` varchar(255) not null, `name` varchar(255), `gender` varchar(20), `url` varchar(255), `twitter` varchar(255), `instagram` varchar(255), `birthdate` date, `ethnicity` varchar(255), `country` varchar(255), `eye_color` varchar(255), `height` varchar(255), `measurements` varchar(255), `fake_tits` varchar(255), `career_length` varchar(255), `tattoos` varchar(255), `piercings` varchar(255), `aliases` varchar(255), `favorite` boolean not null default '0', `created_at` datetime not null, `updated_at` datetime not null, `image` blob not null ); CREATE UNIQUE INDEX `performers_checksum_unique` on `performers` (`checksum`); CREATE INDEX `index_performers_on_name` on `performers` (`name`); INSERT INTO `performers` ( `id`, `checksum`, `name`, `gender`, `url`, `twitter`, `instagram`, `birthdate`, `ethnicity`, `country`, `eye_color`, `height`, `measurements`, `fake_tits`, `career_length`, `tattoos`, `piercings`, `aliases`, `favorite`, `created_at`, `updated_at`, `image` ) SELECT `id`, `checksum`, `name`, `gender`, `url`, `twitter`, `instagram`, `birthdate`, `ethnicity`, `country`, `eye_color`, `height`, `measurements`, `fake_tits`, `career_length`, `tattoos`, `piercings`, `aliases`, `favorite`, `created_at`, `updated_at`, `image` FROM `temp_old_performers`; DROP INDEX `index_performers_scenes_on_scene_id`; DROP INDEX `index_performers_scenes_on_performer_id`; ALTER TABLE performers_scenes RENAME TO temp_old_performers_scenes; CREATE TABLE `performers_scenes` ( `performer_id` integer, `scene_id` integer, foreign key(`performer_id`) references `performers`(`id`), foreign key(`scene_id`) references `scenes`(`id`) ); CREATE INDEX `index_performers_scenes_on_scene_id` on `performers_scenes` (`scene_id`); CREATE INDEX `index_performers_scenes_on_performer_id` on `performers_scenes` (`performer_id`); INSERT INTO `performers_scenes` ( `performer_id`, `scene_id` ) SELECT `performer_id`, `scene_id` FROM `temp_old_performers_scenes`; DROP TABLE `temp_old_performers`; DROP TABLE `temp_old_performers_scenes`; ================================================ FILE: pkg/sqlite/migrations/80_studio_organized.up.sql ================================================ ALTER TABLE `studios` ADD COLUMN `organized` boolean not null default '0'; ================================================ FILE: pkg/sqlite/migrations/81_gallery_custom_fields.up.sql ================================================ CREATE TABLE `gallery_custom_fields` ( `gallery_id` integer NOT NULL, `field` varchar(64) NOT NULL, `value` BLOB NOT NULL, PRIMARY KEY (`gallery_id`, `field`), foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE ); CREATE INDEX `index_gallery_custom_fields_field_value` ON `gallery_custom_fields` (`field`, `value`); ================================================ FILE: pkg/sqlite/migrations/82_group_custom_fields.up.sql ================================================ CREATE TABLE `group_custom_fields` ( `group_id` integer NOT NULL, `field` varchar(64) NOT NULL, `value` BLOB NOT NULL, PRIMARY KEY (`group_id`, `field`), foreign key(`group_id`) references `groups`(`id`) on delete CASCADE ); CREATE INDEX `index_group_custom_fields_field_value` ON `group_custom_fields` (`field`, `value`); ================================================ FILE: pkg/sqlite/migrations/83_image_custom_fields.up.sql ================================================ CREATE TABLE `image_custom_fields` ( `image_id` integer NOT NULL, `field` varchar(64) NOT NULL, `value` BLOB NOT NULL, PRIMARY KEY (`image_id`, `field`), foreign key(`image_id`) references `images`(`id`) on delete CASCADE ); CREATE INDEX `index_image_custom_fields_field_value` ON `image_custom_fields` (`field`, `value`); ================================================ FILE: pkg/sqlite/migrations/84_folder_basename.up.sql ================================================ -- we cannot add basename column directly because we require it to be NOT NULL -- recreate folders table with basename column PRAGMA foreign_keys=OFF; CREATE TABLE `folders_new` ( `id` integer not null primary key autoincrement, `basename` varchar(255) NOT NULL, `path` varchar(255) NOT NULL, `parent_folder_id` integer, `zip_file_id` integer REFERENCES `files`(`id`), `mod_time` datetime not null, `created_at` datetime not null, `updated_at` datetime not null, foreign key(`parent_folder_id`) references `folders`(`id`) on delete SET NULL ); -- copy data from old table to new table, setting basename to path temporarily INSERT INTO `folders_new` ( `id`, `basename`, `path`, `parent_folder_id`, `zip_file_id`, `mod_time`, `created_at`, `updated_at` ) SELECT `id`, `path`, `path`, `parent_folder_id`, `zip_file_id`, `mod_time`, `created_at`, `updated_at` FROM `folders`; DROP INDEX IF EXISTS `index_folders_on_parent_folder_id`; DROP INDEX IF EXISTS `index_folders_on_path_unique`; DROP INDEX IF EXISTS `index_folders_on_zip_file_id`; DROP TABLE `folders`; ALTER TABLE `folders_new` RENAME TO `folders`; CREATE UNIQUE INDEX `index_folders_on_path_unique` on `folders` (`path`); CREATE UNIQUE INDEX `index_folders_on_parent_folder_id_basename_unique` on `folders` (`parent_folder_id`, `basename`); CREATE INDEX `index_folders_on_zip_file_id` on `folders` (`zip_file_id`) WHERE `zip_file_id` IS NOT NULL; CREATE INDEX `index_folders_on_basename` on `folders` (`basename`); PRAGMA foreign_keys=ON; ================================================ FILE: pkg/sqlite/migrations/84_postmigrate.go ================================================ package migrations import ( "context" "database/sql" "errors" "fmt" "path/filepath" "slices" "time" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/sqlite" "gopkg.in/guregu/null.v4" ) func post84(ctx context.Context, db *sqlx.DB) error { logger.Info("Running post-migration for schema version 84") m := schema84Migrator{ migrator: migrator{ db: db, }, folderCache: make(map[string]folderInfo), } rootPaths := config.GetInstance().GetStashPaths().Paths() if err := m.createMissingFolderHierarchies(ctx, rootPaths); err != nil { return fmt.Errorf("creating missing folder hierarchies: %w", err) } if err := m.fixIncorrectParents(ctx, rootPaths); err != nil { return fmt.Errorf("fixing incorrect parent folders: %w", err) } if err := m.migrateFolders(ctx); err != nil { return fmt.Errorf("migrating folders: %w", err) } return nil } type schema84Migrator struct { migrator folderCache map[string]folderInfo } func (m *schema84Migrator) createMissingFolderHierarchies(ctx context.Context, rootPaths []string) error { // before we set the basenames, we need to address any folders that are missing their // parent folders. const ( limit = 1000 logEvery = 10000 ) lastID := 0 count := 0 logged := false for { gotSome := false if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { query := "SELECT `folders`.`id`, `folders`.`path` FROM `folders` WHERE `folders`.`parent_folder_id` IS NULL " if lastID != 0 { query += fmt.Sprintf("AND `folders`.`id` > %d ", lastID) } query += fmt.Sprintf("ORDER BY `folders`.`id` LIMIT %d", limit) rows, err := tx.Query(query) if err != nil { return err } defer rows.Close() for rows.Next() { // log once if we find any folders with missing parent folders if !logged { logger.Info("Migrating folders with missing parents...") logged = true } var id int var p string err := rows.Scan(&id, &p) if err != nil { return err } lastID = id gotSome = true count++ // don't try to create parent folders for root paths if slices.Contains(rootPaths, p) { continue } parentDir := filepath.Dir(p) if parentDir == p { // this can happen if the path is something like "C:\", where the parent directory is the same as the current directory continue } parentID, err := m.getOrCreateFolderHierarchy(tx, parentDir, rootPaths) if err != nil { return fmt.Errorf("error creating parent folder for folder %d %q: %w", id, p, err) } if parentID == nil { continue } // now set the parent folder ID for the current folder logger.Debugf("Migrating folder %d %q: setting parent folder ID to %d", id, p, *parentID) _, err = tx.Exec("UPDATE `folders` SET `parent_folder_id` = ? WHERE `id` = ?", *parentID, id) if err != nil { return fmt.Errorf("error setting parent folder for folder %d %q: %w", id, p, err) } } return rows.Err() }); err != nil { return err } if !gotSome { break } if count%logEvery == 0 { logger.Infof("Migrated %d folders", count) } } return nil } func (m *schema84Migrator) findFolderByPath(tx *sqlx.Tx, path string) (*int, error) { query := "SELECT `folders`.`id` FROM `folders` WHERE `folders`.`path` = ?" var id int if err := tx.Get(&id, query, path); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, err } return &id, nil } // this is a copy of the GetOrCreateFolderHierarchy function from pkg/file/folder.go, // but modified to use low-level SQL queries instead of the models.FolderFinderCreator interface, to avoid func (m *schema84Migrator) getOrCreateFolderHierarchy(tx *sqlx.Tx, path string, rootPaths []string) (*int, error) { // get or create folder hierarchy folderID, err := m.findFolderByPath(tx, path) if err != nil { return nil, err } if folderID == nil { var parentID *int if !slices.Contains(rootPaths, path) { parentPath := filepath.Dir(path) // it's possible that the parent path is the same as the current path, if there are folders outside // of the root paths. In that case, we should just return nil for the parent ID. if parentPath == path { return nil, nil } parentID, err = m.getOrCreateFolderHierarchy(tx, parentPath, rootPaths) if err != nil { return nil, err } } logger.Debugf("%s doesn't exist. Creating new folder entry...", path) // we need to set basename to path, which will be addressed in the next step const insertSQL = "INSERT INTO `folders` (`path`,`basename`,`parent_folder_id`,`mod_time`,`created_at`,`updated_at`) VALUES (?,?,?,?,?,?)" var parentFolderID null.Int if parentID != nil { parentFolderID = null.IntFrom(int64(*parentID)) } now := time.Now() result, err := tx.Exec(insertSQL, path, path, parentFolderID, time.Time{}, now, now) if err != nil { return nil, fmt.Errorf("creating folder %s: %w", path, err) } id, err := result.LastInsertId() if err != nil { return nil, fmt.Errorf("creating folder %s: %w", path, err) } idInt := int(id) folderID = &idInt } return folderID, nil } func (m *schema84Migrator) fixIncorrectParents(ctx context.Context, rootPaths []string) error { const ( limit = 1000 logEvery = 10000 ) lastID := 0 count := 0 fixed := 0 logged := false for { gotSome := false if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { query := "SELECT f.id, f.path, f.parent_folder_id, pf.path AS parent_path " + "FROM folders f " + "JOIN folders pf ON f.parent_folder_id = pf.id " if lastID != 0 { query += fmt.Sprintf("WHERE f.id > %d ", lastID) } query += fmt.Sprintf("ORDER BY f.id LIMIT %d", limit) rows, err := tx.Query(query) if err != nil { return err } defer rows.Close() for rows.Next() { var id int var p string var parentFolderID int var parentPath string err := rows.Scan(&id, &p, &parentFolderID, &parentPath) if err != nil { return err } lastID = id gotSome = true count++ expectedParent := filepath.Dir(p) if expectedParent == parentPath { continue } if !logged { logger.Info("Fixing folders with incorrect parent folder assignments...") logged = true } correctParentID, err := m.getOrCreateFolderHierarchy(tx, expectedParent, rootPaths) if err != nil { return fmt.Errorf("error getting/creating correct parent for folder %d %q: %w", id, p, err) } if correctParentID == nil { continue } logger.Debugf("Fixing folder %d %q: changing parent_folder_id from %d to %d", id, p, parentFolderID, *correctParentID) _, err = tx.Exec("UPDATE `folders` SET `parent_folder_id` = ? WHERE `id` = ?", *correctParentID, id) if err != nil { return fmt.Errorf("error fixing parent folder for folder %d %q: %w", id, p, err) } fixed++ } return rows.Err() }); err != nil { return err } if !gotSome { break } if count%logEvery == 0 { logger.Infof("Checked %d folders", count) } } if fixed > 0 { logger.Infof("Fixed %d folders with incorrect parent assignments", fixed) } return nil } func (m *schema84Migrator) migrateFolders(ctx context.Context) error { const ( limit = 1000 logEvery = 10000 ) lastID := 0 count := 0 logged := false for { gotSome := false if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { query := "SELECT `folders`.`id`, `folders`.`path` FROM `folders` " if lastID != 0 { query += fmt.Sprintf("WHERE `folders`.`id` > %d ", lastID) } query += fmt.Sprintf("ORDER BY `folders`.`id` LIMIT %d", limit) rows, err := tx.Query(query) if err != nil { return err } defer rows.Close() for rows.Next() { if !logged { logger.Infof("Migrating folders to set basenames...") logged = true } var id int var p string err := rows.Scan(&id, &p) if err != nil { return err } lastID = id gotSome = true count++ basename := filepath.Base(p) logger.Debugf("Migrating folder %d %q: setting basename to %q", id, p, basename) _, err = tx.Exec("UPDATE `folders` SET `basename` = ? WHERE `id` = ?", basename, id) if err != nil { return fmt.Errorf("error migrating folder %d %q: %w", id, p, err) } } return rows.Err() }); err != nil { return err } if !gotSome { break } if count%logEvery == 0 { logger.Infof("Migrated %d folders", count) } } return nil } func init() { sqlite.RegisterPostMigration(84, post84) } ================================================ FILE: pkg/sqlite/migrations/85_performer_career_dates.up.sql ================================================ -- have to change the type of the career start/end columns so need to recreate the table PRAGMA foreign_keys=OFF; CREATE TABLE IF NOT EXISTS "performers_new" ( `id` integer not null primary key autoincrement, `name` varchar(255) not null, `disambiguation` varchar(255), `gender` varchar(20), `birthdate` date, `birthdate_precision` TINYINT, `ethnicity` varchar(255), `country` varchar(255), `eye_color` varchar(255), `height` int, `measurements` varchar(255), `fake_tits` varchar(255), `tattoos` varchar(255), `piercings` varchar(255), `favorite` boolean not null default '0', `created_at` datetime not null, `updated_at` datetime not null, `details` text, `death_date` date, `death_date_precision` TINYINT, `hair_color` varchar(255), `weight` integer, `rating` tinyint, `ignore_auto_tag` boolean not null default '0', `penis_length` float, `circumcised` varchar[10], `career_start` date, `career_start_precision` TINYINT, `career_end` date, `career_end_precision` TINYINT, `image_blob` varchar(255) REFERENCES `blobs`(`checksum`) ); INSERT INTO `performers_new` ( `id`, `name`, `disambiguation`, `gender`, `birthdate`, `ethnicity`, `country`, `eye_color`, `height`, `measurements`, `fake_tits`, `tattoos`, `piercings`, `favorite`, `created_at`, `updated_at`, `details`, `death_date`, `hair_color`, `weight`, `rating`, `ignore_auto_tag`, `image_blob`, `penis_length`, `circumcised`, `birthdate_precision`, `death_date_precision`, `career_start`, `career_end` ) SELECT `id`, `name`, `disambiguation`, `gender`, `birthdate`, `ethnicity`, `country`, `eye_color`, `height`, `measurements`, `fake_tits`, `tattoos`, `piercings`, `favorite`, `created_at`, `updated_at`, `details`, `death_date`, `hair_color`, `weight`, `rating`, `ignore_auto_tag`, `image_blob`, `penis_length`, `circumcised`, `birthdate_precision`, `death_date_precision`, CAST(`career_start` AS TEXT), CAST(`career_end` AS TEXT) FROM `performers`; DROP INDEX IF EXISTS `performers_name_disambiguation_unique`; DROP INDEX IF EXISTS `performers_name_unique`; DROP TABLE `performers`; ALTER TABLE `performers_new` RENAME TO `performers`; UPDATE "performers" SET `career_start` = CONCAT(`career_start`, '-01-01'), "career_start_precision" = 2 WHERE "career_start" IS NOT NULL; UPDATE "performers" SET `career_end` = CONCAT(`career_end`, '-01-01'), "career_end_precision" = 2 WHERE "career_end" IS NOT NULL; CREATE UNIQUE INDEX `performers_name_disambiguation_unique` on `performers` (`name`, `disambiguation`) WHERE `disambiguation` IS NOT NULL; CREATE UNIQUE INDEX `performers_name_unique` on `performers` (`name`) WHERE `disambiguation` IS NULL; PRAGMA foreign_keys=ON; ================================================ FILE: pkg/sqlite/migrations/8_movie_fix.up.sql ================================================ ALTER TABLE `movies` rename to `_movies_old`; ALTER TABLE `movies_scenes` rename to `_movies_scenes_old`; DROP INDEX IF EXISTS `movies_checksum_unique`; DROP INDEX IF EXISTS `index_movie_id_scene_index_unique`; DROP INDEX IF EXISTS `index_movies_scenes_on_movie_id`; DROP INDEX IF EXISTS `index_movies_scenes_on_scene_id`; -- recreate the movies table with fixed column types and constraints CREATE TABLE `movies` ( `id` integer not null primary key autoincrement, -- add not null `name` varchar(255) not null, `aliases` varchar(255), -- varchar(6) -> integer `duration` integer, `date` date, -- varchar(1) -> tinyint `rating` tinyint, `studio_id` integer, `director` varchar(255), `synopsis` text, `checksum` varchar(255) not null, `url` varchar(255), `created_at` datetime not null, `updated_at` datetime not null, `front_image` blob not null, `back_image` blob, foreign key(`studio_id`) references `studios`(`id`) on delete set null ); CREATE TABLE `movies_scenes` ( `movie_id` integer, `scene_id` integer, -- varchar(2) -> tinyint `scene_index` tinyint, foreign key(`movie_id`) references `movies`(`id`) on delete cascade, foreign key(`scene_id`) references `scenes`(`id`) on delete cascade ); -- add unique index on movie name CREATE UNIQUE INDEX `movies_name_unique` on `movies` (`name`); CREATE UNIQUE INDEX `movies_checksum_unique` on `movies` (`checksum`); -- remove unique index on movies_scenes CREATE INDEX `index_movies_scenes_on_movie_id` on `movies_scenes` (`movie_id`); CREATE INDEX `index_movies_scenes_on_scene_id` on `movies_scenes` (`scene_id`); CREATE INDEX `index_movies_on_studio_id` on `movies` (`studio_id`); -- custom functions cannot accept NULL values, so massage the old data UPDATE `_movies_old` set `duration` = 0 WHERE `duration` IS NULL; -- now populate from the old tables INSERT INTO `movies` ( `id`, `name`, `aliases`, `duration`, `date`, `rating`, `director`, `synopsis`, `front_image`, `back_image`, `checksum`, `url`, `created_at`, `updated_at` ) SELECT `id`, `name`, `aliases`, durationToTinyInt(`duration`), `date`, CAST(`rating` as tinyint), `director`, `synopsis`, `front_image`, `back_image`, `checksum`, `url`, `created_at`, `updated_at` FROM `_movies_old` -- ignore null named movies WHERE `name` is not null; -- durationToTinyInt returns 0 if it cannot parse the string -- set these values to null instead UPDATE `movies` SET `duration` = NULL WHERE `duration` = 0; INSERT INTO `movies_scenes` ( `movie_id`, `scene_id`, `scene_index` ) SELECT `movie_id`, `scene_id`, CAST(`scene_index` as tinyint) FROM `_movies_scenes_old`; -- drop old tables DROP TABLE `_movies_scenes_old`; DROP TABLE `_movies_old`; ================================================ FILE: pkg/sqlite/migrations/9_studios_parent_studio.up.sql ================================================ ALTER TABLE studios ADD COLUMN parent_id INTEGER DEFAULT NULL CHECK ( id IS NOT parent_id ) REFERENCES studios(id) on delete set null; CREATE INDEX index_studios_on_parent_id on studios (parent_id); ================================================ FILE: pkg/sqlite/migrations/README.md ================================================ # Creating a migration 1. Create new migration file in the migrations directory with the format `NN_description.up.sql`, where `NN` is the next sequential number. 2. Update `pkg/sqlite/database.go` to update the `appSchemaVersion` value to the new migration number. For migrations requiring complex logic or config file changes, see existing custom migrations for examples. ================================================ FILE: pkg/sqlite/migrations/custom_migration.go ================================================ package migrations import ( "context" "fmt" "github.com/jmoiron/sqlx" ) type migrator struct { db *sqlx.DB } func (m *migrator) withTxn(ctx context.Context, fn func(tx *sqlx.Tx) error) error { tx, err := m.db.BeginTxx(ctx, nil) if err != nil { return fmt.Errorf("beginning transaction: %w", err) } defer func() { if p := recover(); p != nil { // a panic occurred, rollback and repanic _ = tx.Rollback() panic(p) } if err != nil { // something went wrong, rollback _ = tx.Rollback() } else { // all good, commit err = tx.Commit() } }() err = fn(tx) return err } func (m *migrator) execAll(stmts []string) error { for _, stmt := range stmts { if _, err := m.db.Exec(stmt); err != nil { return fmt.Errorf("executing statement %s: %w", stmt, err) } } return nil } ================================================ FILE: pkg/sqlite/performer.go ================================================ package sqlite import ( "context" "database/sql" "errors" "fmt" "slices" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" "gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4/zero" ) const ( performerTable = "performers" performerIDColumn = "performer_id" performersAliasesTable = "performer_aliases" performerAliasColumn = "alias" performersTagsTable = "performers_tags" performerURLsTable = "performer_urls" performerURLColumn = "url" performerImageBlobColumn = "image_blob" ) type performerRow struct { ID int `db:"id" goqu:"skipinsert"` Name null.String `db:"name"` // TODO: make schema non-nullable Disambigation zero.String `db:"disambiguation"` Gender zero.String `db:"gender"` Birthdate NullDate `db:"birthdate"` BirthdatePrecision null.Int `db:"birthdate_precision"` Ethnicity zero.String `db:"ethnicity"` Country zero.String `db:"country"` EyeColor zero.String `db:"eye_color"` Height null.Int `db:"height"` Measurements zero.String `db:"measurements"` FakeTits zero.String `db:"fake_tits"` PenisLength null.Float `db:"penis_length"` Circumcised zero.String `db:"circumcised"` CareerStart NullDate `db:"career_start"` CareerStartPrecision null.Int `db:"career_start_precision"` CareerEnd NullDate `db:"career_end"` CareerEndPrecision null.Int `db:"career_end_precision"` Tattoos zero.String `db:"tattoos"` Piercings zero.String `db:"piercings"` Favorite bool `db:"favorite"` CreatedAt Timestamp `db:"created_at"` UpdatedAt Timestamp `db:"updated_at"` // expressed as 1-100 Rating null.Int `db:"rating"` Details zero.String `db:"details"` DeathDate NullDate `db:"death_date"` DeathDatePrecision null.Int `db:"death_date_precision"` HairColor zero.String `db:"hair_color"` Weight null.Int `db:"weight"` IgnoreAutoTag bool `db:"ignore_auto_tag"` // not used in resolution or updates ImageBlob zero.String `db:"image_blob"` } func (r *performerRow) fromPerformer(o models.Performer) { r.ID = o.ID r.Name = null.StringFrom(o.Name) r.Disambigation = zero.StringFrom(o.Disambiguation) if o.Gender != nil && o.Gender.IsValid() { r.Gender = zero.StringFrom(o.Gender.String()) } r.Birthdate = NullDateFromDatePtr(o.Birthdate) r.BirthdatePrecision = datePrecisionFromDatePtr(o.Birthdate) r.Ethnicity = zero.StringFrom(o.Ethnicity) r.Country = zero.StringFrom(o.Country) r.EyeColor = zero.StringFrom(o.EyeColor) r.Height = intFromPtr(o.Height) r.Measurements = zero.StringFrom(o.Measurements) r.FakeTits = zero.StringFrom(o.FakeTits) r.PenisLength = null.FloatFromPtr(o.PenisLength) if o.Circumcised != nil && o.Circumcised.IsValid() { r.Circumcised = zero.StringFrom(o.Circumcised.String()) } r.CareerStart = NullDateFromDatePtr(o.CareerStart) r.CareerStartPrecision = datePrecisionFromDatePtr(o.CareerStart) r.CareerEnd = NullDateFromDatePtr(o.CareerEnd) r.CareerEndPrecision = datePrecisionFromDatePtr(o.CareerEnd) r.Tattoos = zero.StringFrom(o.Tattoos) r.Piercings = zero.StringFrom(o.Piercings) r.Favorite = o.Favorite r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} r.Rating = intFromPtr(o.Rating) r.Details = zero.StringFrom(o.Details) r.DeathDate = NullDateFromDatePtr(o.DeathDate) r.DeathDatePrecision = datePrecisionFromDatePtr(o.DeathDate) r.HairColor = zero.StringFrom(o.HairColor) r.Weight = intFromPtr(o.Weight) r.IgnoreAutoTag = o.IgnoreAutoTag } func (r *performerRow) resolve() *models.Performer { ret := &models.Performer{ ID: r.ID, Name: r.Name.String, Disambiguation: r.Disambigation.String, Birthdate: r.Birthdate.DatePtr(r.BirthdatePrecision), Ethnicity: r.Ethnicity.String, Country: r.Country.String, EyeColor: r.EyeColor.String, Height: nullIntPtr(r.Height), Measurements: r.Measurements.String, FakeTits: r.FakeTits.String, PenisLength: nullFloatPtr(r.PenisLength), CareerStart: r.CareerStart.DatePtr(r.CareerStartPrecision), CareerEnd: r.CareerEnd.DatePtr(r.CareerEndPrecision), Tattoos: r.Tattoos.String, Piercings: r.Piercings.String, Favorite: r.Favorite, CreatedAt: r.CreatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp, // expressed as 1-100 Rating: nullIntPtr(r.Rating), Details: r.Details.String, DeathDate: r.DeathDate.DatePtr(r.DeathDatePrecision), HairColor: r.HairColor.String, Weight: nullIntPtr(r.Weight), IgnoreAutoTag: r.IgnoreAutoTag, } if r.Gender.ValueOrZero() != "" { v := models.GenderEnum(r.Gender.String) ret.Gender = &v } if r.Circumcised.ValueOrZero() != "" { v := models.CircumcisedEnum(r.Circumcised.String) ret.Circumcised = &v } return ret } type performerRowRecord struct { updateRecord } func (r *performerRowRecord) fromPartial(o models.PerformerPartial) { r.setString("name", o.Name) r.setNullString("disambiguation", o.Disambiguation) r.setNullString("gender", o.Gender) r.setNullDate("birthdate", "birthdate_precision", o.Birthdate) r.setNullString("ethnicity", o.Ethnicity) r.setNullString("country", o.Country) r.setNullString("eye_color", o.EyeColor) r.setNullInt("height", o.Height) r.setNullString("measurements", o.Measurements) r.setNullString("fake_tits", o.FakeTits) r.setNullFloat64("penis_length", o.PenisLength) r.setNullString("circumcised", o.Circumcised) r.setNullDate("career_start", "career_start_precision", o.CareerStart) r.setNullDate("career_end", "career_end_precision", o.CareerEnd) r.setNullString("tattoos", o.Tattoos) r.setNullString("piercings", o.Piercings) r.setBool("favorite", o.Favorite) r.setTimestamp("created_at", o.CreatedAt) r.setTimestamp("updated_at", o.UpdatedAt) r.setNullInt("rating", o.Rating) r.setNullString("details", o.Details) r.setNullDate("death_date", "death_date_precision", o.DeathDate) r.setNullString("hair_color", o.HairColor) r.setNullInt("weight", o.Weight) r.setBool("ignore_auto_tag", o.IgnoreAutoTag) } type performerRepositoryType struct { repository tags joinRepository stashIDs stashIDRepository scenes joinRepository images joinRepository galleries joinRepository } var ( performerRepository = performerRepositoryType{ repository: repository{ tableName: performerTable, idColumn: idColumn, }, tags: joinRepository{ repository: repository{ tableName: performersTagsTable, idColumn: performerIDColumn, }, fkColumn: tagIDColumn, foreignTable: tagTable, orderBy: tagTableSortSQL, }, stashIDs: stashIDRepository{ repository{ tableName: "performer_stash_ids", idColumn: performerIDColumn, }, }, scenes: joinRepository{ repository: repository{ tableName: performersScenesTable, idColumn: performerIDColumn, }, fkColumn: sceneIDColumn, foreignTable: sceneTable, }, images: joinRepository{ repository: repository{ tableName: performersImagesTable, idColumn: performerIDColumn, }, fkColumn: imageIDColumn, foreignTable: imageTable, }, galleries: joinRepository{ repository: repository{ tableName: performersGalleriesTable, idColumn: performerIDColumn, }, fkColumn: galleryIDColumn, foreignTable: galleryTable, }, } ) type PerformerStore struct { blobJoinQueryBuilder customFieldsStore tableMgr *table } func NewPerformerStore(blobStore *BlobStore) *PerformerStore { return &PerformerStore{ blobJoinQueryBuilder: blobJoinQueryBuilder{ blobStore: blobStore, joinTable: performerTable, }, customFieldsStore: customFieldsStore{ table: performersCustomFieldsTable, fk: performersCustomFieldsTable.Col(performerIDColumn), }, tableMgr: performerTableMgr, } } func (qb *PerformerStore) table() exp.IdentifierExpression { return qb.tableMgr.table } func (qb *PerformerStore) selectDataset() *goqu.SelectDataset { return dialect.From(qb.table()).Select(qb.table().All()) } func (qb *PerformerStore) Create(ctx context.Context, newObject *models.CreatePerformerInput) error { var r performerRow r.fromPerformer(*newObject.Performer) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { return err } if newObject.Aliases.Loaded() { if err := performersAliasesTableMgr.insertJoins(ctx, id, newObject.Aliases.List()); err != nil { return err } } if newObject.URLs.Loaded() { const startPos = 0 if err := performersURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { return err } } if newObject.TagIDs.Loaded() { if err := performersTagsTableMgr.insertJoins(ctx, id, newObject.TagIDs.List()); err != nil { return err } } if newObject.StashIDs.Loaded() { if err := performersStashIDsTableMgr.insertJoins(ctx, id, newObject.StashIDs.List()); err != nil { return err } } const partial = false if err := qb.setCustomFields(ctx, id, newObject.CustomFields, partial); err != nil { return err } updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } *newObject.Performer = *updated return nil } func (qb *PerformerStore) UpdatePartial(ctx context.Context, id int, partial models.PerformerPartial) (*models.Performer, error) { r := performerRowRecord{ updateRecord{ Record: make(exp.Record), }, } r.fromPartial(partial) if len(r.Record) > 0 { if err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil { return nil, err } } if partial.Aliases != nil { if err := performersAliasesTableMgr.modifyJoins(ctx, id, partial.Aliases.Values, partial.Aliases.Mode); err != nil { return nil, err } } if partial.URLs != nil { if err := performersURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { return nil, err } } if partial.TagIDs != nil { if err := performersTagsTableMgr.modifyJoins(ctx, id, partial.TagIDs.IDs, partial.TagIDs.Mode); err != nil { return nil, err } } if partial.StashIDs != nil { if err := performersStashIDsTableMgr.modifyJoins(ctx, id, partial.StashIDs.StashIDs, partial.StashIDs.Mode); err != nil { return nil, err } } if err := qb.SetCustomFields(ctx, id, partial.CustomFields); err != nil { return nil, err } return qb.find(ctx, id) } func (qb *PerformerStore) Update(ctx context.Context, updatedObject *models.UpdatePerformerInput) error { var r performerRow r.fromPerformer(*updatedObject.Performer) if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { return err } if updatedObject.Aliases.Loaded() { if err := performersAliasesTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.Aliases.List()); err != nil { return err } } if updatedObject.URLs.Loaded() { if err := performersURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { return err } } if updatedObject.TagIDs.Loaded() { if err := performersTagsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.TagIDs.List()); err != nil { return err } } if updatedObject.StashIDs.Loaded() { if err := performersStashIDsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.StashIDs.List()); err != nil { return err } } if err := qb.SetCustomFields(ctx, updatedObject.ID, updatedObject.CustomFields); err != nil { return err } return nil } func (qb *PerformerStore) Destroy(ctx context.Context, id int) error { // must handle image checksums manually if err := qb.destroyImage(ctx, id); err != nil { return err } return performerRepository.destroyExisting(ctx, []int{id}) } // returns nil, nil if not found func (qb *PerformerStore) Find(ctx context.Context, id int) (*models.Performer, error) { ret, err := qb.find(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } return ret, err } func (qb *PerformerStore) FindMany(ctx context.Context, ids []int) ([]*models.Performer, error) { tableMgr := performerTableMgr ret := make([]*models.Performer, len(ids)) if err := batchExec(ids, defaultBatchSize, func(batch []int) error { q := goqu.Select("*").From(tableMgr.table).Where(tableMgr.byIDInts(batch...)) unsorted, err := qb.getMany(ctx, q) if err != nil { return err } for _, s := range unsorted { i := slices.Index(ids, s.ID) ret[i] = s } return nil }); err != nil { return nil, err } for i := range ret { if ret[i] == nil { return nil, fmt.Errorf("performer with id %d not found", ids[i]) } } return ret, nil } // returns nil, sql.ErrNoRows if not found func (qb *PerformerStore) find(ctx context.Context, id int) (*models.Performer, error) { q := qb.selectDataset().Where(qb.tableMgr.byID(id)) ret, err := qb.get(ctx, q) if err != nil { return nil, err } return ret, nil } func (qb *PerformerStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]*models.Performer, error) { table := qb.table() q := qb.selectDataset().Where( table.Col(idColumn).Eq( sq, ), ) return qb.getMany(ctx, q) } // returns nil, sql.ErrNoRows if not found func (qb *PerformerStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Performer, error) { ret, err := qb.getMany(ctx, q) if err != nil { return nil, err } if len(ret) == 0 { return nil, sql.ErrNoRows } return ret[0], nil } func (qb *PerformerStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Performer, error) { const single = false var ret []*models.Performer if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { var f performerRow if err := r.StructScan(&f); err != nil { return err } s := f.resolve() ret = append(ret, s) return nil }); err != nil { return nil, err } return ret, nil } func (qb *PerformerStore) FindBySceneID(ctx context.Context, sceneID int) ([]*models.Performer, error) { sq := dialect.From(scenesPerformersJoinTable).Select(scenesPerformersJoinTable.Col(performerIDColumn)).Where( scenesPerformersJoinTable.Col(sceneIDColumn).Eq(sceneID), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting performers for scene %d: %w", sceneID, err) } return ret, nil } func (qb *PerformerStore) FindByImageID(ctx context.Context, imageID int) ([]*models.Performer, error) { sq := dialect.From(performersImagesJoinTable).Select(performersImagesJoinTable.Col(performerIDColumn)).Where( performersImagesJoinTable.Col(imageIDColumn).Eq(imageID), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting performers for image %d: %w", imageID, err) } return ret, nil } func (qb *PerformerStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Performer, error) { sq := dialect.From(performersGalleriesJoinTable).Select(performersGalleriesJoinTable.Col(performerIDColumn)).Where( performersGalleriesJoinTable.Col(galleryIDColumn).Eq(galleryID), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting performers for gallery %d: %w", galleryID, err) } return ret, nil } func (qb *PerformerStore) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Performer, error) { clause := "name " if nocase { clause += "COLLATE NOCASE " } clause += "IN " + getInBinding(len(names)) var args []interface{} for _, name := range names { args = append(args, name) } sq := qb.selectDataset().Prepared(true).Where( goqu.L(clause, args...), ) ret, err := qb.getMany(ctx, sq) if err != nil { return nil, fmt.Errorf("getting performers by names: %w", err) } return ret, nil } func (qb *PerformerStore) CountByTagID(ctx context.Context, tagID int) (int, error) { joinTable := performersTagsJoinTable q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col(tagIDColumn).Eq(tagID)) return count(ctx, q) } func (qb *PerformerStore) Count(ctx context.Context) (int, error) { q := dialect.Select(goqu.COUNT("*")).From(qb.table()) return count(ctx, q) } func (qb *PerformerStore) All(ctx context.Context) ([]*models.Performer, error) { table := qb.table() return qb.getMany(ctx, qb.selectDataset().Order(table.Col("name").Asc())) } func (qb *PerformerStore) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Performer, error) { // TODO - Query needs to be changed to support queries of this type, and // this method should be removed table := qb.table() sq := dialect.From(table).Select(table.Col(idColumn)) // TODO - disabled alias matching until we get finer control over it // .LeftJoin( // performersAliasesJoinTable, // goqu.On(performersAliasesJoinTable.Col(performerIDColumn).Eq(table.Col(idColumn))), // ) var whereClauses []exp.Expression for _, w := range words { whereClauses = append(whereClauses, table.Col("name").Like(w+"%")) // TODO - see above // whereClauses = append(whereClauses, performersAliasesJoinTable.Col("alias").Like(w+"%")) } sq = sq.Where( goqu.Or(whereClauses...), table.Col("ignore_auto_tag").Eq(0), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting performers for autotag: %w", err) } return ret, nil } func (qb *PerformerStore) makeQuery(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if performerFilter == nil { performerFilter = &models.PerformerFilterType{} } if findFilter == nil { findFilter = &models.FindFilterType{} } query := performerRepository.newQuery() distinctIDs(&query, performerTable) if q := findFilter.Q; q != nil && *q != "" { query.join(performersAliasesTable, "", "performer_aliases.performer_id = performers.id") searchColumns := []string{"performers.name", "performer_aliases.alias"} query.parseQueryString(searchColumns, *q) } filter := filterBuilderFromHandler(ctx, &performerFilterHandler{ performerFilter: performerFilter, }) if err := query.addFilter(filter); err != nil { return nil, err } var err error query.sortAndPagination, err = qb.getPerformerSort(findFilter) if err != nil { return nil, err } query.sortAndPagination += getPagination(findFilter) return &query, nil } func (qb *PerformerStore) Query(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) { query, err := qb.makeQuery(ctx, performerFilter, findFilter) if err != nil { return nil, 0, err } idsResult, countResult, err := query.executeFind(ctx) if err != nil { return nil, 0, err } performers, err := qb.FindMany(ctx, idsResult) if err != nil { return nil, 0, err } return performers, countResult, nil } func (qb *PerformerStore) QueryCount(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) (int, error) { query, err := qb.makeQuery(ctx, performerFilter, findFilter) if err != nil { return 0, err } return query.executeCount(ctx) } func (qb *PerformerStore) sortByOCounter(direction string) string { // need to sum the o_counter from scenes and images return " ORDER BY (" + selectPerformerOCountSQL + ") " + direction } func (qb *PerformerStore) sortByPlayCount(direction string) string { // need to sum the o_counter from scenes and images return " ORDER BY (" + selectPerformerPlayCountSQL + ") " + direction } // used for sorting on performer last o_date var selectPerformerLastOAtSQL = utils.StrFormat( "SELECT MAX(o_date) FROM ("+ "SELECT {o_date} FROM {performers_scenes} s "+ "LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} "+ "LEFT JOIN {scenes_o_dates} ON {scenes_o_dates}.{scene_id} = {scenes}.id "+ "WHERE s.{performer_id} = {performers}.id"+ ")", map[string]interface{}{ "performer_id": performerIDColumn, "performers": performerTable, "performers_scenes": performersScenesTable, "scenes": sceneTable, "scene_id": sceneIDColumn, "scenes_o_dates": scenesODatesTable, "o_date": sceneODateColumn, }, ) func (qb *PerformerStore) sortByLastOAt(direction string) string { // need to get the o_dates from scenes return " ORDER BY (" + selectPerformerLastOAtSQL + ") " + direction } // used for sorting on performer latest scene var selectPerformerLatestSceneSQL = utils.StrFormat( "SELECT MAX(date) FROM ("+ "SELECT {date} FROM {performers_scenes} s "+ "LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} "+ "WHERE s.{performer_id} = {performers}.id"+ ")", map[string]interface{}{ "performer_id": performerIDColumn, "performers": performerTable, "performers_scenes": performersScenesTable, "scenes": sceneTable, "scene_id": sceneIDColumn, "date": sceneDateColumn, }, ) func (qb *PerformerStore) sortByLatestScene(direction string) string { // need to get the latest date from scenes return " ORDER BY (" + selectPerformerLatestSceneSQL + ") " + direction } // used for sorting on performer last view_date var selectPerformerLastPlayedAtSQL = utils.StrFormat( "SELECT MAX(view_date) FROM ("+ "SELECT {view_date} FROM {performers_scenes} s "+ "LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} "+ "LEFT JOIN {scenes_view_dates} ON {scenes_view_dates}.{scene_id} = {scenes}.id "+ "WHERE s.{performer_id} = {performers}.id"+ ")", map[string]interface{}{ "performer_id": performerIDColumn, "performers": performerTable, "performers_scenes": performersScenesTable, "scenes": sceneTable, "scene_id": sceneIDColumn, "scenes_view_dates": scenesViewDatesTable, "view_date": sceneViewDateColumn, }, ) func (qb *PerformerStore) sortByLastPlayedAt(direction string) string { // need to get the view_dates from scenes return " ORDER BY (" + selectPerformerLastPlayedAtSQL + ") " + direction } // used for sorting by total scene duration var selectPerformerScenesDurationSQL = utils.StrFormat( "SELECT COALESCE(SUM(video_files.duration), 0) FROM {performers_scenes} s "+ "LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} "+ "LEFT JOIN {scenes_files} ON {scenes_files}.{scene_id} = {scenes}.id "+ "LEFT JOIN video_files ON video_files.file_id = {scenes_files}.file_id "+ "WHERE s.{performer_id} = {performers}.id", map[string]interface{}{ "performer_id": performerIDColumn, "performers": performerTable, "performers_scenes": performersScenesTable, "scenes": sceneTable, "scene_id": sceneIDColumn, "scenes_files": scenesFilesTable, }, ) func (qb *PerformerStore) sortByScenesDuration(direction string) string { // need to sum duration from all scenes for this performer return " ORDER BY (" + selectPerformerScenesDurationSQL + ") " + direction } // used for sorting by total scene file size var selectPerformerScenesSizeSQL = utils.StrFormat( "SELECT COALESCE(SUM({files}.size), 0) FROM {performers_scenes} s "+ "LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} "+ "LEFT JOIN {scenes_files} ON {scenes_files}.{scene_id} = {scenes}.id "+ "LEFT JOIN {files} ON {files}.id = {scenes_files}.file_id "+ "WHERE s.{performer_id} = {performers}.id", map[string]interface{}{ "performer_id": performerIDColumn, "performers": performerTable, "performers_scenes": performersScenesTable, "scenes": sceneTable, "scene_id": sceneIDColumn, "scenes_files": scenesFilesTable, "files": fileTable, }, ) func (qb *PerformerStore) sortByScenesSize(direction string) string { return " ORDER BY (" + selectPerformerScenesSizeSQL + ") " + direction } var performerSortOptions = sortOptions{ "birthdate", "career_start", "career_end", "created_at", "galleries_count", "height", "id", "images_count", "last_o_at", "last_played_at", "latest_scene", "measurements", "name", "o_counter", "penis_length", "play_count", "random", "rating", "scenes_count", "scenes_duration", "scenes_size", "tag_count", "updated_at", "weight", } func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (string, error) { var sort string var direction string if findFilter == nil { sort = "name" direction = "ASC" } else { sort = findFilter.GetSort("name") direction = findFilter.GetDirection() } // CVE-2024-32231 - ensure sort is in the list of allowed sorts if err := performerSortOptions.validateSort(sort); err != nil { return "", err } sortQuery := "" switch sort { case "tag_count": sortQuery += getCountSort(performerTable, performersTagsTable, performerIDColumn, direction) case "scenes_count": sortQuery += getCountSort(performerTable, performersScenesTable, performerIDColumn, direction) case "scenes_duration": sortQuery += qb.sortByScenesDuration(direction) case "scenes_size": sortQuery += qb.sortByScenesSize(direction) case "images_count": sortQuery += getCountSort(performerTable, performersImagesTable, performerIDColumn, direction) case "galleries_count": sortQuery += getCountSort(performerTable, performersGalleriesTable, performerIDColumn, direction) case "play_count": sortQuery += qb.sortByPlayCount(direction) case "o_counter": sortQuery += qb.sortByOCounter(direction) case "last_played_at": sortQuery += qb.sortByLastPlayedAt(direction) case "last_o_at": sortQuery += qb.sortByLastOAt(direction) case "latest_scene": sortQuery += qb.sortByLatestScene(direction) default: sortQuery += getSort(sort, direction, "performers") } // Whatever the sorting, always use name/id as a final sort sortQuery += ", COALESCE(performers.name, performers.id) COLLATE NATURAL_CI ASC" return sortQuery, nil } func (qb *PerformerStore) GetTagIDs(ctx context.Context, id int) ([]int, error) { return performerRepository.tags.getIDs(ctx, id) } func (qb *PerformerStore) GetImage(ctx context.Context, performerID int) ([]byte, error) { return qb.blobJoinQueryBuilder.GetImage(ctx, performerID, performerImageBlobColumn) } func (qb *PerformerStore) HasImage(ctx context.Context, performerID int) (bool, error) { return qb.blobJoinQueryBuilder.HasImage(ctx, performerID, performerImageBlobColumn) } func (qb *PerformerStore) UpdateImage(ctx context.Context, performerID int, image []byte) error { return qb.blobJoinQueryBuilder.UpdateImage(ctx, performerID, performerImageBlobColumn, image) } func (qb *PerformerStore) destroyImage(ctx context.Context, performerID int) error { return qb.blobJoinQueryBuilder.DestroyImage(ctx, performerID, performerImageBlobColumn) } func (qb *PerformerStore) GetAliases(ctx context.Context, performerID int) ([]string, error) { return performersAliasesTableMgr.get(ctx, performerID) } func (qb *PerformerStore) GetURLs(ctx context.Context, performerID int) ([]string, error) { return performersURLsTableMgr.get(ctx, performerID) } func (qb *PerformerStore) GetStashIDs(ctx context.Context, performerID int) ([]models.StashID, error) { return performersStashIDsTableMgr.get(ctx, performerID) } func (qb *PerformerStore) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Performer, error) { sq := dialect.From(performersStashIDsJoinTable).Select(performersStashIDsJoinTable.Col(performerIDColumn)).Where( performersStashIDsJoinTable.Col("stash_id").Eq(stashID.StashID), performersStashIDsJoinTable.Col("endpoint").Eq(stashID.Endpoint), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting performers for stash ID %s: %w", stashID.StashID, err) } return ret, nil } func (qb *PerformerStore) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Performer, error) { table := qb.table() sq := dialect.From(table).LeftJoin( performersStashIDsJoinTable, goqu.On(table.Col(idColumn).Eq(performersStashIDsJoinTable.Col(performerIDColumn))), ).Select(table.Col(idColumn)) if hasStashID { sq = sq.Where( performersStashIDsJoinTable.Col("stash_id").IsNotNull(), performersStashIDsJoinTable.Col("endpoint").Eq(stashboxEndpoint), ) } else { sq = sq.Where( performersStashIDsJoinTable.Col("stash_id").IsNull(), ) } ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting performers for stash-box endpoint %s: %w", stashboxEndpoint, err) } return ret, nil } func (qb *PerformerStore) Merge(ctx context.Context, source []int, destination int) error { if len(source) == 0 { return nil } inBinding := getInBinding(len(source)) args := []interface{}{destination} srcArgs := make([]interface{}, len(source)) for i, id := range source { if id == destination { return errors.New("cannot merge where source == destination") } srcArgs[i] = id } args = append(args, srcArgs...) performerTables := map[string]string{ performersScenesTable: sceneIDColumn, performersGalleriesTable: galleryIDColumn, performersImagesTable: imageIDColumn, performersTagsTable: tagIDColumn, } args = append(args, destination) // for each table, update source performer ids to destination performer id, ignoring duplicates for table, idColumn := range performerTables { _, err := dbWrapper.Exec(ctx, `UPDATE OR IGNORE `+table+` SET performer_id = ? WHERE performer_id IN `+inBinding+` AND NOT EXISTS(SELECT 1 FROM `+table+` o WHERE o.`+idColumn+` = `+table+`.`+idColumn+` AND o.performer_id = ?)`, args..., ) if err != nil { return err } // delete source performer ids from the table where they couldn't be set if _, err := dbWrapper.Exec(ctx, `DELETE FROM `+table+` WHERE performer_id IN `+inBinding, srcArgs...); err != nil { return err } } for _, id := range source { err := qb.Destroy(ctx, id) if err != nil { return err } } return nil } ================================================ FILE: pkg/sqlite/performer_filter.go ================================================ package sqlite import ( "context" "fmt" "strconv" "strings" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) type performerFilterHandler struct { performerFilter *models.PerformerFilterType } func (qb *performerFilterHandler) validate() error { filter := qb.performerFilter if filter == nil { return nil } if err := validateFilterCombination(filter.OperatorFilter); err != nil { return err } if subFilter := filter.SubFilter(); subFilter != nil { sqb := &performerFilterHandler{performerFilter: subFilter} if err := sqb.validate(); err != nil { return err } } // if legacy height filter used, ensure only supported modifiers are used if filter.Height != nil { // treat as an int filter intCrit := &models.IntCriterionInput{ Modifier: filter.Height.Modifier, } if !intCrit.ValidModifier() { return fmt.Errorf("invalid height modifier: %s", filter.Height.Modifier) } // ensure value is a valid number if _, err := strconv.Atoi(filter.Height.Value); err != nil { return fmt.Errorf("invalid height value: %s", filter.Height.Value) } } // if legacy career length filter used, ensure only supported modifiers are used and value is valid if filter.CareerLength != nil { careerLength := filter.CareerLength switch careerLength.Modifier { case models.CriterionModifierEquals: start, end, err := models.ParseYearRangeString(careerLength.Value) if err != nil { return fmt.Errorf("invalid career length value: %s", careerLength.Value) } // ensure career start/end is not set if start != nil && filter.CareerStart != nil { return fmt.Errorf("cannot use legacy CareerLength filter with CareerStart filter") } if end != nil && filter.CareerEnd != nil { return fmt.Errorf("cannot use legacy CareerLength filter with CareerEnd filter") } case models.CriterionModifierIsNull, models.CriterionModifierNotNull: // valid modifiers, no value parsing needed default: return fmt.Errorf("invalid career length modifier: %s", careerLength.Modifier) } } // validate date formats if filter.Birthdate != nil && filter.Birthdate.Value != "" { if _, err := models.ParseDate(filter.Birthdate.Value); err != nil { return fmt.Errorf("invalid birthdate value: %s", filter.Birthdate.Value) } } if filter.DeathDate != nil && filter.DeathDate.Value != "" { if _, err := models.ParseDate(filter.DeathDate.Value); err != nil { return fmt.Errorf("invalid death date value: %s", filter.DeathDate.Value) } } if filter.CareerStart != nil && filter.CareerStart.Value != "" { if _, err := models.ParseDate(filter.CareerStart.Value); err != nil { return fmt.Errorf("invalid career start value: %s", filter.CareerStart.Value) } } if filter.CareerEnd != nil && filter.CareerEnd.Value != "" { if _, err := models.ParseDate(filter.CareerEnd.Value); err != nil { return fmt.Errorf("invalid career end value: %s", filter.CareerEnd.Value) } } return nil } func (qb *performerFilterHandler) handle(ctx context.Context, f *filterBuilder) { filter := qb.performerFilter if filter == nil { return } if err := qb.validate(); err != nil { f.setError(err) return } sf := filter.SubFilter() if sf != nil { sub := &performerFilterHandler{sf} handleSubFilter(ctx, sub, f, filter.OperatorFilter) } f.handleCriterion(ctx, qb.criterionHandler()) } func (qb *performerFilterHandler) criterionHandler() criterionHandler { // make a copy of the filter to modify with legacy conversions without affecting original filter used for subfilters filter := *qb.performerFilter const tableName = performerTable heightCmCrit := filter.HeightCm convertLegacyCareerLengthFilter(&filter) return compoundHandler{ stringCriterionHandler(filter.Name, tableName+".name"), stringCriterionHandler(filter.Disambiguation, tableName+".disambiguation"), stringCriterionHandler(filter.Details, tableName+".details"), boolCriterionHandler(filter.FilterFavorites, tableName+".favorite", nil), boolCriterionHandler(filter.IgnoreAutoTag, tableName+".ignore_auto_tag", nil), yearFilterCriterionHandler(filter.BirthYear, tableName+".birthdate"), yearFilterCriterionHandler(filter.DeathYear, tableName+".death_date"), qb.performerAgeFilterCriterionHandler(filter.Age), criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if gender := filter.Gender; gender != nil { genderCopy := *gender if genderCopy.Value.IsValid() && len(genderCopy.ValueList) == 0 { genderCopy.ValueList = []models.GenderEnum{genderCopy.Value} } v := utils.StringerSliceToStringSlice(genderCopy.ValueList) enumCriterionHandler(genderCopy.Modifier, v, tableName+".gender")(ctx, f) } }), qb.performerIsMissingCriterionHandler(filter.IsMissing), stringCriterionHandler(filter.Ethnicity, tableName+".ethnicity"), stringCriterionHandler(filter.Country, tableName+".country"), stringCriterionHandler(filter.EyeColor, tableName+".eye_color"), // special handler for legacy height filter criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if heightCmCrit == nil && filter.Height != nil { heightCm, _ := strconv.Atoi(filter.Height.Value) // already validated heightCmCrit = &models.IntCriterionInput{ Value: heightCm, Modifier: filter.Height.Modifier, } } }), intCriterionHandler(heightCmCrit, tableName+".height", nil), stringCriterionHandler(filter.Measurements, tableName+".measurements"), stringCriterionHandler(filter.FakeTits, tableName+".fake_tits"), floatCriterionHandler(filter.PenisLength, tableName+".penis_length", nil), criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if circumcised := filter.Circumcised; circumcised != nil { v := utils.StringerSliceToStringSlice(circumcised.Value) enumCriterionHandler(circumcised.Modifier, v, tableName+".circumcised")(ctx, f) } }), // CareerLength filter is deprecated and non-functional (column removed in schema 78) &dateCriterionHandler{filter.CareerStart, tableName + ".career_start", nil}, &dateCriterionHandler{filter.CareerEnd, tableName + ".career_end", nil}, stringCriterionHandler(filter.Tattoos, tableName+".tattoos"), stringCriterionHandler(filter.Piercings, tableName+".piercings"), intCriterionHandler(filter.Rating100, tableName+".rating", nil), stringCriterionHandler(filter.HairColor, tableName+".hair_color"), qb.urlsCriterionHandler(filter.URL), intCriterionHandler(filter.Weight, tableName+".weight", nil), criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if filter.StashID != nil { performerRepository.stashIDs.join(f, "performer_stash_ids", "performers.id") stringCriterionHandler(filter.StashID, "performer_stash_ids.stash_id")(ctx, f) } }), &stashIDCriterionHandler{ c: filter.StashIDEndpoint, stashIDRepository: &performerRepository.stashIDs, stashIDTableAs: "performer_stash_ids", parentIDCol: "performers.id", }, &stashIDsCriterionHandler{ c: filter.StashIDsEndpoint, stashIDRepository: &performerRepository.stashIDs, stashIDTableAs: "performer_stash_ids", parentIDCol: "performers.id", }, qb.aliasCriterionHandler(filter.Aliases), qb.tagsCriterionHandler(filter.Tags), qb.studiosCriterionHandler(filter.Studios), qb.groupsCriterionHandler(filter.Groups), qb.appearsWithCriterionHandler(filter.Performers), qb.tagCountCriterionHandler(filter.TagCount), qb.sceneCountCriterionHandler(filter.SceneCount), qb.markerCountCriterionHandler(filter.MarkerCount), qb.imageCountCriterionHandler(filter.ImageCount), qb.galleryCountCriterionHandler(filter.GalleryCount), qb.playCounterCriterionHandler(filter.PlayCount), qb.oCounterCriterionHandler(filter.OCounter), &dateCriterionHandler{filter.Birthdate, tableName + ".birthdate", nil}, &dateCriterionHandler{filter.DeathDate, tableName + ".death_date", nil}, ×tampCriterionHandler{filter.CreatedAt, tableName + ".created_at", nil}, ×tampCriterionHandler{filter.UpdatedAt, tableName + ".updated_at", nil}, &relatedFilterHandler{ relatedIDCol: "scene_markers.id", relatedRepo: sceneMarkerRepository.repository, relatedHandler: &sceneMarkerFilterHandler{filter.MarkersFilter}, joinFn: func(f *filterBuilder) { performerRepository.scenes.innerJoin(f, "", "performers.id") f.addInnerJoin(sceneMarkerTable, "", "scene_markers.scene_id = performers_scenes.scene_id") }, }, &relatedFilterHandler{ relatedIDCol: "performers_scenes.scene_id", relatedRepo: sceneRepository.repository, relatedHandler: &sceneFilterHandler{filter.ScenesFilter}, joinFn: func(f *filterBuilder) { performerRepository.scenes.innerJoin(f, "", "performers.id") }, }, &relatedFilterHandler{ relatedIDCol: "performers_images.image_id", relatedRepo: imageRepository.repository, relatedHandler: &imageFilterHandler{filter.ImagesFilter}, joinFn: func(f *filterBuilder) { performerRepository.images.innerJoin(f, "", "performers.id") }, }, &relatedFilterHandler{ relatedIDCol: "performers_galleries.gallery_id", relatedRepo: galleryRepository.repository, relatedHandler: &galleryFilterHandler{filter.GalleriesFilter}, joinFn: func(f *filterBuilder) { performerRepository.galleries.innerJoin(f, "", "performers.id") }, }, &relatedFilterHandler{ relatedIDCol: "performer_tag.tag_id", relatedRepo: tagRepository.repository, relatedHandler: &tagFilterHandler{filter.TagsFilter}, joinFn: func(f *filterBuilder) { performerRepository.tags.innerJoin(f, "performer_tag", "performers.id") }, }, &customFieldsFilterHandler{ table: performersCustomFieldsTable.GetTable(), fkCol: performerIDColumn, c: filter.CustomFields, idCol: "performers.id", }, } } func convertLegacyCareerLengthFilter(filter *models.PerformerFilterType) { // convert legacy career length filter to career start/end filters if filter.CareerLength != nil { careerLength := filter.CareerLength switch careerLength.Modifier { case models.CriterionModifierEquals: start, end, _ := models.ParseYearRangeString(careerLength.Value) if start != nil { start = &models.Date{ Time: start.AddDate(0, 0, -1), // make exclusive Precision: models.DatePrecisionDay, } filter.CareerStart = &models.DateCriterionInput{ Value: start.String(), Modifier: models.CriterionModifierGreaterThan, } } if end != nil { end = &models.Date{ Time: end.AddDate(1, 0, 0), // make exclusive Precision: models.DatePrecisionDay, } filter.CareerEnd = &models.DateCriterionInput{ Value: end.String(), // plus one to make it exclusive Modifier: models.CriterionModifierLessThan, } } case models.CriterionModifierIsNull: filter.CareerStart = &models.DateCriterionInput{ Modifier: models.CriterionModifierIsNull, } filter.CareerEnd = &models.DateCriterionInput{ Modifier: models.CriterionModifierIsNull, } case models.CriterionModifierNotNull: filter.CareerStart = &models.DateCriterionInput{ Modifier: models.CriterionModifierNotNull, } filter.CareerEnd = &models.DateCriterionInput{ Modifier: models.CriterionModifierNotNull, } } } } // TODO - we need to provide a whitelist of possible values func (qb *performerFilterHandler) performerIsMissingCriterionHandler(isMissing *string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if isMissing != nil && *isMissing != "" { switch *isMissing { case "url": performersURLsTableMgr.join(f, "", "performers.id") f.addWhere("performer_urls.url IS NULL") case "scenes": // Deprecated: use `scene_count == 0` filter instead f.addLeftJoin(performersScenesTable, "scenes_join", "scenes_join.performer_id = performers.id") f.addWhere("scenes_join.scene_id IS NULL") case "image": f.addWhere("performers.image_blob IS NULL") case "stash_id": performersStashIDsTableMgr.join(f, "performer_stash_ids", "performers.id") f.addWhere("performer_stash_ids.performer_id IS NULL") case "aliases": performersAliasesTableMgr.join(f, "", "performers.id") f.addWhere("performer_aliases.alias IS NULL") case "tags": f.addLeftJoin(performersTagsTable, "tags_join", "tags_join.performer_id = performers.id") f.addWhere("tags_join.performer_id IS NULL") default: if err := validateIsMissing(*isMissing, []string{ "disambiguation", "gender", "birthdate", "death_date", "ethnicity", "country", "hair_color", "eye_color", "height", "weight", "measurements", "fake_tits", "penis_length", "circumcised", "career_start", "career_end", "tattoos", "piercings", "details", "rating", }); err != nil { f.setError(err) return } f.addWhere("(performers." + *isMissing + " IS NULL OR TRIM(performers." + *isMissing + ") = '')") } } } } func (qb *performerFilterHandler) performerAgeFilterCriterionHandler(age *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if age != nil && age.Modifier.IsValid() { clause, args := getIntCriterionWhereClause( "cast(IFNULL(strftime('%Y.%m%d', performers.death_date), strftime('%Y.%m%d', 'now')) - strftime('%Y.%m%d', performers.birthdate) as int)", *age, ) f.addWhere(clause, args...) } } } func (qb *performerFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ primaryTable: performerTable, primaryFK: performerIDColumn, joinTable: performerURLsTable, stringColumn: performerURLColumn, addJoinTable: func(f *filterBuilder) { performersURLsTableMgr.join(f, "", "performers.id") }, } return h.handler(url) } func (qb *performerFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ primaryTable: performerTable, primaryFK: performerIDColumn, joinTable: performersAliasesTable, stringColumn: performerAliasColumn, addJoinTable: func(f *filterBuilder) { performersAliasesTableMgr.join(f, "", "performers.id") }, } return h.handler(alias) } func (qb *performerFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { h := joinedHierarchicalMultiCriterionHandlerBuilder{ primaryTable: performerTable, foreignTable: tagTable, foreignFK: "tag_id", relationsTable: "tags_relations", joinAs: "performer_tag", joinTable: performersTagsTable, primaryFK: performerIDColumn, } return h.handler(tags) } func (qb *performerFilterHandler) tagCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: performerTable, joinTable: performersTagsTable, primaryFK: performerIDColumn, } return h.handler(count) } func (qb *performerFilterHandler) sceneCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: performerTable, joinTable: performersScenesTable, primaryFK: performerIDColumn, } return h.handler(count) } func (qb *performerFilterHandler) markerCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if count != nil { performerRepository.scenes.innerJoin(f, "", "performers.id") const query = `(SELECT COUNT(*) FROM scene_markers INNER JOIN scenes ON scene_markers.scene_id = scenes.id INNER JOIN performers_scenes ON performers_scenes.scene_id = scenes.id WHERE performers_scenes.performer_id = performers.id)` clause, args := getIntCriterionWhereClause(query, *count) f.addWhere(clause, args...) } } } func (qb *performerFilterHandler) imageCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: performerTable, joinTable: performersImagesTable, primaryFK: performerIDColumn, } return h.handler(count) } func (qb *performerFilterHandler) galleryCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: performerTable, joinTable: performersGalleriesTable, primaryFK: performerIDColumn, } return h.handler(count) } // used for sorting and filtering on performer o-count var selectPerformerOCountSQL = utils.StrFormat( "SELECT SUM(o_counter) "+ "FROM ("+ "SELECT SUM(o_counter) as o_counter from {performers_images} s "+ "LEFT JOIN {images} ON {images}.id = s.{images_id} "+ "WHERE s.{performer_id} = {performers}.id "+ "UNION ALL "+ "SELECT COUNT({scenes_o_dates}.{o_date}) as o_counter from {performers_scenes} s "+ "LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} "+ "LEFT JOIN {scenes_o_dates} ON {scenes_o_dates}.{scene_id} = {scenes}.id "+ "WHERE s.{performer_id} = {performers}.id "+ ")", map[string]interface{}{ "performers_images": performersImagesTable, "images": imageTable, "performer_id": performerIDColumn, "images_id": imageIDColumn, "performers": performerTable, "performers_scenes": performersScenesTable, "scenes": sceneTable, "scene_id": sceneIDColumn, "scenes_o_dates": scenesODatesTable, "o_date": sceneODateColumn, }, ) // used for sorting and filtering play count on performer view count var selectPerformerPlayCountSQL = utils.StrFormat( "SELECT COUNT(DISTINCT {view_date}) FROM ("+ "SELECT {view_date} FROM {performers_scenes} s "+ "LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} "+ "LEFT JOIN {scenes_view_dates} ON {scenes_view_dates}.{scene_id} = {scenes}.id "+ "WHERE s.{performer_id} = {performers}.id"+ ")", map[string]interface{}{ "performer_id": performerIDColumn, "performers": performerTable, "performers_scenes": performersScenesTable, "scenes": sceneTable, "scene_id": sceneIDColumn, "scenes_view_dates": scenesViewDatesTable, "view_date": sceneViewDateColumn, }, ) func (qb *performerFilterHandler) oCounterCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if count == nil { return } lhs := "(" + selectPerformerOCountSQL + ")" clause, args := getIntCriterionWhereClause(lhs, *count) f.addWhere(clause, args...) } } func (qb *performerFilterHandler) playCounterCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if count == nil { return } lhs := "(" + selectPerformerPlayCountSQL + ")" clause, args := getIntCriterionWhereClause(lhs, *count) f.addWhere(clause, args...) } } func (qb *performerFilterHandler) studiosCriterionHandler(studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if studios != nil { formatMaps := []utils.StrFormatMap{ { "primaryTable": sceneTable, "joinTable": performersScenesTable, "primaryFK": sceneIDColumn, }, { "primaryTable": imageTable, "joinTable": performersImagesTable, "primaryFK": imageIDColumn, }, { "primaryTable": galleryTable, "joinTable": performersGalleriesTable, "primaryFK": galleryIDColumn, }, } if studios.Modifier == models.CriterionModifierIsNull || studios.Modifier == models.CriterionModifierNotNull { var notClause string if studios.Modifier == models.CriterionModifierNotNull { notClause = "NOT" } var conditions []string for _, c := range formatMaps { f.addLeftJoin(c["joinTable"].(string), "", fmt.Sprintf("%s.performer_id = performers.id", c["joinTable"])) f.addLeftJoin(c["primaryTable"].(string), "", fmt.Sprintf("%s.%s = %s.id", c["joinTable"], c["primaryFK"], c["primaryTable"])) conditions = append(conditions, fmt.Sprintf("%s.studio_id IS NULL", c["primaryTable"])) } f.addWhere(fmt.Sprintf("%s (%s)", notClause, strings.Join(conditions, " AND "))) return } if len(studios.Value) == 0 && len(studios.Excludes) == 0 { return } var clauseCondition string switch studios.Modifier { case models.CriterionModifierIncludes: // return performers who appear in scenes/images/galleries with any of the given studios clauseCondition = "NOT" case models.CriterionModifierExcludes: // exclude performers who appear in scenes/images/galleries with any of the given studios clauseCondition = "" default: return } if len(studios.Value) > 0 { const derivedPerformerStudioTable = "performer_studio" valuesClause, err := getHierarchicalValues(ctx, studios.Value, studioTable, "", "parent_id", "child_id", studios.Depth) if err != nil { f.setError(err) return } f.addWith("studio(root_id, item_id) AS (" + valuesClause + ")") templStr := `SELECT performer_id FROM {primaryTable} INNER JOIN {joinTable} ON {primaryTable}.id = {joinTable}.{primaryFK} INNER JOIN studio ON {primaryTable}.studio_id = studio.item_id` var unions []string for _, c := range formatMaps { unions = append(unions, utils.StrFormat(templStr, c)) } f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerStudioTable, strings.Join(unions, " UNION "))) f.addLeftJoin(derivedPerformerStudioTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerStudioTable)) f.addWhere(fmt.Sprintf("%s.performer_id IS %s NULL", derivedPerformerStudioTable, clauseCondition)) } // #6412 - handle excludes as well if len(studios.Excludes) > 0 { excludeValuesClause, err := getHierarchicalValues(ctx, studios.Excludes, studioTable, "", "parent_id", "child_id", studios.Depth) if err != nil { f.setError(err) return } f.addWith("exclude_studio(root_id, item_id) AS (" + excludeValuesClause + ")") excludeTemplStr := `SELECT performer_id FROM {primaryTable} INNER JOIN {joinTable} ON {primaryTable}.id = {joinTable}.{primaryFK} INNER JOIN exclude_studio ON {primaryTable}.studio_id = exclude_studio.item_id` var unions []string for _, c := range formatMaps { unions = append(unions, utils.StrFormat(excludeTemplStr, c)) } const excludePerformerStudioTable = "performer_studio_exclude" f.addWith(fmt.Sprintf("%s AS (%s)", excludePerformerStudioTable, strings.Join(unions, " UNION "))) f.addLeftJoin(excludePerformerStudioTable, "", fmt.Sprintf("performers.id = %s.performer_id", excludePerformerStudioTable)) f.addWhere(fmt.Sprintf("%s.performer_id IS NULL", excludePerformerStudioTable)) } } } } func (qb *performerFilterHandler) groupsCriterionHandler(groups *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if groups != nil { if groups.Modifier == models.CriterionModifierIsNull || groups.Modifier == models.CriterionModifierNotNull { var notClause string if groups.Modifier == models.CriterionModifierNotNull { notClause = "NOT" } f.addLeftJoin(performersScenesTable, "", "performers_scenes.performer_id = performers.id") f.addLeftJoin(groupsScenesTable, "", "performers_scenes.scene_id = groups_scenes.scene_id") f.addWhere(fmt.Sprintf("%s groups_scenes.group_id IS NULL", notClause)) return } if len(groups.Value) == 0 { return } var clauseCondition string switch groups.Modifier { case models.CriterionModifierIncludes: // return performers who appear in scenes with any of the given groups clauseCondition = "NOT" case models.CriterionModifierExcludes: // exclude performers who appear in scenes with any of the given groups clauseCondition = "" default: return } const derivedPerformerGroupTable = "performer_group" // Simplified approach: direct group-scene-performer relationship without hierarchy var args []interface{} for _, val := range groups.Value { args = append(args, val) } // If depth is specified and not 0, we need hierarchy, otherwise use simple approach depthVal := 0 if groups.Depth != nil { depthVal = *groups.Depth } if depthVal == 0 { // Simple case: no hierarchy, direct group relationship f.addWith(fmt.Sprintf("group_values(id) AS (VALUES %s)", strings.Repeat("(?),", len(groups.Value)-1)+"(?)"), args...) templStr := `SELECT performer_id FROM {joinTable} INNER JOIN {primaryTable} ON {joinTable}.scene_id = {primaryTable}.scene_id INNER JOIN group_values ON {primaryTable}.{groupFK} = group_values.id` formatMaps := []utils.StrFormatMap{ { "primaryTable": groupsScenesTable, "joinTable": performersScenesTable, "primaryFK": sceneIDColumn, "groupFK": groupIDColumn, }, } var unions []string for _, c := range formatMaps { unions = append(unions, utils.StrFormat(templStr, c)) } f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerGroupTable, strings.Join(unions, " UNION "))) } else { // Complex case: with hierarchy var depthCondition string if depthVal != -1 { depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) } // Build recursive CTE for group hierarchy hierarchyQuery := fmt.Sprintf(`group_hierarchy AS ( SELECT sub_id AS root_id, sub_id AS item_id, 0 AS depth FROM groups_relations WHERE sub_id IN%s UNION SELECT root_id, sub_id, depth + 1 FROM groups_relations INNER JOIN group_hierarchy ON item_id = containing_id %s )`, getInBinding(len(groups.Value)), depthCondition) f.addRecursiveWith(hierarchyQuery, args...) templStr := `SELECT performer_id FROM {joinTable} INNER JOIN {primaryTable} ON {joinTable}.scene_id = {primaryTable}.scene_id INNER JOIN group_hierarchy ON {primaryTable}.{groupFK} = group_hierarchy.item_id` formatMaps := []utils.StrFormatMap{ { "primaryTable": groupsScenesTable, "joinTable": performersScenesTable, "primaryFK": sceneIDColumn, "groupFK": groupIDColumn, }, } var unions []string for _, c := range formatMaps { unions = append(unions, utils.StrFormat(templStr, c)) } f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerGroupTable, strings.Join(unions, " UNION "))) } f.addLeftJoin(derivedPerformerGroupTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerGroupTable)) f.addWhere(fmt.Sprintf("%s.performer_id IS %s NULL", derivedPerformerGroupTable, clauseCondition)) } } } func (qb *performerFilterHandler) appearsWithCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if performers != nil { formatMaps := []utils.StrFormatMap{ { "primaryTable": performersScenesTable, "joinTable": performersScenesTable, "primaryFK": sceneIDColumn, }, { "primaryTable": performersImagesTable, "joinTable": performersImagesTable, "primaryFK": imageIDColumn, }, { "primaryTable": performersGalleriesTable, "joinTable": performersGalleriesTable, "primaryFK": galleryIDColumn, }, } if len(performers.Value) == '0' { return } const derivedPerformerPerformersTable = "performer_performers" valuesClause := strings.Join(performers.Value, "),(") f.addWith("performer(id) AS (VALUES(" + valuesClause + "))") templStr := `SELECT {primaryTable}2.performer_id FROM {primaryTable} INNER JOIN {primaryTable} AS {primaryTable}2 ON {primaryTable}.{primaryFK} = {primaryTable}2.{primaryFK} INNER JOIN performer ON {primaryTable}.performer_id = performer.id WHERE {primaryTable}2.performer_id != performer.id` if performers.Modifier == models.CriterionModifierIncludesAll && len(performers.Value) > 1 { templStr += ` GROUP BY {primaryTable}2.performer_id HAVING(count(distinct {primaryTable}.performer_id) IS ` + strconv.Itoa(len(performers.Value)) + `)` } var unions []string for _, c := range formatMaps { unions = append(unions, utils.StrFormat(templStr, c)) } f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerPerformersTable, strings.Join(unions, " UNION "))) f.addInnerJoin(derivedPerformerPerformersTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerPerformersTable)) } } } ================================================ FILE: pkg/sqlite/performer_test.go ================================================ //go:build integration // +build integration package sqlite_test import ( "context" "fmt" "math" "strconv" "strings" "testing" "time" "github.com/stashapp/stash/pkg/models" "github.com/stretchr/testify/assert" ) var testCustomFields = map[string]interface{}{ "string": "aaa", "int": int64(123), // int64 to match the type of the field in the database "real": 1.23, } func loadPerformerRelationships(ctx context.Context, expected models.Performer, actual *models.Performer) error { if expected.Aliases.Loaded() { if err := actual.LoadAliases(ctx, db.Performer); err != nil { return err } } if expected.URLs.Loaded() { if err := actual.LoadURLs(ctx, db.Performer); err != nil { return err } } if expected.TagIDs.Loaded() { if err := actual.LoadTagIDs(ctx, db.Performer); err != nil { return err } } if expected.StashIDs.Loaded() { if err := actual.LoadStashIDs(ctx, db.Performer); err != nil { return err } } return nil } func Test_PerformerStore_Create(t *testing.T) { var ( name = "name" disambiguation = "disambiguation" gender = models.GenderEnumFemale details = "details" url = "url" twitter = "twitter" instagram = "instagram" urls = []string{url, twitter, instagram} rating = 3 ethnicity = "ethnicity" country = "country" eyeColor = "eyeColor" height = 134 measurements = "measurements" fakeTits = "fakeTits" penisLength = 1.23 circumcised = models.CircumcisedEnumCut careerStart = models.DateFromYear(2005) careerEnd = models.DateFromYear(2015) tattoos = "tattoos" piercings = "piercings" aliases = []string{"alias1", "alias2"} hairColor = "hairColor" weight = 123 ignoreAutoTag = true favorite = true endpoint1 = "endpoint1" endpoint2 = "endpoint2" stashID1 = "stashid1" stashID2 = "stashid2" createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) birthdate, _ = models.ParseDate("2003-02-01") deathdate, _ = models.ParseDate("2023-02-01") ) tests := []struct { name string newObject models.CreatePerformerInput wantErr bool }{ { "full", models.CreatePerformerInput{ Performer: &models.Performer{ Name: name, Disambiguation: disambiguation, Gender: &gender, URLs: models.NewRelatedStrings(urls), Birthdate: &birthdate, Ethnicity: ethnicity, Country: country, EyeColor: eyeColor, Height: &height, Measurements: measurements, FakeTits: fakeTits, PenisLength: &penisLength, Circumcised: &circumcised, CareerStart: &careerStart, CareerEnd: &careerEnd, Tattoos: tattoos, Piercings: piercings, Favorite: favorite, Rating: &rating, Details: details, DeathDate: &deathdate, HairColor: hairColor, Weight: &weight, IgnoreAutoTag: ignoreAutoTag, TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithPerformer], tagIDs[tagIdx1WithDupName]}), Aliases: models.NewRelatedStrings(aliases), StashIDs: models.NewRelatedStashIDs([]models.StashID{ { StashID: stashID1, Endpoint: endpoint1, UpdatedAt: epochTime, }, { StashID: stashID2, Endpoint: endpoint2, UpdatedAt: epochTime, }, }), CreatedAt: createdAt, UpdatedAt: updatedAt, }, CustomFields: testCustomFields, }, false, }, { "invalid tag id", models.CreatePerformerInput{ Performer: &models.Performer{ Name: name, TagIDs: models.NewRelatedIDs([]int{invalidID}), }, }, true, }, } qb := db.Performer for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) p := tt.newObject if err := qb.Create(ctx, &p); (err != nil) != tt.wantErr { t.Errorf("PerformerStore.Create() error = %v, wantErr = %v", err, tt.wantErr) } if tt.wantErr { assert.Zero(p.ID) return } assert.NotZero(p.ID) copy := *tt.newObject.Performer copy.ID = p.ID // load relationships if err := loadPerformerRelationships(ctx, copy, p.Performer); err != nil { t.Errorf("loadPerformerRelationships() error = %v", err) return } assert.Equal(copy, *p.Performer) // ensure can find the performer found, err := qb.Find(ctx, p.ID) if err != nil { t.Errorf("PerformerStore.Find() error = %v", err) } if !assert.NotNil(found) { return } // load relationships if err := loadPerformerRelationships(ctx, copy, found); err != nil { t.Errorf("loadPerformerRelationships() error = %v", err) return } assert.Equal(copy, *found) // ensure custom fields are set cf, err := qb.GetCustomFields(ctx, p.ID) if err != nil { t.Errorf("PerformerStore.GetCustomFields() error = %v", err) return } assert.Equal(tt.newObject.CustomFields, cf) }) } } func Test_PerformerStore_Update(t *testing.T) { var ( name = "name" disambiguation = "disambiguation" gender = models.GenderEnumFemale details = "details" url = "url" twitter = "twitter" instagram = "instagram" urls = []string{url, twitter, instagram} rating = 3 ethnicity = "ethnicity" country = "country" eyeColor = "eyeColor" height = 134 measurements = "measurements" fakeTits = "fakeTits" penisLength = 1.23 circumcised = models.CircumcisedEnumCut careerStart = models.DateFromYear(2005) careerEnd = models.DateFromYear(2015) tattoos = "tattoos" piercings = "piercings" aliases = []string{"alias1", "alias2"} hairColor = "hairColor" weight = 123 ignoreAutoTag = true favorite = true endpoint1 = "endpoint1" endpoint2 = "endpoint2" stashID1 = "stashid1" stashID2 = "stashid2" createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) birthdate, _ = models.ParseDate("2003-02-01") deathdate, _ = models.ParseDate("2023-02-01") ) tests := []struct { name string updatedObject models.UpdatePerformerInput wantErr bool }{ { "full", models.UpdatePerformerInput{ Performer: &models.Performer{ ID: performerIDs[performerIdxWithGallery], Name: name, Disambiguation: disambiguation, Gender: &gender, URLs: models.NewRelatedStrings(urls), Birthdate: &birthdate, Ethnicity: ethnicity, Country: country, EyeColor: eyeColor, Height: &height, Measurements: measurements, FakeTits: fakeTits, PenisLength: &penisLength, Circumcised: &circumcised, CareerStart: &careerStart, CareerEnd: &careerEnd, Tattoos: tattoos, Piercings: piercings, Favorite: favorite, Rating: &rating, Details: details, DeathDate: &deathdate, HairColor: hairColor, Weight: &weight, IgnoreAutoTag: ignoreAutoTag, Aliases: models.NewRelatedStrings(aliases), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithPerformer]}), StashIDs: models.NewRelatedStashIDs([]models.StashID{ { StashID: stashID1, Endpoint: endpoint1, UpdatedAt: epochTime, }, { StashID: stashID2, Endpoint: endpoint2, UpdatedAt: epochTime, }, }), CreatedAt: createdAt, UpdatedAt: updatedAt, }, }, false, }, { "clear nullables", models.UpdatePerformerInput{ Performer: &models.Performer{ ID: performerIDs[performerIdxWithGallery], Aliases: models.NewRelatedStrings([]string{}), URLs: models.NewRelatedStrings([]string{}), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), }, }, false, }, { "clear tag ids", models.UpdatePerformerInput{ Performer: &models.Performer{ ID: performerIDs[sceneIdxWithTag], TagIDs: models.NewRelatedIDs([]int{}), }, }, false, }, { "set custom fields", models.UpdatePerformerInput{ Performer: &models.Performer{ ID: performerIDs[performerIdxWithGallery], }, CustomFields: models.CustomFieldsInput{ Full: testCustomFields, }, }, false, }, { "clear custom fields", models.UpdatePerformerInput{ Performer: &models.Performer{ ID: performerIDs[performerIdxWithGallery], }, CustomFields: models.CustomFieldsInput{ Full: map[string]interface{}{}, }, }, false, }, { "invalid tag id", models.UpdatePerformerInput{ Performer: &models.Performer{ ID: performerIDs[sceneIdxWithGallery], TagIDs: models.NewRelatedIDs([]int{invalidID}), }, }, true, }, } qb := db.Performer for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) copy := *tt.updatedObject.Performer if err := qb.Update(ctx, &tt.updatedObject); (err != nil) != tt.wantErr { t.Errorf("PerformerStore.Update() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { return } s, err := qb.Find(ctx, tt.updatedObject.ID) if err != nil { t.Errorf("PerformerStore.Find() error = %v", err) } // load relationships if err := loadPerformerRelationships(ctx, copy, s); err != nil { t.Errorf("loadPerformerRelationships() error = %v", err) return } assert.Equal(copy, *s) // ensure custom fields are correct if tt.updatedObject.CustomFields.Full != nil { cf, err := qb.GetCustomFields(ctx, tt.updatedObject.ID) if err != nil { t.Errorf("PerformerStore.GetCustomFields() error = %v", err) return } assert.Equal(tt.updatedObject.CustomFields.Full, cf) } }) } } func clearPerformerPartial() models.PerformerPartial { nullString := models.OptionalString{Set: true, Null: true} nullDate := models.OptionalDate{Set: true, Null: true} nullInt := models.OptionalInt{Set: true, Null: true} nullFloat := models.OptionalFloat64{Set: true, Null: true} // leave mandatory fields return models.PerformerPartial{ Disambiguation: nullString, Gender: nullString, URLs: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet}, Birthdate: nullDate, Ethnicity: nullString, Country: nullString, EyeColor: nullString, Height: nullInt, Measurements: nullString, FakeTits: nullString, PenisLength: nullFloat, Circumcised: nullString, CareerStart: nullDate, CareerEnd: nullDate, Tattoos: nullString, Piercings: nullString, Aliases: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet}, Rating: nullInt, Details: nullString, DeathDate: nullDate, HairColor: nullString, Weight: nullInt, TagIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet}, StashIDs: &models.UpdateStashIDs{Mode: models.RelationshipUpdateModeSet}, } } func Test_PerformerStore_UpdatePartial(t *testing.T) { var ( name = "name" disambiguation = "disambiguation" gender = models.GenderEnumFemale details = "details" url = "url" twitter = "twitter" instagram = "instagram" urls = []string{url, twitter, instagram} rating = 3 ethnicity = "ethnicity" country = "country" eyeColor = "eyeColor" height = 143 measurements = "measurements" fakeTits = "fakeTits" penisLength = 1.23 circumcised = models.CircumcisedEnumCut careerStart = models.DateFromYear(2005) careerEnd = models.DateFromYear(2015) tattoos = "tattoos" piercings = "piercings" aliases = []string{"alias1", "alias2"} hairColor = "hairColor" weight = 123 ignoreAutoTag = true favorite = true endpoint1 = "endpoint1" endpoint2 = "endpoint2" stashID1 = "stashid1" stashID2 = "stashid2" createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) birthdate, _ = models.ParseDate("2003-02-01") deathdate, _ = models.ParseDate("2023-02-01") ) tests := []struct { name string id int partial models.PerformerPartial want models.Performer wantErr bool }{ { "full", performerIDs[performerIdxWithDupName], models.PerformerPartial{ Name: models.NewOptionalString(name), Disambiguation: models.NewOptionalString(disambiguation), Gender: models.NewOptionalString(gender.String()), URLs: &models.UpdateStrings{ Values: urls, Mode: models.RelationshipUpdateModeSet, }, Birthdate: models.NewOptionalDate(birthdate), Ethnicity: models.NewOptionalString(ethnicity), Country: models.NewOptionalString(country), EyeColor: models.NewOptionalString(eyeColor), Height: models.NewOptionalInt(height), Measurements: models.NewOptionalString(measurements), FakeTits: models.NewOptionalString(fakeTits), PenisLength: models.NewOptionalFloat64(penisLength), Circumcised: models.NewOptionalString(circumcised.String()), CareerStart: models.NewOptionalDate(careerStart), CareerEnd: models.NewOptionalDate(careerEnd), Tattoos: models.NewOptionalString(tattoos), Piercings: models.NewOptionalString(piercings), Aliases: &models.UpdateStrings{ Values: aliases, Mode: models.RelationshipUpdateModeSet, }, Favorite: models.NewOptionalBool(favorite), Rating: models.NewOptionalInt(rating), Details: models.NewOptionalString(details), DeathDate: models.NewOptionalDate(deathdate), HairColor: models.NewOptionalString(hairColor), Weight: models.NewOptionalInt(weight), IgnoreAutoTag: models.NewOptionalBool(ignoreAutoTag), TagIDs: &models.UpdateIDs{ IDs: []int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithPerformer]}, Mode: models.RelationshipUpdateModeSet, }, StashIDs: &models.UpdateStashIDs{ StashIDs: []models.StashID{ { StashID: stashID1, Endpoint: endpoint1, UpdatedAt: epochTime, }, { StashID: stashID2, Endpoint: endpoint2, UpdatedAt: epochTime, }, }, Mode: models.RelationshipUpdateModeSet, }, CreatedAt: models.NewOptionalTime(createdAt), UpdatedAt: models.NewOptionalTime(updatedAt), }, models.Performer{ ID: performerIDs[performerIdxWithDupName], Name: name, Disambiguation: disambiguation, Gender: &gender, URLs: models.NewRelatedStrings(urls), Birthdate: &birthdate, Ethnicity: ethnicity, Country: country, EyeColor: eyeColor, Height: &height, Measurements: measurements, FakeTits: fakeTits, PenisLength: &penisLength, Circumcised: &circumcised, CareerStart: &careerStart, CareerEnd: &careerEnd, Tattoos: tattoos, Piercings: piercings, Aliases: models.NewRelatedStrings(aliases), Favorite: favorite, Rating: &rating, Details: details, DeathDate: &deathdate, HairColor: hairColor, Weight: &weight, IgnoreAutoTag: ignoreAutoTag, TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithPerformer]}), StashIDs: models.NewRelatedStashIDs([]models.StashID{ { StashID: stashID1, Endpoint: endpoint1, UpdatedAt: epochTime, }, { StashID: stashID2, Endpoint: endpoint2, UpdatedAt: epochTime, }, }), CreatedAt: createdAt, UpdatedAt: updatedAt, }, false, }, { "clear all", performerIDs[performerIdxWithTwoTags], clearPerformerPartial(), models.Performer{ ID: performerIDs[performerIdxWithTwoTags], Name: getPerformerStringValue(performerIdxWithTwoTags, "Name"), Favorite: getPerformerBoolValue(performerIdxWithTwoTags), URLs: models.NewRelatedStrings([]string{}), Aliases: models.NewRelatedStrings([]string{}), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), IgnoreAutoTag: getIgnoreAutoTag(performerIdxWithTwoTags), }, false, }, { "invalid id", invalidID, models.PerformerPartial{}, models.Performer{}, true, }, } for _, tt := range tests { qb := db.Performer runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.UpdatePartial(ctx, tt.id, tt.partial) if (err != nil) != tt.wantErr { t.Errorf("PerformerStore.UpdatePartial() error = %v, wantErr %v", err, tt.wantErr) return } if tt.wantErr { return } if err := loadPerformerRelationships(ctx, tt.want, got); err != nil { t.Errorf("loadPerformerRelationships() error = %v", err) return } assert.Equal(tt.want, *got) s, err := qb.Find(ctx, tt.id) if err != nil { t.Errorf("PerformerStore.Find() error = %v", err) } // load relationships if err := loadPerformerRelationships(ctx, tt.want, s); err != nil { t.Errorf("loadPerformerRelationships() error = %v", err) return } assert.Equal(tt.want, *s) }) } } func Test_PerformerStore_UpdatePartialCustomFields(t *testing.T) { tests := []struct { name string id int partial models.PerformerPartial expected map[string]interface{} // nil to use the partial }{ { "set custom fields", performerIDs[performerIdxWithGallery], models.PerformerPartial{ CustomFields: models.CustomFieldsInput{ Full: testCustomFields, }, }, nil, }, { "clear custom fields", performerIDs[performerIdxWithGallery], models.PerformerPartial{ CustomFields: models.CustomFieldsInput{ Full: map[string]interface{}{}, }, }, nil, }, { "partial custom fields", performerIDs[performerIdxWithGallery], models.PerformerPartial{ CustomFields: models.CustomFieldsInput{ Partial: map[string]interface{}{ "string": "bbb", "new_field": "new", }, }, }, map[string]interface{}{ "int": int64(3), "real": 1.3, "string": "bbb", "new_field": "new", }, }, } for _, tt := range tests { qb := db.Performer runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) _, err := qb.UpdatePartial(ctx, tt.id, tt.partial) if err != nil { t.Errorf("PerformerStore.UpdatePartial() error = %v", err) return } // ensure custom fields are correct cf, err := qb.GetCustomFields(ctx, tt.id) if err != nil { t.Errorf("PerformerStore.GetCustomFields() error = %v", err) return } if tt.expected == nil { assert.Equal(tt.partial.CustomFields.Full, cf) } else { assert.Equal(tt.expected, cf) } }) } } func TestPerformerFindBySceneID(t *testing.T) { withTxn(func(ctx context.Context) error { pqb := db.Performer sceneID := sceneIDs[sceneIdxWithPerformer] performers, err := pqb.FindBySceneID(ctx, sceneID) if err != nil { t.Errorf("Error finding performer: %s", err.Error()) } if !assert.Equal(t, 1, len(performers)) { return nil } performer := performers[0] assert.Equal(t, getPerformerStringValue(performerIdxWithScene, "Name"), performer.Name) performers, err = pqb.FindBySceneID(ctx, 0) if err != nil { t.Errorf("Error finding performer: %s", err.Error()) } assert.Equal(t, 0, len(performers)) return nil }) } func TestPerformerFindByImageID(t *testing.T) { withTxn(func(ctx context.Context) error { pqb := db.Performer imageID := imageIDs[imageIdxWithPerformer] performers, err := pqb.FindByImageID(ctx, imageID) if err != nil { t.Errorf("Error finding performer: %s", err.Error()) } if !assert.Equal(t, 1, len(performers)) { return nil } performer := performers[0] assert.Equal(t, getPerformerStringValue(performerIdxWithImage, "Name"), performer.Name) performers, err = pqb.FindByImageID(ctx, 0) if err != nil { t.Errorf("Error finding performer: %s", err.Error()) } assert.Equal(t, 0, len(performers)) return nil }) } func TestPerformerFindByGalleryID(t *testing.T) { withTxn(func(ctx context.Context) error { pqb := db.Performer galleryID := galleryIDs[galleryIdxWithPerformer] performers, err := pqb.FindByGalleryID(ctx, galleryID) if err != nil { t.Errorf("Error finding performer: %s", err.Error()) } if !assert.Equal(t, 1, len(performers)) { return nil } performer := performers[0] assert.Equal(t, getPerformerStringValue(performerIdxWithGallery, "Name"), performer.Name) performers, err = pqb.FindByGalleryID(ctx, 0) if err != nil { t.Errorf("Error finding performer: %s", err.Error()) } assert.Equal(t, 0, len(performers)) return nil }) } func TestPerformerFindByNames(t *testing.T) { getNames := func(p []*models.Performer) []string { var ret []string for _, pp := range p { ret = append(ret, pp.Name) } return ret } withTxn(func(ctx context.Context) error { var names []string pqb := db.Performer names = append(names, performerNames[performerIdxWithScene]) // find performers by names performers, err := pqb.FindByNames(ctx, names, false) if err != nil { t.Errorf("Error finding performers: %s", err.Error()) } assert.Len(t, performers, 1) assert.Equal(t, performerNames[performerIdxWithScene], performers[0].Name) performers, err = pqb.FindByNames(ctx, names, true) // find performers by names nocase if err != nil { t.Errorf("Error finding performers: %s", err.Error()) } assert.Len(t, performers, 2) // performerIdxWithScene and performerIdxWithDupName assert.Equal(t, strings.ToLower(performerNames[performerIdxWithScene]), strings.ToLower(performers[0].Name)) assert.Equal(t, strings.ToLower(performerNames[performerIdxWithScene]), strings.ToLower(performers[1].Name)) names = append(names, performerNames[performerIdx1WithScene]) // find performers by names ( 2 names ) performers, err = pqb.FindByNames(ctx, names, false) if err != nil { t.Errorf("Error finding performers: %s", err.Error()) } retNames := getNames(performers) assert.Equal(t, names, retNames) performers, err = pqb.FindByNames(ctx, names, true) // find performers by names ( 2 names nocase) if err != nil { t.Errorf("Error finding performers: %s", err.Error()) } retNames = getNames(performers) assert.Equal(t, []string{ performerNames[performerIdxWithScene], performerNames[performerIdx1WithScene], performerNames[performerIdx1WithDupName], performerNames[performerIdxWithDupName], }, retNames) return nil }) } func TestPerformerQueryEthnicityOr(t *testing.T) { const performer1Idx = 1 const performer2Idx = 2 performer1Eth := getPerformerStringValue(performer1Idx, "Ethnicity") performer2Eth := getPerformerStringValue(performer2Idx, "Ethnicity") performerFilter := models.PerformerFilterType{ Ethnicity: &models.StringCriterionInput{ Value: performer1Eth, Modifier: models.CriterionModifierEquals, }, OperatorFilter: models.OperatorFilter[models.PerformerFilterType]{ Or: &models.PerformerFilterType{ Ethnicity: &models.StringCriterionInput{ Value: performer2Eth, Modifier: models.CriterionModifierEquals, }, }, }, } withTxn(func(ctx context.Context) error { performers := queryPerformers(ctx, t, &performerFilter, nil) assert.Len(t, performers, 2) assert.Equal(t, performer1Eth, performers[0].Ethnicity) assert.Equal(t, performer2Eth, performers[1].Ethnicity) return nil }) } func TestPerformerQueryEthnicityAndRating(t *testing.T) { const performerIdx = 1 performerEth := getPerformerStringValue(performerIdx, "Ethnicity") performerRating := int(getRating(performerIdx).Int64) performerFilter := models.PerformerFilterType{ Ethnicity: &models.StringCriterionInput{ Value: performerEth, Modifier: models.CriterionModifierEquals, }, OperatorFilter: models.OperatorFilter[models.PerformerFilterType]{ And: &models.PerformerFilterType{ Rating100: &models.IntCriterionInput{ Value: performerRating, Modifier: models.CriterionModifierEquals, }, }, }, } withTxn(func(ctx context.Context) error { performers := queryPerformers(ctx, t, &performerFilter, nil) if !assert.Len(t, performers, 1) { return nil } assert.Equal(t, performerEth, performers[0].Ethnicity) if assert.NotNil(t, performers[0].Rating) { assert.Equal(t, performerRating, *performers[0].Rating) } return nil }) } func TestPerformerQueryEthnicityNotRating(t *testing.T) { const performerIdx = 1 performerRating := getRating(performerIdx) ethCriterion := models.StringCriterionInput{ Value: "performer_.*1_Ethnicity", Modifier: models.CriterionModifierMatchesRegex, } ratingCriterion := models.IntCriterionInput{ Value: int(performerRating.Int64), Modifier: models.CriterionModifierEquals, } performerFilter := models.PerformerFilterType{ Ethnicity: ðCriterion, OperatorFilter: models.OperatorFilter[models.PerformerFilterType]{ Not: &models.PerformerFilterType{ Rating100: &ratingCriterion, }, }, } withTxn(func(ctx context.Context) error { performers := queryPerformers(ctx, t, &performerFilter, nil) for _, performer := range performers { verifyString(t, performer.Ethnicity, ethCriterion) ratingCriterion.Modifier = models.CriterionModifierNotEquals verifyIntPtr(t, performer.Rating, ratingCriterion) } return nil }) } func TestPerformerIllegalQuery(t *testing.T) { assert := assert.New(t) const performerIdx = 1 subFilter := models.PerformerFilterType{ Ethnicity: &models.StringCriterionInput{ Value: getPerformerStringValue(performerIdx, "Ethnicity"), Modifier: models.CriterionModifierEquals, }, } tests := []struct { name string filter models.PerformerFilterType }{ { // And and Or in the same filter "AndOr", models.PerformerFilterType{ OperatorFilter: models.OperatorFilter[models.PerformerFilterType]{ And: &subFilter, Or: &subFilter, }, }, }, { // And and Not in the same filter "AndNot", models.PerformerFilterType{ OperatorFilter: models.OperatorFilter[models.PerformerFilterType]{ And: &subFilter, Not: &subFilter, }, }, }, { // Or and Not in the same filter "OrNot", models.PerformerFilterType{ OperatorFilter: models.OperatorFilter[models.PerformerFilterType]{ Or: &subFilter, Not: &subFilter, }, }, }, { "invalid height modifier", models.PerformerFilterType{ Height: &models.StringCriterionInput{ Modifier: models.CriterionModifierMatchesRegex, Value: "123", }, }, }, { "invalid height value", models.PerformerFilterType{ Height: &models.StringCriterionInput{ Modifier: models.CriterionModifierEquals, Value: "foo", }, }, }, } sqb := db.Performer for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { _, _, err := sqb.Query(ctx, &tt.filter, nil) assert.NotNil(err) }) } } func TestPerformerQueryIgnoreAutoTag(t *testing.T) { withTxn(func(ctx context.Context) error { ignoreAutoTag := true performerFilter := models.PerformerFilterType{ IgnoreAutoTag: &ignoreAutoTag, } performers := queryPerformers(ctx, t, &performerFilter, nil) assert.Len(t, performers, int(math.Ceil(float64(totalPerformers)/5))) for _, p := range performers { assert.True(t, p.IgnoreAutoTag) } return nil }) } func TestPerformerQuery(t *testing.T) { var ( endpoint = performerStashID(performerIdxWithGallery).Endpoint stashID = performerStashID(performerIdxWithGallery).StashID stashID2 = performerStashID(performerIdx1WithGallery).StashID stashIDs = []*string{&stashID, &stashID2} ) tests := []struct { name string findFilter *models.FindFilterType filter *models.PerformerFilterType includeIdxs []int excludeIdxs []int wantErr bool }{ { "stash id with endpoint", nil, &models.PerformerFilterType{ StashIDEndpoint: &models.StashIDCriterionInput{ Endpoint: &endpoint, StashID: &stashID, Modifier: models.CriterionModifierEquals, }, }, []int{performerIdxWithGallery}, nil, false, }, { "exclude stash id with endpoint", nil, &models.PerformerFilterType{ StashIDEndpoint: &models.StashIDCriterionInput{ Endpoint: &endpoint, StashID: &stashID, Modifier: models.CriterionModifierNotEquals, }, }, nil, []int{performerIdxWithGallery}, false, }, { "null stash id with endpoint", nil, &models.PerformerFilterType{ StashIDEndpoint: &models.StashIDCriterionInput{ Endpoint: &endpoint, Modifier: models.CriterionModifierIsNull, }, }, nil, []int{performerIdxWithGallery}, false, }, { "not null stash id with endpoint", nil, &models.PerformerFilterType{ StashIDEndpoint: &models.StashIDCriterionInput{ Endpoint: &endpoint, Modifier: models.CriterionModifierNotNull, }, }, []int{performerIdxWithGallery}, nil, false, }, { "stash ids with endpoint", nil, &models.PerformerFilterType{ StashIDsEndpoint: &models.StashIDsCriterionInput{ Endpoint: &endpoint, StashIDs: stashIDs, Modifier: models.CriterionModifierEquals, }, }, []int{performerIdxWithGallery, performerIdx1WithGallery}, nil, false, }, { "exclude stash ids with endpoint", nil, &models.PerformerFilterType{ StashIDsEndpoint: &models.StashIDsCriterionInput{ Endpoint: &endpoint, StashIDs: stashIDs, Modifier: models.CriterionModifierNotEquals, }, }, nil, []int{performerIdxWithGallery, performerIdx1WithGallery}, false, }, { "null stash ids with endpoint", nil, &models.PerformerFilterType{ StashIDsEndpoint: &models.StashIDsCriterionInput{ Endpoint: &endpoint, Modifier: models.CriterionModifierIsNull, }, }, nil, []int{performerIdxWithGallery, performerIdx1WithGallery}, false, }, { "not null stash ids with endpoint", nil, &models.PerformerFilterType{ StashIDsEndpoint: &models.StashIDsCriterionInput{ Endpoint: &endpoint, Modifier: models.CriterionModifierNotNull, }, }, []int{performerIdxWithGallery, performerIdx1WithGallery}, nil, false, }, { "circumcised (cut)", nil, &models.PerformerFilterType{ Circumcised: &models.CircumcisionCriterionInput{ Value: []models.CircumcisedEnum{models.CircumcisedEnumCut}, Modifier: models.CriterionModifierIncludes, }, }, []int{performerIdx1WithScene}, []int{performerIdxWithScene, performerIdx2WithScene}, false, }, { "circumcised (excludes cut)", nil, &models.PerformerFilterType{ Circumcised: &models.CircumcisionCriterionInput{ Value: []models.CircumcisedEnum{models.CircumcisedEnumCut}, Modifier: models.CriterionModifierExcludes, }, }, []int{performerIdx2WithScene}, // performerIdxWithScene has null value []int{performerIdx1WithScene, performerIdxWithScene}, false, }, { "include scene studio", nil, &models.PerformerFilterType{ Studios: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(studioIDs[studioIdxWithScenePerformer])}, Modifier: models.CriterionModifierIncludes, }, }, []int{performerIdxWithSceneStudio}, nil, false, }, { "include image studio", nil, &models.PerformerFilterType{ Studios: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(studioIDs[studioIdxWithImagePerformer])}, Modifier: models.CriterionModifierIncludes, }, }, []int{performerIdxWithImageStudio}, nil, false, }, { "include gallery studio", nil, &models.PerformerFilterType{ Studios: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(studioIDs[studioIdxWithGalleryPerformer])}, Modifier: models.CriterionModifierIncludes, }, }, []int{performerIdxWithGalleryStudio}, nil, false, }, { "exclude scene studio", nil, &models.PerformerFilterType{ Studios: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(studioIDs[studioIdxWithScenePerformer])}, Modifier: models.CriterionModifierExcludes, }, }, nil, []int{performerIdxWithSceneStudio}, false, }, { "exclude image studio", nil, &models.PerformerFilterType{ Studios: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(studioIDs[studioIdxWithImagePerformer])}, Modifier: models.CriterionModifierExcludes, }, }, nil, []int{performerIdxWithImageStudio}, false, }, { "exclude gallery studio", nil, &models.PerformerFilterType{ Studios: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(studioIDs[studioIdxWithGalleryPerformer])}, Modifier: models.CriterionModifierExcludes, }, }, nil, []int{performerIdxWithGalleryStudio}, false, }, { "include and exclude scene studio", nil, &models.PerformerFilterType{ Studios: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(studioIDs[studioIdx1WithTwoScenePerformer])}, Modifier: models.CriterionModifierIncludes, Excludes: []string{strconv.Itoa(studioIDs[studioIdx2WithTwoScenePerformer])}, }, }, nil, []int{performerIdxWithTwoSceneStudio}, false, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) performers, _, err := db.Performer.Query(ctx, tt.filter, tt.findFilter) if (err != nil) != tt.wantErr { t.Errorf("PerformerStore.Query() error = %v, wantErr %v", err, tt.wantErr) return } ids := performersToIDs(performers) include := indexesToIDs(performerIDs, tt.includeIdxs) exclude := indexesToIDs(performerIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(ids, i) } for _, e := range exclude { assert.NotContains(ids, e) } }) } } func TestPerformerQueryCustomFields(t *testing.T) { tests := []struct { name string filter *models.PerformerFilterType includeIdxs []int excludeIdxs []int wantErr bool }{ { "equals", &models.PerformerFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierEquals, Value: []any{getPerformerStringValue(performerIdxWithGallery, "custom")}, }, }, }, []int{performerIdxWithGallery}, nil, false, }, { "not equals", &models.PerformerFilterType{ Name: &models.StringCriterionInput{ Value: getPerformerStringValue(performerIdxWithGallery, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotEquals, Value: []any{getPerformerStringValue(performerIdxWithGallery, "custom")}, }, }, }, nil, []int{performerIdxWithGallery}, false, }, { "includes", &models.PerformerFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierIncludes, Value: []any{getPerformerStringValue(performerIdxWithGallery, "custom")[9:]}, }, }, }, []int{performerIdxWithGallery}, nil, false, }, { "excludes", &models.PerformerFilterType{ Name: &models.StringCriterionInput{ Value: getPerformerStringValue(performerIdxWithGallery, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierExcludes, Value: []any{getPerformerStringValue(performerIdxWithGallery, "custom")[9:]}, }, }, }, nil, []int{performerIdxWithGallery}, false, }, { "regex", &models.PerformerFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierMatchesRegex, Value: []any{".*13_custom"}, }, }, }, []int{performerIdxWithGallery}, nil, false, }, { "invalid regex", &models.PerformerFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierMatchesRegex, Value: []any{"["}, }, }, }, nil, nil, true, }, { "not matches regex", &models.PerformerFilterType{ Name: &models.StringCriterionInput{ Value: getPerformerStringValue(performerIdxWithGallery, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotMatchesRegex, Value: []any{".*13_custom"}, }, }, }, nil, []int{performerIdxWithGallery}, false, }, { "invalid not matches regex", &models.PerformerFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotMatchesRegex, Value: []any{"["}, }, }, }, nil, nil, true, }, { "null", &models.PerformerFilterType{ Name: &models.StringCriterionInput{ Value: getPerformerStringValue(performerIdxWithGallery, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "not existing", Modifier: models.CriterionModifierIsNull, }, }, }, []int{performerIdxWithGallery}, nil, false, }, { "null", &models.PerformerFilterType{ Name: &models.StringCriterionInput{ Value: getPerformerStringValue(performerIdxWithGallery, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotNull, }, }, }, []int{performerIdxWithGallery}, nil, false, }, { "between", &models.PerformerFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "real", Modifier: models.CriterionModifierBetween, Value: []any{0.05, 0.15}, }, }, }, []int{performerIdx1WithScene}, nil, false, }, { "not between", &models.PerformerFilterType{ Name: &models.StringCriterionInput{ Value: getPerformerStringValue(performerIdx1WithScene, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "real", Modifier: models.CriterionModifierNotBetween, Value: []any{0.05, 0.15}, }, }, }, nil, []int{performerIdx1WithScene}, false, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) performers, _, err := db.Performer.Query(ctx, tt.filter, nil) if (err != nil) != tt.wantErr { t.Errorf("PerformerStore.Query() error = %v, wantErr %v", err, tt.wantErr) return } ids := performersToIDs(performers) include := indexesToIDs(performerIDs, tt.includeIdxs) exclude := indexesToIDs(performerIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(ids, i) } for _, e := range exclude { assert.NotContains(ids, e) } }) } } func TestPerformerQueryPenisLength(t *testing.T) { var upper = 4.0 tests := []struct { name string modifier models.CriterionModifier value float64 value2 *float64 }{ { "equals", models.CriterionModifierEquals, 1, nil, }, { "not equals", models.CriterionModifierNotEquals, 1, nil, }, { "greater than", models.CriterionModifierGreaterThan, 1, nil, }, { "between", models.CriterionModifierBetween, 2, &upper, }, { "greater than", models.CriterionModifierNotBetween, 2, &upper, }, { "null", models.CriterionModifierIsNull, 0, nil, }, { "not null", models.CriterionModifierNotNull, 0, nil, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { filter := &models.PerformerFilterType{ PenisLength: &models.FloatCriterionInput{ Modifier: tt.modifier, Value: tt.value, Value2: tt.value2, }, } performers, _, err := db.Performer.Query(ctx, filter, nil) if err != nil { t.Errorf("PerformerStore.Query() error = %v", err) return } for _, p := range performers { verifyFloat(t, p.PenisLength, *filter.PenisLength) } }) } } func verifyFloat(t *testing.T, value *float64, criterion models.FloatCriterionInput) bool { t.Helper() assert := assert.New(t) switch criterion.Modifier { case models.CriterionModifierEquals: return assert.NotNil(value) && assert.Equal(criterion.Value, *value) case models.CriterionModifierNotEquals: return assert.NotNil(value) && assert.NotEqual(criterion.Value, *value) case models.CriterionModifierGreaterThan: return assert.NotNil(value) && assert.Greater(*value, criterion.Value) case models.CriterionModifierLessThan: return assert.NotNil(value) && assert.Less(*value, criterion.Value) case models.CriterionModifierBetween: return assert.NotNil(value) && assert.GreaterOrEqual(*value, criterion.Value) && assert.LessOrEqual(*value, *criterion.Value2) case models.CriterionModifierNotBetween: return assert.NotNil(value) && assert.True(*value < criterion.Value || *value > *criterion.Value2) case models.CriterionModifierIsNull: return assert.Nil(value) case models.CriterionModifierNotNull: return assert.NotNil(value) } return false } func TestPerformerQueryForAutoTag(t *testing.T) { withTxn(func(ctx context.Context) error { tqb := db.Performer name := performerNames[performerIdx1WithScene] // find a performer by name performers, err := tqb.QueryForAutoTag(ctx, []string{name}) if err != nil { t.Errorf("Error finding performers: %s", err.Error()) } assert.Len(t, performers, 2) assert.Equal(t, strings.ToLower(performerNames[performerIdx1WithScene]), strings.ToLower(performers[0].Name)) assert.Equal(t, strings.ToLower(performerNames[performerIdx1WithScene]), strings.ToLower(performers[1].Name)) return nil }) } func TestPerformerUpdatePerformerImage(t *testing.T) { if err := withRollbackTxn(func(ctx context.Context) error { qb := db.Performer // create performer to test against const name = "TestPerformerUpdatePerformerImage" performer := models.Performer{ Name: name, } err := qb.Create(ctx, &models.CreatePerformerInput{Performer: &performer}) if err != nil { return fmt.Errorf("Error creating performer: %s", err.Error()) } return testUpdateImage(t, ctx, performer.ID, qb.UpdateImage, qb.GetImage) }); err != nil { t.Error(err.Error()) } } func TestPerformerQueryAge(t *testing.T) { const age = 19 ageCriterion := models.IntCriterionInput{ Value: age, Modifier: models.CriterionModifierEquals, } verifyPerformerAge(t, ageCriterion) ageCriterion.Modifier = models.CriterionModifierNotEquals verifyPerformerAge(t, ageCriterion) ageCriterion.Modifier = models.CriterionModifierGreaterThan verifyPerformerAge(t, ageCriterion) ageCriterion.Modifier = models.CriterionModifierLessThan verifyPerformerAge(t, ageCriterion) } func verifyPerformerAge(t *testing.T, ageCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { qb := db.Performer performerFilter := models.PerformerFilterType{ Age: &ageCriterion, } performers, _, err := qb.Query(ctx, &performerFilter, nil) if err != nil { t.Errorf("Error querying performer: %s", err.Error()) } now := time.Now() for _, performer := range performers { cd := now if performer.DeathDate != nil { cd = performer.DeathDate.Time } d := performer.Birthdate.Time age := cd.Year() - d.Year() // using YearDay screws up on leap years if cd.Month() < d.Month() || (cd.Month() == d.Month() && cd.Day() < d.Day()) { age = age - 1 } if !verifyInt(t, age, ageCriterion) { t.Errorf("Performer birthdate: %s, deathdate: %s", performer.Birthdate.String(), performer.DeathDate.String()) } } return nil }) } func TestPerformerQueryLegacyCareerLength(t *testing.T) { const value = "2002 - 2012" tests := []struct { name string c models.StringCriterionInput careerStartCrit *models.DateCriterionInput careerEndCrit *models.DateCriterionInput err bool }{ { name: "valid format", c: models.StringCriterionInput{ Value: value, Modifier: models.CriterionModifierEquals, }, careerStartCrit: &models.DateCriterionInput{ Value: "2001-12-31", Modifier: models.CriterionModifierGreaterThan, }, careerEndCrit: &models.DateCriterionInput{ Value: "2013-01-01", Modifier: models.CriterionModifierLessThan, }, err: false, }, { name: "invalid format", c: models.StringCriterionInput{ Value: "invalid format", Modifier: models.CriterionModifierEquals, }, err: true, }, { name: "is null", c: models.StringCriterionInput{ Modifier: models.CriterionModifierIsNull, }, careerStartCrit: &models.DateCriterionInput{ Modifier: models.CriterionModifierIsNull, }, careerEndCrit: &models.DateCriterionInput{ Modifier: models.CriterionModifierIsNull, }, err: false, }, { name: "not null", c: models.StringCriterionInput{ Modifier: models.CriterionModifierNotNull, }, careerStartCrit: &models.DateCriterionInput{ Modifier: models.CriterionModifierNotNull, }, careerEndCrit: &models.DateCriterionInput{ Modifier: models.CriterionModifierNotNull, }, err: false, }, { name: "invalid modifier", c: models.StringCriterionInput{ Value: value, Modifier: models.CriterionModifierMatchesRegex, }, err: true, }, } qb := db.Performer for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { performers, _, err := qb.Query(ctx, &models.PerformerFilterType{ CareerLength: &tt.c, }, nil) if err != nil && !tt.err { t.Errorf("Error querying performer: %s", err.Error()) } else if err == nil && tt.err { t.Errorf("Expected error but got none") } if err != nil || tt.err { return } if len(performers) == 0 { t.Errorf("Expected to find performers but found none") } for _, performer := range performers { verifyDatePtr(t, performer.CareerStart, *tt.careerStartCrit) verifyDatePtr(t, performer.CareerEnd, *tt.careerEndCrit) } }) } } func TestPerformerQueryCareerStart(t *testing.T) { const value = "2002" criterion := models.DateCriterionInput{ Value: value, Modifier: models.CriterionModifierEquals, } withTxn(func(ctx context.Context) error { qb := db.Performer performerFilter := models.PerformerFilterType{ CareerStart: &criterion, } performers, _, err := qb.Query(ctx, &performerFilter, nil) if err != nil { t.Errorf("Error querying performer: %s", err.Error()) } for _, performer := range performers { verifyDatePtr(t, performer.CareerStart, criterion) } return nil }) } func TestPerformerQueryCareerEnd(t *testing.T) { const value = "2012" criterion := models.DateCriterionInput{ Value: value, Modifier: models.CriterionModifierEquals, } withTxn(func(ctx context.Context) error { qb := db.Performer performerFilter := models.PerformerFilterType{ CareerEnd: &criterion, } performers, _, err := qb.Query(ctx, &performerFilter, nil) if err != nil { t.Errorf("Error querying performer: %s", err.Error()) } for _, performer := range performers { verifyDatePtr(t, performer.CareerEnd, criterion) } return nil }) } func TestPerformerQueryURL(t *testing.T) { const sceneIdx = 1 performerURL := getPerformerStringValue(sceneIdx, urlField) urlCriterion := models.StringCriterionInput{ Value: performerURL, Modifier: models.CriterionModifierEquals, } filter := models.PerformerFilterType{ URL: &urlCriterion, } verifyFn := func(g *models.Performer) { t.Helper() urls := g.URLs.List() var url string if len(urls) > 0 { url = urls[0] } verifyString(t, url, urlCriterion) } verifyPerformerQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierNotEquals verifyPerformerQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierMatchesRegex urlCriterion.Value = "performer_.*1_URL" verifyPerformerQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierNotMatchesRegex verifyPerformerQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierIsNull urlCriterion.Value = "" verifyPerformerQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierNotNull verifyPerformerQuery(t, filter, verifyFn) } func verifyPerformerQuery(t *testing.T, filter models.PerformerFilterType, verifyFn func(s *models.Performer)) { withTxn(func(ctx context.Context) error { t.Helper() performers := queryPerformers(ctx, t, &filter, nil) for _, performer := range performers { if err := performer.LoadURLs(ctx, db.Performer); err != nil { t.Errorf("Error loading url relationships: %v", err) } } // assume it should find at least one assert.Greater(t, len(performers), 0) for _, p := range performers { verifyFn(p) } return nil }) } func queryPerformers(ctx context.Context, t *testing.T, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) []*models.Performer { t.Helper() performers, _, err := db.Performer.Query(ctx, performerFilter, findFilter) if err != nil { t.Errorf("Error querying performers: %s", err.Error()) } return performers } func TestPerformerQueryTags(t *testing.T) { withTxn(func(ctx context.Context) error { tagCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithPerformer]), strconv.Itoa(tagIDs[tagIdx1WithPerformer]), }, Modifier: models.CriterionModifierIncludes, } performerFilter := models.PerformerFilterType{ Tags: &tagCriterion, } // ensure ids are correct performers := queryPerformers(ctx, t, &performerFilter, nil) assert.Len(t, performers, 2) for _, performer := range performers { assert.True(t, performer.ID == performerIDs[performerIdxWithTag] || performer.ID == performerIDs[performerIdxWithTwoTags]) } tagCriterion = models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdx1WithPerformer]), strconv.Itoa(tagIDs[tagIdx2WithPerformer]), }, Modifier: models.CriterionModifierIncludesAll, } performers = queryPerformers(ctx, t, &performerFilter, nil) assert.Len(t, performers, 1) assert.Equal(t, sceneIDs[performerIdxWithTwoTags], performers[0].ID) tagCriterion = models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdx1WithPerformer]), }, Modifier: models.CriterionModifierExcludes, } q := getSceneStringValue(performerIdxWithTwoTags, titleField) findFilter := models.FindFilterType{ Q: &q, } performers = queryPerformers(ctx, t, &performerFilter, &findFilter) assert.Len(t, performers, 0) return nil }) } func TestPerformerQueryTagCount(t *testing.T) { const tagCount = 1 tagCountCriterion := models.IntCriterionInput{ Value: tagCount, Modifier: models.CriterionModifierEquals, } verifyPerformersTagCount(t, tagCountCriterion) tagCountCriterion.Modifier = models.CriterionModifierNotEquals verifyPerformersTagCount(t, tagCountCriterion) tagCountCriterion.Modifier = models.CriterionModifierGreaterThan verifyPerformersTagCount(t, tagCountCriterion) tagCountCriterion.Modifier = models.CriterionModifierLessThan verifyPerformersTagCount(t, tagCountCriterion) } func verifyPerformersTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Performer performerFilter := models.PerformerFilterType{ TagCount: &tagCountCriterion, } performers := queryPerformers(ctx, t, &performerFilter, nil) assert.Greater(t, len(performers), 0) for _, performer := range performers { ids, err := sqb.GetTagIDs(ctx, performer.ID) if err != nil { return err } verifyInt(t, len(ids), tagCountCriterion) } return nil }) } func TestPerformerQuerySceneCount(t *testing.T) { const sceneCount = 1 sceneCountCriterion := models.IntCriterionInput{ Value: sceneCount, Modifier: models.CriterionModifierEquals, } verifyPerformersSceneCount(t, sceneCountCriterion) sceneCountCriterion.Modifier = models.CriterionModifierNotEquals verifyPerformersSceneCount(t, sceneCountCriterion) sceneCountCriterion.Modifier = models.CriterionModifierGreaterThan verifyPerformersSceneCount(t, sceneCountCriterion) sceneCountCriterion.Modifier = models.CriterionModifierLessThan verifyPerformersSceneCount(t, sceneCountCriterion) } func verifyPerformersSceneCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { performerFilter := models.PerformerFilterType{ SceneCount: &sceneCountCriterion, } performers := queryPerformers(ctx, t, &performerFilter, nil) assert.Greater(t, len(performers), 0) for _, performer := range performers { ids, err := db.Scene.FindByPerformerID(ctx, performer.ID) if err != nil { return err } verifyInt(t, len(ids), sceneCountCriterion) } return nil }) } func TestPerformerQueryImageCount(t *testing.T) { const imageCount = 1 imageCountCriterion := models.IntCriterionInput{ Value: imageCount, Modifier: models.CriterionModifierEquals, } verifyPerformersImageCount(t, imageCountCriterion) imageCountCriterion.Modifier = models.CriterionModifierNotEquals verifyPerformersImageCount(t, imageCountCriterion) imageCountCriterion.Modifier = models.CriterionModifierGreaterThan verifyPerformersImageCount(t, imageCountCriterion) imageCountCriterion.Modifier = models.CriterionModifierLessThan verifyPerformersImageCount(t, imageCountCriterion) } func verifyPerformersImageCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { performerFilter := models.PerformerFilterType{ ImageCount: &imageCountCriterion, } performers := queryPerformers(ctx, t, &performerFilter, nil) assert.Greater(t, len(performers), 0) for _, performer := range performers { pp := 0 result, err := db.Image.Query(ctx, models.ImageQueryOptions{ QueryOptions: models.QueryOptions{ FindFilter: &models.FindFilterType{ PerPage: &pp, }, Count: true, }, ImageFilter: &models.ImageFilterType{ Performers: &models.MultiCriterionInput{ Value: []string{strconv.Itoa(performer.ID)}, Modifier: models.CriterionModifierIncludes, }, }, }) if err != nil { return err } verifyInt(t, result.Count, imageCountCriterion) } return nil }) } func TestPerformerQueryGalleryCount(t *testing.T) { const galleryCount = 1 galleryCountCriterion := models.IntCriterionInput{ Value: galleryCount, Modifier: models.CriterionModifierEquals, } verifyPerformersGalleryCount(t, galleryCountCriterion) galleryCountCriterion.Modifier = models.CriterionModifierNotEquals verifyPerformersGalleryCount(t, galleryCountCriterion) galleryCountCriterion.Modifier = models.CriterionModifierGreaterThan verifyPerformersGalleryCount(t, galleryCountCriterion) galleryCountCriterion.Modifier = models.CriterionModifierLessThan verifyPerformersGalleryCount(t, galleryCountCriterion) } func verifyPerformersGalleryCount(t *testing.T, galleryCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { performerFilter := models.PerformerFilterType{ GalleryCount: &galleryCountCriterion, } performers := queryPerformers(ctx, t, &performerFilter, nil) assert.Greater(t, len(performers), 0) for _, performer := range performers { pp := 0 _, count, err := db.Gallery.Query(ctx, &models.GalleryFilterType{ Performers: &models.MultiCriterionInput{ Value: []string{strconv.Itoa(performer.ID)}, Modifier: models.CriterionModifierIncludes, }, }, &models.FindFilterType{ PerPage: &pp, }) if err != nil { return err } verifyInt(t, count, galleryCountCriterion) } return nil }) } func TestPerformerQueryStudio(t *testing.T) { withTxn(func(ctx context.Context) error { testCases := []struct { studioIndex int performerIndex int }{ {studioIndex: studioIdxWithScenePerformer, performerIndex: performerIdxWithSceneStudio}, {studioIndex: studioIdxWithImagePerformer, performerIndex: performerIdxWithImageStudio}, {studioIndex: studioIdxWithGalleryPerformer, performerIndex: performerIdxWithGalleryStudio}, } for _, tc := range testCases { studioCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[tc.studioIndex]), }, Modifier: models.CriterionModifierIncludes, } performerFilter := models.PerformerFilterType{ Studios: &studioCriterion, } performers := queryPerformers(ctx, t, &performerFilter, nil) assert.Len(t, performers, 1) // ensure id is correct assert.Equal(t, performerIDs[tc.performerIndex], performers[0].ID) studioCriterion = models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[tc.studioIndex]), }, Modifier: models.CriterionModifierExcludes, } q := getPerformerStringValue(tc.performerIndex, "Name") findFilter := models.FindFilterType{ Q: &q, } performers = queryPerformers(ctx, t, &performerFilter, &findFilter) assert.Len(t, performers, 0) } // test NULL/not NULL q := getPerformerStringValue(performerIdx1WithImage, "Name") performerFilter := &models.PerformerFilterType{ Studios: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIsNull, }, } findFilter := &models.FindFilterType{ Q: &q, } performers := queryPerformers(ctx, t, performerFilter, findFilter) assert.Len(t, performers, 1) assert.Equal(t, imageIDs[performerIdx1WithImage], performers[0].ID) q = getPerformerStringValue(performerIdxWithSceneStudio, "Name") performers = queryPerformers(ctx, t, performerFilter, findFilter) assert.Len(t, performers, 0) performerFilter.Studios.Modifier = models.CriterionModifierNotNull performers = queryPerformers(ctx, t, performerFilter, findFilter) assert.Len(t, performers, 1) assert.Equal(t, imageIDs[performerIdxWithSceneStudio], performers[0].ID) q = getPerformerStringValue(performerIdx1WithImage, "Name") performers = queryPerformers(ctx, t, performerFilter, findFilter) assert.Len(t, performers, 0) return nil }) } func TestPerformerStashIDs(t *testing.T) { if err := withRollbackTxn(func(ctx context.Context) error { qb := db.Performer // create scene to test against const name = "TestPerformerStashIDs" performer := &models.Performer{ Name: name, } if err := qb.Create(ctx, &models.CreatePerformerInput{Performer: performer}); err != nil { return fmt.Errorf("Error creating performer: %s", err.Error()) } if err := performer.LoadStashIDs(ctx, qb); err != nil { return err } testPerformerStashIDs(ctx, t, performer) return nil }); err != nil { t.Error(err.Error()) } } func testPerformerStashIDs(ctx context.Context, t *testing.T, s *models.Performer) { // ensure no stash IDs to begin with assert.Len(t, s.StashIDs.List(), 0) // add stash ids const stashIDStr = "stashID" const endpoint = "endpoint" stashID := models.StashID{ StashID: stashIDStr, Endpoint: endpoint, UpdatedAt: epochTime, } qb := db.Performer // update stash ids and ensure was updated var err error s, err = qb.UpdatePartial(ctx, s.ID, models.PerformerPartial{ StashIDs: &models.UpdateStashIDs{ StashIDs: []models.StashID{stashID}, Mode: models.RelationshipUpdateModeSet, }, }) if err != nil { t.Error(err.Error()) } if err := s.LoadStashIDs(ctx, qb); err != nil { t.Error(err.Error()) return } assert.Equal(t, []models.StashID{stashID}, s.StashIDs.List()) // remove stash ids and ensure was updated s, err = qb.UpdatePartial(ctx, s.ID, models.PerformerPartial{ StashIDs: &models.UpdateStashIDs{ StashIDs: []models.StashID{stashID}, Mode: models.RelationshipUpdateModeRemove, }, }) if err != nil { t.Error(err.Error()) } if err := s.LoadStashIDs(ctx, qb); err != nil { t.Error(err.Error()) return } assert.Len(t, s.StashIDs.List(), 0) } func TestPerformerQueryRating100(t *testing.T) { const rating = 60 ratingCriterion := models.IntCriterionInput{ Value: rating, Modifier: models.CriterionModifierEquals, } verifyPerformersRating100(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierNotEquals verifyPerformersRating100(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierGreaterThan verifyPerformersRating100(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierLessThan verifyPerformersRating100(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierIsNull verifyPerformersRating100(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierNotNull verifyPerformersRating100(t, ratingCriterion) } func verifyPerformersRating100(t *testing.T, ratingCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { performerFilter := models.PerformerFilterType{ Rating100: &ratingCriterion, } performers := queryPerformers(ctx, t, &performerFilter, nil) for _, performer := range performers { verifyIntPtr(t, performer.Rating, ratingCriterion) } return nil }) } func performerQueryIsMissing(ctx context.Context, t *testing.T, m string) []*models.Performer { performerFilter := models.PerformerFilterType{ IsMissing: &m, } return queryPerformers(ctx, t, &performerFilter, nil) } func TestPerformerQueryIsMissingRating(t *testing.T) { withTxn(func(ctx context.Context) error { performers := performerQueryIsMissing(ctx, t, "rating") assert.True(t, len(performers) > 0) for _, performer := range performers { assert.Nil(t, performer.Rating) } return nil }) } func TestPerformerQueryIsMissingImage(t *testing.T) { withTxn(func(ctx context.Context) error { performers := performerQueryIsMissing(ctx, t, "image") assert.True(t, len(performers) > 0) for _, performer := range performers { img, err := db.Performer.GetImage(ctx, performer.ID) if err != nil { t.Errorf("error getting performer image: %s", err.Error()) } assert.Nil(t, img) } return nil }) } func TestPerformerQueryIsMissingAlias(t *testing.T) { withTxn(func(ctx context.Context) error { performers := performerQueryIsMissing(ctx, t, "aliases") assert.True(t, len(performers) > 0) for _, performer := range performers { a, err := db.Performer.GetAliases(ctx, performer.ID) if err != nil { t.Errorf("error getting performer aliases: %s", err.Error()) } assert.Nil(t, a) } return nil }) } func TestPerformerQuerySortScenesCount(t *testing.T) { sort := "scenes_count" direction := models.SortDirectionEnumDesc findFilter := &models.FindFilterType{ Sort: &sort, Direction: &direction, } withTxn(func(ctx context.Context) error { // just ensure it queries without error performers, _, err := db.Performer.Query(ctx, nil, findFilter) if err != nil { t.Errorf("Error querying performers: %s", err.Error()) } assert.True(t, len(performers) > 0) // first performer should be performerIdx1WithScene firstPerformer := performers[0] assert.Equal(t, performerIDs[performerIdx1WithScene], firstPerformer.ID) // sort in ascending order direction = models.SortDirectionEnumAsc performers, _, err = db.Performer.Query(ctx, nil, findFilter) if err != nil { t.Errorf("Error querying performers: %s", err.Error()) } assert.True(t, len(performers) > 0) lastPerformer := performers[len(performers)-1] assert.Equal(t, performerIDs[performerIdxWithTwoSceneStudio], lastPerformer.ID) return nil }) } func TestPerformerCountByTagID(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Performer count, err := sqb.CountByTagID(ctx, tagIDs[tagIdxWithPerformer]) if err != nil { t.Errorf("Error counting performers: %s", err.Error()) } assert.Equal(t, 1, count) count, err = sqb.CountByTagID(ctx, 0) if err != nil { t.Errorf("Error counting performers: %s", err.Error()) } assert.Equal(t, 0, count) return nil }) } func TestPerformerCount(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Performer count, err := sqb.Count(ctx) if err != nil { t.Errorf("Error counting performers: %s", err.Error()) } assert.Equal(t, totalPerformers, count) return nil }) } func TestPerformerAll(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Performer all, err := sqb.All(ctx) if err != nil { t.Errorf("Error counting performers: %s", err.Error()) } assert.Len(t, all, totalPerformers) return nil }) } func performersToIDs(i []*models.Performer) []int { ret := make([]int, len(i)) for i, v := range i { ret[i] = v.ID } return ret } func TestPerformerStore_FindByStashID(t *testing.T) { type args struct { stashID models.StashID } tests := []struct { name string stashID models.StashID expectedIDs []int wantErr bool }{ { name: "existing", stashID: performerStashID(performerIdxWithScene), expectedIDs: []int{performerIDs[performerIdxWithScene]}, wantErr: false, }, { name: "non-existing", stashID: models.StashID{ StashID: getPerformerStringValue(performerIdxWithScene, "stashid"), Endpoint: "non-existing", }, expectedIDs: []int{}, wantErr: false, }, } qb := db.Performer for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { got, err := qb.FindByStashID(ctx, tt.stashID) if (err != nil) != tt.wantErr { t.Errorf("PerformerStore.FindByStashID() error = %v, wantErr %v", err, tt.wantErr) return } assert.ElementsMatch(t, performersToIDs(got), tt.expectedIDs) }) } } func TestPerformerStore_FindByStashIDStatus(t *testing.T) { type args struct { stashID models.StashID } tests := []struct { name string hasStashID bool stashboxEndpoint string include []int exclude []int wantErr bool }{ { name: "existing", hasStashID: true, stashboxEndpoint: getPerformerStringValue(performerIdxWithScene, "endpoint"), include: []int{performerIdxWithScene}, wantErr: false, }, { name: "non-existing", hasStashID: true, stashboxEndpoint: getPerformerStringValue(performerIdxWithScene, "non-existing"), exclude: []int{performerIdxWithScene}, wantErr: false, }, { name: "!hasStashID", hasStashID: false, stashboxEndpoint: getPerformerStringValue(performerIdxWithScene, "endpoint"), include: []int{performerIdxWithTwoScenes}, exclude: []int{performerIdx2WithScene}, wantErr: false, }, } qb := db.Performer for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { got, err := qb.FindByStashIDStatus(ctx, tt.hasStashID, tt.stashboxEndpoint) if (err != nil) != tt.wantErr { t.Errorf("PerformerStore.FindByStashIDStatus() error = %v, wantErr %v", err, tt.wantErr) return } include := indexesToIDs(performerIDs, tt.include) exclude := indexesToIDs(performerIDs, tt.exclude) ids := performersToIDs(got) assert := assert.New(t) for _, i := range include { assert.Contains(ids, i) } for _, e := range exclude { assert.NotContains(ids, e) } }) } } func TestPerformerMerge(t *testing.T) { tests := []struct { name string srcIdxs []int destIdx int wantErr bool }{ { name: "merge into self", srcIdxs: []int{performerIdx1WithDupName}, destIdx: performerIdx1WithDupName, wantErr: true, }, { name: "merge multiple", srcIdxs: []int{ performerIdx2WithScene, performerIdxWithTwoScenes, performerIdx1WithImage, performerIdxWithTwoImages, performerIdxWithGallery, performerIdxWithTwoGalleries, performerIdxWithTag, performerIdxWithTwoTags, }, destIdx: tagIdxWithPerformer, wantErr: false, }, } qb := db.Performer for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) // load src tag ids to compare after merge performerTagIds := make(map[int][]int) for _, srcIdx := range tt.srcIdxs { srcPerformer, err := qb.Find(ctx, performerIDs[srcIdx]) if err != nil { t.Errorf("Error finding performer: %s", err.Error()) } if err := srcPerformer.LoadTagIDs(ctx, qb); err != nil { t.Errorf("Error loading performer tag IDs: %s", err.Error()) } srcTagIDs := srcPerformer.TagIDs.List() performerTagIds[srcIdx] = srcTagIDs } err := qb.Merge(ctx, indexesToIDs(tagIDs, tt.srcIdxs), tagIDs[tt.destIdx]) if (err != nil) != tt.wantErr { t.Errorf("PerformerStore.Merge() error = %v, wantErr %v", err, tt.wantErr) return } if err != nil { return } // ensure source performers are destroyed for _, srcIdx := range tt.srcIdxs { p, err := qb.Find(ctx, performerIDs[srcIdx]) // not found returns nil performer and nil error if err != nil { t.Errorf("Error finding performer: %s", err.Error()) continue } assert.Nil(p) } // ensure items point to new performer for _, srcIdx := range tt.srcIdxs { sceneIdxs := scenePerformers.reverseLookup(srcIdx) for _, sceneIdx := range sceneIdxs { s, err := db.Scene.Find(ctx, sceneIDs[sceneIdx]) if err != nil { t.Errorf("Error finding scene: %s", err.Error()) } if err := s.LoadPerformerIDs(ctx, db.Scene); err != nil { t.Errorf("Error loading scene performer IDs: %s", err.Error()) } scenePerformerIDs := s.PerformerIDs.List() assert.Contains(scenePerformerIDs, performerIDs[tt.destIdx]) assert.NotContains(scenePerformerIDs, performerIDs[srcIdx]) } imageIdxs := imagePerformers.reverseLookup(srcIdx) for _, imageIdx := range imageIdxs { i, err := db.Image.Find(ctx, imageIDs[imageIdx]) if err != nil { t.Errorf("Error finding image: %s", err.Error()) } if err := i.LoadPerformerIDs(ctx, db.Image); err != nil { t.Errorf("Error loading image performer IDs: %s", err.Error()) } imagePerformerIDs := i.PerformerIDs.List() assert.Contains(imagePerformerIDs, performerIDs[tt.destIdx]) assert.NotContains(imagePerformerIDs, performerIDs[srcIdx]) } galleryIdxs := galleryPerformers.reverseLookup(srcIdx) for _, galleryIdx := range galleryIdxs { g, err := db.Gallery.Find(ctx, galleryIDs[galleryIdx]) if err != nil { t.Errorf("Error finding gallery: %s", err.Error()) } if err := g.LoadPerformerIDs(ctx, db.Gallery); err != nil { t.Errorf("Error loading gallery performer IDs: %s", err.Error()) } galleryPerformerIDs := g.PerformerIDs.List() assert.Contains(galleryPerformerIDs, performerIDs[tt.destIdx]) assert.NotContains(galleryPerformerIDs, performerIDs[srcIdx]) } } // ensure tags were merged destPerformer, err := qb.Find(ctx, performerIDs[tt.destIdx]) if err != nil { t.Errorf("Error finding performer: %s", err.Error()) } if err := destPerformer.LoadTagIDs(ctx, qb); err != nil { t.Errorf("Error loading performer tag IDs: %s", err.Error()) } destTagIDs := destPerformer.TagIDs.List() for _, srcIdx := range tt.srcIdxs { for _, tagID := range performerTagIds[srcIdx] { assert.Contains(destTagIDs, tagID) } } }) } } // TODO Update // TODO Destroy // TODO Find // TODO Query ================================================ FILE: pkg/sqlite/phash.go ================================================ package sqlite import "github.com/corona10/goimagehash" func phashDistanceFn(phash1 int64, phash2 int64) (int64, error) { hash1 := goimagehash.NewImageHash(uint64(phash1), goimagehash.PHash) hash2 := goimagehash.NewImageHash(uint64(phash2), goimagehash.PHash) distance, _ := hash1.Distance(hash2) return int64(distance), nil } ================================================ FILE: pkg/sqlite/query.go ================================================ package sqlite import ( "context" "fmt" "strings" "github.com/stashapp/stash/pkg/models" ) type queryBuilder struct { repository *repository columns []string from string joins joins whereClauses []string havingClauses []string withClauses []string recursiveWith bool withArgs []interface{} joinArgs []interface{} whereArgs []interface{} havingArgs []interface{} sortAndPagination string } func (qb queryBuilder) allArgs() []interface{} { var args []interface{} args = append(args, qb.withArgs...) args = append(args, qb.joinArgs...) args = append(args, qb.whereArgs...) args = append(args, qb.havingArgs...) return args } func (qb queryBuilder) body(includeSortPagination bool) string { return fmt.Sprintf("SELECT %s FROM %s%s", strings.Join(qb.columns, ", "), qb.from, qb.joins.toSQL(includeSortPagination)) } func (qb *queryBuilder) addColumn(column string) { qb.columns = append(qb.columns, column) } func (qb queryBuilder) toSQL(includeSortPagination bool) string { body := qb.body(includeSortPagination) withClause := "" if len(qb.withClauses) > 0 { var recursive string if qb.recursiveWith { recursive = " RECURSIVE " } withClause = "WITH " + recursive + strings.Join(qb.withClauses, ", ") + " " } body = withClause + qb.repository.buildQueryBody(body, qb.whereClauses, qb.havingClauses) if includeSortPagination { body += qb.sortAndPagination } return body } func (qb queryBuilder) findIDs(ctx context.Context) ([]int, error) { const includeSortPagination = true sql := qb.toSQL(includeSortPagination) return qb.repository.runIdsQuery(ctx, sql, qb.allArgs()) } func (qb queryBuilder) executeFind(ctx context.Context) ([]int, int, error) { const includeSortPagination = true body := qb.body(includeSortPagination) return qb.repository.executeFindQuery(ctx, body, qb.allArgs(), qb.sortAndPagination, qb.whereClauses, qb.havingClauses, qb.withClauses, qb.recursiveWith) } func (qb queryBuilder) executeCount(ctx context.Context) (int, error) { const includeSortPagination = false body := qb.body(includeSortPagination) withClause := "" if len(qb.withClauses) > 0 { var recursive string if qb.recursiveWith { recursive = " RECURSIVE " } withClause = "WITH " + recursive + strings.Join(qb.withClauses, ", ") + " " } body = qb.repository.buildQueryBody(body, qb.whereClauses, qb.havingClauses) countQuery := withClause + qb.repository.buildCountQuery(body) return qb.repository.runCountQuery(ctx, countQuery, qb.allArgs()) } func (qb *queryBuilder) addWhere(clauses ...string) { for _, clause := range clauses { if len(clause) > 0 { qb.whereClauses = append(qb.whereClauses, clause) } } } func (qb *queryBuilder) addHaving(clauses ...string) { for _, clause := range clauses { if len(clause) > 0 { qb.havingClauses = append(qb.havingClauses, clause) } } } func (qb *queryBuilder) addWith(recursive bool, clauses ...string) { for _, clause := range clauses { if len(clause) > 0 { qb.withClauses = append(qb.withClauses, clause) } } qb.recursiveWith = qb.recursiveWith || recursive } func (qb *queryBuilder) addArg(args ...interface{}) { qb.whereArgs = append(qb.whereArgs, args...) } func (qb *queryBuilder) addHavingArg(args ...interface{}) { qb.havingArgs = append(qb.havingArgs, args...) } func (qb *queryBuilder) hasJoin(alias string) bool { for _, j := range qb.joins { if j.alias() == alias { return true } } return false } func (qb *queryBuilder) join(table, as, onClause string) { newJoin := join{ table: table, as: as, onClause: onClause, joinType: "LEFT", } qb.joins.add(newJoin) } func (qb *queryBuilder) joinSort(table, as, onClause string) { newJoin := join{ sort: true, table: table, as: as, onClause: onClause, joinType: "LEFT", } qb.joins.add(newJoin) } func (qb *queryBuilder) addJoins(joins ...join) { for _, j := range joins { if qb.joins.addUnique(j) { qb.joinArgs = append(qb.joinArgs, j.args...) } } } func (qb *queryBuilder) addFilter(f *filterBuilder) error { err := f.getError() if err != nil { return err } clause, args := f.generateWithClauses() if len(clause) > 0 { qb.addWith(f.recursiveWith, clause) } if len(args) > 0 { qb.withArgs = append(qb.withArgs, args...) } qb.addJoins(f.getAllJoins()...) clause, args = f.generateWhereClauses() if len(clause) > 0 { qb.addWhere(clause) } if len(args) > 0 { qb.addArg(args...) } clause, args = f.generateHavingClauses() if len(clause) > 0 { qb.addHaving(clause) } if len(args) > 0 { qb.addHavingArg(args...) } return nil } func (qb *queryBuilder) parseQueryString(columns []string, q string) { specs := models.ParseSearchString(q) for _, t := range specs.MustHave { var clauses []string for _, column := range columns { clauses = append(clauses, column+" LIKE ?") qb.addArg(like(t)) } qb.addWhere("(" + strings.Join(clauses, " OR ") + ")") } for _, t := range specs.MustNot { for _, column := range columns { qb.addWhere(coalesce(column) + " NOT LIKE ?") qb.addArg(like(t)) } } for _, set := range specs.AnySets { var clauses []string for _, column := range columns { for _, v := range set { clauses = append(clauses, column+" LIKE ?") qb.addArg(like(v)) } } qb.addWhere("(" + strings.Join(clauses, " OR ") + ")") } } ================================================ FILE: pkg/sqlite/record.go ================================================ package sqlite import ( "github.com/doug-martin/goqu/v9/exp" "github.com/stashapp/stash/pkg/models" "gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4/zero" ) type updateRecord struct { exp.Record } func (r *updateRecord) set(destField string, v interface{}) { r.Record[destField] = v } func (r *updateRecord) setString(destField string, v models.OptionalString) { if v.Set { if v.Null { panic("null value not allowed in optional string") } r.set(destField, v.Value) } } func (r *updateRecord) setNullString(destField string, v models.OptionalString) { if v.Set { r.set(destField, zero.StringFromPtr(v.Ptr())) } } func (r *updateRecord) setBool(destField string, v models.OptionalBool) { if v.Set { if v.Null { panic("null value not allowed in optional bool") } r.set(destField, v.Value) } } func (r *updateRecord) setInt(destField string, v models.OptionalInt) { if v.Set { if v.Null { panic("null value not allowed in optional int") } r.set(destField, v.Value) } } func (r *updateRecord) setNullInt(destField string, v models.OptionalInt) { if v.Set { r.set(destField, intFromPtr(v.Ptr())) } } // func (r *updateRecord) setInt64(destField string, v models.OptionalInt64) { // if v.Set { // if v.Null { // panic("null value not allowed in optional int64") // } // r.set(destField, v.Value) // } // } // func (r *updateRecord) setNullInt64(destField string, v models.OptionalInt64) { // if v.Set { // r.set(destField, null.IntFromPtr(v.Ptr())) // } // } func (r *updateRecord) setFloat64(destField string, v models.OptionalFloat64) { if v.Set { if v.Null { panic("null value not allowed in optional float64") } r.set(destField, v.Value) } } func (r *updateRecord) setNullFloat64(destField string, v models.OptionalFloat64) { if v.Set { r.set(destField, null.FloatFromPtr(v.Ptr())) } } func (r *updateRecord) setTimestamp(destField string, v models.OptionalTime) { if v.Set { if v.Null { panic("null value not allowed in optional time") } r.set(destField, Timestamp{Timestamp: v.Value}) } } //nolint:golint,unused func (r *updateRecord) setNullTimestamp(destField string, v models.OptionalTime) { if v.Set { r.set(destField, NullTimestampFromTimePtr(v.Ptr())) } } func (r *updateRecord) setNullDate(destField string, precisionField string, v models.OptionalDate) { if v.Set { r.set(destField, NullDateFromDatePtr(v.Ptr())) r.set(precisionField, datePrecisionFromDatePtr(v.Ptr())) } } ================================================ FILE: pkg/sqlite/regex.go ================================================ package sqlite import ( "regexp" lru "github.com/hashicorp/golang-lru/v2" ) // size of the regex LRU cache in elements. // A small number number was chosen because it's most likely use is for a // single query - this function gets called for every row in the (filtered) // results. It's likely to only need no more than 1 or 2 in any given query. // After that point, it's just sitting in the cache and is unlikely to be used // again. const regexCacheSize = 10 var regexCache *lru.Cache[string, *regexp.Regexp] func init() { regexCache, _ = lru.New[string, *regexp.Regexp](regexCacheSize) } // regexFn is registered as an SQLite function as "regexp" // It uses an LRU cache to cache recent regex patterns to reduce CPU load over // identical patterns. func regexFn(re, s string) (bool, error) { compiled, ok := regexCache.Get(re) if !ok { var err error compiled, err = regexp.Compile(re) if err != nil { return false, err } regexCache.Add(re, compiled) } return compiled.MatchString(s), nil } ================================================ FILE: pkg/sqlite/relationships.go ================================================ package sqlite import ( "context" "github.com/stashapp/stash/pkg/models" ) type idRelationshipStore struct { joinTable *joinTable } func (s *idRelationshipStore) createRelationships(ctx context.Context, id int, fkIDs models.RelatedIDs) error { if fkIDs.Loaded() { if err := s.joinTable.insertJoins(ctx, id, fkIDs.List()); err != nil { return err } } return nil } func (s *idRelationshipStore) modifyRelationships(ctx context.Context, id int, fkIDs *models.UpdateIDs) error { if fkIDs != nil { if err := s.joinTable.modifyJoins(ctx, id, fkIDs.IDs, fkIDs.Mode); err != nil { return err } } return nil } func (s *idRelationshipStore) replaceRelationships(ctx context.Context, id int, fkIDs models.RelatedIDs) error { if fkIDs.Loaded() { if err := s.joinTable.replaceJoins(ctx, id, fkIDs.List()); err != nil { return err } } return nil } ================================================ FILE: pkg/sqlite/repository.go ================================================ package sqlite import ( "context" "database/sql" "errors" "fmt" "strings" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/models" ) const idColumn = "id" type repository struct { tableName string idColumn string } func (r *repository) getAll(ctx context.Context, id int, f func(rows *sqlx.Rows) error) error { stmt := fmt.Sprintf("SELECT * FROM %s WHERE %s = ?", r.tableName, r.idColumn) return r.queryFunc(ctx, stmt, []interface{}{id}, false, f) } func (r *repository) destroyExisting(ctx context.Context, ids []int) error { for _, id := range ids { exists, err := r.exists(ctx, id) if err != nil { return err } if !exists { return fmt.Errorf("%s %d does not exist in %s", r.idColumn, id, r.tableName) } } return r.destroy(ctx, ids) } func (r *repository) destroy(ctx context.Context, ids []int) error { for _, id := range ids { stmt := fmt.Sprintf("DELETE FROM %s WHERE %s = ?", r.tableName, r.idColumn) if _, err := dbWrapper.Exec(ctx, stmt, id); err != nil { return err } } return nil } func (r *repository) exists(ctx context.Context, id int) (bool, error) { stmt := fmt.Sprintf("SELECT %s FROM %s WHERE %s = ? LIMIT 1", r.idColumn, r.tableName, r.idColumn) stmt = r.buildCountQuery(stmt) c, err := r.runCountQuery(ctx, stmt, []interface{}{id}) if err != nil { return false, err } return c == 1, nil } func (r *repository) buildCountQuery(query string) string { return "SELECT COUNT(*) as count FROM (" + query + ") as temp" } func (r *repository) runCountQuery(ctx context.Context, query string, args []interface{}) (int, error) { result := struct { Int int `db:"count"` }{0} // Perform query and fetch result if err := dbWrapper.Get(ctx, &result, query, args...); err != nil && !errors.Is(err, sql.ErrNoRows) { return 0, err } return result.Int, nil } func (r *repository) runIdsQuery(ctx context.Context, query string, args []interface{}) ([]int, error) { var result []struct { Int int `db:"id"` } if err := dbWrapper.Select(ctx, &result, query, args...); err != nil && !errors.Is(err, sql.ErrNoRows) { return []int{}, fmt.Errorf("running query: %s [%v]: %w", query, args, err) } vsm := make([]int, len(result)) for i, v := range result { vsm[i] = v.Int } return vsm, nil } func (r *repository) queryFunc(ctx context.Context, query string, args []interface{}, single bool, f func(rows *sqlx.Rows) error) error { rows, err := dbWrapper.QueryxContext(ctx, query, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { return err } defer rows.Close() for rows.Next() { if err := f(rows); err != nil { return err } if single { break } } if err := rows.Err(); err != nil { return err } return nil } // queryStruct executes a query and scans the result into the provided struct. // Unlike the other query methods, this will return an error if no rows are found. func (r *repository) queryStruct(ctx context.Context, query string, args []interface{}, out interface{}) error { // changed from queryFunc, since it was not logging the performance correctly, // since the query doesn't actually execute until Scan is called if err := dbWrapper.Get(ctx, out, query, args...); err != nil { return fmt.Errorf("executing query: %s [%v]: %w", query, args, err) } return nil } func (r *repository) querySimple(ctx context.Context, query string, args []interface{}, out interface{}) error { rows, err := dbWrapper.Queryx(ctx, query, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { return err } defer rows.Close() if rows.Next() { if err := rows.Scan(out); err != nil { return err } } if err := rows.Err(); err != nil { return err } return nil } func (r *repository) buildQueryBody(body string, whereClauses []string, havingClauses []string) string { if len(whereClauses) > 0 { body = body + " WHERE " + strings.Join(whereClauses, " AND ") // TODO handle AND or OR } if len(havingClauses) > 0 { body = body + " GROUP BY " + r.tableName + ".id " body = body + " HAVING " + strings.Join(havingClauses, " AND ") // TODO handle AND or OR } return body } func (r *repository) executeFindQuery(ctx context.Context, body string, args []interface{}, sortAndPagination string, whereClauses []string, havingClauses []string, withClauses []string, recursiveWith bool) ([]int, int, error) { body = r.buildQueryBody(body, whereClauses, havingClauses) withClause := "" if len(withClauses) > 0 { var recursive string if recursiveWith { recursive = " RECURSIVE " } withClause = "WITH " + recursive + strings.Join(withClauses, ", ") + " " } countQuery := withClause + r.buildCountQuery(body) idsQuery := withClause + body + sortAndPagination // Perform query and fetch result var countResult int var countErr error var idsResult []int var idsErr error countResult, countErr = r.runCountQuery(ctx, countQuery, args) idsResult, idsErr = r.runIdsQuery(ctx, idsQuery, args) if countErr != nil { return nil, 0, fmt.Errorf("error executing count query with SQL: %s, args: %v, error: %s", countQuery, args, countErr.Error()) } if idsErr != nil { return nil, 0, fmt.Errorf("error executing find query with SQL: %s, args: %v, error: %s", idsQuery, args, idsErr.Error()) } return idsResult, countResult, nil } func (r *repository) newQuery() queryBuilder { return queryBuilder{ repository: r, } } func (r *repository) join(j joiner, as string, parentIDCol string) { t := r.tableName if as != "" { t = as } j.addLeftJoin(r.tableName, as, fmt.Sprintf("%s.%s = %s", t, r.idColumn, parentIDCol)) } func (r *repository) innerJoin(j joiner, as string, parentIDCol string) { t := r.tableName if as != "" { t = as } j.addInnerJoin(r.tableName, as, fmt.Sprintf("%s.%s = %s", t, r.idColumn, parentIDCol)) } type joiner interface { addLeftJoin(table, as, onClause string, args ...interface{}) addInnerJoin(table, as, onClause string, args ...interface{}) } type joinRepository struct { repository fkColumn string // fields for ordering foreignTable string orderBy string } func (r *joinRepository) getIDs(ctx context.Context, id int) ([]int, error) { var joinStr string if r.foreignTable != "" { joinStr = fmt.Sprintf(" INNER JOIN %s ON %[1]s.id = %s.%s", r.foreignTable, r.tableName, r.fkColumn) } query := fmt.Sprintf(`SELECT %[2]s.%[1]s as id from %s%s WHERE %s = ?`, r.fkColumn, r.tableName, joinStr, r.idColumn) if r.orderBy != "" { query += " ORDER BY " + r.orderBy } return r.runIdsQuery(ctx, query, []interface{}{id}) } func (r *joinRepository) insert(ctx context.Context, id int, foreignIDs ...int) error { stmt, err := dbWrapper.Prepare(ctx, fmt.Sprintf("INSERT INTO %s (%s, %s) VALUES (?, ?)", r.tableName, r.idColumn, r.fkColumn)) if err != nil { return err } defer stmt.Close() for _, fk := range foreignIDs { if _, err := dbWrapper.ExecStmt(ctx, stmt, id, fk); err != nil { return err } } return nil } // insertOrIgnore inserts a join into the table, silently failing in the event that a conflict occurs (ie when the join already exists) func (r *joinRepository) insertOrIgnore(ctx context.Context, id int, foreignIDs ...int) error { stmt, err := dbWrapper.Prepare(ctx, fmt.Sprintf("INSERT INTO %s (%s, %s) VALUES (?, ?) ON CONFLICT (%[2]s, %s) DO NOTHING", r.tableName, r.idColumn, r.fkColumn)) if err != nil { return err } defer stmt.Close() for _, fk := range foreignIDs { if _, err := dbWrapper.ExecStmt(ctx, stmt, id, fk); err != nil { return err } } return nil } func (r *joinRepository) destroyJoins(ctx context.Context, id int, foreignIDs ...int) error { stmt := fmt.Sprintf("DELETE FROM %s WHERE %s = ? AND %s IN %s", r.tableName, r.idColumn, r.fkColumn, getInBinding(len(foreignIDs))) args := make([]interface{}, len(foreignIDs)+1) args[0] = id for i, v := range foreignIDs { args[i+1] = v } if _, err := dbWrapper.Exec(ctx, stmt, args...); err != nil { return err } return nil } func (r *joinRepository) replace(ctx context.Context, id int, foreignIDs []int) error { if err := r.destroy(ctx, []int{id}); err != nil { return err } for _, fk := range foreignIDs { if err := r.insert(ctx, id, fk); err != nil { return err } } return nil } type captionRepository struct { repository } func (r *captionRepository) get(ctx context.Context, id models.FileID) ([]*models.VideoCaption, error) { query := fmt.Sprintf("SELECT %s, %s, %s from %s WHERE %s = ?", captionCodeColumn, captionFilenameColumn, captionTypeColumn, r.tableName, r.idColumn) var ret []*models.VideoCaption err := r.queryFunc(ctx, query, []interface{}{id}, false, func(rows *sqlx.Rows) error { var captionCode string var captionFilename string var captionType string if err := rows.Scan(&captionCode, &captionFilename, &captionType); err != nil { return err } caption := &models.VideoCaption{ LanguageCode: captionCode, Filename: captionFilename, CaptionType: captionType, } ret = append(ret, caption) return nil }) return ret, err } func (r *captionRepository) insert(ctx context.Context, id models.FileID, caption *models.VideoCaption) (sql.Result, error) { stmt := fmt.Sprintf("INSERT INTO %s (%s, %s, %s, %s) VALUES (?, ?, ?, ?)", r.tableName, r.idColumn, captionCodeColumn, captionFilenameColumn, captionTypeColumn) return dbWrapper.Exec(ctx, stmt, id, caption.LanguageCode, caption.Filename, caption.CaptionType) } func (r *captionRepository) replace(ctx context.Context, id models.FileID, captions []*models.VideoCaption) error { if err := r.destroy(ctx, []int{int(id)}); err != nil { return err } for _, caption := range captions { if _, err := r.insert(ctx, id, caption); err != nil { return err } } return nil } type stringRepository struct { repository stringColumn string } func (r *stringRepository) get(ctx context.Context, id int) ([]string, error) { query := fmt.Sprintf("SELECT %s from %s WHERE %s = ?", r.stringColumn, r.tableName, r.idColumn) var ret []string err := r.queryFunc(ctx, query, []interface{}{id}, false, func(rows *sqlx.Rows) error { var out string if err := rows.Scan(&out); err != nil { return err } ret = append(ret, out) return nil }) return ret, err } func (r *stringRepository) insert(ctx context.Context, id int, s string) (sql.Result, error) { stmt := fmt.Sprintf("INSERT INTO %s (%s, %s) VALUES (?, ?)", r.tableName, r.idColumn, r.stringColumn) return dbWrapper.Exec(ctx, stmt, id, s) } func (r *stringRepository) replace(ctx context.Context, id int, newStrings []string) error { if err := r.destroy(ctx, []int{id}); err != nil { return err } for _, s := range newStrings { if _, err := r.insert(ctx, id, s); err != nil { return err } } return nil } type stashIDRepository struct { repository } type stashIDs []models.StashID func (s *stashIDs) Append(o interface{}) { *s = append(*s, o.(models.StashID)) } func (s *stashIDs) New() interface{} { return &models.StashID{} } func (r *stashIDRepository) get(ctx context.Context, id int) ([]models.StashID, error) { query := fmt.Sprintf("SELECT stash_id, endpoint, updated_at from %s WHERE %s = ?", r.tableName, r.idColumn) var ret stashIDs err := r.queryFunc(ctx, query, []interface{}{id}, false, func(rows *sqlx.Rows) error { var v stashIDRow if err := rows.StructScan(&v); err != nil { return err } ret.Append(v.resolve()) return nil }) return ret, err } type filesRepository struct { repository } type relatedFileRow struct { ID int `db:"id"` FileID models.FileID `db:"file_id"` Primary bool `db:"primary"` } func idToIndexMap(ids []int) map[int]int { ret := make(map[int]int) for i, id := range ids { ret[id] = i } return ret } func (r *filesRepository) getMany(ctx context.Context, ids []int, primaryOnly bool) ([][]models.FileID, error) { var primaryClause string if primaryOnly { primaryClause = " AND `primary` = 1" } query := fmt.Sprintf("SELECT %s as id, file_id, `primary` from %s WHERE %[1]s IN %[3]s%s", r.idColumn, r.tableName, getInBinding(len(ids)), primaryClause) idi := make([]interface{}, len(ids)) for i, id := range ids { idi[i] = id } var fileRows []relatedFileRow if err := r.queryFunc(ctx, query, idi, false, func(rows *sqlx.Rows) error { var f relatedFileRow if err := rows.StructScan(&f); err != nil { return err } fileRows = append(fileRows, f) return nil }); err != nil { return nil, err } ret := make([][]models.FileID, len(ids)) idToIndex := idToIndexMap(ids) for _, row := range fileRows { id := row.ID fileID := row.FileID if row.Primary { // prepend to list ret[idToIndex[id]] = append([]models.FileID{fileID}, ret[idToIndex[id]]...) } else { ret[idToIndex[id]] = append(ret[idToIndex[id]], row.FileID) } } return ret, nil } func (r *filesRepository) get(ctx context.Context, id int) ([]models.FileID, error) { query := fmt.Sprintf("SELECT file_id, `primary` from %s WHERE %s = ?", r.tableName, r.idColumn) type relatedFile struct { FileID models.FileID `db:"file_id"` Primary bool `db:"primary"` } var ret []models.FileID if err := r.queryFunc(ctx, query, []interface{}{id}, false, func(rows *sqlx.Rows) error { var f relatedFile if err := rows.StructScan(&f); err != nil { return err } if f.Primary { // prepend to list ret = append([]models.FileID{f.FileID}, ret...) } else { ret = append(ret, f.FileID) } return nil }); err != nil { return nil, err } return ret, nil } ================================================ FILE: pkg/sqlite/saved_filter.go ================================================ package sqlite import ( "context" "database/sql" "encoding/json" "errors" "fmt" "slices" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) const ( savedFilterTable = "saved_filters" savedFilterDefaultName = "" ) type savedFilterRow struct { ID int `db:"id" goqu:"skipinsert"` Mode models.FilterMode `db:"mode"` Name string `db:"name"` FindFilter string `db:"find_filter"` ObjectFilter string `db:"object_filter"` UIOptions string `db:"ui_options"` } func encodeJSONOrEmpty(v interface{}) string { if v == nil { return "" } encoded, err := json.Marshal(v) if err != nil { logger.Errorf("error encoding json %v: %v", v, err) } return string(encoded) } func decodeJSON(s string, v interface{}) { if s == "" { return } if err := json.Unmarshal([]byte(s), v); err != nil { logger.Errorf("error decoding json %q: %v", s, err) } } func (r *savedFilterRow) fromSavedFilter(o models.SavedFilter) { r.ID = o.ID r.Mode = o.Mode r.Name = o.Name // encode the filters as json r.FindFilter = encodeJSONOrEmpty(o.FindFilter) r.ObjectFilter = encodeJSONOrEmpty(o.ObjectFilter) r.UIOptions = encodeJSONOrEmpty(o.UIOptions) } func (r *savedFilterRow) resolve() *models.SavedFilter { ret := &models.SavedFilter{ ID: r.ID, Mode: r.Mode, Name: r.Name, } // decode the filters from json if r.FindFilter != "" { ret.FindFilter = &models.FindFilterType{} decodeJSON(r.FindFilter, &ret.FindFilter) } if r.ObjectFilter != "" { ret.ObjectFilter = make(map[string]interface{}) decodeJSON(r.ObjectFilter, &ret.ObjectFilter) } if r.UIOptions != "" { ret.UIOptions = make(map[string]interface{}) decodeJSON(r.UIOptions, &ret.UIOptions) } return ret } type SavedFilterStore struct { repository tableMgr *table } func NewSavedFilterStore() *SavedFilterStore { return &SavedFilterStore{ repository: repository{ tableName: savedFilterTable, idColumn: idColumn, }, tableMgr: savedFilterTableMgr, } } func (qb *SavedFilterStore) table() exp.IdentifierExpression { return qb.tableMgr.table } func (qb *SavedFilterStore) selectDataset() *goqu.SelectDataset { return dialect.From(qb.table()).Select(qb.table().All()) } func (qb *SavedFilterStore) Create(ctx context.Context, newObject *models.SavedFilter) error { var r savedFilterRow r.fromSavedFilter(*newObject) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { return err } updated, err := qb.Find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } *newObject = *updated return nil } func (qb *SavedFilterStore) Update(ctx context.Context, updatedObject *models.SavedFilter) error { var r savedFilterRow r.fromSavedFilter(*updatedObject) if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { return err } return nil } func (qb *SavedFilterStore) Destroy(ctx context.Context, id int) error { return qb.destroyExisting(ctx, []int{id}) } // returns nil, nil if not found func (qb *SavedFilterStore) Find(ctx context.Context, id int) (*models.SavedFilter, error) { ret, err := qb.find(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } return ret, err } func (qb *SavedFilterStore) FindMany(ctx context.Context, ids []int, ignoreNotFound bool) ([]*models.SavedFilter, error) { ret := make([]*models.SavedFilter, len(ids)) table := qb.table() q := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(ids)) unsorted, err := qb.getMany(ctx, q) if err != nil { return nil, err } for _, s := range unsorted { i := slices.Index(ids, s.ID) ret[i] = s } if !ignoreNotFound { for i := range ret { if ret[i] == nil { return nil, fmt.Errorf("filter with id %d not found", ids[i]) } } } return ret, nil } // returns nil, sql.ErrNoRows if not found func (qb *SavedFilterStore) find(ctx context.Context, id int) (*models.SavedFilter, error) { q := qb.selectDataset().Where(qb.tableMgr.byID(id)) ret, err := qb.get(ctx, q) if err != nil { return nil, err } return ret, nil } func (qb *SavedFilterStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.SavedFilter, error) { ret, err := qb.getMany(ctx, q) if err != nil { return nil, err } if len(ret) == 0 { return nil, sql.ErrNoRows } return ret[0], nil } func (qb *SavedFilterStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.SavedFilter, error) { const single = false var ret []*models.SavedFilter if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { var f savedFilterRow if err := r.StructScan(&f); err != nil { return err } s := f.resolve() ret = append(ret, s) return nil }); err != nil { return nil, err } return ret, nil } func (qb *SavedFilterStore) FindByMode(ctx context.Context, mode models.FilterMode) ([]*models.SavedFilter, error) { // SELECT * FROM %s WHERE mode = ? AND name != ? ORDER BY name ASC table := qb.table() // TODO - querying on groups needs to include movies // remove this when we migrate to remove the movies filter mode in the database var whereClause exp.Expression if mode == models.FilterModeGroups || mode == models.FilterModeMovies { whereClause = goqu.Or( table.Col("mode").Eq(models.FilterModeGroups), table.Col("mode").Eq(models.FilterModeMovies), ) } else { whereClause = table.Col("mode").Eq(mode) } sq := qb.selectDataset().Prepared(true).Where(whereClause).Order(table.Col("name").Asc()) ret, err := qb.getMany(ctx, sq) if err != nil { return nil, err } return ret, nil } func (qb *SavedFilterStore) All(ctx context.Context) ([]*models.SavedFilter, error) { return qb.getMany(ctx, qb.selectDataset()) } ================================================ FILE: pkg/sqlite/saved_filter_test.go ================================================ //go:build integration // +build integration package sqlite_test import ( "context" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stretchr/testify/assert" ) func TestSavedFilterFind(t *testing.T) { withTxn(func(ctx context.Context) error { savedFilter, err := db.SavedFilter.Find(ctx, savedFilterIDs[savedFilterIdxImage]) if err != nil { t.Errorf("Error finding saved filter: %s", err.Error()) } assert.Equal(t, savedFilterIDs[savedFilterIdxImage], savedFilter.ID) return nil }) } func TestSavedFilterFindByMode(t *testing.T) { withTxn(func(ctx context.Context) error { savedFilters, err := db.SavedFilter.FindByMode(ctx, models.FilterModeScenes) if err != nil { t.Errorf("Error finding saved filters: %s", err.Error()) } assert.Len(t, savedFilters, 1) assert.Equal(t, savedFilterIDs[savedFilterIdxScene], savedFilters[0].ID) return nil }) } func TestSavedFilterDestroy(t *testing.T) { const filterName = "filterToDestroy" filterQ := "" filterPage := 1 filterPerPage := 40 filterSort := "date" filterDirection := models.SortDirectionEnumAsc findFilter := models.FindFilterType{ Q: &filterQ, Page: &filterPage, PerPage: &filterPerPage, Sort: &filterSort, Direction: &filterDirection, } objectFilter := map[string]interface{}{ "test": "foo", } uiOptions := map[string]interface{}{ "display_mode": 1, "zoom_index": 1, } var id int // create the saved filter to destroy withTxn(func(ctx context.Context) error { newFilter := models.SavedFilter{ Name: filterName, Mode: models.FilterModeScenes, FindFilter: &findFilter, ObjectFilter: objectFilter, UIOptions: uiOptions, } err := db.SavedFilter.Create(ctx, &newFilter) if err == nil { id = newFilter.ID } return err }) withTxn(func(ctx context.Context) error { return db.SavedFilter.Destroy(ctx, id) }) // now try to find it withTxn(func(ctx context.Context) error { found, err := db.SavedFilter.Find(ctx, id) if err == nil { assert.Nil(t, found) } return err }) } // TODO Update // TODO Destroy // TODO Find // TODO GetMarkerStrings // TODO Wall // TODO Query ================================================ FILE: pkg/sqlite/scene.go ================================================ package sqlite import ( "context" "database/sql" "errors" "fmt" "path/filepath" "slices" "sort" "strconv" "strings" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4/zero" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/utils" ) const ( sceneTable = "scenes" scenesFilesTable = "scenes_files" sceneIDColumn = "scene_id" sceneDateColumn = "date" performersScenesTable = "performers_scenes" scenesTagsTable = "scenes_tags" scenesGalleriesTable = "scenes_galleries" groupsScenesTable = "groups_scenes" scenesURLsTable = "scene_urls" sceneURLColumn = "url" scenesViewDatesTable = "scenes_view_dates" sceneViewDateColumn = "view_date" scenesODatesTable = "scenes_o_dates" sceneODateColumn = "o_date" sceneCoverBlobColumn = "cover_blob" ) var findExactDuplicateQuery = ` SELECT GROUP_CONCAT(DISTINCT scene_id) as ids FROM ( SELECT scenes.id as scene_id , video_files.duration as file_duration , files.size as file_size , files_fingerprints.fingerprint as phash , abs(max(video_files.duration) OVER (PARTITION by files_fingerprints.fingerprint) - video_files.duration) as durationDiff FROM scenes INNER JOIN scenes_files ON (scenes.id = scenes_files.scene_id) INNER JOIN files ON (scenes_files.file_id = files.id) INNER JOIN files_fingerprints ON (scenes_files.file_id = files_fingerprints.file_id AND files_fingerprints.type = 'phash') INNER JOIN video_files ON (files.id == video_files.file_id) ) WHERE durationDiff <= ?1 OR ?1 < 0 -- Always TRUE if the parameter is negative. -- That will disable the durationDiff checking. GROUP BY phash HAVING COUNT(phash) > 1 AND COUNT(DISTINCT scene_id) > 1 ORDER BY SUM(file_size) DESC; ` var findAllPhashesQuery = ` SELECT scenes.id as id , files_fingerprints.fingerprint as phash , video_files.duration as duration FROM scenes INNER JOIN scenes_files ON (scenes.id = scenes_files.scene_id) INNER JOIN files ON (scenes_files.file_id = files.id) INNER JOIN files_fingerprints ON (scenes_files.file_id = files_fingerprints.file_id AND files_fingerprints.type = 'phash') INNER JOIN video_files ON (files.id == video_files.file_id) ORDER BY files.size DESC; ` type sceneRow struct { ID int `db:"id" goqu:"skipinsert"` Title zero.String `db:"title"` Code zero.String `db:"code"` Details zero.String `db:"details"` Director zero.String `db:"director"` Date NullDate `db:"date"` DatePrecision null.Int `db:"date_precision"` // expressed as 1-100 Rating null.Int `db:"rating"` Organized bool `db:"organized"` StudioID null.Int `db:"studio_id,omitempty"` CreatedAt Timestamp `db:"created_at"` UpdatedAt Timestamp `db:"updated_at"` ResumeTime float64 `db:"resume_time"` PlayDuration float64 `db:"play_duration"` // not used in resolutions or updates CoverBlob zero.String `db:"cover_blob"` } func (r *sceneRow) fromScene(o models.Scene) { r.ID = o.ID r.Title = zero.StringFrom(o.Title) r.Code = zero.StringFrom(o.Code) r.Details = zero.StringFrom(o.Details) r.Director = zero.StringFrom(o.Director) r.Date = NullDateFromDatePtr(o.Date) r.DatePrecision = datePrecisionFromDatePtr(o.Date) r.Rating = intFromPtr(o.Rating) r.Organized = o.Organized r.StudioID = intFromPtr(o.StudioID) r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} r.ResumeTime = o.ResumeTime r.PlayDuration = o.PlayDuration } type sceneQueryRow struct { sceneRow PrimaryFileID null.Int `db:"primary_file_id"` PrimaryFileFolderPath zero.String `db:"primary_file_folder_path"` PrimaryFileBasename zero.String `db:"primary_file_basename"` PrimaryFileOshash zero.String `db:"primary_file_oshash"` PrimaryFileChecksum zero.String `db:"primary_file_checksum"` } func (r *sceneQueryRow) resolve() *models.Scene { ret := &models.Scene{ ID: r.ID, Title: r.Title.String, Code: r.Code.String, Details: r.Details.String, Director: r.Director.String, Date: r.Date.DatePtr(r.DatePrecision), Rating: nullIntPtr(r.Rating), Organized: r.Organized, StudioID: nullIntPtr(r.StudioID), PrimaryFileID: nullIntFileIDPtr(r.PrimaryFileID), OSHash: r.PrimaryFileOshash.String, Checksum: r.PrimaryFileChecksum.String, CreatedAt: r.CreatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp, ResumeTime: r.ResumeTime, PlayDuration: r.PlayDuration, } if r.PrimaryFileFolderPath.Valid && r.PrimaryFileBasename.Valid { ret.Path = filepath.Join(r.PrimaryFileFolderPath.String, r.PrimaryFileBasename.String) } return ret } type sceneRowRecord struct { updateRecord } func (r *sceneRowRecord) fromPartial(o models.ScenePartial) { r.setNullString("title", o.Title) r.setNullString("code", o.Code) r.setNullString("details", o.Details) r.setNullString("director", o.Director) r.setNullDate("date", "date_precision", o.Date) r.setNullInt("rating", o.Rating) r.setBool("organized", o.Organized) r.setNullInt("studio_id", o.StudioID) r.setTimestamp("created_at", o.CreatedAt) r.setTimestamp("updated_at", o.UpdatedAt) r.setFloat64("resume_time", o.ResumeTime) r.setFloat64("play_duration", o.PlayDuration) } type sceneRepositoryType struct { repository galleries joinRepository tags joinRepository performers joinRepository groups repository files filesRepository stashIDs stashIDRepository } var ( sceneRepository = sceneRepositoryType{ repository: repository{ tableName: sceneTable, idColumn: idColumn, }, galleries: joinRepository{ repository: repository{ tableName: scenesGalleriesTable, idColumn: sceneIDColumn, }, fkColumn: galleryIDColumn, }, tags: joinRepository{ repository: repository{ tableName: scenesTagsTable, idColumn: sceneIDColumn, }, fkColumn: tagIDColumn, foreignTable: tagTable, orderBy: tagTableSortSQL, }, performers: joinRepository{ repository: repository{ tableName: performersScenesTable, idColumn: sceneIDColumn, }, fkColumn: performerIDColumn, }, groups: repository{ tableName: groupsScenesTable, idColumn: sceneIDColumn, }, files: filesRepository{ repository: repository{ tableName: scenesFilesTable, idColumn: sceneIDColumn, }, }, stashIDs: stashIDRepository{ repository{ tableName: "scene_stash_ids", idColumn: sceneIDColumn, }, }, } ) type SceneStore struct { blobJoinQueryBuilder customFieldsStore tableMgr *table oDateManager viewDateManager repo *storeRepository } func NewSceneStore(r *storeRepository, blobStore *BlobStore) *SceneStore { return &SceneStore{ blobJoinQueryBuilder: blobJoinQueryBuilder{ blobStore: blobStore, joinTable: sceneTable, }, customFieldsStore: customFieldsStore{ table: scenesCustomFieldsTable, fk: scenesCustomFieldsTable.Col(sceneIDColumn), }, tableMgr: sceneTableMgr, viewDateManager: viewDateManager{scenesViewTableMgr}, oDateManager: oDateManager{scenesOTableMgr}, repo: r, } } func (qb *SceneStore) table() exp.IdentifierExpression { return qb.tableMgr.table } func (qb *SceneStore) selectDataset() *goqu.SelectDataset { table := qb.table() files := fileTableMgr.table folders := folderTableMgr.table checksum := fingerprintTableMgr.table.As("fingerprint_md5") oshash := fingerprintTableMgr.table.As("fingerprint_oshash") return dialect.From(table).LeftJoin( scenesFilesJoinTable, goqu.On( scenesFilesJoinTable.Col(sceneIDColumn).Eq(table.Col(idColumn)), scenesFilesJoinTable.Col("primary").Eq(1), ), ).LeftJoin( files, goqu.On(files.Col(idColumn).Eq(scenesFilesJoinTable.Col(fileIDColumn))), ).LeftJoin( folders, goqu.On(folders.Col(idColumn).Eq(files.Col("parent_folder_id"))), ).LeftJoin( checksum, goqu.On( checksum.Col(fileIDColumn).Eq(scenesFilesJoinTable.Col(fileIDColumn)), checksum.Col("type").Eq(models.FingerprintTypeMD5), ), ).LeftJoin( oshash, goqu.On( oshash.Col(fileIDColumn).Eq(scenesFilesJoinTable.Col(fileIDColumn)), oshash.Col("type").Eq(models.FingerprintTypeOshash), ), ).Select( qb.table().All(), scenesFilesJoinTable.Col(fileIDColumn).As("primary_file_id"), folders.Col("path").As("primary_file_folder_path"), files.Col("basename").As("primary_file_basename"), checksum.Col("fingerprint").As("primary_file_checksum"), oshash.Col("fingerprint").As("primary_file_oshash"), ) } func (qb *SceneStore) Create(ctx context.Context, newObject *models.Scene, fileIDs []models.FileID) error { var r sceneRow r.fromScene(*newObject) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { return err } if len(fileIDs) > 0 { const firstPrimary = true if err := scenesFilesTableMgr.insertJoins(ctx, id, firstPrimary, fileIDs); err != nil { return err } } if newObject.URLs.Loaded() { const startPos = 0 if err := scenesURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { return err } } if newObject.PerformerIDs.Loaded() { if err := scenesPerformersTableMgr.insertJoins(ctx, id, newObject.PerformerIDs.List()); err != nil { return err } } if newObject.TagIDs.Loaded() { if err := scenesTagsTableMgr.insertJoins(ctx, id, newObject.TagIDs.List()); err != nil { return err } } if newObject.GalleryIDs.Loaded() { if err := scenesGalleriesTableMgr.insertJoins(ctx, id, newObject.GalleryIDs.List()); err != nil { return err } } if newObject.StashIDs.Loaded() { if err := scenesStashIDsTableMgr.insertJoins(ctx, id, newObject.StashIDs.List()); err != nil { return err } } if newObject.Groups.Loaded() { if err := scenesGroupsTableMgr.insertJoins(ctx, id, newObject.Groups.List()); err != nil { return err } } updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } *newObject = *updated return nil } func (qb *SceneStore) UpdatePartial(ctx context.Context, id int, partial models.ScenePartial) (*models.Scene, error) { r := sceneRowRecord{ updateRecord{ Record: make(exp.Record), }, } r.fromPartial(partial) if len(r.Record) > 0 { if err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil { return nil, err } } if partial.URLs != nil { if err := scenesURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { return nil, err } } if partial.PerformerIDs != nil { if err := scenesPerformersTableMgr.modifyJoins(ctx, id, partial.PerformerIDs.IDs, partial.PerformerIDs.Mode); err != nil { return nil, err } } if partial.TagIDs != nil { if err := scenesTagsTableMgr.modifyJoins(ctx, id, partial.TagIDs.IDs, partial.TagIDs.Mode); err != nil { return nil, err } } if partial.GalleryIDs != nil { if err := scenesGalleriesTableMgr.modifyJoins(ctx, id, partial.GalleryIDs.IDs, partial.GalleryIDs.Mode); err != nil { return nil, err } } if partial.StashIDs != nil { if err := scenesStashIDsTableMgr.modifyJoins(ctx, id, partial.StashIDs.StashIDs, partial.StashIDs.Mode); err != nil { return nil, err } } if partial.GroupIDs != nil { if err := scenesGroupsTableMgr.modifyJoins(ctx, id, partial.GroupIDs.Groups, partial.GroupIDs.Mode); err != nil { return nil, err } } if partial.PrimaryFileID != nil { if err := scenesFilesTableMgr.setPrimary(ctx, id, *partial.PrimaryFileID); err != nil { return nil, err } } return qb.find(ctx, id) } func (qb *SceneStore) Update(ctx context.Context, updatedObject *models.Scene) error { var r sceneRow r.fromScene(*updatedObject) if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { return err } if updatedObject.URLs.Loaded() { if err := scenesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { return err } } if updatedObject.PerformerIDs.Loaded() { if err := scenesPerformersTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.PerformerIDs.List()); err != nil { return err } } if updatedObject.TagIDs.Loaded() { if err := scenesTagsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.TagIDs.List()); err != nil { return err } } if updatedObject.GalleryIDs.Loaded() { if err := scenesGalleriesTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.GalleryIDs.List()); err != nil { return err } } if updatedObject.StashIDs.Loaded() { if err := scenesStashIDsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.StashIDs.List()); err != nil { return err } } if updatedObject.Groups.Loaded() { if err := scenesGroupsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.Groups.List()); err != nil { return err } } if updatedObject.Files.Loaded() { fileIDs := make([]models.FileID, len(updatedObject.Files.List())) for i, f := range updatedObject.Files.List() { fileIDs[i] = f.ID } if err := scenesFilesTableMgr.replaceJoins(ctx, updatedObject.ID, fileIDs); err != nil { return err } } return nil } func (qb *SceneStore) Destroy(ctx context.Context, id int) error { // must handle image checksums manually if err := qb.destroyCover(ctx, id); err != nil { return err } // scene markers should be handled prior to calling destroy // galleries should be handled prior to calling destroy return qb.tableMgr.destroyExisting(ctx, []int{id}) } // returns nil, nil if not found func (qb *SceneStore) Find(ctx context.Context, id int) (*models.Scene, error) { ret, err := qb.find(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } return ret, err } // FindByIDs finds multiple scenes by their IDs. // No check is made to see if the scenes exist, and the order of the returned scenes // is not guaranteed to be the same as the order of the input IDs. func (qb *SceneStore) FindByIDs(ctx context.Context, ids []int) ([]*models.Scene, error) { scenes := make([]*models.Scene, 0, len(ids)) table := qb.table() if err := batchExec(ids, defaultBatchSize, func(batch []int) error { q := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(batch)) unsorted, err := qb.getMany(ctx, q) if err != nil { return err } scenes = append(scenes, unsorted...) return nil }); err != nil { return nil, err } return scenes, nil } func (qb *SceneStore) FindMany(ctx context.Context, ids []int) ([]*models.Scene, error) { scenes := make([]*models.Scene, len(ids)) unsorted, err := qb.FindByIDs(ctx, ids) if err != nil { return nil, err } for _, s := range unsorted { i := slices.Index(ids, s.ID) scenes[i] = s } for i := range scenes { if scenes[i] == nil { return nil, fmt.Errorf("scene with id %d not found", ids[i]) } } return scenes, nil } // returns nil, sql.ErrNoRows if not found func (qb *SceneStore) find(ctx context.Context, id int) (*models.Scene, error) { q := qb.selectDataset().Where(qb.tableMgr.byID(id)) ret, err := qb.get(ctx, q) if err != nil { return nil, err } return ret, nil } func (qb *SceneStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]*models.Scene, error) { table := qb.table() q := qb.selectDataset().Where( table.Col(idColumn).Eq( sq, ), ) return qb.getMany(ctx, q) } // returns nil, sql.ErrNoRows if not found func (qb *SceneStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Scene, error) { ret, err := qb.getMany(ctx, q) if err != nil { return nil, err } if len(ret) == 0 { return nil, sql.ErrNoRows } return ret[0], nil } func (qb *SceneStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Scene, error) { const single = false var ret []*models.Scene var lastID int if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { var f sceneQueryRow if err := r.StructScan(&f); err != nil { return err } s := f.resolve() if s.ID == lastID { return fmt.Errorf("internal error: multiple rows returned for single scene id %d", s.ID) } lastID = s.ID ret = append(ret, s) return nil }); err != nil { return nil, err } return ret, nil } func (qb *SceneStore) GetFiles(ctx context.Context, id int) ([]*models.VideoFile, error) { fileIDs, err := sceneRepository.files.get(ctx, id) if err != nil { return nil, err } // use fileStore to load files files, err := qb.repo.File.Find(ctx, fileIDs...) if err != nil { return nil, err } ret := make([]*models.VideoFile, len(files)) for i, f := range files { var ok bool ret[i], ok = f.(*models.VideoFile) if !ok { return nil, fmt.Errorf("expected file to be *file.VideoFile not %T", f) } } return ret, nil } func (qb *SceneStore) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) { const primaryOnly = false return sceneRepository.files.getMany(ctx, ids, primaryOnly) } func (qb *SceneStore) FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Scene, error) { sq := dialect.From(scenesFilesJoinTable).Select(scenesFilesJoinTable.Col(sceneIDColumn)).Where( scenesFilesJoinTable.Col(fileIDColumn).Eq(fileID), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting scenes by file id %d: %w", fileID, err) } return ret, nil } func (qb *SceneStore) FindByPrimaryFileID(ctx context.Context, fileID models.FileID) ([]*models.Scene, error) { sq := dialect.From(scenesFilesJoinTable).Select(scenesFilesJoinTable.Col(sceneIDColumn)).Where( scenesFilesJoinTable.Col(fileIDColumn).Eq(fileID), scenesFilesJoinTable.Col("primary").Eq(1), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting scenes by primary file id %d: %w", fileID, err) } return ret, nil } func (qb *SceneStore) CountByFileID(ctx context.Context, fileID models.FileID) (int, error) { joinTable := scenesFilesJoinTable q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col(fileIDColumn).Eq(fileID)) return count(ctx, q) } func (qb *SceneStore) FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Scene, error) { fingerprintTable := fingerprintTableMgr.table var ex []exp.Expression for _, v := range fp { ex = append(ex, goqu.And( fingerprintTable.Col("type").Eq(v.Type), fingerprintTable.Col("fingerprint").Eq(v.Fingerprint), )) } sq := dialect.From(scenesFilesJoinTable). InnerJoin( fingerprintTable, goqu.On(fingerprintTable.Col(fileIDColumn).Eq(scenesFilesJoinTable.Col(fileIDColumn))), ). Select(scenesFilesJoinTable.Col(sceneIDColumn)).Where(goqu.Or(ex...)) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting scenes by fingerprints: %w", err) } return ret, nil } func (qb *SceneStore) FindByChecksum(ctx context.Context, checksum string) ([]*models.Scene, error) { return qb.FindByFingerprints(ctx, []models.Fingerprint{ { Type: models.FingerprintTypeMD5, Fingerprint: checksum, }, }) } func (qb *SceneStore) FindByOSHash(ctx context.Context, oshash string) ([]*models.Scene, error) { return qb.FindByFingerprints(ctx, []models.Fingerprint{ { Type: models.FingerprintTypeOshash, Fingerprint: oshash, }, }) } func (qb *SceneStore) FindByPath(ctx context.Context, p string) ([]*models.Scene, error) { filesTable := fileTableMgr.table foldersTable := folderTableMgr.table basename := filepath.Base(p) dir := filepath.Dir(p) // replace wildcards basename = strings.ReplaceAll(basename, "*", "%") dir = strings.ReplaceAll(dir, "*", "%") sq := dialect.From(scenesFilesJoinTable).InnerJoin( filesTable, goqu.On(filesTable.Col(idColumn).Eq(scenesFilesJoinTable.Col(fileIDColumn))), ).InnerJoin( foldersTable, goqu.On(foldersTable.Col(idColumn).Eq(filesTable.Col("parent_folder_id"))), ).Select(scenesFilesJoinTable.Col(sceneIDColumn)).Where( foldersTable.Col("path").Like(dir), filesTable.Col("basename").Like(basename), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("getting scene by path %s: %w", p, err) } return ret, nil } func (qb *SceneStore) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Scene, error) { sq := dialect.From(scenesPerformersJoinTable).Select(scenesPerformersJoinTable.Col(sceneIDColumn)).Where( scenesPerformersJoinTable.Col(performerIDColumn).Eq(performerID), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting scenes for performer %d: %w", performerID, err) } return ret, nil } func (qb *SceneStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Scene, error) { sq := dialect.From(galleriesScenesJoinTable).Select(galleriesScenesJoinTable.Col(sceneIDColumn)).Where( galleriesScenesJoinTable.Col(galleryIDColumn).Eq(galleryID), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting scenes for gallery %d: %w", galleryID, err) } return ret, nil } func (qb *SceneStore) CountByPerformerID(ctx context.Context, performerID int) (int, error) { joinTable := scenesPerformersJoinTable q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col(performerIDColumn).Eq(performerID)) return count(ctx, q) } func (qb *SceneStore) OCountByPerformerID(ctx context.Context, performerID int) (int, error) { table := qb.table() joinTable := scenesPerformersJoinTable oHistoryTable := goqu.T(scenesODatesTable) q := dialect.Select(goqu.COUNT("*")).From(table).InnerJoin( oHistoryTable, goqu.On(table.Col(idColumn).Eq(oHistoryTable.Col(sceneIDColumn))), ).InnerJoin( joinTable, goqu.On( table.Col(idColumn).Eq(joinTable.Col(sceneIDColumn)), ), ).Where(joinTable.Col(performerIDColumn).Eq(performerID)) var ret int if err := querySimple(ctx, q, &ret); err != nil { return 0, err } return ret, nil } func (qb *SceneStore) OCountByGroupID(ctx context.Context, groupID int) (int, error) { table := qb.table() joinTable := scenesGroupsJoinTable oHistoryTable := goqu.T(scenesODatesTable) q := dialect.Select(goqu.COUNT("*")).From(table).InnerJoin( oHistoryTable, goqu.On(table.Col(idColumn).Eq(oHistoryTable.Col(sceneIDColumn))), ).InnerJoin( joinTable, goqu.On( table.Col(idColumn).Eq(joinTable.Col(sceneIDColumn)), ), ).Where(joinTable.Col(groupIDColumn).Eq(groupID)) var ret int if err := querySimple(ctx, q, &ret); err != nil { return 0, err } return ret, nil } func (qb *SceneStore) OCountByStudioID(ctx context.Context, studioID int) (int, error) { table := qb.table() oHistoryTable := goqu.T(scenesODatesTable) q := dialect.Select(goqu.COUNT("*")).From(table).InnerJoin( oHistoryTable, goqu.On(table.Col(idColumn).Eq(oHistoryTable.Col(sceneIDColumn))), ).Where(table.Col(studioIDColumn).Eq(studioID)) var ret int if err := querySimple(ctx, q, &ret); err != nil { return 0, err } return ret, nil } func (qb *SceneStore) FindByGroupID(ctx context.Context, groupID int) ([]*models.Scene, error) { sq := dialect.From(scenesGroupsJoinTable).Select(scenesGroupsJoinTable.Col(sceneIDColumn)).Where( scenesGroupsJoinTable.Col(groupIDColumn).Eq(groupID), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting scenes for group %d: %w", groupID, err) } return ret, nil } func (qb *SceneStore) Count(ctx context.Context) (int, error) { q := dialect.Select(goqu.COUNT("*")).From(qb.table()) return count(ctx, q) } func (qb *SceneStore) Size(ctx context.Context) (float64, error) { table := qb.table() fileTable := fileTableMgr.table q := dialect.Select( goqu.COALESCE(goqu.SUM(fileTableMgr.table.Col("size")), 0), ).From(table).InnerJoin( scenesFilesJoinTable, goqu.On(table.Col(idColumn).Eq(scenesFilesJoinTable.Col(sceneIDColumn))), ).InnerJoin( fileTable, goqu.On(scenesFilesJoinTable.Col(fileIDColumn).Eq(fileTable.Col(idColumn))), ) var ret float64 if err := querySimple(ctx, q, &ret); err != nil { return 0, err } return ret, nil } func (qb *SceneStore) Duration(ctx context.Context) (float64, error) { table := qb.table() videoFileTable := videoFileTableMgr.table q := dialect.Select( goqu.COALESCE(goqu.SUM(videoFileTable.Col("duration")), 0), ).From(table).InnerJoin( scenesFilesJoinTable, goqu.On(scenesFilesJoinTable.Col("scene_id").Eq(table.Col(idColumn))), ).InnerJoin( videoFileTable, goqu.On(videoFileTable.Col("file_id").Eq(scenesFilesJoinTable.Col("file_id"))), ) var ret float64 if err := querySimple(ctx, q, &ret); err != nil { return 0, err } return ret, nil } func (qb *SceneStore) PlayDuration(ctx context.Context) (float64, error) { table := qb.table() q := dialect.Select(goqu.COALESCE(goqu.SUM("play_duration"), 0)).From(table) var ret float64 if err := querySimple(ctx, q, &ret); err != nil { return 0, err } return ret, nil } // TODO - currently only used by unit test func (qb *SceneStore) CountByStudioID(ctx context.Context, studioID int) (int, error) { table := qb.table() q := dialect.Select(goqu.COUNT("*")).From(table).Where(table.Col(studioIDColumn).Eq(studioID)) return count(ctx, q) } func (qb *SceneStore) countMissingFingerprints(ctx context.Context, fpType string) (int, error) { fpTable := fingerprintTableMgr.table.As("fingerprints_temp") q := dialect.From(scenesFilesJoinTable).LeftJoin( fpTable, goqu.On( scenesFilesJoinTable.Col(fileIDColumn).Eq(fpTable.Col(fileIDColumn)), fpTable.Col("type").Eq(fpType), ), ).Select(goqu.COUNT(goqu.DISTINCT(scenesFilesJoinTable.Col(sceneIDColumn)))).Where(fpTable.Col("fingerprint").IsNull()) return count(ctx, q) } // CountMissingChecksum returns the number of scenes missing a checksum value. func (qb *SceneStore) CountMissingChecksum(ctx context.Context) (int, error) { return qb.countMissingFingerprints(ctx, "md5") } // CountMissingOSHash returns the number of scenes missing an oshash value. func (qb *SceneStore) CountMissingOSHash(ctx context.Context) (int, error) { return qb.countMissingFingerprints(ctx, "oshash") } func (qb *SceneStore) Wall(ctx context.Context, q *string) ([]*models.Scene, error) { s := "" if q != nil { s = *q } table := qb.table() qq := qb.selectDataset().Prepared(true).Where(table.Col("details").Like("%" + s + "%")).Order(goqu.L("RANDOM()").Asc()).Limit(80) return qb.getMany(ctx, qq) } func (qb *SceneStore) All(ctx context.Context) ([]*models.Scene, error) { table := qb.table() fileTable := fileTableMgr.table folderTable := folderTableMgr.table return qb.getMany(ctx, qb.selectDataset().Order( folderTable.Col("path").Asc(), fileTable.Col("basename").Asc(), table.Col("date").Asc(), )) } func (qb *SceneStore) makeQuery(ctx context.Context, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if sceneFilter == nil { sceneFilter = &models.SceneFilterType{} } if findFilter == nil { findFilter = &models.FindFilterType{} } query := sceneRepository.newQuery() distinctIDs(&query, sceneTable) if q := findFilter.Q; q != nil && *q != "" { query.addJoins( join{ table: scenesFilesTable, onClause: "scenes_files.scene_id = scenes.id", }, join{ table: fileTable, onClause: "scenes_files.file_id = files.id", }, join{ table: folderTable, onClause: "files.parent_folder_id = folders.id", }, join{ table: fingerprintTable, onClause: "files_fingerprints.file_id = scenes_files.file_id", }, join{ table: sceneMarkerTable, onClause: "scene_markers.scene_id = scenes.id", }, ) filepathColumn := "folders.path || '" + string(filepath.Separator) + "' || files.basename" searchColumns := []string{"scenes.title", "scenes.details", filepathColumn, "files_fingerprints.fingerprint", "scene_markers.title"} query.parseQueryString(searchColumns, *q) } filter := filterBuilderFromHandler(ctx, &sceneFilterHandler{ sceneFilter: sceneFilter, }) if err := query.addFilter(filter); err != nil { return nil, err } if err := qb.setSceneSort(&query, findFilter); err != nil { return nil, err } query.sortAndPagination += getPagination(findFilter) return &query, nil } func (qb *SceneStore) Query(ctx context.Context, options models.SceneQueryOptions) (*models.SceneQueryResult, error) { query, err := qb.makeQuery(ctx, options.SceneFilter, options.FindFilter) if err != nil { return nil, err } result, err := qb.queryGroupedFields(ctx, options, *query) if err != nil { return nil, fmt.Errorf("error querying aggregate fields: %w", err) } idsResult, err := query.findIDs(ctx) if err != nil { return nil, fmt.Errorf("error finding IDs: %w", err) } result.IDs = idsResult return result, nil } func (qb *SceneStore) queryGroupedFields(ctx context.Context, options models.SceneQueryOptions, query queryBuilder) (*models.SceneQueryResult, error) { if !options.Count && !options.TotalDuration && !options.TotalSize { // nothing to do - return empty result return models.NewSceneQueryResult(qb), nil } aggregateQuery := sceneRepository.newQuery() if options.Count { aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total") } if options.TotalDuration { query.addJoins( join{ table: scenesFilesTable, onClause: "scenes_files.scene_id = scenes.id", }, join{ table: videoFileTable, onClause: "scenes_files.file_id = video_files.file_id", }, ) query.addColumn("COALESCE(video_files.duration, 0) as duration") aggregateQuery.addColumn("SUM(temp.duration) as duration") } if options.TotalSize { query.addJoins( join{ table: scenesFilesTable, onClause: "scenes_files.scene_id = scenes.id", }, join{ table: fileTable, onClause: "scenes_files.file_id = files.id", }, ) query.addColumn("COALESCE(files.size, 0) as size") aggregateQuery.addColumn("SUM(temp.size) as size") } const includeSortPagination = false aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination)) out := struct { Total int Duration null.Float Size null.Float }{} if err := sceneRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil { return nil, err } ret := models.NewSceneQueryResult(qb) ret.Count = out.Total ret.TotalDuration = out.Duration.Float64 ret.TotalSize = out.Size.Float64 return ret, nil } func (qb *SceneStore) QueryCount(ctx context.Context, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) (int, error) { query, err := qb.makeQuery(ctx, sceneFilter, findFilter) if err != nil { return 0, err } return query.executeCount(ctx) } var sceneSortOptions = sortOptions{ "bitrate", "created_at", "code", "date", "file_count", "filesize", "duration", "file_mod_time", "framerate", "group_scene_number", "id", "interactive", "interactive_speed", "last_o_at", "last_played_at", "movie_scene_number", "o_counter", "organized", "performer_count", "play_count", "play_duration", "resume_time", "path", "perceptual_similarity", "random", "rating", "resolution", "studio", "tag_count", "title", "updated_at", "performer_age", } func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindFilterType) error { if findFilter == nil || findFilter.Sort == nil || *findFilter.Sort == "" { return nil } sort := findFilter.GetSort("title") // CVE-2024-32231 - ensure sort is in the list of allowed sorts if err := sceneSortOptions.validateSort(sort); err != nil { return err } addFileTable := func() { query.addJoins( join{ sort: true, table: scenesFilesTable, onClause: "scenes_files.scene_id = scenes.id", }, join{ sort: true, table: fileTable, onClause: "scenes_files.file_id = files.id", }, ) } addVideoFileTable := func() { addFileTable() query.addJoins( join{ sort: true, table: videoFileTable, onClause: "video_files.file_id = scenes_files.file_id", }, ) } addFolderTable := func() { query.addJoins( join{ sort: true, table: folderTable, onClause: "files.parent_folder_id = folders.id", }, ) } direction := findFilter.GetDirection() switch sort { case "movie_scene_number": query.joinSort(groupsScenesTable, "", "scenes.id = groups_scenes.scene_id") query.sortAndPagination += getSort("scene_index", direction, groupsScenesTable) case "group_scene_number": query.joinSort(groupsScenesTable, "scene_group", "scenes.id = scene_group.scene_id") query.sortAndPagination += getSort("scene_index", direction, "scene_group") case "tag_count": query.sortAndPagination += getCountSort(sceneTable, scenesTagsTable, sceneIDColumn, direction) case "performer_count": query.sortAndPagination += getCountSort(sceneTable, performersScenesTable, sceneIDColumn, direction) case "file_count": query.sortAndPagination += getCountSort(sceneTable, scenesFilesTable, sceneIDColumn, direction) case "path": // special handling for path addFileTable() addFolderTable() query.sortAndPagination += fmt.Sprintf(" ORDER BY COALESCE(folders.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI %s", direction) case "perceptual_similarity": // special handling for phash addFileTable() query.addJoins( join{ sort: true, table: fingerprintTable, as: "fingerprints_phash", onClause: "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'", }, ) query.sortAndPagination += " ORDER BY fingerprints_phash.fingerprint " + direction + ", files.size DESC" case "bitrate": sort = "bit_rate" addVideoFileTable() query.sortAndPagination += getSort(sort, direction, videoFileTable) case "file_mod_time": sort = "mod_time" addFileTable() query.sortAndPagination += getSort(sort, direction, fileTable) case "framerate": sort = "frame_rate" addVideoFileTable() query.sortAndPagination += getSort(sort, direction, videoFileTable) case "resolution": addVideoFileTable() query.sortAndPagination += fmt.Sprintf(" ORDER BY MIN(%s.width, %s.height) %s", videoFileTable, videoFileTable, getSortDirection(direction)) case "filesize": addFileTable() query.sortAndPagination += getSort(sort, direction, fileTable) case "duration": addVideoFileTable() query.sortAndPagination += getSort(sort, direction, videoFileTable) case "interactive", "interactive_speed": addVideoFileTable() query.sortAndPagination += getSort(sort, direction, videoFileTable) case "title": addFileTable() addFolderTable() query.sortAndPagination += " ORDER BY COALESCE(scenes.title, files.basename) COLLATE NATURAL_CI " + direction + ", folders.path COLLATE NATURAL_CI " + direction case "play_count": query.sortAndPagination += getCountSort(sceneTable, scenesViewDatesTable, sceneIDColumn, direction) case "last_played_at": query.sortAndPagination += fmt.Sprintf(" ORDER BY (SELECT MAX(view_date) FROM %s AS sort WHERE sort.%s = %s.id) %s", scenesViewDatesTable, sceneIDColumn, sceneTable, getSortDirection(direction)) case "last_o_at": query.sortAndPagination += fmt.Sprintf(" ORDER BY (SELECT MAX(o_date) FROM %s AS sort WHERE sort.%s = %s.id) %s", scenesODatesTable, sceneIDColumn, sceneTable, getSortDirection(direction)) case "o_counter": query.sortAndPagination += getCountSort(sceneTable, scenesODatesTable, sceneIDColumn, direction) case "performer_age": // Looking at the youngest performer by default aggregation := "MIN" if direction == "DESC" { // When sorting by performer_'s age DESC, I should consider the oldest performer instead aggregation = "MAX" } fallback := "NULL" if direction == "ASC" { // When sorting ascending, NULLs are first by default. Coalescing to the MAX int value supported by sqlite fallback = "9223372036854775807" } query.sortAndPagination += fmt.Sprintf( " ORDER BY (SELECT COALESCE(%s(JulianDay(scenes.date) - JulianDay(performers.birthdate)), %s) FROM %s as performers INNER JOIN %s AS aggregation WHERE performers.id = aggregation.%s AND aggregation.%s = %s.id) %s", aggregation, fallback, performerTable, performersScenesTable, performerIDColumn, sceneIDColumn, sceneTable, getSortDirection(direction), ) case "studio": query.joinSort(studioTable, "", "scenes.studio_id = studios.id") query.sortAndPagination += getSort("name", direction, studioTable) default: query.sortAndPagination += getSort(sort, direction, "scenes") } // Whatever the sorting, always use title/id as a final sort query.sortAndPagination += ", COALESCE(scenes.title, scenes.id) COLLATE NATURAL_CI ASC" return nil } func (qb *SceneStore) SaveActivity(ctx context.Context, id int, resumeTime *float64, playDuration *float64) (bool, error) { if err := qb.tableMgr.checkIDExists(ctx, id); err != nil { return false, err } record := goqu.Record{} if resumeTime != nil { record["resume_time"] = resumeTime } if playDuration != nil { record["play_duration"] = goqu.L("play_duration + ?", playDuration) } if len(record) > 0 { if err := qb.tableMgr.updateByID(ctx, id, record); err != nil { return false, err } } return true, nil } func (qb *SceneStore) ResetActivity(ctx context.Context, id int, resetResume bool, resetDuration bool) (bool, error) { if err := qb.tableMgr.checkIDExists(ctx, id); err != nil { return false, err } record := goqu.Record{} if resetResume { record["resume_time"] = 0.0 } if resetDuration { record["play_duration"] = 0.0 } if len(record) > 0 { if err := qb.tableMgr.updateByID(ctx, id, record); err != nil { return false, err } } return true, nil } func (qb *SceneStore) GetURLs(ctx context.Context, sceneID int) ([]string, error) { return scenesURLsTableMgr.get(ctx, sceneID) } func (qb *SceneStore) GetCover(ctx context.Context, sceneID int) ([]byte, error) { return qb.GetImage(ctx, sceneID, sceneCoverBlobColumn) } func (qb *SceneStore) HasCover(ctx context.Context, sceneID int) (bool, error) { return qb.HasImage(ctx, sceneID, sceneCoverBlobColumn) } func (qb *SceneStore) UpdateCover(ctx context.Context, sceneID int, image []byte) error { return qb.UpdateImage(ctx, sceneID, sceneCoverBlobColumn, image) } func (qb *SceneStore) destroyCover(ctx context.Context, sceneID int) error { return qb.DestroyImage(ctx, sceneID, sceneCoverBlobColumn) } func (qb *SceneStore) AssignFiles(ctx context.Context, sceneID int, fileIDs []models.FileID) error { // assuming a file can only be assigned to a single scene if err := scenesFilesTableMgr.destroyJoins(ctx, fileIDs); err != nil { return err } // assign primary only if destination has no files existingFileIDs, err := sceneRepository.files.get(ctx, sceneID) if err != nil { return err } firstPrimary := len(existingFileIDs) == 0 return scenesFilesTableMgr.insertJoins(ctx, sceneID, firstPrimary, fileIDs) } func (qb *SceneStore) GetGroups(ctx context.Context, id int) (ret []models.GroupsScenes, err error) { ret = []models.GroupsScenes{} if err := sceneRepository.groups.getAll(ctx, id, func(rows *sqlx.Rows) error { var ms groupsScenesRow if err := rows.StructScan(&ms); err != nil { return err } ret = append(ret, ms.resolve(id)) return nil }); err != nil { return nil, err } return ret, nil } func (qb *SceneStore) AddFileID(ctx context.Context, id int, fileID models.FileID) error { const firstPrimary = false return scenesFilesTableMgr.insertJoins(ctx, id, firstPrimary, []models.FileID{fileID}) } func (qb *SceneStore) GetPerformerIDs(ctx context.Context, id int) ([]int, error) { return sceneRepository.performers.getIDs(ctx, id) } func (qb *SceneStore) GetTagIDs(ctx context.Context, id int) ([]int, error) { return sceneRepository.tags.getIDs(ctx, id) } func (qb *SceneStore) GetGalleryIDs(ctx context.Context, id int) ([]int, error) { return sceneRepository.galleries.getIDs(ctx, id) } func (qb *SceneStore) AddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error { return scenesGalleriesTableMgr.addJoins(ctx, sceneID, galleryIDs) } func (qb *SceneStore) GetStashIDs(ctx context.Context, sceneID int) ([]models.StashID, error) { return sceneRepository.stashIDs.get(ctx, sceneID) } func (qb *SceneStore) FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*models.Scene, error) { var dupeIds [][]int if distance == 0 { var ids []string if err := dbWrapper.Select(ctx, &ids, findExactDuplicateQuery, durationDiff); err != nil { return nil, err } for _, id := range ids { strIds := strings.Split(id, ",") var sceneIds []int for _, strId := range strIds { if intId, err := strconv.Atoi(strId); err == nil { sceneIds = sliceutil.AppendUnique(sceneIds, intId) } } // filter out if len(sceneIds) > 1 { dupeIds = append(dupeIds, sceneIds) } } } else { var hashes []*utils.Phash if err := sceneRepository.queryFunc(ctx, findAllPhashesQuery, nil, false, func(rows *sqlx.Rows) error { phash := utils.Phash{ Bucket: -1, Duration: -1, } if err := rows.StructScan(&phash); err != nil { return err } hashes = append(hashes, &phash) return nil }); err != nil { return nil, err } dupeIds = utils.FindDuplicates(hashes, distance, durationDiff) } var duplicates [][]*models.Scene for _, sceneIds := range dupeIds { if scenes, err := qb.FindMany(ctx, sceneIds); err == nil { duplicates = append(duplicates, scenes) } } sortByPath(duplicates) return duplicates, nil } func sortByPath(scenes [][]*models.Scene) { lessFunc := func(i int, j int) bool { firstPathI := getFirstPath(scenes[i]) firstPathJ := getFirstPath(scenes[j]) return firstPathI < firstPathJ } sort.SliceStable(scenes, lessFunc) } func getFirstPath(scenes []*models.Scene) string { var firstPath string for i, scene := range scenes { if i == 0 || scene.Path < firstPath { firstPath = scene.Path } } return firstPath } ================================================ FILE: pkg/sqlite/scene_filter.go ================================================ package sqlite import ( "context" "fmt" "github.com/stashapp/stash/pkg/models" ) type sceneFilterHandler struct { sceneFilter *models.SceneFilterType } func (qb *sceneFilterHandler) validate() error { sceneFilter := qb.sceneFilter if sceneFilter == nil { return nil } if err := validateFilterCombination(sceneFilter.OperatorFilter); err != nil { return err } if subFilter := sceneFilter.SubFilter(); subFilter != nil { sqb := &sceneFilterHandler{sceneFilter: subFilter} if err := sqb.validate(); err != nil { return err } } return nil } func (qb *sceneFilterHandler) handle(ctx context.Context, f *filterBuilder) { sceneFilter := qb.sceneFilter if sceneFilter == nil { return } if err := qb.validate(); err != nil { f.setError(err) return } sf := sceneFilter.SubFilter() if sf != nil { sub := &sceneFilterHandler{sf} handleSubFilter(ctx, sub, f, sceneFilter.OperatorFilter) } f.handleCriterion(ctx, qb.criterionHandler()) } func (qb *sceneFilterHandler) criterionHandler() criterionHandler { sceneFilter := qb.sceneFilter return compoundHandler{ intCriterionHandler(sceneFilter.ID, "scenes.id", nil), pathCriterionHandler(sceneFilter.Path, "folders.path", "files.basename", qb.addFoldersTable), qb.fileCountCriterionHandler(sceneFilter.FileCount), stringCriterionHandler(sceneFilter.Title, "scenes.title"), stringCriterionHandler(sceneFilter.Code, "scenes.code"), stringCriterionHandler(sceneFilter.Details, "scenes.details"), stringCriterionHandler(sceneFilter.Director, "scenes.director"), criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if sceneFilter.Oshash != nil { qb.addSceneFilesTable(f) f.addLeftJoin(fingerprintTable, "fingerprints_oshash", "scenes_files.file_id = fingerprints_oshash.file_id AND fingerprints_oshash.type = 'oshash'") } stringCriterionHandler(sceneFilter.Oshash, "fingerprints_oshash.fingerprint")(ctx, f) }), criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if sceneFilter.Checksum != nil { qb.addSceneFilesTable(f) f.addLeftJoin(fingerprintTable, "fingerprints_md5", "scenes_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") } stringCriterionHandler(sceneFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f) }), criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if sceneFilter.Phash != nil { // backwards compatibility h := phashDistanceCriterionHandler{ joinFn: func(f *filterBuilder) { qb.addSceneFilesTable(f) f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") }, criterion: &models.PhashDistanceCriterionInput{ Value: sceneFilter.Phash.Value, Modifier: sceneFilter.Phash.Modifier, }, } h.handle(ctx, f) } }), &phashDistanceCriterionHandler{ joinFn: func(f *filterBuilder) { qb.addSceneFilesTable(f) f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") }, criterion: sceneFilter.PhashDistance, }, intCriterionHandler(sceneFilter.Rating100, "scenes.rating", nil), qb.oCountCriterionHandler(sceneFilter.OCounter), boolCriterionHandler(sceneFilter.Organized, "scenes.organized", nil), floatIntCriterionHandler(sceneFilter.Duration, "video_files.duration", qb.addVideoFilesTable), resolutionCriterionHandler(sceneFilter.Resolution, "video_files.height", "video_files.width", qb.addVideoFilesTable), orientationCriterionHandler(sceneFilter.Orientation, "video_files.height", "video_files.width", qb.addVideoFilesTable), floatIntCriterionHandler(sceneFilter.Framerate, "ROUND(video_files.frame_rate)", qb.addVideoFilesTable), intCriterionHandler(sceneFilter.Bitrate, "video_files.bit_rate", qb.addVideoFilesTable), qb.codecCriterionHandler(sceneFilter.VideoCodec, "video_files.video_codec", qb.addVideoFilesTable), qb.codecCriterionHandler(sceneFilter.AudioCodec, "video_files.audio_codec", qb.addVideoFilesTable), qb.hasMarkersCriterionHandler(sceneFilter.HasMarkers), qb.isMissingCriterionHandler(sceneFilter.IsMissing), qb.urlsCriterionHandler(sceneFilter.URL), criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if sceneFilter.StashID != nil { sceneRepository.stashIDs.join(f, "scene_stash_ids", "scenes.id") stringCriterionHandler(sceneFilter.StashID, "scene_stash_ids.stash_id")(ctx, f) } }), &stashIDCriterionHandler{ c: sceneFilter.StashIDEndpoint, stashIDRepository: &sceneRepository.stashIDs, stashIDTableAs: "scene_stash_ids", parentIDCol: "scenes.id", }, &stashIDsCriterionHandler{ c: sceneFilter.StashIDsEndpoint, stashIDRepository: &sceneRepository.stashIDs, stashIDTableAs: "scene_stash_ids", parentIDCol: "scenes.id", }, qb.stashIDCountCriterionHandler(sceneFilter.StashIDCount), boolCriterionHandler(sceneFilter.Interactive, "video_files.interactive", qb.addVideoFilesTable), intCriterionHandler(sceneFilter.InteractiveSpeed, "video_files.interactive_speed", qb.addVideoFilesTable), qb.captionCriterionHandler(sceneFilter.Captions), floatIntCriterionHandler(sceneFilter.ResumeTime, "scenes.resume_time", nil), floatIntCriterionHandler(sceneFilter.PlayDuration, "scenes.play_duration", nil), qb.playCountCriterionHandler(sceneFilter.PlayCount), criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if sceneFilter.LastPlayedAt != nil { f.addLeftJoin( fmt.Sprintf("(SELECT %s, MAX(%s) as last_played_at FROM %s GROUP BY %s)", sceneIDColumn, sceneViewDateColumn, scenesViewDatesTable, sceneIDColumn), "scene_last_view", fmt.Sprintf("scene_last_view.%s = scenes.id", sceneIDColumn), ) h := timestampCriterionHandler{sceneFilter.LastPlayedAt, "IFNULL(last_played_at, datetime(0))", nil} h.handle(ctx, f) } }), qb.tagsCriterionHandler(sceneFilter.Tags), qb.tagCountCriterionHandler(sceneFilter.TagCount), qb.performersCriterionHandler(sceneFilter.Performers), qb.performerCountCriterionHandler(sceneFilter.PerformerCount), studioCriterionHandler(sceneTable, sceneFilter.Studios), qb.groupsCriterionHandler(sceneFilter.Groups), qb.moviesCriterionHandler(sceneFilter.Movies), qb.galleriesCriterionHandler(sceneFilter.Galleries), qb.performerTagsCriterionHandler(sceneFilter.PerformerTags), qb.performerFavoriteCriterionHandler(sceneFilter.PerformerFavorite), qb.performerAgeCriterionHandler(sceneFilter.PerformerAge), qb.duplicatedCriterionHandler(sceneFilter.Duplicated), &dateCriterionHandler{sceneFilter.Date, "scenes.date", nil}, ×tampCriterionHandler{sceneFilter.CreatedAt, "scenes.created_at", nil}, ×tampCriterionHandler{sceneFilter.UpdatedAt, "scenes.updated_at", nil}, &customFieldsFilterHandler{ table: scenesCustomFieldsTable.GetTable(), fkCol: sceneIDColumn, c: sceneFilter.CustomFields, idCol: "scenes.id", }, &relatedFilterHandler{ relatedIDCol: "scenes_galleries.gallery_id", relatedRepo: galleryRepository.repository, relatedHandler: &galleryFilterHandler{sceneFilter.GalleriesFilter}, joinFn: func(f *filterBuilder) { sceneRepository.galleries.innerJoin(f, "", "scenes.id") }, }, &relatedFilterHandler{ relatedIDCol: "performers_join.performer_id", relatedRepo: performerRepository.repository, relatedHandler: &performerFilterHandler{sceneFilter.PerformersFilter}, joinFn: func(f *filterBuilder) { sceneRepository.performers.innerJoin(f, "performers_join", "scenes.id") }, }, &relatedFilterHandler{ relatedIDCol: "scenes.studio_id", relatedRepo: studioRepository.repository, relatedHandler: &studioFilterHandler{sceneFilter.StudiosFilter}, }, &relatedFilterHandler{ relatedIDCol: "scene_tag.tag_id", relatedRepo: tagRepository.repository, relatedHandler: &tagFilterHandler{sceneFilter.TagsFilter}, joinFn: func(f *filterBuilder) { sceneRepository.tags.innerJoin(f, "scene_tag", "scenes.id") }, }, &relatedFilterHandler{ relatedIDCol: "groups_scenes.group_id", relatedRepo: groupRepository.repository, relatedHandler: &groupFilterHandler{sceneFilter.MoviesFilter}, joinFn: func(f *filterBuilder) { sceneRepository.groups.innerJoin(f, "", "scenes.id") }, }, &relatedFilterHandler{ relatedIDCol: "files.id", relatedRepo: fileRepository.repository, relatedHandler: &fileFilterHandler{ fileFilter: sceneFilter.FilesFilter, isRelated: true, }, joinFn: func(f *filterBuilder) { qb.addFilesTable(f) qb.addFoldersTable(f) }, // don't use a subquery; join directly directJoin: true, }, &relatedFilterHandler{ relatedIDCol: "scene_markers.id", relatedRepo: sceneMarkerRepository.repository, relatedHandler: &sceneMarkerFilterHandler{sceneFilter.MarkersFilter}, joinFn: func(f *filterBuilder) { f.addInnerJoin("scene_markers", "", "scenes.id") }, }, } } func (qb *sceneFilterHandler) addSceneFilesTable(f *filterBuilder) { f.addLeftJoin(scenesFilesTable, "", "scenes_files.scene_id = scenes.id") } func (qb *sceneFilterHandler) addFilesTable(f *filterBuilder) { qb.addSceneFilesTable(f) f.addLeftJoin(fileTable, "", "scenes_files.file_id = files.id") } func (qb *sceneFilterHandler) addFoldersTable(f *filterBuilder) { qb.addFilesTable(f) f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id") } func (qb *sceneFilterHandler) addVideoFilesTable(f *filterBuilder) { qb.addSceneFilesTable(f) f.addLeftJoin(videoFileTable, "", "video_files.file_id = scenes_files.file_id") } func (qb *sceneFilterHandler) playCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: sceneTable, joinTable: scenesViewDatesTable, primaryFK: sceneIDColumn, } return h.handler(count) } func (qb *sceneFilterHandler) oCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: sceneTable, joinTable: scenesODatesTable, primaryFK: sceneIDColumn, } return h.handler(count) } func (qb *sceneFilterHandler) fileCountCriterionHandler(fileCount *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: sceneTable, joinTable: scenesFilesTable, primaryFK: sceneIDColumn, } return h.handler(fileCount) } func (qb *sceneFilterHandler) duplicatedCriterionHandler(duplicatedFilter *models.DuplicationCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if duplicatedFilter == nil { return } // Handle legacy 'duplicated' field - treat as phash if phash not explicitly set //nolint:staticcheck if duplicatedFilter.Duplicated != nil && duplicatedFilter.Phash == nil { //nolint:staticcheck duplicatedFilter.Phash = duplicatedFilter.Duplicated } // Handle explicit fields if duplicatedFilter.Phash != nil { qb.addSceneFilesTable(f) qb.applyPhashDuplication(f, *duplicatedFilter.Phash) } if duplicatedFilter.StashID != nil { qb.applyStashIDDuplication(f, *duplicatedFilter.StashID) } if duplicatedFilter.Title != nil { qb.applyTitleDuplication(f, *duplicatedFilter.Title) } if duplicatedFilter.URL != nil { qb.applyURLDuplication(f, *duplicatedFilter.URL) } } } // getCountOperator returns ">" for duplicated items (count > 1) or "=" for unique items (count = 1) func getCountOperator(duplicated bool) string { if duplicated { return ">" } return "=" } func (qb *sceneFilterHandler) applyPhashDuplication(f *filterBuilder, duplicated bool) { // TODO: Wishlist item: Implement Distance matching v := getCountOperator(duplicated) f.addInnerJoin("(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) "+v+" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)", "scph", "scenes_files.file_id = scph.file_id") } func (qb *sceneFilterHandler) applyStashIDDuplication(f *filterBuilder, duplicated bool) { v := getCountOperator(duplicated) // Find stash_ids that appear on more than one scene f.addInnerJoin("(SELECT scene_id FROM scene_stash_ids INNER JOIN (SELECT stash_id FROM scene_stash_ids GROUP BY stash_id HAVING COUNT(DISTINCT scene_id) "+v+" 1) dupes ON scene_stash_ids.stash_id = dupes.stash_id)", "scsi", "scenes.id = scsi.scene_id") } func (qb *sceneFilterHandler) applyTitleDuplication(f *filterBuilder, duplicated bool) { v := getCountOperator(duplicated) // Find titles that appear on more than one scene (excluding empty titles) f.addInnerJoin("(SELECT id FROM scenes WHERE title != '' AND title IS NOT NULL AND title IN (SELECT title FROM scenes WHERE title != '' AND title IS NOT NULL GROUP BY title HAVING COUNT(*) "+v+" 1))", "sctitle", "scenes.id = sctitle.id") } func (qb *sceneFilterHandler) applyURLDuplication(f *filterBuilder, duplicated bool) { v := getCountOperator(duplicated) // Find URLs that appear on more than one scene f.addInnerJoin("(SELECT scene_id FROM scene_urls INNER JOIN (SELECT url FROM scene_urls GROUP BY url HAVING COUNT(DISTINCT scene_id) "+v+" 1) dupes ON scene_urls.url = dupes.url)", "scurl", "scenes.id = scurl.scene_id") } func (qb *sceneFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if codec != nil { if addJoinFn != nil { addJoinFn(f) } stringCriterionHandler(codec, codecColumn)(ctx, f) } } } func (qb *sceneFilterHandler) hasMarkersCriterionHandler(hasMarkers *string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if hasMarkers != nil { f.addLeftJoin("scene_markers", "", "scene_markers.scene_id = scenes.id") if *hasMarkers == "true" { f.addHaving("count(scene_markers.scene_id) > 0") } else { f.addWhere("scene_markers.id IS NULL") } } } } func (qb *sceneFilterHandler) isMissingCriterionHandler(isMissing *string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if isMissing != nil && *isMissing != "" { switch *isMissing { case "url": scenesURLsTableMgr.join(f, "", "scenes.id") f.addWhere("scene_urls.url IS NULL") case "galleries": sceneRepository.galleries.join(f, "galleries_join", "scenes.id") f.addWhere("galleries_join.scene_id IS NULL") case "studio": f.addWhere("scenes.studio_id IS NULL") case "movie", "group": sceneRepository.groups.join(f, "groups_join", "scenes.id") f.addWhere("groups_join.scene_id IS NULL") case "performers": sceneRepository.performers.join(f, "performers_join", "scenes.id") f.addWhere("performers_join.scene_id IS NULL") case "date": f.addWhere(`scenes.date IS NULL OR scenes.date IS ""`) case "tags": sceneRepository.tags.join(f, "tags_join", "scenes.id") f.addWhere("tags_join.scene_id IS NULL") case "stash_id": sceneRepository.stashIDs.join(f, "scene_stash_ids", "scenes.id") f.addWhere("scene_stash_ids.scene_id IS NULL") case "phash": qb.addSceneFilesTable(f) f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") f.addWhere("fingerprints_phash.fingerprint IS NULL") case "cover": f.addWhere("scenes.cover_blob IS NULL") default: if err := validateIsMissing(*isMissing, []string{ "title", "code", "details", "director", "rating", }); err != nil { f.setError(err) return } f.addWhere("(scenes." + *isMissing + " IS NULL OR TRIM(scenes." + *isMissing + ") = '')") } } } } func (qb *sceneFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ primaryTable: sceneTable, primaryFK: sceneIDColumn, joinTable: scenesURLsTable, stringColumn: sceneURLColumn, addJoinTable: func(f *filterBuilder) { scenesURLsTableMgr.join(f, "", "scenes.id") }, } return h.handler(url) } func (qb *sceneFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { return multiCriterionHandlerBuilder{ primaryTable: sceneTable, foreignTable: foreignTable, joinTable: joinTable, primaryFK: sceneIDColumn, foreignFK: foreignFK, addJoinsFunc: addJoinsFunc, } } func (qb *sceneFilterHandler) captionCriterionHandler(captions *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ primaryTable: sceneTable, primaryFK: sceneIDColumn, joinTable: videoCaptionsTable, stringColumn: captionCodeColumn, addJoinTable: func(f *filterBuilder) { qb.addSceneFilesTable(f) f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = scenes_files.file_id") }, excludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) { excludeClause := `scenes.id NOT IN ( SELECT scenes_files.scene_id from scenes_files INNER JOIN video_captions on video_captions.file_id = scenes_files.file_id WHERE video_captions.language_code LIKE ? )` f.addWhere(excludeClause, criterion.Value) // TODO - should we also exclude null values? }, } return h.handler(captions) } func (qb *sceneFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { h := joinedHierarchicalMultiCriterionHandlerBuilder{ primaryTable: sceneTable, foreignTable: tagTable, foreignFK: "tag_id", relationsTable: "tags_relations", joinAs: "scene_tag", joinTable: scenesTagsTable, primaryFK: sceneIDColumn, } return h.handler(tags) } func (qb *sceneFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: sceneTable, joinTable: scenesTagsTable, primaryFK: sceneIDColumn, } return h.handler(tagCount) } func (qb *sceneFilterHandler) stashIDCountCriterionHandler(stashIDCount *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: sceneTable, joinTable: "scene_stash_ids", primaryFK: sceneIDColumn, } return h.handler(stashIDCount) } func (qb *sceneFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { h := joinedMultiCriterionHandlerBuilder{ primaryTable: sceneTable, joinTable: performersScenesTable, joinAs: "performers_join", primaryFK: sceneIDColumn, foreignFK: performerIDColumn, addJoinTable: func(f *filterBuilder) { sceneRepository.performers.join(f, "performers_join", "scenes.id") }, } return h.handler(performers) } func (qb *sceneFilterHandler) performerCountCriterionHandler(performerCount *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: sceneTable, joinTable: performersScenesTable, primaryFK: sceneIDColumn, } return h.handler(performerCount) } func (qb *sceneFilterHandler) performerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if performerfavorite != nil { f.addLeftJoin("performers_scenes", "", "scenes.id = performers_scenes.scene_id") if *performerfavorite { // contains at least one favorite f.addLeftJoin("performers", "", "performers.id = performers_scenes.performer_id") f.addWhere("performers.favorite = 1") } else { // contains zero favorites f.addLeftJoin(`(SELECT performers_scenes.scene_id as id FROM performers_scenes JOIN performers ON performers.id = performers_scenes.performer_id GROUP BY performers_scenes.scene_id HAVING SUM(performers.favorite) = 0)`, "nofaves", "scenes.id = nofaves.id") f.addWhere("performers_scenes.scene_id IS NULL OR nofaves.id IS NOT NULL") } } } } func (qb *sceneFilterHandler) performerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if performerAge != nil { f.addInnerJoin("performers_scenes", "", "scenes.id = performers_scenes.scene_id") f.addInnerJoin("performers", "", "performers_scenes.performer_id = performers.id") f.addWhere("scenes.date != '' AND performers.birthdate != ''") f.addWhere("scenes.date IS NOT NULL AND performers.birthdate IS NOT NULL") ageCalc := "cast(strftime('%Y.%m%d', scenes.date) - strftime('%Y.%m%d', performers.birthdate) as int)" whereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2) f.addWhere(whereClause, args...) } } } // legacy handler func (qb *sceneFilterHandler) moviesCriterionHandler(movies *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { sceneRepository.groups.join(f, "", "scenes.id") f.addLeftJoin("groups", "", "groups_scenes.group_id = groups.id") } h := qb.getMultiCriterionHandlerBuilder(groupTable, groupsScenesTable, "group_id", addJoinsFunc) return h.handler(movies) } func (qb *sceneFilterHandler) groupsCriterionHandler(groups *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { h := joinedHierarchicalMultiCriterionHandlerBuilder{ primaryTable: sceneTable, foreignTable: groupTable, foreignFK: "group_id", relationsTable: groupRelationsTable, parentFK: "containing_id", childFK: "sub_id", joinAs: "scene_group", joinTable: groupsScenesTable, primaryFK: sceneIDColumn, } return h.handler(groups) } func (qb *sceneFilterHandler) galleriesCriterionHandler(galleries *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { sceneRepository.galleries.join(f, "", "scenes.id") f.addLeftJoin("galleries", "", "scenes_galleries.gallery_id = galleries.id") } h := qb.getMultiCriterionHandlerBuilder(galleryTable, scenesGalleriesTable, "gallery_id", addJoinsFunc) return h.handler(galleries) } func (qb *sceneFilterHandler) performerTagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandler { return &joinedPerformerTagsHandler{ criterion: tags, primaryTable: sceneTable, joinTable: performersScenesTable, joinPrimaryKey: sceneIDColumn, } } ================================================ FILE: pkg/sqlite/scene_marker.go ================================================ package sqlite import ( "context" "database/sql" "errors" "fmt" "slices" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "gopkg.in/guregu/null.v4" "github.com/stashapp/stash/pkg/models" ) const ( sceneMarkerTable = "scene_markers" sceneMarkersTagsTable = "scene_markers_tags" sceneMarkerIDColumn = "scene_marker_id" ) const countSceneMarkersForTagQuery = ` SELECT scene_markers.id FROM scene_markers LEFT JOIN scene_markers_tags as tags_join on tags_join.scene_marker_id = scene_markers.id WHERE tags_join.tag_id = ? OR scene_markers.primary_tag_id = ? GROUP BY scene_markers.id ` type sceneMarkerRow struct { ID int `db:"id" goqu:"skipinsert"` Title string `db:"title"` // TODO: make db schema (and gql schema) nullable Seconds float64 `db:"seconds"` PrimaryTagID int `db:"primary_tag_id"` SceneID int `db:"scene_id"` CreatedAt Timestamp `db:"created_at"` UpdatedAt Timestamp `db:"updated_at"` EndSeconds null.Float `db:"end_seconds"` } func (r *sceneMarkerRow) fromSceneMarker(o models.SceneMarker) { r.ID = o.ID r.Title = o.Title r.Seconds = o.Seconds if o.EndSeconds != nil { r.EndSeconds = null.FloatFrom(*o.EndSeconds) } r.PrimaryTagID = o.PrimaryTagID r.SceneID = o.SceneID r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} } func (r *sceneMarkerRow) resolve() *models.SceneMarker { ret := &models.SceneMarker{ ID: r.ID, Title: r.Title, Seconds: r.Seconds, EndSeconds: r.EndSeconds.Ptr(), PrimaryTagID: r.PrimaryTagID, SceneID: r.SceneID, CreatedAt: r.CreatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp, } return ret } type sceneMarkerRowRecord struct { updateRecord } func (r *sceneMarkerRowRecord) fromPartial(o models.SceneMarkerPartial) { // TODO: replace with setNullString after schema is made nullable // r.setNullString("title", o.Title) // saves a null input as the empty string if o.Title.Set { r.set("title", o.Title.Value) } r.setFloat64("seconds", o.Seconds) r.setNullFloat64("end_seconds", o.EndSeconds) r.setInt("primary_tag_id", o.PrimaryTagID) r.setInt("scene_id", o.SceneID) r.setTimestamp("created_at", o.CreatedAt) r.setTimestamp("updated_at", o.UpdatedAt) } type sceneMarkerRepositoryType struct { repository scenes repository tags joinRepository } var ( sceneMarkerRepository = sceneMarkerRepositoryType{ repository: repository{ tableName: sceneMarkerTable, idColumn: idColumn, }, scenes: repository{ tableName: sceneTable, idColumn: idColumn, }, tags: joinRepository{ repository: repository{ tableName: sceneMarkersTagsTable, idColumn: sceneMarkerIDColumn, }, fkColumn: tagIDColumn, }, } ) type SceneMarkerStore struct{} func NewSceneMarkerStore() *SceneMarkerStore { return &SceneMarkerStore{} } func (qb *SceneMarkerStore) table() exp.IdentifierExpression { return sceneMarkerTableMgr.table } func (qb *SceneMarkerStore) selectDataset() *goqu.SelectDataset { return dialect.From(qb.table()).Select(qb.table().All()) } func (qb *SceneMarkerStore) Create(ctx context.Context, newObject *models.SceneMarker) error { var r sceneMarkerRow r.fromSceneMarker(*newObject) id, err := sceneMarkerTableMgr.insertID(ctx, r) if err != nil { return err } updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } *newObject = *updated return nil } func (qb *SceneMarkerStore) UpdatePartial(ctx context.Context, id int, partial models.SceneMarkerPartial) (*models.SceneMarker, error) { r := sceneMarkerRowRecord{ updateRecord{ Record: make(exp.Record), }, } r.fromPartial(partial) if len(r.Record) > 0 { if err := sceneMarkerTableMgr.updateByID(ctx, id, r.Record); err != nil { return nil, err } } if partial.TagIDs != nil { if err := sceneMarkersTagsTableMgr.modifyJoins(ctx, id, partial.TagIDs.IDs, partial.TagIDs.Mode); err != nil { return nil, fmt.Errorf("modifying scene marker tags: %w", err) } } return qb.find(ctx, id) } func (qb *SceneMarkerStore) Update(ctx context.Context, updatedObject *models.SceneMarker) error { var r sceneMarkerRow r.fromSceneMarker(*updatedObject) if err := sceneMarkerTableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { return err } return nil } func (qb *SceneMarkerStore) Destroy(ctx context.Context, id int) error { return sceneMarkerRepository.destroyExisting(ctx, []int{id}) } // returns nil, nil if not found func (qb *SceneMarkerStore) Find(ctx context.Context, id int) (*models.SceneMarker, error) { ret, err := qb.find(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } return ret, err } func (qb *SceneMarkerStore) FindMany(ctx context.Context, ids []int) ([]*models.SceneMarker, error) { ret := make([]*models.SceneMarker, len(ids)) table := qb.table() q := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(ids)) unsorted, err := qb.getMany(ctx, q) if err != nil { return nil, err } for _, s := range unsorted { i := slices.Index(ids, s.ID) ret[i] = s } for i := range ret { if ret[i] == nil { return nil, fmt.Errorf("scene marker with id %d not found", ids[i]) } } return ret, nil } // returns nil, sql.ErrNoRows if not found func (qb *SceneMarkerStore) find(ctx context.Context, id int) (*models.SceneMarker, error) { q := qb.selectDataset().Where(sceneMarkerTableMgr.byID(id)) ret, err := qb.get(ctx, q) if err != nil { return nil, err } return ret, nil } // returns nil, sql.ErrNoRows if not found func (qb *SceneMarkerStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.SceneMarker, error) { ret, err := qb.getMany(ctx, q) if err != nil { return nil, err } if len(ret) == 0 { return nil, sql.ErrNoRows } return ret[0], nil } func (qb *SceneMarkerStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.SceneMarker, error) { const single = false var ret []*models.SceneMarker if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { var f sceneMarkerRow if err := r.StructScan(&f); err != nil { return err } s := f.resolve() ret = append(ret, s) return nil }); err != nil { return nil, err } return ret, nil } func (qb *SceneMarkerStore) FindBySceneID(ctx context.Context, sceneID int) ([]*models.SceneMarker, error) { query := ` SELECT scene_markers.* FROM scene_markers WHERE scene_markers.scene_id = ? GROUP BY scene_markers.id ORDER BY scene_markers.seconds ASC ` args := []interface{}{sceneID} return qb.querySceneMarkers(ctx, query, args) } func (qb *SceneMarkerStore) CountByTagID(ctx context.Context, tagID int) (int, error) { args := []interface{}{tagID, tagID} return sceneMarkerRepository.runCountQuery(ctx, sceneMarkerRepository.buildCountQuery(countSceneMarkersForTagQuery), args) } func (qb *SceneMarkerStore) GetMarkerStrings(ctx context.Context, q *string, sort *string) ([]*models.MarkerStringsResultType, error) { query := "SELECT count(*) as `count`, scene_markers.id as id, scene_markers.title as title FROM scene_markers" if q != nil { query += " WHERE title LIKE '%" + *q + "%'" } query += " GROUP BY title" if sort != nil && *sort == "count" { query += " ORDER BY `count` DESC" } else { query += " ORDER BY title ASC" } var args []interface{} return qb.queryMarkerStringsResultType(ctx, query, args) } func (qb *SceneMarkerStore) Wall(ctx context.Context, q *string) ([]*models.SceneMarker, error) { s := "" if q != nil { s = *q } table := qb.table() qq := qb.selectDataset().Prepared(true).Where(table.Col("title").Like("%" + s + "%")).Order(goqu.L("RANDOM()").Asc()).Limit(80) return qb.getMany(ctx, qq) } func (qb *SceneMarkerStore) makeQuery(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if sceneMarkerFilter == nil { sceneMarkerFilter = &models.SceneMarkerFilterType{} } if findFilter == nil { findFilter = &models.FindFilterType{} } query := sceneMarkerRepository.newQuery() distinctIDs(&query, sceneMarkerTable) if q := findFilter.Q; q != nil && *q != "" { query.join(sceneTable, "", "scenes.id = scene_markers.scene_id") query.join(tagTable, "", "scene_markers.primary_tag_id = tags.id") searchColumns := []string{"scene_markers.title", "scenes.title", "tags.name"} query.parseQueryString(searchColumns, *q) } filter := filterBuilderFromHandler(ctx, &sceneMarkerFilterHandler{ sceneMarkerFilter: sceneMarkerFilter, }) if err := query.addFilter(filter); err != nil { return nil, err } if err := qb.setSceneMarkerSort(&query, findFilter); err != nil { return nil, err } query.sortAndPagination += getPagination(findFilter) return &query, nil } func (qb *SceneMarkerStore) Query(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, findFilter *models.FindFilterType) ([]*models.SceneMarker, int, error) { query, err := qb.makeQuery(ctx, sceneMarkerFilter, findFilter) if err != nil { return nil, 0, err } idsResult, countResult, err := query.executeFind(ctx) if err != nil { return nil, 0, err } sceneMarkers, err := qb.FindMany(ctx, idsResult) if err != nil { return nil, 0, err } return sceneMarkers, countResult, nil } func (qb *SceneMarkerStore) QueryCount(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, findFilter *models.FindFilterType) (int, error) { query, err := qb.makeQuery(ctx, sceneMarkerFilter, findFilter) if err != nil { return 0, err } return query.executeCount(ctx) } var sceneMarkerSortOptions = sortOptions{ "created_at", "id", "title", "random", "scene_id", "scenes_updated_at", "seconds", "updated_at", "duration", } func (qb *SceneMarkerStore) setSceneMarkerSort(query *queryBuilder, findFilter *models.FindFilterType) error { sort := findFilter.GetSort("title") direction := findFilter.GetDirection() // CVE-2024-32231 - ensure sort is in the list of allowed sorts if err := sceneMarkerSortOptions.validateSort(sort); err != nil { return err } switch sort { case "scenes_updated_at": sort = "updated_at" query.joinSort(sceneTable, "", "scenes.id = scene_markers.scene_id") query.sortAndPagination += getSort(sort, direction, sceneTable) case "title": query.joinSort(tagTable, "", "scene_markers.primary_tag_id = tags.id") query.sortAndPagination += " ORDER BY COALESCE(NULLIF(scene_markers.title,''), tags.name) COLLATE NATURAL_CI " + direction case "duration": sort = "(scene_markers.end_seconds - scene_markers.seconds)" query.sortAndPagination += getSort(sort, direction, sceneMarkerTable) default: query.sortAndPagination += getSort(sort, direction, sceneMarkerTable) } query.sortAndPagination += ", scene_markers.scene_id ASC, scene_markers.seconds ASC" return nil } func (qb *SceneMarkerStore) querySceneMarkers(ctx context.Context, query string, args []interface{}) ([]*models.SceneMarker, error) { const single = false var ret []*models.SceneMarker if err := sceneMarkerRepository.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { var f sceneMarkerRow if err := r.StructScan(&f); err != nil { return err } s := f.resolve() ret = append(ret, s) return nil }); err != nil { return nil, err } return ret, nil } func (qb *SceneMarkerStore) queryMarkerStringsResultType(ctx context.Context, query string, args []interface{}) ([]*models.MarkerStringsResultType, error) { rows, err := dbWrapper.Queryx(ctx, query, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, err } defer rows.Close() markerStrings := make([]*models.MarkerStringsResultType, 0) for rows.Next() { markerString := models.MarkerStringsResultType{} if err := rows.StructScan(&markerString); err != nil { return nil, err } markerStrings = append(markerStrings, &markerString) } if err := rows.Err(); err != nil { return nil, err } return markerStrings, nil } func (qb *SceneMarkerStore) GetTagIDs(ctx context.Context, id int) ([]int, error) { return sceneMarkerRepository.tags.getIDs(ctx, id) } func (qb *SceneMarkerStore) UpdateTags(ctx context.Context, id int, tagIDs []int) error { // Delete the existing joins and then create new ones return sceneMarkerRepository.tags.replace(ctx, id, tagIDs) } func (qb *SceneMarkerStore) Count(ctx context.Context) (int, error) { q := dialect.Select(goqu.COUNT("*")).From(qb.table()) return count(ctx, q) } func (qb *SceneMarkerStore) All(ctx context.Context) ([]*models.SceneMarker, error) { return qb.getMany(ctx, qb.selectDataset()) } ================================================ FILE: pkg/sqlite/scene_marker_filter.go ================================================ package sqlite import ( "context" "fmt" "github.com/stashapp/stash/pkg/models" ) type sceneMarkerFilterHandler struct { sceneMarkerFilter *models.SceneMarkerFilterType } func (qb *sceneMarkerFilterHandler) validate() error { return nil } func (qb *sceneMarkerFilterHandler) handle(ctx context.Context, f *filterBuilder) { sceneMarkerFilter := qb.sceneMarkerFilter if sceneMarkerFilter == nil { return } if err := qb.validate(); err != nil { f.setError(err) return } f.handleCriterion(ctx, qb.criterionHandler()) } func (qb *sceneMarkerFilterHandler) joinScenes(f *filterBuilder) { sceneMarkerRepository.scenes.innerJoin(f, "", "scene_markers.scene_id") } func (qb *sceneMarkerFilterHandler) criterionHandler() criterionHandler { sceneMarkerFilter := qb.sceneMarkerFilter return compoundHandler{ qb.tagIDCriterionHandler(sceneMarkerFilter.TagID), qb.tagsCriterionHandler(sceneMarkerFilter.Tags), qb.sceneTagsCriterionHandler(sceneMarkerFilter.SceneTags), qb.performersCriterionHandler(sceneMarkerFilter.Performers), qb.scenesCriterionHandler(sceneMarkerFilter.Scenes), floatCriterionHandler(sceneMarkerFilter.Duration, "COALESCE(scene_markers.end_seconds - scene_markers.seconds, NULL)", nil), ×tampCriterionHandler{sceneMarkerFilter.CreatedAt, "scene_markers.created_at", nil}, ×tampCriterionHandler{sceneMarkerFilter.UpdatedAt, "scene_markers.updated_at", nil}, &dateCriterionHandler{sceneMarkerFilter.SceneDate, "scenes.date", qb.joinScenes}, ×tampCriterionHandler{sceneMarkerFilter.SceneCreatedAt, "scenes.created_at", qb.joinScenes}, ×tampCriterionHandler{sceneMarkerFilter.SceneUpdatedAt, "scenes.updated_at", qb.joinScenes}, &relatedFilterHandler{ relatedIDCol: "scenes.id", relatedRepo: sceneRepository.repository, relatedHandler: &sceneFilterHandler{sceneMarkerFilter.SceneFilter}, joinFn: func(f *filterBuilder) { qb.joinScenes(f) }, }, } } func (qb *sceneMarkerFilterHandler) tagIDCriterionHandler(tagID *string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if tagID != nil { f.addLeftJoin("scene_markers_tags", "", "scene_markers_tags.scene_marker_id = scene_markers.id") f.addWhere("(scene_markers.primary_tag_id = ? OR scene_markers_tags.tag_id = ?)", *tagID, *tagID) } } } func (qb *sceneMarkerFilterHandler) tagsCriterionHandler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if criterion != nil { tags := criterion.CombineExcludes() if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { var notClause string if tags.Modifier == models.CriterionModifierNotNull { notClause = "NOT" } f.addLeftJoin("scene_markers_tags", "", "scene_markers.id = scene_markers_tags.scene_marker_id") f.addWhere(fmt.Sprintf("%s scene_markers_tags.tag_id IS NULL", notClause)) return } if tags.Modifier == models.CriterionModifierEquals && tags.Depth != nil && *tags.Depth != 0 { f.setError(fmt.Errorf("depth is not supported for equals modifier for marker tag filtering")) return } if len(tags.Value) == 0 && len(tags.Excludes) == 0 { return } if len(tags.Value) > 0 { valuesClause, err := getHierarchicalValues(ctx, tags.Value, tagTable, "tags_relations", "parent_id", "child_id", tags.Depth) if err != nil { f.setError(err) return } f.addWith(`marker_tags AS ( SELECT mt.scene_marker_id, t.column1 AS root_tag_id FROM scene_markers_tags mt INNER JOIN (` + valuesClause + `) t ON t.column2 = mt.tag_id UNION SELECT m.id, t.column1 FROM scene_markers m INNER JOIN (` + valuesClause + `) t ON t.column2 = m.primary_tag_id )`) f.addLeftJoin("marker_tags", "", "marker_tags.scene_marker_id = scene_markers.id") switch tags.Modifier { case models.CriterionModifierEquals: // includes only the provided ids f.addWhere("marker_tags.root_tag_id IS NOT NULL") tagsLen := len(tags.Value) f.addHaving(fmt.Sprintf("count(distinct marker_tags.root_tag_id) IS %d", tagsLen)) // decrement by one to account for primary tag id f.addWhere("(SELECT COUNT(*) FROM scene_markers_tags s WHERE s.scene_marker_id = scene_markers.id) = ?", tagsLen-1) case models.CriterionModifierNotEquals: f.setError(fmt.Errorf("not equals modifier is not supported for scene marker tags")) default: addHierarchicalConditionClauses(f, tags, "marker_tags", "root_tag_id") } } if len(criterion.Excludes) > 0 { valuesClause, err := getHierarchicalValues(ctx, tags.Excludes, tagTable, "tags_relations", "parent_id", "child_id", tags.Depth) if err != nil { f.setError(err) return } clause := "scene_markers.id NOT IN (SELECT scene_markers_tags.scene_marker_id FROM scene_markers_tags WHERE scene_markers_tags.tag_id IN (SELECT column2 FROM (%s)))" f.addWhere(fmt.Sprintf(clause, valuesClause)) f.addWhere(fmt.Sprintf("scene_markers.primary_tag_id NOT IN (SELECT column2 FROM (%s))", valuesClause)) } } } } func (qb *sceneMarkerFilterHandler) sceneTagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if tags != nil { f.addLeftJoin("scenes_tags", "", "scene_markers.scene_id = scenes_tags.scene_id") h := joinedHierarchicalMultiCriterionHandlerBuilder{ primaryTable: "scene_markers", primaryKey: sceneIDColumn, foreignTable: tagTable, foreignFK: tagIDColumn, relationsTable: "tags_relations", joinTable: "scenes_tags", joinAs: "marker_scenes_tags", primaryFK: sceneIDColumn, } h.handler(tags).handle(ctx, f) } } } func (qb *sceneMarkerFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { h := joinedMultiCriterionHandlerBuilder{ primaryTable: sceneTable, joinTable: performersScenesTable, joinAs: "performers_join", primaryFK: sceneIDColumn, foreignFK: performerIDColumn, addJoinTable: func(f *filterBuilder) { f.addLeftJoin(performersScenesTable, "performers_join", "performers_join.scene_id = scene_markers.scene_id") }, } handler := h.handler(performers) return func(ctx context.Context, f *filterBuilder) { if performers == nil { return } // Make sure scenes is included, otherwise excludes filter fails qb.joinScenes(f) handler(ctx, f) } } func (qb *sceneMarkerFilterHandler) scenesCriterionHandler(scenes *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { f.addLeftJoin(sceneTable, "markers_scenes", "markers_scenes.id = scene_markers.scene_id") } h := multiCriterionHandlerBuilder{ primaryTable: sceneMarkerTable, foreignTable: "markers_scenes", joinTable: "", primaryFK: sceneIDColumn, foreignFK: sceneIDColumn, addJoinsFunc: addJoinsFunc, } return h.handler(scenes) } ================================================ FILE: pkg/sqlite/scene_marker_test.go ================================================ //go:build integration // +build integration package sqlite_test import ( "context" "slices" "strconv" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stretchr/testify/assert" ) func TestMarkerFindBySceneID(t *testing.T) { withTxn(func(ctx context.Context) error { mqb := db.SceneMarker sceneID := sceneIDs[sceneIdxWithMarkers] markers, err := mqb.FindBySceneID(ctx, sceneID) if err != nil { t.Errorf("Error finding markers: %s", err.Error()) } assert.Greater(t, len(markers), 0) for _, marker := range markers { assert.Equal(t, sceneIDs[sceneIdxWithMarkers], marker.SceneID) } markers, err = mqb.FindBySceneID(ctx, 0) if err != nil { t.Errorf("Error finding marker: %s", err.Error()) } assert.Len(t, markers, 0) return nil }) } func TestMarkerCountByTagID(t *testing.T) { withTxn(func(ctx context.Context) error { mqb := db.SceneMarker markerCount, err := mqb.CountByTagID(ctx, tagIDs[tagIdxWithPrimaryMarkers]) if err != nil { t.Errorf("error calling CountByTagID: %s", err.Error()) } assert.Equal(t, 6, markerCount) markerCount, err = mqb.CountByTagID(ctx, tagIDs[tagIdxWithMarkers]) if err != nil { t.Errorf("error calling CountByTagID: %s", err.Error()) } assert.Equal(t, 2, markerCount) markerCount, err = mqb.CountByTagID(ctx, 0) if err != nil { t.Errorf("error calling CountByTagID: %s", err.Error()) } assert.Equal(t, 0, markerCount) return nil }) } func TestMarkerQueryQ(t *testing.T) { withTxn(func(ctx context.Context) error { q := getSceneTitle(sceneIdxWithMarkers) m, _, err := db.SceneMarker.Query(ctx, nil, &models.FindFilterType{ Q: &q, }) if err != nil { t.Errorf("Error querying scene markers: %s", err.Error()) } if !assert.Greater(t, len(m), 0) { return nil } assert.Equal(t, sceneIDs[sceneIdxWithMarkers], m[0].SceneID) return nil }) } func TestMarkerQuerySortBySceneUpdated(t *testing.T) { withTxn(func(ctx context.Context) error { sort := "scenes_updated_at" _, _, err := db.SceneMarker.Query(ctx, nil, &models.FindFilterType{ Sort: &sort, }) if err != nil { t.Errorf("Error querying scene markers: %s", err.Error()) } return nil }) } func verifyIDs(t *testing.T, modifier models.CriterionModifier, values []int, results []int) { t.Helper() switch modifier { case models.CriterionModifierIsNull: assert.Len(t, results, 0) case models.CriterionModifierNotNull: assert.NotEqual(t, 0, len(results)) case models.CriterionModifierIncludes: for _, v := range values { assert.Contains(t, results, v) } case models.CriterionModifierExcludes: for _, v := range values { assert.NotContains(t, results, v) } case models.CriterionModifierEquals: for _, v := range values { assert.Contains(t, results, v) } assert.Len(t, results, len(values)) case models.CriterionModifierNotEquals: foundAll := true for _, v := range values { if !slices.Contains(results, v) { foundAll = false break } } if foundAll && len(results) == len(values) { t.Errorf("expected ids not equal to %v - found %v", values, results) } } } func TestMarkerQueryTags(t *testing.T) { type test struct { name string markerFilter *models.SceneMarkerFilterType findFilter *models.FindFilterType } withTxn(func(ctx context.Context) error { testTags := func(t *testing.T, m *models.SceneMarker, markerFilter *models.SceneMarkerFilterType) { tagIDs, err := db.SceneMarker.GetTagIDs(ctx, m.ID) if err != nil { t.Errorf("error getting marker tag ids: %v", err) } // HACK - if modifier isn't null/not null, then add the primary tag id if markerFilter.Tags.Modifier != models.CriterionModifierIsNull && markerFilter.Tags.Modifier != models.CriterionModifierNotNull { tagIDs = append(tagIDs, m.PrimaryTagID) } values, _ := stringslice.StringSliceToIntSlice(markerFilter.Tags.Value) verifyIDs(t, markerFilter.Tags.Modifier, values, tagIDs) } cases := []test{ { "is null", &models.SceneMarkerFilterType{ Tags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIsNull, }, }, nil, }, { "not null", &models.SceneMarkerFilterType{ Tags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierNotNull, }, }, nil, }, { "includes", &models.SceneMarkerFilterType{ Tags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIncludes, Value: []string{ strconv.Itoa(tagIDs[tagIdxWithMarkers]), }, }, }, nil, }, { "includes all", &models.SceneMarkerFilterType{ Tags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIncludesAll, Value: []string{ strconv.Itoa(tagIDs[tagIdxWithMarkers]), strconv.Itoa(tagIDs[tagIdx2WithMarkers]), }, }, }, nil, }, { "equals", &models.SceneMarkerFilterType{ Tags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierEquals, Value: []string{ strconv.Itoa(tagIDs[tagIdxWithPrimaryMarkers]), strconv.Itoa(tagIDs[tagIdxWithMarkers]), strconv.Itoa(tagIDs[tagIdx2WithMarkers]), }, }, }, nil, }, // not equals not supported // { // "not equals", // &models.SceneMarkerFilterType{ // Tags: &models.HierarchicalMultiCriterionInput{ // Modifier: models.CriterionModifierNotEquals, // Value: []string{ // strconv.Itoa(tagIDs[tagIdx2WithScene]), // strconv.Itoa(tagIDs[tagIdx3WithScene]), // }, // }, // }, // nil, // }, { "excludes", &models.SceneMarkerFilterType{ Tags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIncludes, Value: []string{ strconv.Itoa(tagIDs[tagIdx2WithMarkers]), }, }, }, nil, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { markers := queryMarkers(ctx, t, db.SceneMarker, tc.markerFilter, tc.findFilter) assert.Greater(t, len(markers), 0) for _, m := range markers { testTags(t, m, tc.markerFilter) } }) } return nil }) } func TestMarkerQuerySceneTags(t *testing.T) { type test struct { name string markerFilter *models.SceneMarkerFilterType findFilter *models.FindFilterType } withTxn(func(ctx context.Context) error { testTags := func(t *testing.T, m *models.SceneMarker, markerFilter *models.SceneMarkerFilterType) { s, err := db.Scene.Find(ctx, m.SceneID) if err != nil { t.Errorf("error getting marker tag ids: %v", err) return } if err := s.LoadTagIDs(ctx, db.Scene); err != nil { t.Errorf("error getting marker tag ids: %v", err) return } tagIDs := s.TagIDs.List() values, _ := stringslice.StringSliceToIntSlice(markerFilter.SceneTags.Value) verifyIDs(t, markerFilter.SceneTags.Modifier, values, tagIDs) } cases := []test{ { "is null", &models.SceneMarkerFilterType{ SceneTags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIsNull, }, }, nil, }, { "not null", &models.SceneMarkerFilterType{ SceneTags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierNotNull, }, }, nil, }, { "includes", &models.SceneMarkerFilterType{ SceneTags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIncludes, Value: []string{ strconv.Itoa(tagIDs[tagIdx3WithScene]), }, }, }, nil, }, { "includes all", &models.SceneMarkerFilterType{ SceneTags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIncludesAll, Value: []string{ strconv.Itoa(tagIDs[tagIdx2WithScene]), strconv.Itoa(tagIDs[tagIdx3WithScene]), }, }, }, nil, }, { "equals", &models.SceneMarkerFilterType{ SceneTags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierEquals, Value: []string{ strconv.Itoa(tagIDs[tagIdx2WithScene]), strconv.Itoa(tagIDs[tagIdx3WithScene]), }, }, }, nil, }, // not equals not supported // { // "not equals", // &models.SceneMarkerFilterType{ // SceneTags: &models.HierarchicalMultiCriterionInput{ // Modifier: models.CriterionModifierNotEquals, // Value: []string{ // strconv.Itoa(tagIDs[tagIdx2WithScene]), // strconv.Itoa(tagIDs[tagIdx3WithScene]), // }, // }, // }, // nil, // }, { "excludes", &models.SceneMarkerFilterType{ SceneTags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIncludes, Value: []string{ strconv.Itoa(tagIDs[tagIdx2WithScene]), }, }, }, nil, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { markers := queryMarkers(ctx, t, db.SceneMarker, tc.markerFilter, tc.findFilter) assert.Greater(t, len(markers), 0) for _, m := range markers { testTags(t, m, tc.markerFilter) } }) } return nil }) } func markersToIDs(i []*models.SceneMarker) []int { ret := make([]int, len(i)) for i, v := range i { ret[i] = v.ID } return ret } func TestMarkerQueryDuration(t *testing.T) { type test struct { name string markerFilter *models.SceneMarkerFilterType include []int exclude []int } cases := []test{ { "is null", &models.SceneMarkerFilterType{ Duration: &models.FloatCriterionInput{ Modifier: models.CriterionModifierIsNull, }, }, []int{markerIdxWithScene}, []int{markerIdxWithDuration}, }, { "not null", &models.SceneMarkerFilterType{ Duration: &models.FloatCriterionInput{ Modifier: models.CriterionModifierNotNull, }, }, []int{markerIdxWithDuration}, []int{markerIdxWithScene}, }, { "equals", &models.SceneMarkerFilterType{ Duration: &models.FloatCriterionInput{ Modifier: models.CriterionModifierEquals, Value: markerIdxWithDuration, }, }, []int{markerIdxWithDuration}, []int{markerIdx2WithDuration, markerIdxWithScene}, }, { "not equals", &models.SceneMarkerFilterType{ Duration: &models.FloatCriterionInput{ Modifier: models.CriterionModifierNotEquals, Value: markerIdx2WithDuration, }, }, []int{markerIdxWithDuration}, []int{markerIdx2WithDuration, markerIdxWithScene}, }, { "greater than", &models.SceneMarkerFilterType{ Duration: &models.FloatCriterionInput{ Modifier: models.CriterionModifierGreaterThan, Value: markerIdxWithDuration, }, }, []int{markerIdx2WithDuration}, []int{markerIdxWithDuration, markerIdxWithScene}, }, { "less than", &models.SceneMarkerFilterType{ Duration: &models.FloatCriterionInput{ Modifier: models.CriterionModifierLessThan, Value: markerIdx2WithDuration, }, }, []int{markerIdxWithDuration}, []int{markerIdx2WithDuration, markerIdxWithScene}, }, } qb := db.SceneMarker for _, tt := range cases { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, _, err := qb.Query(ctx, tt.markerFilter, nil) if err != nil { t.Errorf("SceneMarkerStore.Query() error = %v", err) return } ids := markersToIDs(got) include := indexesToIDs(markerIDs, tt.include) exclude := indexesToIDs(markerIDs, tt.exclude) for _, i := range include { assert.Contains(ids, i) } for _, e := range exclude { assert.NotContains(ids, e) } }) } } func queryMarkers(ctx context.Context, t *testing.T, sqb models.SceneMarkerReader, markerFilter *models.SceneMarkerFilterType, findFilter *models.FindFilterType) []*models.SceneMarker { t.Helper() result, _, err := sqb.Query(ctx, markerFilter, findFilter) if err != nil { t.Errorf("Error querying markers: %v", err) } return result } // TODO Update // TODO Destroy // TODO Find // TODO GetMarkerStrings // TODO Wall // TODO Count // TODO All // TODO Query ================================================ FILE: pkg/sqlite/scene_test.go ================================================ //go:build integration // +build integration package sqlite_test import ( "context" "fmt" "math" "path/filepath" "reflect" "regexp" "strconv" "testing" "time" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sliceutil/intslice" "github.com/stretchr/testify/assert" ) func loadSceneRelationships(ctx context.Context, expected models.Scene, actual *models.Scene) error { if expected.URLs.Loaded() { if err := actual.LoadURLs(ctx, db.Scene); err != nil { return err } } if expected.GalleryIDs.Loaded() { if err := actual.LoadGalleryIDs(ctx, db.Scene); err != nil { return err } } if expected.TagIDs.Loaded() { if err := actual.LoadTagIDs(ctx, db.Scene); err != nil { return err } } if expected.PerformerIDs.Loaded() { if err := actual.LoadPerformerIDs(ctx, db.Scene); err != nil { return err } } if expected.Groups.Loaded() { if err := actual.LoadGroups(ctx, db.Scene); err != nil { return err } } if expected.StashIDs.Loaded() { if err := actual.LoadStashIDs(ctx, db.Scene); err != nil { return err } } if expected.Files.Loaded() { if err := actual.LoadFiles(ctx, db.Scene); err != nil { return err } } // clear Path, Checksum, PrimaryFileID if expected.Path == "" { actual.Path = "" } if expected.Checksum == "" { actual.Checksum = "" } if expected.OSHash == "" { actual.OSHash = "" } if expected.PrimaryFileID == nil { actual.PrimaryFileID = nil } return nil } func Test_sceneQueryBuilder_Create(t *testing.T) { var ( title = "title" code = "1337" details = "details" director = "director" url = "url" rating = 60 resumeTime = 10.0 playDuration = 34.0 createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) sceneIndex = 123 sceneIndex2 = 234 endpoint1 = "endpoint1" endpoint2 = "endpoint2" stashID1 = "stashid1" stashID2 = "stashid2" date, _ = models.ParseDate("2003-02-01") videoFile = makeFileWithID(fileIdxStartVideoFiles) ) tests := []struct { name string newObject models.Scene wantErr bool }{ { "full", models.Scene{ Title: title, Code: code, Details: details, Director: director, URLs: models.NewRelatedStrings([]string{url}), Date: &date, Rating: &rating, Organized: true, StudioID: &studioIDs[studioIdxWithScene], CreatedAt: createdAt, UpdatedAt: updatedAt, GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithScene]}), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithScene]}), PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithDupName]}), Groups: models.NewRelatedGroups([]models.GroupsScenes{ { GroupID: groupIDs[groupIdxWithScene], SceneIndex: &sceneIndex, }, { GroupID: groupIDs[groupIdxWithStudio], SceneIndex: &sceneIndex2, }, }), StashIDs: models.NewRelatedStashIDs([]models.StashID{ { StashID: stashID1, Endpoint: endpoint1, UpdatedAt: epochTime, }, { StashID: stashID2, Endpoint: endpoint2, UpdatedAt: epochTime, }, }), ResumeTime: float64(resumeTime), PlayDuration: playDuration, }, false, }, { "with file", models.Scene{ Title: title, Code: code, Details: details, Director: director, URLs: models.NewRelatedStrings([]string{url}), Date: &date, Rating: &rating, Organized: true, StudioID: &studioIDs[studioIdxWithScene], Files: models.NewRelatedVideoFiles([]*models.VideoFile{ videoFile.(*models.VideoFile), }), CreatedAt: createdAt, UpdatedAt: updatedAt, GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithScene]}), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithScene]}), PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithDupName]}), Groups: models.NewRelatedGroups([]models.GroupsScenes{ { GroupID: groupIDs[groupIdxWithScene], SceneIndex: &sceneIndex, }, { GroupID: groupIDs[groupIdxWithStudio], SceneIndex: &sceneIndex2, }, }), StashIDs: models.NewRelatedStashIDs([]models.StashID{ { StashID: stashID1, Endpoint: endpoint1, UpdatedAt: epochTime, }, { StashID: stashID2, Endpoint: endpoint2, UpdatedAt: epochTime, }, }), ResumeTime: resumeTime, PlayDuration: playDuration, }, false, }, { "invalid studio id", models.Scene{ StudioID: &invalidID, }, true, }, { "invalid gallery id", models.Scene{ GalleryIDs: models.NewRelatedIDs([]int{invalidID}), }, true, }, { "invalid tag id", models.Scene{ TagIDs: models.NewRelatedIDs([]int{invalidID}), }, true, }, { "invalid performer id", models.Scene{ PerformerIDs: models.NewRelatedIDs([]int{invalidID}), }, true, }, { "invalid group id", models.Scene{ Groups: models.NewRelatedGroups([]models.GroupsScenes{ { GroupID: invalidID, SceneIndex: &sceneIndex, }, }), }, true, }, } qb := db.Scene for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) var fileIDs []models.FileID if tt.newObject.Files.Loaded() { for _, f := range tt.newObject.Files.List() { fileIDs = append(fileIDs, f.ID) } } s := tt.newObject if err := qb.Create(ctx, &s, fileIDs); (err != nil) != tt.wantErr { t.Errorf("sceneQueryBuilder.Create() error = %v, wantErr = %v", err, tt.wantErr) } if tt.wantErr { assert.Zero(s.ID) return } assert.NotZero(s.ID) copy := tt.newObject copy.ID = s.ID // load relationships if err := loadSceneRelationships(ctx, copy, &s); err != nil { t.Errorf("loadSceneRelationships() error = %v", err) return } assert.Equal(copy, s) // ensure can find the scene found, err := qb.Find(ctx, s.ID) if err != nil { t.Errorf("sceneQueryBuilder.Find() error = %v", err) } if !assert.NotNil(found) { return } // load relationships if err := loadSceneRelationships(ctx, copy, found); err != nil { t.Errorf("loadSceneRelationships() error = %v", err) return } assert.Equal(copy, *found) return }) } } func clearSceneFileIDs(scene *models.Scene) { if scene.Files.Loaded() { for _, f := range scene.Files.List() { f.Base().ID = 0 } } } func makeSceneFileWithID(i int) *models.VideoFile { ret := makeSceneFile(i) ret.ID = sceneFileIDs[i] return ret } func Test_sceneQueryBuilder_Update(t *testing.T) { var ( title = "title" code = "1337" details = "details" director = "director" url = "url" rating = 60 resumeTime = 10.0 playDuration = 34.0 createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) sceneIndex = 123 sceneIndex2 = 234 endpoint1 = "endpoint1" endpoint2 = "endpoint2" stashID1 = "stashid1" stashID2 = "stashid2" date, _ = models.ParseDate("2003-02-01") ) tests := []struct { name string updatedObject *models.Scene wantErr bool }{ { "full", &models.Scene{ ID: sceneIDs[sceneIdxWithGallery], Title: title, Code: code, Details: details, Director: director, URLs: models.NewRelatedStrings([]string{url}), Date: &date, Rating: &rating, Organized: true, StudioID: &studioIDs[studioIdxWithScene], CreatedAt: createdAt, UpdatedAt: updatedAt, GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithScene]}), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithScene]}), PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithDupName]}), Groups: models.NewRelatedGroups([]models.GroupsScenes{ { GroupID: groupIDs[groupIdxWithScene], SceneIndex: &sceneIndex, }, { GroupID: groupIDs[groupIdxWithStudio], SceneIndex: &sceneIndex2, }, }), StashIDs: models.NewRelatedStashIDs([]models.StashID{ { StashID: stashID1, Endpoint: endpoint1, UpdatedAt: epochTime, }, { StashID: stashID2, Endpoint: endpoint2, UpdatedAt: epochTime, }, }), ResumeTime: resumeTime, PlayDuration: playDuration, }, false, }, { "clear nullables", &models.Scene{ ID: sceneIDs[sceneIdxWithSpacedName], GalleryIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), PerformerIDs: models.NewRelatedIDs([]int{}), Groups: models.NewRelatedGroups([]models.GroupsScenes{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), }, false, }, { "clear gallery ids", &models.Scene{ ID: sceneIDs[sceneIdxWithGallery], GalleryIDs: models.NewRelatedIDs([]int{}), }, false, }, { "clear tag ids", &models.Scene{ ID: sceneIDs[sceneIdxWithTag], TagIDs: models.NewRelatedIDs([]int{}), }, false, }, { "clear performer ids", &models.Scene{ ID: sceneIDs[sceneIdxWithPerformer], PerformerIDs: models.NewRelatedIDs([]int{}), }, false, }, { "clear groups", &models.Scene{ ID: sceneIDs[sceneIdxWithGroup], Groups: models.NewRelatedGroups([]models.GroupsScenes{}), }, false, }, { "invalid studio id", &models.Scene{ ID: sceneIDs[sceneIdxWithGallery], StudioID: &invalidID, }, true, }, { "invalid gallery id", &models.Scene{ ID: sceneIDs[sceneIdxWithGallery], GalleryIDs: models.NewRelatedIDs([]int{invalidID}), }, true, }, { "invalid tag id", &models.Scene{ ID: sceneIDs[sceneIdxWithGallery], TagIDs: models.NewRelatedIDs([]int{invalidID}), }, true, }, { "invalid performer id", &models.Scene{ ID: sceneIDs[sceneIdxWithGallery], PerformerIDs: models.NewRelatedIDs([]int{invalidID}), }, true, }, { "invalid group id", &models.Scene{ ID: sceneIDs[sceneIdxWithSpacedName], Groups: models.NewRelatedGroups([]models.GroupsScenes{ { GroupID: invalidID, SceneIndex: &sceneIndex, }, }), }, true, }, } qb := db.Scene for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) copy := *tt.updatedObject if err := qb.Update(ctx, tt.updatedObject); (err != nil) != tt.wantErr { t.Errorf("sceneQueryBuilder.Update() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { return } s, err := qb.Find(ctx, tt.updatedObject.ID) if err != nil { t.Errorf("sceneQueryBuilder.Find() error = %v", err) } // load relationships if err := loadSceneRelationships(ctx, copy, s); err != nil { t.Errorf("loadSceneRelationships() error = %v", err) return } assert.Equal(copy, *s) }) } } func clearScenePartial() models.ScenePartial { // leave mandatory fields return models.ScenePartial{ Title: models.OptionalString{Set: true, Null: true}, Code: models.OptionalString{Set: true, Null: true}, Details: models.OptionalString{Set: true, Null: true}, Director: models.OptionalString{Set: true, Null: true}, URLs: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet}, Date: models.OptionalDate{Set: true, Null: true}, Rating: models.OptionalInt{Set: true, Null: true}, StudioID: models.OptionalInt{Set: true, Null: true}, GalleryIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet}, TagIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet}, PerformerIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet}, StashIDs: &models.UpdateStashIDs{Mode: models.RelationshipUpdateModeSet}, } } func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) { var ( title = "title" code = "1337" details = "details" director = "director" url = "url" rating = 60 resumeTime = 10.0 playDuration = 34.0 createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) sceneIndex = 123 sceneIndex2 = 234 endpoint1 = "endpoint1" endpoint2 = "endpoint2" stashID1 = "stashid1" stashID2 = "stashid2" date, _ = models.ParseDate("2003-02-01") ) tests := []struct { name string id int partial models.ScenePartial want models.Scene wantErr bool }{ { "full", sceneIDs[sceneIdxWithSpacedName], models.ScenePartial{ Title: models.NewOptionalString(title), Code: models.NewOptionalString(code), Details: models.NewOptionalString(details), Director: models.NewOptionalString(director), URLs: &models.UpdateStrings{ Values: []string{url}, Mode: models.RelationshipUpdateModeSet, }, Date: models.NewOptionalDate(date), Rating: models.NewOptionalInt(rating), Organized: models.NewOptionalBool(true), StudioID: models.NewOptionalInt(studioIDs[studioIdxWithScene]), CreatedAt: models.NewOptionalTime(createdAt), UpdatedAt: models.NewOptionalTime(updatedAt), GalleryIDs: &models.UpdateIDs{ IDs: []int{galleryIDs[galleryIdxWithScene]}, Mode: models.RelationshipUpdateModeSet, }, TagIDs: &models.UpdateIDs{ IDs: []int{tagIDs[tagIdx1WithScene], tagIDs[tagIdx1WithDupName]}, Mode: models.RelationshipUpdateModeSet, }, PerformerIDs: &models.UpdateIDs{ IDs: []int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithDupName]}, Mode: models.RelationshipUpdateModeSet, }, GroupIDs: &models.UpdateGroupIDs{ Groups: []models.GroupsScenes{ { GroupID: groupIDs[groupIdxWithScene], SceneIndex: &sceneIndex, }, { GroupID: groupIDs[groupIdxWithStudio], SceneIndex: &sceneIndex2, }, }, Mode: models.RelationshipUpdateModeSet, }, StashIDs: &models.UpdateStashIDs{ StashIDs: []models.StashID{ { StashID: stashID1, Endpoint: endpoint1, UpdatedAt: epochTime, }, { StashID: stashID2, Endpoint: endpoint2, UpdatedAt: epochTime, }, }, Mode: models.RelationshipUpdateModeSet, }, ResumeTime: models.NewOptionalFloat64(resumeTime), PlayDuration: models.NewOptionalFloat64(playDuration), }, models.Scene{ ID: sceneIDs[sceneIdxWithSpacedName], Files: models.NewRelatedVideoFiles([]*models.VideoFile{ makeSceneFile(sceneIdxWithSpacedName), }), Title: title, Code: code, Details: details, Director: director, URLs: models.NewRelatedStrings([]string{url}), Date: &date, Rating: &rating, Organized: true, StudioID: &studioIDs[studioIdxWithScene], CreatedAt: createdAt, UpdatedAt: updatedAt, GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithScene]}), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithScene]}), PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithDupName]}), Groups: models.NewRelatedGroups([]models.GroupsScenes{ { GroupID: groupIDs[groupIdxWithScene], SceneIndex: &sceneIndex, }, { GroupID: groupIDs[groupIdxWithStudio], SceneIndex: &sceneIndex2, }, }), StashIDs: models.NewRelatedStashIDs([]models.StashID{ { StashID: stashID1, Endpoint: endpoint1, UpdatedAt: epochTime, }, { StashID: stashID2, Endpoint: endpoint2, UpdatedAt: epochTime, }, }), ResumeTime: resumeTime, PlayDuration: playDuration, }, false, }, { "clear all", sceneIDs[sceneIdxWithSpacedName], clearScenePartial(), models.Scene{ ID: sceneIDs[sceneIdxWithSpacedName], Files: models.NewRelatedVideoFiles([]*models.VideoFile{ makeSceneFile(sceneIdxWithSpacedName), }), GalleryIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), PerformerIDs: models.NewRelatedIDs([]int{}), Groups: models.NewRelatedGroups([]models.GroupsScenes{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), PlayDuration: getScenePlayDuration(sceneIdxWithSpacedName), ResumeTime: getSceneResumeTime(sceneIdxWithSpacedName), }, false, }, { "invalid id", invalidID, models.ScenePartial{}, models.Scene{}, true, }, } for _, tt := range tests { qb := db.Scene runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.UpdatePartial(ctx, tt.id, tt.partial) if (err != nil) != tt.wantErr { t.Errorf("sceneQueryBuilder.UpdatePartial() error = %v, wantErr %v", err, tt.wantErr) return } if tt.wantErr { return } // load relationships if err := loadSceneRelationships(ctx, tt.want, got); err != nil { t.Errorf("loadSceneRelationships() error = %v", err) return } // ignore file ids clearSceneFileIDs(got) assert.Equal(tt.want, *got) s, err := qb.Find(ctx, tt.id) if err != nil { t.Errorf("sceneQueryBuilder.Find() error = %v", err) } // load relationships if err := loadSceneRelationships(ctx, tt.want, s); err != nil { t.Errorf("loadSceneRelationships() error = %v", err) return } // ignore file ids clearSceneFileIDs(s) assert.Equal(tt.want, *s) }) } } func Test_sceneQueryBuilder_UpdatePartialRelationships(t *testing.T) { var ( sceneIndex = 123 sceneIndex2 = 234 endpoint1 = "endpoint1" endpoint2 = "endpoint2" stashID1 = "stashid1" stashID2 = "stashid2" groupScenes = []models.GroupsScenes{ { GroupID: groupIDs[groupIdxWithDupName], SceneIndex: &sceneIndex, }, { GroupID: groupIDs[groupIdxWithStudio], SceneIndex: &sceneIndex2, }, } stashIDs = []models.StashID{ { StashID: stashID1, Endpoint: endpoint1, UpdatedAt: epochTime, }, { StashID: stashID2, Endpoint: endpoint2, UpdatedAt: epochTime, }, } ) tests := []struct { name string id int partial models.ScenePartial want models.Scene wantErr bool }{ { "add galleries", sceneIDs[sceneIdxWithGallery], models.ScenePartial{ GalleryIDs: &models.UpdateIDs{ IDs: []int{galleryIDs[galleryIdx1WithImage], galleryIDs[galleryIdx1WithPerformer]}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Scene{ GalleryIDs: models.NewRelatedIDs(append(indexesToIDs(galleryIDs, sceneGalleries[sceneIdxWithGallery]), galleryIDs[galleryIdx1WithImage], galleryIDs[galleryIdx1WithPerformer], )), }, false, }, { "add identical galleries", sceneIDs[sceneIdxWithGallery], models.ScenePartial{ GalleryIDs: &models.UpdateIDs{ IDs: []int{galleryIDs[galleryIdx1WithImage], galleryIDs[galleryIdx1WithImage]}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Scene{ GalleryIDs: models.NewRelatedIDs(append(indexesToIDs(galleryIDs, sceneGalleries[sceneIdxWithGallery]), galleryIDs[galleryIdx1WithImage], )), }, false, }, { "add tags", sceneIDs[sceneIdxWithTwoTags], models.ScenePartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGallery]}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Scene{ TagIDs: models.NewRelatedIDs(append( []int{ tagIDs[tagIdx1WithGallery], tagIDs[tagIdx1WithDupName], }, indexesToIDs(tagIDs, sceneTags[sceneIdxWithTwoTags])..., )), }, false, }, { "add identical tags", sceneIDs[sceneIdxWithTwoTags], models.ScenePartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithDupName]}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Scene{ TagIDs: models.NewRelatedIDs(append( []int{ tagIDs[tagIdx1WithDupName], }, indexesToIDs(tagIDs, sceneTags[sceneIdxWithTwoTags])..., )), }, false, }, { "add performers", sceneIDs[sceneIdxWithTwoPerformers], models.ScenePartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerIDs[performerIdx1WithDupName], performerIDs[performerIdx1WithGallery]}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Scene{ PerformerIDs: models.NewRelatedIDs(append(indexesToIDs(performerIDs, scenePerformers[sceneIdxWithTwoPerformers]), performerIDs[performerIdx1WithDupName], performerIDs[performerIdx1WithGallery], )), }, false, }, { "add identical performers", sceneIDs[sceneIdxWithTwoPerformers], models.ScenePartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerIDs[performerIdx1WithDupName], performerIDs[performerIdx1WithDupName]}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Scene{ PerformerIDs: models.NewRelatedIDs(append(indexesToIDs(performerIDs, scenePerformers[sceneIdxWithTwoPerformers]), performerIDs[performerIdx1WithDupName], )), }, false, }, { "add groups", sceneIDs[sceneIdxWithGroup], models.ScenePartial{ GroupIDs: &models.UpdateGroupIDs{ Groups: groupScenes, Mode: models.RelationshipUpdateModeAdd, }, }, models.Scene{ Groups: models.NewRelatedGroups(append([]models.GroupsScenes{ { GroupID: indexesToIDs(groupIDs, sceneGroups[sceneIdxWithGroup])[0], }, }, groupScenes...)), }, false, }, { "add groups to empty", sceneIDs[sceneIdx1WithPerformer], models.ScenePartial{ GroupIDs: &models.UpdateGroupIDs{ Groups: groupScenes, Mode: models.RelationshipUpdateModeAdd, }, }, models.Scene{ Groups: models.NewRelatedGroups([]models.GroupsScenes{ { GroupID: groupIDs[groupIdxWithDupName], SceneIndex: &sceneIndex, }, { GroupID: groupIDs[groupIdxWithStudio], SceneIndex: &sceneIndex2, }, }), }, false, }, { "add stash ids", sceneIDs[sceneIdxWithSpacedName], models.ScenePartial{ StashIDs: &models.UpdateStashIDs{ StashIDs: stashIDs, Mode: models.RelationshipUpdateModeAdd, }, }, models.Scene{ StashIDs: models.NewRelatedStashIDs(append([]models.StashID{sceneStashID(sceneIdxWithSpacedName)}, stashIDs...)), }, false, }, { "add duplicate galleries", sceneIDs[sceneIdxWithGallery], models.ScenePartial{ GalleryIDs: &models.UpdateIDs{ IDs: []int{galleryIDs[galleryIdxWithScene], galleryIDs[galleryIdx1WithPerformer]}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Scene{ GalleryIDs: models.NewRelatedIDs(append(indexesToIDs(galleryIDs, sceneGalleries[sceneIdxWithGallery]), galleryIDs[galleryIdx1WithPerformer], )), }, false, }, { "add duplicate tags", sceneIDs[sceneIdxWithTwoTags], models.ScenePartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagIDs[tagIdx1WithScene], tagIDs[tagIdx1WithGallery]}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Scene{ TagIDs: models.NewRelatedIDs(append( []int{tagIDs[tagIdx1WithGallery]}, indexesToIDs(tagIDs, sceneTags[sceneIdxWithTwoTags])..., )), }, false, }, { "add duplicate performers", sceneIDs[sceneIdxWithTwoPerformers], models.ScenePartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithGallery]}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Scene{ PerformerIDs: models.NewRelatedIDs(append(indexesToIDs(performerIDs, scenePerformers[sceneIdxWithTwoPerformers]), performerIDs[performerIdx1WithGallery], )), }, false, }, { "add duplicate groups", sceneIDs[sceneIdxWithGroup], models.ScenePartial{ GroupIDs: &models.UpdateGroupIDs{ Groups: append([]models.GroupsScenes{ { GroupID: groupIDs[groupIdxWithScene], SceneIndex: &sceneIndex, }, }, groupScenes..., ), Mode: models.RelationshipUpdateModeAdd, }, }, models.Scene{ Groups: models.NewRelatedGroups(append([]models.GroupsScenes{ { GroupID: indexesToIDs(groupIDs, sceneGroups[sceneIdxWithGroup])[0], }, }, groupScenes...)), }, false, }, { "add duplicate stash ids", sceneIDs[sceneIdxWithSpacedName], models.ScenePartial{ StashIDs: &models.UpdateStashIDs{ StashIDs: []models.StashID{ sceneStashID(sceneIdxWithSpacedName), }, Mode: models.RelationshipUpdateModeAdd, }, }, models.Scene{ StashIDs: models.NewRelatedStashIDs([]models.StashID{sceneStashID(sceneIdxWithSpacedName)}), }, false, }, { "add invalid galleries", sceneIDs[sceneIdxWithGallery], models.ScenePartial{ GalleryIDs: &models.UpdateIDs{ IDs: []int{invalidID}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Scene{}, true, }, { "add invalid tags", sceneIDs[sceneIdxWithTwoTags], models.ScenePartial{ TagIDs: &models.UpdateIDs{ IDs: []int{invalidID}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Scene{}, true, }, { "add invalid performers", sceneIDs[sceneIdxWithTwoPerformers], models.ScenePartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{invalidID}, Mode: models.RelationshipUpdateModeAdd, }, }, models.Scene{}, true, }, { "add invalid groups", sceneIDs[sceneIdxWithGroup], models.ScenePartial{ GroupIDs: &models.UpdateGroupIDs{ Groups: []models.GroupsScenes{ { GroupID: invalidID, }, }, Mode: models.RelationshipUpdateModeAdd, }, }, models.Scene{}, true, }, { "remove galleries", sceneIDs[sceneIdxWithGallery], models.ScenePartial{ GalleryIDs: &models.UpdateIDs{ IDs: []int{galleryIDs[galleryIdxWithScene]}, Mode: models.RelationshipUpdateModeRemove, }, }, models.Scene{ GalleryIDs: models.NewRelatedIDs([]int{}), }, false, }, { "remove tags", sceneIDs[sceneIdxWithTwoTags], models.ScenePartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagIDs[tagIdx1WithScene]}, Mode: models.RelationshipUpdateModeRemove, }, }, models.Scene{ TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx2WithScene]}), }, false, }, { "remove performers", sceneIDs[sceneIdxWithTwoPerformers], models.ScenePartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerIDs[performerIdx1WithScene]}, Mode: models.RelationshipUpdateModeRemove, }, }, models.Scene{ PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx2WithScene]}), }, false, }, { "remove groups", sceneIDs[sceneIdxWithGroup], models.ScenePartial{ GroupIDs: &models.UpdateGroupIDs{ Groups: []models.GroupsScenes{ { GroupID: groupIDs[groupIdxWithScene], }, }, Mode: models.RelationshipUpdateModeRemove, }, }, models.Scene{ Groups: models.NewRelatedGroups([]models.GroupsScenes{}), }, false, }, { "remove stash ids", sceneIDs[sceneIdxWithSpacedName], models.ScenePartial{ StashIDs: &models.UpdateStashIDs{ StashIDs: []models.StashID{sceneStashID(sceneIdxWithSpacedName)}, Mode: models.RelationshipUpdateModeRemove, }, }, models.Scene{ StashIDs: models.NewRelatedStashIDs([]models.StashID{}), }, false, }, { "remove unrelated galleries", sceneIDs[sceneIdxWithGallery], models.ScenePartial{ GalleryIDs: &models.UpdateIDs{ IDs: []int{galleryIDs[galleryIdx1WithImage]}, Mode: models.RelationshipUpdateModeRemove, }, }, models.Scene{ GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithScene]}), }, false, }, { "remove unrelated tags", sceneIDs[sceneIdxWithTwoTags], models.ScenePartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagIDs[tagIdx1WithPerformer]}, Mode: models.RelationshipUpdateModeRemove, }, }, models.Scene{ TagIDs: models.NewRelatedIDs(indexesToIDs(tagIDs, sceneTags[sceneIdxWithTwoTags])), }, false, }, { "remove unrelated performers", sceneIDs[sceneIdxWithTwoPerformers], models.ScenePartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerIDs[performerIdx1WithDupName]}, Mode: models.RelationshipUpdateModeRemove, }, }, models.Scene{ PerformerIDs: models.NewRelatedIDs(indexesToIDs(performerIDs, scenePerformers[sceneIdxWithTwoPerformers])), }, false, }, { "remove unrelated groups", sceneIDs[sceneIdxWithGroup], models.ScenePartial{ GroupIDs: &models.UpdateGroupIDs{ Groups: []models.GroupsScenes{ { GroupID: groupIDs[groupIdxWithDupName], }, }, Mode: models.RelationshipUpdateModeRemove, }, }, models.Scene{ Groups: models.NewRelatedGroups([]models.GroupsScenes{ { GroupID: indexesToIDs(groupIDs, sceneGroups[sceneIdxWithGroup])[0], }, }), }, false, }, { "remove unrelated stash ids", sceneIDs[sceneIdxWithGallery], models.ScenePartial{ StashIDs: &models.UpdateStashIDs{ StashIDs: stashIDs, Mode: models.RelationshipUpdateModeRemove, }, }, models.Scene{ StashIDs: models.NewRelatedStashIDs([]models.StashID{sceneStashID(sceneIdxWithGallery)}), }, false, }, } for _, tt := range tests { qb := db.Scene runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.UpdatePartial(ctx, tt.id, tt.partial) if (err != nil) != tt.wantErr { t.Errorf("sceneQueryBuilder.UpdatePartial() error = %v, wantErr %v", err, tt.wantErr) return } if tt.wantErr { return } s, err := qb.Find(ctx, tt.id) if err != nil { t.Errorf("sceneQueryBuilder.Find() error = %v", err) } // load relationships if err := loadSceneRelationships(ctx, tt.want, got); err != nil { t.Errorf("loadSceneRelationships() error = %v", err) return } if err := loadSceneRelationships(ctx, tt.want, s); err != nil { t.Errorf("loadSceneRelationships() error = %v", err) return } // only compare fields that were in the partial if tt.partial.PerformerIDs != nil { assert.ElementsMatch(tt.want.PerformerIDs.List(), got.PerformerIDs.List()) assert.ElementsMatch(tt.want.PerformerIDs.List(), s.PerformerIDs.List()) } if tt.partial.TagIDs != nil { assert.ElementsMatch(tt.want.TagIDs.List(), got.TagIDs.List()) assert.ElementsMatch(tt.want.TagIDs.List(), s.TagIDs.List()) } if tt.partial.GalleryIDs != nil { assert.ElementsMatch(tt.want.GalleryIDs.List(), got.GalleryIDs.List()) assert.ElementsMatch(tt.want.GalleryIDs.List(), s.GalleryIDs.List()) } if tt.partial.GroupIDs != nil { assert.ElementsMatch(tt.want.Groups.List(), got.Groups.List()) assert.ElementsMatch(tt.want.Groups.List(), s.Groups.List()) } if tt.partial.StashIDs != nil { assert.ElementsMatch(tt.want.StashIDs.List(), got.StashIDs.List()) assert.ElementsMatch(tt.want.StashIDs.List(), s.StashIDs.List()) } }) } } func Test_sceneQueryBuilder_AddO(t *testing.T) { tests := []struct { name string id int want int wantErr bool }{ { "increment", sceneIDs[1], 1, false, }, { "invalid", invalidID, 0, true, }, } qb := db.Scene for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { got, err := qb.AddO(ctx, tt.id, nil) if (err != nil) != tt.wantErr { t.Errorf("sceneQueryBuilder.AddO() error = %v, wantErr %v", err, tt.wantErr) return } if len(got) != tt.want { t.Errorf("sceneQueryBuilder.AddO() = %v, want %v", got, tt.want) } }) } } func Test_sceneQueryBuilder_DeleteO(t *testing.T) { tests := []struct { name string id int want int wantErr bool }{ { "decrement", sceneIDs[2], 0, false, }, { "zero", sceneIDs[0], 0, false, }, } qb := db.Scene for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { got, err := qb.DeleteO(ctx, tt.id, nil) if (err != nil) != tt.wantErr { t.Errorf("sceneQueryBuilder.DeleteO() error = %v, wantErr %v", err, tt.wantErr) return } if len(got) != tt.want { t.Errorf("sceneQueryBuilder.DeleteO() = %v, want %v", got, tt.want) } }) } } func Test_sceneQueryBuilder_ResetO(t *testing.T) { tests := []struct { name string id int want int wantErr bool }{ { "decrement", sceneIDs[2], 0, false, }, { "zero", sceneIDs[0], 0, false, }, } qb := db.Scene for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { got, err := qb.ResetO(ctx, tt.id) if (err != nil) != tt.wantErr { t.Errorf("sceneQueryBuilder.ResetO() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { t.Errorf("sceneQueryBuilder.ResetOCounter() = %v, want %v", got, tt.want) } }) } } func Test_sceneQueryBuilder_ResetWatchCount(t *testing.T) { return } func Test_sceneQueryBuilder_Destroy(t *testing.T) { tests := []struct { name string id int wantErr bool }{ { "valid", sceneIDs[sceneIdxWithGallery], false, }, { "invalid", invalidID, true, }, } qb := db.Scene for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) if err := qb.Destroy(ctx, tt.id); (err != nil) != tt.wantErr { t.Errorf("sceneQueryBuilder.Destroy() error = %v, wantErr %v", err, tt.wantErr) } // ensure cannot be found i, err := qb.Find(ctx, tt.id) assert.Nil(err) assert.Nil(i) }) } } func makeSceneWithID(index int) *models.Scene { ret := makeScene(index) ret.ID = sceneIDs[index] ret.Files = models.NewRelatedVideoFiles([]*models.VideoFile{makeSceneFile(index)}) return ret } func Test_sceneQueryBuilder_Find(t *testing.T) { tests := []struct { name string id int want *models.Scene wantErr bool }{ { "valid", sceneIDs[sceneIdxWithSpacedName], makeSceneWithID(sceneIdxWithSpacedName), false, }, { "invalid", invalidID, nil, false, }, { "with galleries", sceneIDs[sceneIdxWithGallery], makeSceneWithID(sceneIdxWithGallery), false, }, { "with performers", sceneIDs[sceneIdxWithTwoPerformers], makeSceneWithID(sceneIdxWithTwoPerformers), false, }, { "with tags", sceneIDs[sceneIdxWithTwoTags], makeSceneWithID(sceneIdxWithTwoTags), false, }, { "with groups", sceneIDs[sceneIdxWithGroup], makeSceneWithID(sceneIdxWithGroup), false, }, } qb := db.Scene for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.Find(ctx, tt.id) if (err != nil) != tt.wantErr { t.Errorf("sceneQueryBuilder.Find() error = %v, wantErr %v", err, tt.wantErr) return } if got != nil { // load relationships if err := loadSceneRelationships(ctx, *tt.want, got); err != nil { t.Errorf("loadSceneRelationships() error = %v", err) return } clearSceneFileIDs(got) } assert.Equal(tt.want, got) }) } } func postFindScenes(ctx context.Context, want []*models.Scene, got []*models.Scene) error { for i, s := range got { // load relationships if i < len(want) { if err := loadSceneRelationships(ctx, *want[i], s); err != nil { return err } } clearSceneFileIDs(s) } return nil } func Test_sceneQueryBuilder_FindMany(t *testing.T) { tests := []struct { name string ids []int want []*models.Scene wantErr bool }{ { "valid with relationships", []int{ sceneIDs[sceneIdxWithGallery], sceneIDs[sceneIdxWithTwoPerformers], sceneIDs[sceneIdxWithTwoTags], sceneIDs[sceneIdxWithGroup], }, []*models.Scene{ makeSceneWithID(sceneIdxWithGallery), makeSceneWithID(sceneIdxWithTwoPerformers), makeSceneWithID(sceneIdxWithTwoTags), makeSceneWithID(sceneIdxWithGroup), }, false, }, { "invalid", []int{sceneIDs[sceneIdxWithGallery], sceneIDs[sceneIdxWithTwoPerformers], invalidID}, nil, true, }, } qb := db.Scene for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.FindMany(ctx, tt.ids) if (err != nil) != tt.wantErr { t.Errorf("sceneQueryBuilder.FindMany() error = %v, wantErr %v", err, tt.wantErr) return } if err := postFindScenes(ctx, tt.want, got); err != nil { t.Errorf("loadSceneRelationships() error = %v", err) return } assert.Equal(tt.want, got) }) } } func Test_sceneQueryBuilder_FindByChecksum(t *testing.T) { getChecksum := func(index int) string { return getSceneStringValue(index, checksumField) } tests := []struct { name string checksum string want []*models.Scene wantErr bool }{ { "valid", getChecksum(sceneIdxWithSpacedName), []*models.Scene{makeSceneWithID(sceneIdxWithSpacedName)}, false, }, { "invalid", "invalid checksum", nil, false, }, { "with galleries", getChecksum(sceneIdxWithGallery), []*models.Scene{makeSceneWithID(sceneIdxWithGallery)}, false, }, { "with performers", getChecksum(sceneIdxWithTwoPerformers), []*models.Scene{makeSceneWithID(sceneIdxWithTwoPerformers)}, false, }, { "with tags", getChecksum(sceneIdxWithTwoTags), []*models.Scene{makeSceneWithID(sceneIdxWithTwoTags)}, false, }, { "with groups", getChecksum(sceneIdxWithGroup), []*models.Scene{makeSceneWithID(sceneIdxWithGroup)}, false, }, } qb := db.Scene for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.FindByChecksum(ctx, tt.checksum) if (err != nil) != tt.wantErr { t.Errorf("sceneQueryBuilder.FindByChecksum() error = %v, wantErr %v", err, tt.wantErr) return } if err := postFindScenes(ctx, tt.want, got); err != nil { t.Errorf("loadSceneRelationships() error = %v", err) return } assert.Equal(tt.want, got) }) } } func Test_sceneQueryBuilder_FindByOSHash(t *testing.T) { getOSHash := func(index int) string { return getSceneStringValue(index, "oshash") } tests := []struct { name string oshash string want []*models.Scene wantErr bool }{ { "valid", getOSHash(sceneIdxWithSpacedName), []*models.Scene{makeSceneWithID(sceneIdxWithSpacedName)}, false, }, { "invalid", "invalid oshash", nil, false, }, { "with galleries", getOSHash(sceneIdxWithGallery), []*models.Scene{makeSceneWithID(sceneIdxWithGallery)}, false, }, { "with performers", getOSHash(sceneIdxWithTwoPerformers), []*models.Scene{makeSceneWithID(sceneIdxWithTwoPerformers)}, false, }, { "with tags", getOSHash(sceneIdxWithTwoTags), []*models.Scene{makeSceneWithID(sceneIdxWithTwoTags)}, false, }, { "with groups", getOSHash(sceneIdxWithGroup), []*models.Scene{makeSceneWithID(sceneIdxWithGroup)}, false, }, } qb := db.Scene for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { got, err := qb.FindByOSHash(ctx, tt.oshash) if (err != nil) != tt.wantErr { t.Errorf("sceneQueryBuilder.FindByOSHash() error = %v, wantErr %v", err, tt.wantErr) return } if err := postFindScenes(ctx, tt.want, got); err != nil { t.Errorf("loadSceneRelationships() error = %v", err) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("sceneQueryBuilder.FindByOSHash() = %v, want %v", got, tt.want) } }) } } func Test_sceneQueryBuilder_FindByPath(t *testing.T) { getPath := func(index int) string { return getFilePath(folderIdxWithSceneFiles, getSceneBasename(index)) } tests := []struct { name string path string want []*models.Scene wantErr bool }{ { "valid", getPath(sceneIdxWithSpacedName), []*models.Scene{makeSceneWithID(sceneIdxWithSpacedName)}, false, }, { "invalid", "invalid path", nil, false, }, { "with galleries", getPath(sceneIdxWithGallery), []*models.Scene{makeSceneWithID(sceneIdxWithGallery)}, false, }, { "with performers", getPath(sceneIdxWithTwoPerformers), []*models.Scene{makeSceneWithID(sceneIdxWithTwoPerformers)}, false, }, { "with tags", getPath(sceneIdxWithTwoTags), []*models.Scene{makeSceneWithID(sceneIdxWithTwoTags)}, false, }, { "with groups", getPath(sceneIdxWithGroup), []*models.Scene{makeSceneWithID(sceneIdxWithGroup)}, false, }, } qb := db.Scene for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.FindByPath(ctx, tt.path) if (err != nil) != tt.wantErr { t.Errorf("sceneQueryBuilder.FindByPath() error = %v, wantErr %v", err, tt.wantErr) return } if err := postFindScenes(ctx, tt.want, got); err != nil { t.Errorf("loadSceneRelationships() error = %v", err) return } assert.Equal(tt.want, got) }) } } func Test_sceneQueryBuilder_FindByGalleryID(t *testing.T) { tests := []struct { name string galleryID int want []*models.Scene wantErr bool }{ { "valid", galleryIDs[galleryIdxWithScene], []*models.Scene{makeSceneWithID(sceneIdxWithGallery)}, false, }, { "none", galleryIDs[galleryIdx1WithPerformer], nil, false, }, } qb := db.Scene for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.FindByGalleryID(ctx, tt.galleryID) if (err != nil) != tt.wantErr { t.Errorf("sceneQueryBuilder.FindByGalleryID() error = %v, wantErr %v", err, tt.wantErr) return } if err := postFindScenes(ctx, tt.want, got); err != nil { t.Errorf("loadSceneRelationships() error = %v", err) return } assert.Equal(tt.want, got) return }) } } func TestSceneCountByPerformerID(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene count, err := sqb.CountByPerformerID(ctx, performerIDs[performerIdxWithScene]) if err != nil { t.Errorf("Error counting scenes: %s", err.Error()) } assert.Equal(t, 1, count) count, err = sqb.CountByPerformerID(ctx, 0) if err != nil { t.Errorf("Error counting scenes: %s", err.Error()) } assert.Equal(t, 0, count) return nil }) } func scenesToIDs(i []*models.Scene) []int { ret := make([]int, len(i)) for i, v := range i { ret[i] = v.ID } return ret } func Test_sceneStore_FindByFileID(t *testing.T) { tests := []struct { name string fileID models.FileID include []int exclude []int }{ { "valid", sceneFileIDs[sceneIdx1WithPerformer], []int{sceneIdx1WithPerformer}, nil, }, { "invalid", invalidFileID, nil, []int{sceneIdx1WithPerformer}, }, } qb := db.Scene for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.FindByFileID(ctx, tt.fileID) if err != nil { t.Errorf("SceneStore.FindByFileID() error = %v", err) return } for _, f := range got { clearSceneFileIDs(f) } ids := scenesToIDs(got) include := indexesToIDs(galleryIDs, tt.include) exclude := indexesToIDs(galleryIDs, tt.exclude) for _, i := range include { assert.Contains(ids, i) } for _, e := range exclude { assert.NotContains(ids, e) } }) } } func Test_sceneStore_CountByFileID(t *testing.T) { tests := []struct { name string fileID models.FileID want int }{ { "valid", sceneFileIDs[sceneIdxWithTwoPerformers], 1, }, { "invalid", invalidFileID, 0, }, } qb := db.Scene for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.CountByFileID(ctx, tt.fileID) if err != nil { t.Errorf("SceneStore.CountByFileID() error = %v", err) return } assert.Equal(tt.want, got) }) } } func Test_sceneStore_CountMissingChecksum(t *testing.T) { tests := []struct { name string want int }{ { "valid", 0, }, } qb := db.Scene for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.CountMissingChecksum(ctx) if err != nil { t.Errorf("SceneStore.CountMissingChecksum() error = %v", err) return } assert.Equal(tt.want, got) }) } } func Test_sceneStore_CountMissingOshash(t *testing.T) { tests := []struct { name string want int }{ { "valid", 0, }, } qb := db.Scene for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.CountMissingOSHash(ctx) if err != nil { t.Errorf("SceneStore.CountMissingOSHash() error = %v", err) return } assert.Equal(tt.want, got) }) } } func TestSceneWall(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene const sceneIdx = 2 wallQuery := getSceneStringValue(sceneIdx, "Details") scenes, err := sqb.Wall(ctx, &wallQuery) if err != nil { t.Errorf("Error finding scenes: %s", err.Error()) return nil } assert.Len(t, scenes, 1) scene := scenes[0] assert.Equal(t, sceneIDs[sceneIdx], scene.ID) scenePath := getFilePath(folderIdxWithSceneFiles, getSceneBasename(sceneIdx)) assert.Equal(t, scenePath, scene.Path) wallQuery = "not exist" scenes, err = sqb.Wall(ctx, &wallQuery) if err != nil { t.Errorf("Error finding scene: %s", err.Error()) return nil } assert.Len(t, scenes, 0) return nil }) } func TestSceneQueryQ(t *testing.T) { const sceneIdx = 2 q := getSceneStringValue(sceneIdx, titleField) withTxn(func(ctx context.Context) error { sqb := db.Scene sceneQueryQ(ctx, t, sqb, q, sceneIdx) return nil }) } func queryScene(ctx context.Context, t *testing.T, sqb models.SceneReader, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) []*models.Scene { t.Helper() result, err := sqb.Query(ctx, models.SceneQueryOptions{ QueryOptions: models.QueryOptions{ FindFilter: findFilter, Count: true, }, SceneFilter: sceneFilter, TotalDuration: true, TotalSize: true, }) if err != nil { t.Errorf("Error querying scene: %v", err) return nil } scenes, err := result.Resolve(ctx) if err != nil { t.Errorf("Error resolving scenes: %v", err) } return scenes } func sceneQueryQ(ctx context.Context, t *testing.T, sqb models.SceneReader, q string, expectedSceneIdx int) { filter := models.FindFilterType{ Q: &q, } scenes := queryScene(ctx, t, sqb, nil, &filter) if !assert.Len(t, scenes, 1) { return } scene := scenes[0] assert.Equal(t, sceneIDs[expectedSceneIdx], scene.ID) // no Q should return all results filter.Q = nil pp := totalScenes filter.PerPage = &pp scenes = queryScene(ctx, t, sqb, nil, &filter) assert.Len(t, scenes, totalScenes) } func TestSceneQuery(t *testing.T) { var ( endpoint = sceneStashID(sceneIdxWithGallery).Endpoint stashID = sceneStashID(sceneIdxWithGallery).StashID stashID2 = sceneStashID(sceneIdxWithPerformer).StashID stashIDs = []*string{&stashID, &stashID2} depth = -1 ) tests := []struct { name string findFilter *models.FindFilterType filter *models.SceneFilterType includeIdxs []int excludeIdxs []int wantErr bool }{ { "specific resume time", nil, &models.SceneFilterType{ ResumeTime: &models.IntCriterionInput{ Modifier: models.CriterionModifierEquals, Value: int(getSceneResumeTime(sceneIdxWithGallery)), }, }, []int{sceneIdxWithGallery}, []int{sceneIdxWithGroup}, false, }, { "specific play duration", nil, &models.SceneFilterType{ PlayDuration: &models.IntCriterionInput{ Modifier: models.CriterionModifierEquals, Value: int(getScenePlayDuration(sceneIdxWithGallery)), }, }, []int{sceneIdxWithGallery}, []int{sceneIdxWithGroup}, false, }, // { // "specific play count", // nil, // &models.SceneFilterType{ // PlayCount: &models.IntCriterionInput{ // Modifier: models.CriterionModifierEquals, // Value: getScenePlayCount(sceneIdxWithGallery), // }, // }, // []int{sceneIdxWithGallery}, // []int{sceneIdxWithGroup}, // false, // }, { "stash id with endpoint", nil, &models.SceneFilterType{ StashIDEndpoint: &models.StashIDCriterionInput{ Endpoint: &endpoint, StashID: &stashID, Modifier: models.CriterionModifierEquals, }, }, []int{sceneIdxWithGallery}, nil, false, }, { "exclude stash id with endpoint", nil, &models.SceneFilterType{ StashIDEndpoint: &models.StashIDCriterionInput{ Endpoint: &endpoint, StashID: &stashID, Modifier: models.CriterionModifierNotEquals, }, }, nil, []int{sceneIdxWithGallery}, false, }, { "null stash id with endpoint", nil, &models.SceneFilterType{ StashIDEndpoint: &models.StashIDCriterionInput{ Endpoint: &endpoint, Modifier: models.CriterionModifierIsNull, }, }, nil, []int{sceneIdxWithGallery}, false, }, { "not null stash id with endpoint", nil, &models.SceneFilterType{ StashIDEndpoint: &models.StashIDCriterionInput{ Endpoint: &endpoint, Modifier: models.CriterionModifierNotNull, }, }, []int{sceneIdxWithGallery}, nil, false, }, { "stash ids with endpoint", nil, &models.SceneFilterType{ StashIDsEndpoint: &models.StashIDsCriterionInput{ Endpoint: &endpoint, StashIDs: stashIDs, Modifier: models.CriterionModifierEquals, }, }, []int{sceneIdxWithGallery, sceneIdxWithPerformer}, nil, false, }, { "exclude stash ids with endpoint", nil, &models.SceneFilterType{ StashIDsEndpoint: &models.StashIDsCriterionInput{ Endpoint: &endpoint, StashIDs: stashIDs, Modifier: models.CriterionModifierNotEquals, }, }, nil, []int{sceneIdxWithGallery, sceneIdxWithPerformer}, false, }, { "null stash ids with endpoint", nil, &models.SceneFilterType{ StashIDsEndpoint: &models.StashIDsCriterionInput{ Endpoint: &endpoint, Modifier: models.CriterionModifierIsNull, }, }, nil, []int{sceneIdxWithGallery, sceneIdxWithPerformer}, false, }, { "not null stash ids with endpoint", nil, &models.SceneFilterType{ StashIDsEndpoint: &models.StashIDsCriterionInput{ Endpoint: &endpoint, Modifier: models.CriterionModifierNotNull, }, }, []int{sceneIdxWithGallery, sceneIdxWithPerformer}, nil, false, }, { "with studio id 0 including child studios", nil, &models.SceneFilterType{ Studios: &models.HierarchicalMultiCriterionInput{ Value: []string{"0"}, Modifier: models.CriterionModifierIncludes, Depth: &depth, }, }, nil, nil, false, }, { "single stash id", nil, &models.SceneFilterType{ StashIDCount: &models.IntCriterionInput{ Modifier: models.CriterionModifierEquals, Value: 1, }, }, []int{sceneIdxWithGallery, sceneIdxWithPerformer}, []int{sceneIdxWithGroup}, false, }, { "less than one stash id", nil, &models.SceneFilterType{ StashIDCount: &models.IntCriterionInput{ Modifier: models.CriterionModifierLessThan, Value: 1, }, }, []int{sceneIdxWithGroup}, []int{sceneIdxWithGallery, sceneIdxWithPerformer}, false, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) results, err := db.Scene.Query(ctx, models.SceneQueryOptions{ SceneFilter: tt.filter, QueryOptions: models.QueryOptions{ FindFilter: tt.findFilter, }, }) if (err != nil) != tt.wantErr { t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr) return } include := indexesToIDs(sceneIDs, tt.includeIdxs) exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(results.IDs, i) } for _, e := range exclude { assert.NotContains(results.IDs, e) } }) } } func TestSceneQueryPath(t *testing.T) { const ( sceneIdx = 1 otherSceneIdx = 2 ) folder := folderPaths[folderIdxWithSceneFiles] basename := getSceneBasename(sceneIdx) scenePath := getFilePath(folderIdxWithSceneFiles, getSceneBasename(sceneIdx)) tests := []struct { name string input models.StringCriterionInput mustInclude []int mustExclude []int }{ { "equals full path", models.StringCriterionInput{ Value: scenePath, Modifier: models.CriterionModifierEquals, }, []int{sceneIdx}, []int{otherSceneIdx}, }, { "equals full path wildcard", models.StringCriterionInput{ Value: filepath.Join(folder, "scene_0001_%"), Modifier: models.CriterionModifierEquals, }, []int{sceneIdx}, []int{otherSceneIdx}, }, { "not equals full path", models.StringCriterionInput{ Value: scenePath, Modifier: models.CriterionModifierNotEquals, }, []int{otherSceneIdx}, []int{sceneIdx}, }, { "includes folder name", models.StringCriterionInput{ Value: folder, Modifier: models.CriterionModifierIncludes, }, []int{sceneIdx}, nil, }, { "includes base name", models.StringCriterionInput{ Value: basename, Modifier: models.CriterionModifierIncludes, }, []int{sceneIdx}, nil, }, { "includes full path", models.StringCriterionInput{ Value: scenePath, Modifier: models.CriterionModifierIncludes, }, []int{sceneIdx}, []int{otherSceneIdx}, }, { "matches regex", models.StringCriterionInput{ Value: "scene_.*1_Path", Modifier: models.CriterionModifierMatchesRegex, }, []int{sceneIdx}, nil, }, { "not matches regex", models.StringCriterionInput{ Value: "scene_.*1_Path", Modifier: models.CriterionModifierNotMatchesRegex, }, nil, []int{sceneIdx}, }, } qb := db.Scene for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { got, err := qb.Query(ctx, models.SceneQueryOptions{ SceneFilter: &models.SceneFilterType{ Path: &tt.input, }, }) if err != nil { t.Errorf("sceneQueryBuilder.TestSceneQueryPath() error = %v", err) return } mustInclude := indexesToIDs(sceneIDs, tt.mustInclude) mustExclude := indexesToIDs(sceneIDs, tt.mustExclude) missing := sliceutil.Exclude(mustInclude, got.IDs) if len(missing) > 0 { t.Errorf("SceneStore.TestSceneQueryPath() missing expected IDs: %v", missing) } notExcluded := sliceutil.Intersect(mustExclude, got.IDs) if len(notExcluded) > 0 { t.Errorf("SceneStore.TestSceneQueryPath() expected IDs to be excluded: %v", notExcluded) } }) } } func TestSceneQueryURL(t *testing.T) { const sceneIdx = 1 sceneURL := getSceneStringValue(sceneIdx, urlField) urlCriterion := models.StringCriterionInput{ Value: sceneURL, Modifier: models.CriterionModifierEquals, } filter := models.SceneFilterType{ URL: &urlCriterion, } verifyFn := func(s *models.Scene) { t.Helper() urls := s.URLs.List() var url string if len(urls) > 0 { url = urls[0] } verifyString(t, url, urlCriterion) } verifySceneQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierNotEquals verifySceneQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierMatchesRegex urlCriterion.Value = "scene_.*1_URL" verifySceneQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierNotMatchesRegex verifySceneQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierIsNull urlCriterion.Value = "" verifySceneQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierNotNull verifySceneQuery(t, filter, verifyFn) } func TestSceneQueryPathOr(t *testing.T) { const scene1Idx = 1 const scene2Idx = 2 scene1Path := getFilePath(folderIdxWithSceneFiles, getSceneBasename(scene1Idx)) scene2Path := getFilePath(folderIdxWithSceneFiles, getSceneBasename(scene2Idx)) sceneFilter := models.SceneFilterType{ Path: &models.StringCriterionInput{ Value: scene1Path, Modifier: models.CriterionModifierEquals, }, OperatorFilter: models.OperatorFilter[models.SceneFilterType]{ Or: &models.SceneFilterType{ Path: &models.StringCriterionInput{ Value: scene2Path, Modifier: models.CriterionModifierEquals, }, }, }, } withTxn(func(ctx context.Context) error { sqb := db.Scene scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) if !assert.Len(t, scenes, 2) { return nil } assert.Equal(t, scene1Path, scenes[0].Path) assert.Equal(t, scene2Path, scenes[1].Path) return nil }) } func TestSceneQueryPathAndRating(t *testing.T) { const sceneIdx = 1 scenePath := getFilePath(folderIdxWithSceneFiles, getSceneBasename(sceneIdx)) sceneRating := int(getRating(sceneIdx).Int64) sceneFilter := models.SceneFilterType{ Path: &models.StringCriterionInput{ Value: scenePath, Modifier: models.CriterionModifierEquals, }, OperatorFilter: models.OperatorFilter[models.SceneFilterType]{ And: &models.SceneFilterType{ Rating100: &models.IntCriterionInput{ Value: sceneRating, Modifier: models.CriterionModifierEquals, }, }, }, } withTxn(func(ctx context.Context) error { sqb := db.Scene scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) if !assert.Len(t, scenes, 1) { return nil } assert.Equal(t, scenePath, scenes[0].Path) assert.Equal(t, sceneRating, *scenes[0].Rating) return nil }) } func TestSceneQueryPathNotRating(t *testing.T) { const sceneIdx = 1 sceneRating := getRating(sceneIdx) pathCriterion := models.StringCriterionInput{ Value: "scene_.*1_Path", Modifier: models.CriterionModifierMatchesRegex, } ratingCriterion := models.IntCriterionInput{ Value: int(sceneRating.Int64), Modifier: models.CriterionModifierEquals, } sceneFilter := models.SceneFilterType{ Path: &pathCriterion, OperatorFilter: models.OperatorFilter[models.SceneFilterType]{ Not: &models.SceneFilterType{ Rating100: &ratingCriterion, }, }, } withTxn(func(ctx context.Context) error { sqb := db.Scene scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) for _, scene := range scenes { verifyString(t, scene.Path, pathCriterion) ratingCriterion.Modifier = models.CriterionModifierNotEquals verifyIntPtr(t, scene.Rating, ratingCriterion) } return nil }) } func TestSceneIllegalQuery(t *testing.T) { assert := assert.New(t) const sceneIdx = 1 subFilter := models.SceneFilterType{ Path: &models.StringCriterionInput{ Value: getSceneStringValue(sceneIdx, "Path"), Modifier: models.CriterionModifierEquals, }, } sceneFilter := &models.SceneFilterType{ OperatorFilter: models.OperatorFilter[models.SceneFilterType]{ And: &subFilter, Or: &subFilter, }, } withTxn(func(ctx context.Context) error { sqb := db.Scene queryOptions := models.SceneQueryOptions{ SceneFilter: sceneFilter, } _, err := sqb.Query(ctx, queryOptions) assert.NotNil(err) sceneFilter.Or = nil sceneFilter.Not = &subFilter _, err = sqb.Query(ctx, queryOptions) assert.NotNil(err) sceneFilter.And = nil sceneFilter.Or = &subFilter _, err = sqb.Query(ctx, queryOptions) assert.NotNil(err) return nil }) } func verifySceneQuery(t *testing.T, filter models.SceneFilterType, verifyFn func(s *models.Scene)) { t.Helper() withTxn(func(ctx context.Context) error { t.Helper() sqb := db.Scene scenes := queryScene(ctx, t, sqb, &filter, nil) for _, scene := range scenes { if err := scene.LoadRelationships(ctx, sqb); err != nil { t.Errorf("Error loading scene relationships: %v", err) } } // assume it should find at least one assert.Greater(t, len(scenes), 0) for _, scene := range scenes { verifyFn(scene) } return nil }) } func verifyScenesPath(t *testing.T, pathCriterion models.StringCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Scene sceneFilter := models.SceneFilterType{ Path: &pathCriterion, } scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) for _, scene := range scenes { verifyString(t, scene.Path, pathCriterion) } return nil }) } func verifyStringPtr(t *testing.T, value *string, criterion models.StringCriterionInput) { t.Helper() assert := assert.New(t) if criterion.Modifier == models.CriterionModifierIsNull { if value != nil && *value == "" { // correct return } assert.Nil(value, "expect is null values to be null") } if criterion.Modifier == models.CriterionModifierNotNull { assert.NotNil(value, "expect is null values to be null") assert.Greater(len(*value), 0) } if criterion.Modifier == models.CriterionModifierEquals { assert.Equal(criterion.Value, *value) } if criterion.Modifier == models.CriterionModifierNotEquals { assert.NotEqual(criterion.Value, *value) } if criterion.Modifier == models.CriterionModifierMatchesRegex { assert.NotNil(value) assert.Regexp(regexp.MustCompile(criterion.Value), *value) } if criterion.Modifier == models.CriterionModifierNotMatchesRegex { if value == nil { // correct return } assert.NotRegexp(regexp.MustCompile(criterion.Value), value) } } func verifyString(t *testing.T, value string, criterion models.StringCriterionInput) { t.Helper() assert := assert.New(t) switch criterion.Modifier { case models.CriterionModifierEquals: assert.Equal(criterion.Value, value) case models.CriterionModifierNotEquals: assert.NotEqual(criterion.Value, value) case models.CriterionModifierMatchesRegex: assert.Regexp(regexp.MustCompile(criterion.Value), value) case models.CriterionModifierNotMatchesRegex: assert.NotRegexp(regexp.MustCompile(criterion.Value), value) case models.CriterionModifierIsNull: assert.Equal("", value) case models.CriterionModifierNotNull: assert.NotEqual("", value) } } func verifyStringList(t *testing.T, values []string, criterion models.StringCriterionInput) { t.Helper() assert := assert.New(t) switch criterion.Modifier { case models.CriterionModifierIsNull: assert.Empty(values) case models.CriterionModifierNotNull: assert.NotEmpty(values) default: for _, v := range values { verifyString(t, v, criterion) } } } func TestSceneQueryRating100(t *testing.T) { const rating = 60 ratingCriterion := models.IntCriterionInput{ Value: rating, Modifier: models.CriterionModifierEquals, } verifyScenesRating100(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierNotEquals verifyScenesRating100(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierGreaterThan verifyScenesRating100(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierLessThan verifyScenesRating100(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierIsNull verifyScenesRating100(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierNotNull verifyScenesRating100(t, ratingCriterion) } func verifyScenesRating100(t *testing.T, ratingCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Scene sceneFilter := models.SceneFilterType{ Rating100: &ratingCriterion, } scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) for _, scene := range scenes { verifyIntPtr(t, scene.Rating, ratingCriterion) } return nil }) } func verifyIntPtr(t *testing.T, value *int, criterion models.IntCriterionInput) { t.Helper() assert := assert.New(t) if criterion.Modifier == models.CriterionModifierIsNull { assert.Nil(value, "expect is null values to be null") } if criterion.Modifier == models.CriterionModifierNotNull { assert.NotNil(value, "expect is null values to be null") } if criterion.Modifier == models.CriterionModifierEquals { assert.Equal(criterion.Value, *value) } if criterion.Modifier == models.CriterionModifierNotEquals { assert.NotEqual(criterion.Value, *value) } if criterion.Modifier == models.CriterionModifierGreaterThan { assert.True(*value > criterion.Value) } if criterion.Modifier == models.CriterionModifierLessThan { assert.True(*value < criterion.Value) } } func verifyDatePtr(t *testing.T, value *models.Date, criterion models.DateCriterionInput) { t.Helper() assert := assert.New(t) if criterion.Modifier == models.CriterionModifierIsNull { assert.Nil(value, "expect is null values to be null") } if criterion.Modifier == models.CriterionModifierNotNull { assert.NotNil(value, "expect not null values to be not null") } if criterion.Modifier == models.CriterionModifierEquals { date, _ := models.ParseDate(criterion.Value) assert.Equal(date, *value) } if criterion.Modifier == models.CriterionModifierNotEquals { date, _ := models.ParseDate(criterion.Value) assert.NotEqual(date, *value) } if criterion.Modifier == models.CriterionModifierGreaterThan { date, _ := models.ParseDate(criterion.Value) assert.True(value.After(date)) } if criterion.Modifier == models.CriterionModifierLessThan { date, _ := models.ParseDate(criterion.Value) assert.True(date.After(*value)) } } func TestSceneQueryOCounter(t *testing.T) { const oCounter = 1 oCounterCriterion := models.IntCriterionInput{ Value: oCounter, Modifier: models.CriterionModifierEquals, } verifyScenesOCounter(t, oCounterCriterion) oCounterCriterion.Modifier = models.CriterionModifierNotEquals verifyScenesOCounter(t, oCounterCriterion) oCounterCriterion.Modifier = models.CriterionModifierGreaterThan verifyScenesOCounter(t, oCounterCriterion) oCounterCriterion.Modifier = models.CriterionModifierLessThan verifyScenesOCounter(t, oCounterCriterion) } func verifyScenesOCounter(t *testing.T, oCounterCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Scene sceneFilter := models.SceneFilterType{ OCounter: &oCounterCriterion, } scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) for _, scene := range scenes { count, err := sqb.GetOCount(ctx, scene.ID) if err != nil { t.Errorf("Error getting ocounter: %v", err) } verifyInt(t, count, oCounterCriterion) } return nil }) } func verifyInt(t *testing.T, value int, criterion models.IntCriterionInput) bool { t.Helper() assert := assert.New(t) if criterion.Modifier == models.CriterionModifierEquals { return assert.Equal(criterion.Value, value) } if criterion.Modifier == models.CriterionModifierNotEquals { return assert.NotEqual(criterion.Value, value) } if criterion.Modifier == models.CriterionModifierGreaterThan { return assert.Greater(value, criterion.Value) } if criterion.Modifier == models.CriterionModifierLessThan { return assert.Less(value, criterion.Value) } return true } func TestSceneQueryDuration(t *testing.T) { duration := 200.432 durationCriterion := models.IntCriterionInput{ Value: int(duration), Modifier: models.CriterionModifierEquals, } verifyScenesDuration(t, durationCriterion) durationCriterion.Modifier = models.CriterionModifierNotEquals verifyScenesDuration(t, durationCriterion) durationCriterion.Modifier = models.CriterionModifierGreaterThan verifyScenesDuration(t, durationCriterion) durationCriterion.Modifier = models.CriterionModifierLessThan verifyScenesDuration(t, durationCriterion) durationCriterion.Modifier = models.CriterionModifierIsNull verifyScenesDuration(t, durationCriterion) durationCriterion.Modifier = models.CriterionModifierNotNull verifyScenesDuration(t, durationCriterion) } func verifyScenesDuration(t *testing.T, durationCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Scene sceneFilter := models.SceneFilterType{ Duration: &durationCriterion, } scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) for _, scene := range scenes { if err := scene.LoadPrimaryFile(ctx, db.File); err != nil { t.Errorf("Error querying scene files: %v", err) return nil } duration := scene.Files.Primary().Duration if durationCriterion.Modifier == models.CriterionModifierEquals { assert.True(t, duration >= float64(durationCriterion.Value) && duration < float64(durationCriterion.Value+1)) } else if durationCriterion.Modifier == models.CriterionModifierNotEquals { assert.True(t, duration < float64(durationCriterion.Value) || duration >= float64(durationCriterion.Value+1)) } else { verifyFloat64(t, duration, durationCriterion) } } return nil }) } func verifyFloat64(t *testing.T, value float64, criterion models.IntCriterionInput) { assert := assert.New(t) if criterion.Modifier == models.CriterionModifierEquals { assert.Equal(float64(criterion.Value), value) } if criterion.Modifier == models.CriterionModifierNotEquals { assert.NotEqual(float64(criterion.Value), value) } if criterion.Modifier == models.CriterionModifierGreaterThan { assert.True(value > float64(criterion.Value)) } if criterion.Modifier == models.CriterionModifierLessThan { assert.True(value < float64(criterion.Value)) } } func verifyFloat64Ptr(t *testing.T, value *float64, criterion models.IntCriterionInput) { assert := assert.New(t) switch criterion.Modifier { case models.CriterionModifierIsNull: assert.Nil(value, "expect is null values to be null") case models.CriterionModifierNotNull: assert.NotNil(value, "expect is not null values to not be null") case models.CriterionModifierEquals: assert.EqualValues(float64(criterion.Value), value) case models.CriterionModifierNotEquals: assert.NotEqualValues(float64(criterion.Value), value) case models.CriterionModifierGreaterThan: assert.True(value != nil && *value > float64(criterion.Value)) case models.CriterionModifierLessThan: assert.True(value != nil && *value < float64(criterion.Value)) } } func TestSceneQueryResolution(t *testing.T) { verifyScenesResolution(t, models.ResolutionEnumLow) verifyScenesResolution(t, models.ResolutionEnumStandard) verifyScenesResolution(t, models.ResolutionEnumStandardHd) verifyScenesResolution(t, models.ResolutionEnumFullHd) verifyScenesResolution(t, models.ResolutionEnumFourK) verifyScenesResolution(t, models.ResolutionEnum("unknown")) } func verifyScenesResolution(t *testing.T, resolution models.ResolutionEnum) { withTxn(func(ctx context.Context) error { sqb := db.Scene sceneFilter := models.SceneFilterType{ Resolution: &models.ResolutionCriterionInput{ Value: resolution, Modifier: models.CriterionModifierEquals, }, } scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) for _, scene := range scenes { if err := scene.LoadPrimaryFile(ctx, db.File); err != nil { t.Errorf("Error querying scene files: %v", err) return nil } f := scene.Files.Primary() height := 0 if f != nil { height = f.Height } verifySceneResolution(t, &height, resolution) } return nil }) } func verifySceneResolution(t *testing.T, height *int, resolution models.ResolutionEnum) { if !resolution.IsValid() { return } assert := assert.New(t) assert.NotNil(height) if t.Failed() { return } h := *height switch resolution { case models.ResolutionEnumLow: assert.True(h < 480) case models.ResolutionEnumStandard: assert.True(h >= 480 && h < 720) case models.ResolutionEnumStandardHd: assert.True(h >= 720 && h < 1080) case models.ResolutionEnumFullHd: assert.True(h >= 1080 && h < 2160) case models.ResolutionEnumFourK: assert.True(h >= 2160) } } func TestAllResolutionsHaveResolutionRange(t *testing.T) { for _, resolution := range models.AllResolutionEnum { assert.NotZero(t, resolution.GetMinResolution(), "Define resolution range for %s in extension_resolution.go", resolution) assert.NotZero(t, resolution.GetMaxResolution(), "Define resolution range for %s in extension_resolution.go", resolution) } } func TestSceneQueryResolutionModifiers(t *testing.T) { if err := withRollbackTxn(func(ctx context.Context) error { qb := db.Scene sceneNoResolution, _ := createScene(ctx, 0, 0) firstScene540P, _ := createScene(ctx, 960, 540) secondScene540P, _ := createScene(ctx, 1280, 719) firstScene720P, _ := createScene(ctx, 1280, 720) secondScene720P, _ := createScene(ctx, 1280, 721) thirdScene720P, _ := createScene(ctx, 1920, 1079) scene1080P, _ := createScene(ctx, 1920, 1080) scenesEqualTo720P := queryScenes(ctx, t, qb, models.ResolutionEnumStandardHd, models.CriterionModifierEquals) scenesNotEqualTo720P := queryScenes(ctx, t, qb, models.ResolutionEnumStandardHd, models.CriterionModifierNotEquals) scenesGreaterThan720P := queryScenes(ctx, t, qb, models.ResolutionEnumStandardHd, models.CriterionModifierGreaterThan) scenesLessThan720P := queryScenes(ctx, t, qb, models.ResolutionEnumStandardHd, models.CriterionModifierLessThan) assert.Subset(t, scenesEqualTo720P, []*models.Scene{firstScene720P, secondScene720P, thirdScene720P}) assert.NotSubset(t, scenesEqualTo720P, []*models.Scene{sceneNoResolution, firstScene540P, secondScene540P, scene1080P}) assert.Subset(t, scenesNotEqualTo720P, []*models.Scene{sceneNoResolution, firstScene540P, secondScene540P, scene1080P}) assert.NotSubset(t, scenesNotEqualTo720P, []*models.Scene{firstScene720P, secondScene720P, thirdScene720P}) assert.Subset(t, scenesGreaterThan720P, []*models.Scene{scene1080P}) assert.NotSubset(t, scenesGreaterThan720P, []*models.Scene{sceneNoResolution, firstScene540P, secondScene540P, firstScene720P, secondScene720P, thirdScene720P}) assert.Subset(t, scenesLessThan720P, []*models.Scene{sceneNoResolution, firstScene540P, secondScene540P}) assert.NotSubset(t, scenesLessThan720P, []*models.Scene{scene1080P, firstScene720P, secondScene720P, thirdScene720P}) return nil }); err != nil { t.Error(err.Error()) } } func queryScenes(ctx context.Context, t *testing.T, queryBuilder models.SceneReaderWriter, resolution models.ResolutionEnum, modifier models.CriterionModifier) []*models.Scene { sceneFilter := models.SceneFilterType{ Resolution: &models.ResolutionCriterionInput{ Value: resolution, Modifier: modifier, }, } // needed so that we don't hit the default limit of 25 scenes pp := 1000 findFilter := &models.FindFilterType{ PerPage: &pp, } return queryScene(ctx, t, queryBuilder, &sceneFilter, findFilter) } func createScene(ctx context.Context, width int, height int) (*models.Scene, error) { name := fmt.Sprintf("TestSceneQueryResolutionModifiers %d %d", width, height) sceneFile := &models.VideoFile{ BaseFile: &models.BaseFile{ Basename: name, ParentFolderID: folderIDs[folderIdxWithSceneFiles], }, Width: width, Height: height, } if err := db.File.Create(ctx, sceneFile); err != nil { return nil, err } scene := &models.Scene{} if err := db.Scene.Create(ctx, scene, []models.FileID{sceneFile.ID}); err != nil { return nil, err } return scene, nil } func TestSceneQueryHasMarkers(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene hasMarkers := "true" sceneFilter := models.SceneFilterType{ HasMarkers: &hasMarkers, } q := getSceneStringValue(sceneIdxWithMarkers, titleField) findFilter := models.FindFilterType{ Q: &q, } scenes := queryScene(ctx, t, sqb, &sceneFilter, &findFilter) assert.Len(t, scenes, 1) assert.Equal(t, sceneIDs[sceneIdxWithMarkers], scenes[0].ID) hasMarkers = "false" scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) assert.Len(t, scenes, 0) findFilter.Q = nil scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) assert.NotEqual(t, 0, len(scenes)) // ensure non of the ids equal the one with gallery for _, scene := range scenes { assert.NotEqual(t, sceneIDs[sceneIdxWithMarkers], scene.ID) } return nil }) } func TestSceneQueryIsMissingGallery(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene isMissing := "galleries" sceneFilter := models.SceneFilterType{ IsMissing: &isMissing, } q := getSceneStringValue(sceneIdxWithGallery, titleField) findFilter := models.FindFilterType{ Q: &q, } scenes := queryScene(ctx, t, sqb, &sceneFilter, &findFilter) assert.Len(t, scenes, 0) findFilter.Q = nil scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) // ensure non of the ids equal the one with gallery for _, scene := range scenes { assert.NotEqual(t, sceneIDs[sceneIdxWithGallery], scene.ID) } return nil }) } func TestSceneQueryIsMissingStudio(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene isMissing := "studio" sceneFilter := models.SceneFilterType{ IsMissing: &isMissing, } q := getSceneStringValue(sceneIdxWithStudio, titleField) findFilter := models.FindFilterType{ Q: &q, } scenes := queryScene(ctx, t, sqb, &sceneFilter, &findFilter) assert.Len(t, scenes, 0) findFilter.Q = nil scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) // ensure non of the ids equal the one with studio for _, scene := range scenes { assert.NotEqual(t, sceneIDs[sceneIdxWithStudio], scene.ID) } return nil }) } func TestSceneQueryIsMissingMovies(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene isMissing := "movie" sceneFilter := models.SceneFilterType{ IsMissing: &isMissing, } q := getSceneStringValue(sceneIdxWithGroup, titleField) findFilter := models.FindFilterType{ Q: &q, } scenes := queryScene(ctx, t, sqb, &sceneFilter, &findFilter) assert.Len(t, scenes, 0) findFilter.Q = nil scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) // ensure non of the ids equal the one with movies for _, scene := range scenes { assert.NotEqual(t, sceneIDs[sceneIdxWithGroup], scene.ID) } return nil }) } func TestSceneQueryIsMissingPerformers(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene isMissing := "performers" sceneFilter := models.SceneFilterType{ IsMissing: &isMissing, } q := getSceneStringValue(sceneIdxWithPerformer, titleField) findFilter := models.FindFilterType{ Q: &q, } scenes := queryScene(ctx, t, sqb, &sceneFilter, &findFilter) assert.Len(t, scenes, 0) findFilter.Q = nil scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) assert.True(t, len(scenes) > 0) // ensure non of the ids equal the one with movies for _, scene := range scenes { assert.NotEqual(t, sceneIDs[sceneIdxWithPerformer], scene.ID) } return nil }) } func TestSceneQueryIsMissingDate(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene isMissing := "date" sceneFilter := models.SceneFilterType{ IsMissing: &isMissing, } scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) // one in four scenes have no date assert.Len(t, scenes, int(math.Ceil(float64(totalScenes)/4))) // ensure date is null for _, scene := range scenes { assert.Nil(t, scene.Date) } return nil }) } func TestSceneQueryIsMissingTags(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene isMissing := "tags" sceneFilter := models.SceneFilterType{ IsMissing: &isMissing, } q := getSceneStringValue(sceneIdxWithTwoTags, titleField) findFilter := models.FindFilterType{ Q: &q, } scenes := queryScene(ctx, t, sqb, &sceneFilter, &findFilter) assert.Len(t, scenes, 0) findFilter.Q = nil scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) assert.True(t, len(scenes) > 0) return nil }) } func TestSceneQueryIsMissingRating(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene isMissing := "rating" sceneFilter := models.SceneFilterType{ IsMissing: &isMissing, } scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) assert.True(t, len(scenes) > 0) // ensure rating is null for _, scene := range scenes { assert.Nil(t, scene.Rating) } return nil }) } func TestSceneQueryIsMissingPhash(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene isMissing := "phash" sceneFilter := models.SceneFilterType{ IsMissing: &isMissing, } scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) if !assert.Len(t, scenes, 1) { return nil } assert.Equal(t, sceneIDs[sceneIdxMissingPhash], scenes[0].ID) return nil }) } func TestSceneQueryPerformers(t *testing.T) { tests := []struct { name string filter models.MultiCriterionInput includeIdxs []int excludeIdxs []int wantErr bool }{ { "includes", models.MultiCriterionInput{ Value: []string{ strconv.Itoa(performerIDs[performerIdxWithScene]), strconv.Itoa(performerIDs[performerIdx1WithScene]), }, Modifier: models.CriterionModifierIncludes, }, []int{ sceneIdxWithPerformer, sceneIdxWithTwoPerformers, }, []int{ sceneIdxWithGallery, }, false, }, { "includes all", models.MultiCriterionInput{ Value: []string{ strconv.Itoa(performerIDs[performerIdx1WithScene]), strconv.Itoa(performerIDs[performerIdx2WithScene]), }, Modifier: models.CriterionModifierIncludesAll, }, []int{ sceneIdxWithTwoPerformers, }, []int{ sceneIdxWithPerformer, }, false, }, { "excludes", models.MultiCriterionInput{ Modifier: models.CriterionModifierExcludes, Value: []string{strconv.Itoa(tagIDs[performerIdx1WithScene])}, }, nil, []int{sceneIdxWithTwoPerformers}, false, }, { "is null", models.MultiCriterionInput{ Modifier: models.CriterionModifierIsNull, }, []int{sceneIdxWithTag}, []int{ sceneIdxWithPerformer, sceneIdxWithTwoPerformers, sceneIdxWithPerformerTwoTags, }, false, }, { "not null", models.MultiCriterionInput{ Modifier: models.CriterionModifierNotNull, }, []int{ sceneIdxWithPerformer, sceneIdxWithTwoPerformers, sceneIdxWithPerformerTwoTags, }, []int{sceneIdxWithTag}, false, }, { "equals", models.MultiCriterionInput{ Modifier: models.CriterionModifierEquals, Value: []string{ strconv.Itoa(tagIDs[performerIdx1WithScene]), strconv.Itoa(tagIDs[performerIdx2WithScene]), }, }, []int{sceneIdxWithTwoPerformers}, []int{ sceneIdxWithThreePerformers, }, false, }, { "not equals", models.MultiCriterionInput{ Modifier: models.CriterionModifierNotEquals, Value: []string{ strconv.Itoa(tagIDs[performerIdx1WithScene]), strconv.Itoa(tagIDs[performerIdx2WithScene]), }, }, nil, nil, true, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) results, err := db.Scene.Query(ctx, models.SceneQueryOptions{ SceneFilter: &models.SceneFilterType{ Performers: &tt.filter, }, }) if (err != nil) != tt.wantErr { t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr) return } include := indexesToIDs(sceneIDs, tt.includeIdxs) exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(results.IDs, i) } for _, e := range exclude { assert.NotContains(results.IDs, e) } }) } } func TestSceneQueryTags(t *testing.T) { tests := []struct { name string filter models.HierarchicalMultiCriterionInput includeIdxs []int excludeIdxs []int wantErr bool }{ { "includes", models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithScene]), strconv.Itoa(tagIDs[tagIdx1WithScene]), }, Modifier: models.CriterionModifierIncludes, }, []int{ sceneIdxWithTag, sceneIdxWithTwoTags, }, []int{ sceneIdxWithGallery, }, false, }, { "includes all", models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdx1WithScene]), strconv.Itoa(tagIDs[tagIdx2WithScene]), }, Modifier: models.CriterionModifierIncludesAll, }, []int{ sceneIdxWithTwoTags, }, []int{ sceneIdxWithTag, }, false, }, { "excludes", models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierExcludes, Value: []string{strconv.Itoa(tagIDs[tagIdx1WithScene])}, }, nil, []int{sceneIdxWithTwoTags}, false, }, { "is null", models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIsNull, }, []int{sceneIdx1WithPerformer}, []int{ sceneIdxWithTag, sceneIdxWithTwoTags, sceneIdxWithMarkerAndTag, }, false, }, { "not null", models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierNotNull, }, []int{ sceneIdxWithTag, sceneIdxWithTwoTags, sceneIdxWithMarkerAndTag, }, []int{sceneIdx1WithPerformer}, false, }, { "equals", models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierEquals, Value: []string{ strconv.Itoa(tagIDs[tagIdx1WithScene]), strconv.Itoa(tagIDs[tagIdx2WithScene]), }, }, []int{sceneIdxWithTwoTags}, []int{ sceneIdxWithThreeTags, }, false, }, { "not equals", models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierNotEquals, Value: []string{ strconv.Itoa(tagIDs[tagIdx1WithScene]), strconv.Itoa(tagIDs[tagIdx2WithScene]), }, }, nil, nil, true, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) results, err := db.Scene.Query(ctx, models.SceneQueryOptions{ SceneFilter: &models.SceneFilterType{ Tags: &tt.filter, }, }) if (err != nil) != tt.wantErr { t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr) return } include := indexesToIDs(sceneIDs, tt.includeIdxs) exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(results.IDs, i) } for _, e := range exclude { assert.NotContains(results.IDs, e) } }) } } func TestSceneQueryPerformerTags(t *testing.T) { allDepth := -1 tests := []struct { name string findFilter *models.FindFilterType filter *models.SceneFilterType includeIdxs []int excludeIdxs []int wantErr bool }{ { "includes", nil, &models.SceneFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithPerformer]), strconv.Itoa(tagIDs[tagIdx1WithPerformer]), }, Modifier: models.CriterionModifierIncludes, }, }, []int{ sceneIdxWithPerformerTag, sceneIdxWithPerformerTwoTags, sceneIdxWithTwoPerformerTag, }, []int{ sceneIdxWithPerformer, }, false, }, { "includes sub-tags", nil, &models.SceneFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), }, Depth: &allDepth, Modifier: models.CriterionModifierIncludes, }, }, []int{ sceneIdxWithPerformerParentTag, }, []int{ sceneIdxWithPerformer, sceneIdxWithPerformerTag, sceneIdxWithPerformerTwoTags, sceneIdxWithTwoPerformerTag, }, false, }, { "includes all", nil, &models.SceneFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdx1WithPerformer]), strconv.Itoa(tagIDs[tagIdx2WithPerformer]), }, Modifier: models.CriterionModifierIncludesAll, }, }, []int{ sceneIdxWithPerformerTwoTags, }, []int{ sceneIdxWithPerformer, sceneIdxWithPerformerTag, sceneIdxWithTwoPerformerTag, }, false, }, { "excludes performer tag tagIdx2WithPerformer", nil, &models.SceneFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierExcludes, Value: []string{strconv.Itoa(tagIDs[tagIdx2WithPerformer])}, }, }, nil, []int{sceneIdxWithTwoPerformerTag}, false, }, { "excludes sub-tags", nil, &models.SceneFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), }, Depth: &allDepth, Modifier: models.CriterionModifierExcludes, }, }, []int{ sceneIdxWithPerformer, sceneIdxWithPerformerTag, sceneIdxWithPerformerTwoTags, sceneIdxWithTwoPerformerTag, }, []int{ sceneIdxWithPerformerParentTag, }, false, }, { "is null", nil, &models.SceneFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIsNull, }, }, []int{sceneIdx1WithPerformer}, []int{sceneIdxWithPerformerTag}, false, }, { "not null", nil, &models.SceneFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierNotNull, }, }, []int{sceneIdxWithPerformerTag}, []int{sceneIdx1WithPerformer}, false, }, { "equals", nil, &models.SceneFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierEquals, Value: []string{ strconv.Itoa(tagIDs[tagIdx2WithPerformer]), }, }, }, nil, nil, true, }, { "not equals", nil, &models.SceneFilterType{ PerformerTags: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierNotEquals, Value: []string{ strconv.Itoa(tagIDs[tagIdx2WithPerformer]), }, }, }, nil, nil, true, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) results, err := db.Scene.Query(ctx, models.SceneQueryOptions{ SceneFilter: tt.filter, QueryOptions: models.QueryOptions{ FindFilter: tt.findFilter, }, }) if (err != nil) != tt.wantErr { t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr) return } include := indexesToIDs(sceneIDs, tt.includeIdxs) exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(results.IDs, i) } for _, e := range exclude { assert.NotContains(results.IDs, e) } }) } } func TestSceneQueryStudio(t *testing.T) { tests := []struct { name string q string studioCriterion models.HierarchicalMultiCriterionInput expectedIDs []int wantErr bool }{ { "includes", "", models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithScene]), }, Modifier: models.CriterionModifierIncludes, }, []int{sceneIDs[sceneIdxWithStudio]}, false, }, { "excludes", getSceneStringValue(sceneIdxWithStudio, titleField), models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithScene]), }, Modifier: models.CriterionModifierExcludes, }, []int{}, false, }, { "excludes includes null", getSceneStringValue(sceneIdxWithGallery, titleField), models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithScene]), }, Modifier: models.CriterionModifierExcludes, }, []int{sceneIDs[sceneIdxWithGallery]}, false, }, { "equals", "", models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithScene]), }, Modifier: models.CriterionModifierEquals, }, []int{sceneIDs[sceneIdxWithStudio]}, false, }, { "not equals", getSceneStringValue(sceneIdxWithStudio, titleField), models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithScene]), }, Modifier: models.CriterionModifierNotEquals, }, []int{}, false, }, } qb := db.Scene for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { studioCriterion := tt.studioCriterion sceneFilter := models.SceneFilterType{ Studios: &studioCriterion, } var findFilter *models.FindFilterType if tt.q != "" { findFilter = &models.FindFilterType{ Q: &tt.q, } } scenes := queryScene(ctx, t, qb, &sceneFilter, findFilter) assert.ElementsMatch(t, scenesToIDs(scenes), tt.expectedIDs) }) } } func TestSceneQueryStudioDepth(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene depth := 2 studioCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithGrandChild]), }, Modifier: models.CriterionModifierIncludes, Depth: &depth, } sceneFilter := models.SceneFilterType{ Studios: &studioCriterion, } scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) assert.Len(t, scenes, 1) depth = 1 scenes = queryScene(ctx, t, sqb, &sceneFilter, nil) assert.Len(t, scenes, 0) studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])} scenes = queryScene(ctx, t, sqb, &sceneFilter, nil) assert.Len(t, scenes, 1) // ensure id is correct assert.Equal(t, sceneIDs[sceneIdxWithGrandChildStudio], scenes[0].ID) depth = 2 studioCriterion = models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithGrandChild]), }, Modifier: models.CriterionModifierExcludes, Depth: &depth, } q := getSceneStringValue(sceneIdxWithGrandChildStudio, titleField) findFilter := models.FindFilterType{ Q: &q, } scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) assert.Len(t, scenes, 0) depth = 1 scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) assert.Len(t, scenes, 1) studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])} scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) assert.Len(t, scenes, 0) return nil }) } func TestSceneGroups(t *testing.T) { type criterion struct { valueIdxs []int modifier models.CriterionModifier depth int } tests := []struct { name string c criterion q string includeIdxs []int excludeIdxs []int }{ { "includes", criterion{ []int{groupIdxWithScene}, models.CriterionModifierIncludes, 0, }, "", []int{sceneIdxWithGroup}, nil, }, { "excludes", criterion{ []int{groupIdxWithScene}, models.CriterionModifierExcludes, 0, }, getSceneStringValue(sceneIdxWithGroup, titleField), nil, []int{sceneIdxWithGroup}, }, { "includes (depth = 1)", criterion{ []int{groupIdxWithChildWithScene}, models.CriterionModifierIncludes, 1, }, "", []int{sceneIdxWithGroupWithParent}, nil, }, } for _, tt := range tests { valueIDs := indexesToIDs(groupIDs, tt.c.valueIdxs) runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) sceneFilter := &models.SceneFilterType{ Groups: &models.HierarchicalMultiCriterionInput{ Value: intslice.IntSliceToStringSlice(valueIDs), Modifier: tt.c.modifier, }, } if tt.c.depth != 0 { sceneFilter.Groups.Depth = &tt.c.depth } findFilter := &models.FindFilterType{} if tt.q != "" { findFilter.Q = &tt.q } results, err := db.Scene.Query(ctx, models.SceneQueryOptions{ SceneFilter: sceneFilter, QueryOptions: models.QueryOptions{ FindFilter: findFilter, }, }) if err != nil { t.Errorf("SceneStore.Query() error = %v", err) return } include := indexesToIDs(sceneIDs, tt.includeIdxs) exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) assert.Subset(results.IDs, include) for _, e := range exclude { assert.NotContains(results.IDs, e) } }) } } func TestSceneQueryMovies(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene movieCriterion := models.MultiCriterionInput{ Value: []string{ strconv.Itoa(groupIDs[groupIdxWithScene]), }, Modifier: models.CriterionModifierIncludes, } sceneFilter := models.SceneFilterType{ Movies: &movieCriterion, } scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) assert.Len(t, scenes, 1) // ensure id is correct assert.Equal(t, sceneIDs[sceneIdxWithGroup], scenes[0].ID) movieCriterion = models.MultiCriterionInput{ Value: []string{ strconv.Itoa(groupIDs[groupIdxWithScene]), }, Modifier: models.CriterionModifierExcludes, } q := getSceneStringValue(sceneIdxWithGroup, titleField) findFilter := models.FindFilterType{ Q: &q, } scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) assert.Len(t, scenes, 0) return nil }) } func TestSceneQueryPhashDuplicated(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene duplicated := true phashCriterion := models.DuplicationCriterionInput{ Duplicated: &duplicated, } sceneFilter := models.SceneFilterType{ Duplicated: &phashCriterion, } scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) assert.Len(t, scenes, dupeScenePhashes*2) duplicated = false scenes = queryScene(ctx, t, sqb, &sceneFilter, nil) // -1 for missing phash assert.Len(t, scenes, totalScenes-(dupeScenePhashes*2)-1) return nil }) } func TestSceneQuerySorting(t *testing.T) { tests := []struct { name string sortBy string dir models.SortDirectionEnum firstSceneIdx int // -1 to ignore lastSceneIdx int }{ { "bitrate", "bitrate", models.SortDirectionEnumAsc, -1, -1, }, { "duration", "duration", models.SortDirectionEnumDesc, -1, -1, }, { "file mod time", "file_mod_time", models.SortDirectionEnumDesc, -1, -1, }, { "file size", "filesize", models.SortDirectionEnumDesc, -1, -1, }, { "frame rate", "framerate", models.SortDirectionEnumDesc, -1, -1, }, { "path", "path", models.SortDirectionEnumDesc, -1, -1, }, { "perceptual_similarity", "perceptual_similarity", models.SortDirectionEnumDesc, -1, -1, }, { "play_count", "play_count", models.SortDirectionEnumDesc, -1, -1, }, { "last_played_at", "last_played_at", models.SortDirectionEnumDesc, -1, -1, }, { "resume_time", "resume_time", models.SortDirectionEnumDesc, sceneIDs[sceneIdx1WithPerformer], -1, }, { "play_duration", "play_duration", models.SortDirectionEnumDesc, sceneIDs[sceneIdx1WithPerformer], -1, }, { "performer_age", "performer_age", models.SortDirectionEnumDesc, -1, -1, }, } qb := db.Scene for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) got, err := qb.Query(ctx, models.SceneQueryOptions{ QueryOptions: models.QueryOptions{ FindFilter: &models.FindFilterType{ Sort: &tt.sortBy, Direction: &tt.dir, }, }, }) if err != nil { t.Errorf("sceneQueryBuilder.TestSceneQuerySorting() error = %v", err) return } scenes, err := got.Resolve(ctx) if err != nil { t.Errorf("sceneQueryBuilder.TestSceneQuerySorting() error = %v", err) return } if !assert.Greater(len(scenes), 0) { return } // scenes should be in same order as indexes firstScene := scenes[0] lastScene := scenes[len(scenes)-1] if tt.firstSceneIdx != -1 { firstSceneID := sceneIDs[tt.firstSceneIdx] assert.Equal(firstSceneID, firstScene.ID) } if tt.lastSceneIdx != -1 { lastSceneID := sceneIDs[tt.lastSceneIdx] assert.Equal(lastSceneID, lastScene.ID) } }) } } func TestSceneQueryPagination(t *testing.T) { perPage := 1 findFilter := models.FindFilterType{ PerPage: &perPage, } withTxn(func(ctx context.Context) error { sqb := db.Scene scenes := queryScene(ctx, t, sqb, nil, &findFilter) assert.Len(t, scenes, 1) firstID := scenes[0].ID page := 2 findFilter.Page = &page scenes = queryScene(ctx, t, sqb, nil, &findFilter) assert.Len(t, scenes, 1) secondID := scenes[0].ID assert.NotEqual(t, firstID, secondID) perPage = 2 page = 1 scenes = queryScene(ctx, t, sqb, nil, &findFilter) assert.Len(t, scenes, 2) assert.Equal(t, firstID, scenes[0].ID) assert.Equal(t, secondID, scenes[1].ID) return nil }) } func TestSceneQueryTagCount(t *testing.T) { const tagCount = 1 tagCountCriterion := models.IntCriterionInput{ Value: tagCount, Modifier: models.CriterionModifierEquals, } verifyScenesTagCount(t, tagCountCriterion) tagCountCriterion.Modifier = models.CriterionModifierNotEquals verifyScenesTagCount(t, tagCountCriterion) tagCountCriterion.Modifier = models.CriterionModifierGreaterThan verifyScenesTagCount(t, tagCountCriterion) tagCountCriterion.Modifier = models.CriterionModifierLessThan verifyScenesTagCount(t, tagCountCriterion) } func verifyScenesTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Scene sceneFilter := models.SceneFilterType{ TagCount: &tagCountCriterion, } scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) assert.Greater(t, len(scenes), 0) for _, scene := range scenes { if err := scene.LoadTagIDs(ctx, sqb); err != nil { t.Errorf("scene.LoadTagIDs() error = %v", err) return nil } verifyInt(t, len(scene.TagIDs.List()), tagCountCriterion) } return nil }) } func TestSceneQueryPerformerCount(t *testing.T) { const performerCount = 1 performerCountCriterion := models.IntCriterionInput{ Value: performerCount, Modifier: models.CriterionModifierEquals, } verifyScenesPerformerCount(t, performerCountCriterion) performerCountCriterion.Modifier = models.CriterionModifierNotEquals verifyScenesPerformerCount(t, performerCountCriterion) performerCountCriterion.Modifier = models.CriterionModifierGreaterThan verifyScenesPerformerCount(t, performerCountCriterion) performerCountCriterion.Modifier = models.CriterionModifierLessThan verifyScenesPerformerCount(t, performerCountCriterion) } func verifyScenesPerformerCount(t *testing.T, performerCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Scene sceneFilter := models.SceneFilterType{ PerformerCount: &performerCountCriterion, } scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) assert.Greater(t, len(scenes), 0) for _, scene := range scenes { if err := scene.LoadPerformerIDs(ctx, sqb); err != nil { t.Errorf("scene.LoadPerformerIDs() error = %v", err) return nil } verifyInt(t, len(scene.PerformerIDs.List()), performerCountCriterion) } return nil }) } func TestFindByMovieID(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene scenes, err := sqb.FindByGroupID(ctx, groupIDs[groupIdxWithScene]) if err != nil { t.Errorf("error calling FindByMovieID: %s", err.Error()) } assert.Len(t, scenes, 1) assert.Equal(t, sceneIDs[sceneIdxWithGroup], scenes[0].ID) scenes, err = sqb.FindByGroupID(ctx, 0) if err != nil { t.Errorf("error calling FindByMovieID: %s", err.Error()) } assert.Len(t, scenes, 0) return nil }) } func TestFindByPerformerID(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene scenes, err := sqb.FindByPerformerID(ctx, performerIDs[performerIdxWithScene]) if err != nil { t.Errorf("error calling FindByPerformerID: %s", err.Error()) } assert.Len(t, scenes, 1) assert.Equal(t, sceneIDs[sceneIdxWithPerformer], scenes[0].ID) scenes, err = sqb.FindByPerformerID(ctx, 0) if err != nil { t.Errorf("error calling FindByPerformerID: %s", err.Error()) } assert.Len(t, scenes, 0) return nil }) } func TestSceneUpdateSceneCover(t *testing.T) { if err := withTxn(func(ctx context.Context) error { qb := db.Scene sceneID := sceneIDs[sceneIdxWithGallery] return testUpdateImage(t, ctx, sceneID, qb.UpdateCover, qb.GetCover) }); err != nil { t.Error(err.Error()) } } func TestSceneStashIDs(t *testing.T) { if err := withTxn(func(ctx context.Context) error { qb := db.Scene // create scene to test against const name = "TestSceneStashIDs" scene := &models.Scene{ Title: name, } if err := qb.Create(ctx, scene, nil); err != nil { return fmt.Errorf("Error creating scene: %s", err.Error()) } if err := scene.LoadStashIDs(ctx, qb); err != nil { return err } testSceneStashIDs(ctx, t, scene) return nil }); err != nil { t.Error(err.Error()) } } func testSceneStashIDs(ctx context.Context, t *testing.T, s *models.Scene) { // ensure no stash IDs to begin with assert.Len(t, s.StashIDs.List(), 0) // add stash ids const stashIDStr = "stashID" const endpoint = "endpoint" stashID := models.StashID{ StashID: stashIDStr, Endpoint: endpoint, UpdatedAt: epochTime, } qb := db.Scene // update stash ids and ensure was updated var err error s, err = qb.UpdatePartial(ctx, s.ID, models.ScenePartial{ StashIDs: &models.UpdateStashIDs{ StashIDs: []models.StashID{stashID}, Mode: models.RelationshipUpdateModeSet, }, }) if err != nil { t.Error(err.Error()) } if err := s.LoadStashIDs(ctx, qb); err != nil { t.Error(err.Error()) return } assert.Equal(t, []models.StashID{stashID}, s.StashIDs.List()) // remove stash ids and ensure was updated s, err = qb.UpdatePartial(ctx, s.ID, models.ScenePartial{ StashIDs: &models.UpdateStashIDs{ StashIDs: []models.StashID{stashID}, Mode: models.RelationshipUpdateModeRemove, }, }) if err != nil { t.Error(err.Error()) } if err := s.LoadStashIDs(ctx, qb); err != nil { t.Error(err.Error()) return } assert.Len(t, s.StashIDs.List(), 0) } func TestSceneQueryQTrim(t *testing.T) { if err := withTxn(func(ctx context.Context) error { qb := db.Scene expectedID := sceneIDs[sceneIdxWithSpacedName] type test struct { query string id int count int } tests := []test{ {query: " zzz yyy ", id: expectedID, count: 1}, {query: " \"zzz yyy xxx\" ", id: expectedID, count: 1}, {query: "zzz", id: expectedID, count: 1}, {query: "\" zzz yyy \"", count: 0}, {query: "\"zzz yyy\"", count: 0}, {query: "\" zzz yyy\"", count: 0}, {query: "\"zzz yyy \"", count: 0}, } for _, tst := range tests { f := models.FindFilterType{ Q: &tst.query, } scenes := queryScene(ctx, t, qb, nil, &f) assert.Len(t, scenes, tst.count) if len(scenes) > 0 { assert.Equal(t, tst.id, scenes[0].ID) } } findFilter := models.FindFilterType{} scenes := queryScene(ctx, t, qb, nil, &findFilter) assert.NotEqual(t, 0, len(scenes)) return nil }); err != nil { t.Error(err.Error()) } } func TestSceneStore_All(t *testing.T) { qb := db.Scene withRollbackTxn(func(ctx context.Context) error { got, err := qb.All(ctx) if err != nil { t.Errorf("SceneStore.All() error = %v", err) return nil } // it's possible that other tests have created scenes assert.GreaterOrEqual(t, len(got), len(sceneIDs)) return nil }) } func TestSceneStore_FindDuplicates(t *testing.T) { qb := db.Scene withRollbackTxn(func(ctx context.Context) error { distance := 0 durationDiff := -1. got, err := qb.FindDuplicates(ctx, distance, durationDiff) if err != nil { t.Errorf("SceneStore.FindDuplicates() error = %v", err) return nil } assert.Len(t, got, dupeScenePhashes) distance = 1 durationDiff = -1. got, err = qb.FindDuplicates(ctx, distance, durationDiff) if err != nil { t.Errorf("SceneStore.FindDuplicates() error = %v", err) return nil } assert.Len(t, got, dupeScenePhashes) return nil }) } func TestSceneStore_AssignFiles(t *testing.T) { tests := []struct { name string sceneID int fileID models.FileID wantErr bool }{ { "valid", sceneIDs[sceneIdx1WithPerformer], sceneFileIDs[sceneIdx1WithStudio], false, }, { "invalid file id", sceneIDs[sceneIdx1WithPerformer], invalidFileID, true, }, { "invalid scene id", invalidID, sceneFileIDs[sceneIdx1WithStudio], true, }, } qb := db.Scene for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { withRollbackTxn(func(ctx context.Context) error { if err := qb.AssignFiles(ctx, tt.sceneID, []models.FileID{tt.fileID}); (err != nil) != tt.wantErr { t.Errorf("SceneStore.AssignFiles() error = %v, wantErr %v", err, tt.wantErr) } return nil }) }) } } func TestSceneStore_AddView(t *testing.T) { tests := []struct { name string sceneID int expectedCount int wantErr bool }{ { "valid", sceneIDs[sceneIdx1WithPerformer], 1, //getScenePlayCount(sceneIdx1WithPerformer) + 1, false, }, { "invalid scene id", invalidID, 0, true, }, } qb := db.Scene for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { withRollbackTxn(func(ctx context.Context) error { views, err := qb.AddViews(ctx, tt.sceneID, nil) if (err != nil) != tt.wantErr { t.Errorf("SceneStore.AddView() error = %v, wantErr %v", err, tt.wantErr) } if err != nil { return nil } assert := assert.New(t) assert.Equal(tt.expectedCount, len(views)) // find the scene and check the count count, err := qb.CountViews(ctx, tt.sceneID) if err != nil { t.Errorf("SceneStore.CountViews() error = %v", err) } lastView, err := qb.LastView(ctx, tt.sceneID) if err != nil { t.Errorf("SceneStore.LastView() error = %v", err) } assert.Equal(tt.expectedCount, count) assert.True(lastView.After(time.Now().Add(-1 * time.Minute))) return nil }) }) } } func TestSceneStore_DecrementWatchCount(t *testing.T) { return } func TestSceneStore_SaveActivity(t *testing.T) { var ( resumeTime = 111.2 playDuration = 98.7 ) tests := []struct { name string sceneIdx int resumeTime *float64 playDuration *float64 wantErr bool }{ { "both", sceneIdx1WithPerformer, &resumeTime, &playDuration, false, }, { "resumeTime only", sceneIdx1WithPerformer, &resumeTime, nil, false, }, { "playDuration only", sceneIdx1WithPerformer, nil, &playDuration, false, }, { "none", sceneIdx1WithPerformer, nil, nil, false, }, { "invalid scene id", -1, &resumeTime, &playDuration, true, }, } qb := db.Scene for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { withRollbackTxn(func(ctx context.Context) error { id := -1 if tt.sceneIdx != -1 { id = sceneIDs[tt.sceneIdx] } _, err := qb.SaveActivity(ctx, id, tt.resumeTime, tt.playDuration) if (err != nil) != tt.wantErr { t.Errorf("SceneStore.SaveActivity() error = %v, wantErr %v", err, tt.wantErr) } if err != nil { return nil } assert := assert.New(t) // find the scene and check the values scene, err := qb.Find(ctx, id) if err != nil { t.Errorf("SceneStore.Find() error = %v", err) } expectedResumeTime := getSceneResumeTime(tt.sceneIdx) expectedPlayDuration := getScenePlayDuration(tt.sceneIdx) if tt.resumeTime != nil { expectedResumeTime = *tt.resumeTime } if tt.playDuration != nil { expectedPlayDuration += *tt.playDuration } assert.Equal(expectedResumeTime, scene.ResumeTime) assert.Equal(expectedPlayDuration, scene.PlayDuration) return nil }) }) } } func TestSceneQueryCustomFields(t *testing.T) { tests := []struct { name string filter *models.SceneFilterType includeIdxs []int excludeIdxs []int wantErr bool }{ { "equals", &models.SceneFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierEquals, Value: []any{getSceneStringValue(sceneIdxWithGallery, "custom")}, }, }, }, []int{sceneIdxWithGallery}, nil, false, }, { "not equals", &models.SceneFilterType{ Title: &models.StringCriterionInput{ Value: getSceneTitle(sceneIdxWithGallery), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotEquals, Value: []any{getSceneStringValue(sceneIdxWithGallery, "custom")}, }, }, }, nil, []int{sceneIdxWithGallery}, false, }, { "includes", &models.SceneFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierIncludes, Value: []any{getSceneStringValue(sceneIdxWithGallery, "custom")[9:]}, }, }, }, []int{sceneIdxWithGallery}, nil, false, }, { "excludes", &models.SceneFilterType{ Title: &models.StringCriterionInput{ Value: getSceneTitle(sceneIdxWithGallery), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierExcludes, Value: []any{getSceneStringValue(sceneIdxWithGallery, "custom")[9:]}, }, }, }, nil, []int{sceneIdxWithGallery}, false, }, { "regex", &models.SceneFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierMatchesRegex, Value: []any{".*17_custom"}, }, }, }, []int{sceneIdxWithTwoPerformerTag}, nil, false, }, { "invalid regex", &models.SceneFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierMatchesRegex, Value: []any{"["}, }, }, }, nil, nil, true, }, { "not matches regex", &models.SceneFilterType{ Title: &models.StringCriterionInput{ Value: getSceneTitle(sceneIdxWithTwoPerformerTag), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotMatchesRegex, Value: []any{".*17_custom"}, }, }, }, nil, []int{sceneIdxWithTwoPerformerTag}, false, }, { "invalid not matches regex", &models.SceneFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotMatchesRegex, Value: []any{"["}, }, }, }, nil, nil, true, }, { "null", &models.SceneFilterType{ Title: &models.StringCriterionInput{ Value: getSceneTitle(sceneIdxWithGallery), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "not existing", Modifier: models.CriterionModifierIsNull, }, }, }, []int{sceneIdxWithGallery}, nil, false, }, { "not null", &models.SceneFilterType{ Title: &models.StringCriterionInput{ Value: getSceneTitle(sceneIdxWithGallery), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotNull, }, }, }, []int{sceneIdxWithGallery}, nil, false, }, { "between", &models.SceneFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "real", Modifier: models.CriterionModifierBetween, Value: []any{0.15, 0.25}, }, }, }, []int{sceneIdxWithPerformer}, nil, false, }, { "not between", &models.SceneFilterType{ Title: &models.StringCriterionInput{ Value: getSceneTitle(sceneIdxWithPerformer), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "real", Modifier: models.CriterionModifierNotBetween, Value: []any{0.15, 0.25}, }, }, }, nil, []int{sceneIdxWithPerformer}, false, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) result, err := db.Scene.Query(ctx, models.SceneQueryOptions{ SceneFilter: tt.filter, }) if (err != nil) != tt.wantErr { t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr) } if err != nil { return } scenes, err := result.Resolve(ctx) if err != nil { t.Errorf("SceneStore.Query().Resolve() error = %v", err) return } ids := scenesToIDs(scenes) include := indexesToIDs(sceneIDs, tt.includeIdxs) exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(ids, i) } for _, e := range exclude { assert.NotContains(ids, e) } }) } } // TODO Count // TODO SizeCount // TODO - this should be in history_test and generalised func TestSceneStore_CountAllViews(t *testing.T) { withRollbackTxn(func(ctx context.Context) error { qb := db.Scene sceneID := sceneIDs[sceneIdx1WithPerformer] // get the current play count currentCount, err := qb.CountAllViews(ctx) if err != nil { t.Errorf("SceneStore.CountAllViews() error = %v", err) return nil } // add a view _, err = qb.AddViews(ctx, sceneID, nil) if err != nil { t.Errorf("SceneStore.AddViews() error = %v", err) return nil } // get the new play count newCount, err := qb.CountAllViews(ctx) if err != nil { t.Errorf("SceneStore.CountAllViews() error = %v", err) return nil } assert.Equal(t, currentCount+1, newCount) return nil }) } func TestSceneStore_CountUniqueViews(t *testing.T) { withRollbackTxn(func(ctx context.Context) error { qb := db.Scene sceneID := sceneIDs[sceneIdx1WithPerformer] // get the current play count currentCount, err := qb.CountUniqueViews(ctx) if err != nil { t.Errorf("SceneStore.CountUniqueViews() error = %v", err) return nil } // add a view _, err = qb.AddViews(ctx, sceneID, nil) if err != nil { t.Errorf("SceneStore.AddViews() error = %v", err) return nil } // add a second view _, err = qb.AddViews(ctx, sceneID, nil) if err != nil { t.Errorf("SceneStore.AddViews() error = %v", err) return nil } // get the new play count newCount, err := qb.CountUniqueViews(ctx) if err != nil { t.Errorf("SceneStore.CountUniqueViews() error = %v", err) return nil } assert.Equal(t, currentCount+1, newCount) return nil }) } ================================================ FILE: pkg/sqlite/setup_test.go ================================================ //go:build integration // +build integration package sqlite_test import ( "context" "database/sql" "errors" "fmt" "os" "path/filepath" "slices" "strconv" "testing" "time" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sqlite" "github.com/stashapp/stash/pkg/txn" // necessary to register custom migrations _ "github.com/stashapp/stash/pkg/sqlite/migrations" ) var epochTime = time.Unix(0, 0).UTC() const ( spacedSceneTitle = "zzz yyy xxx" ) const ( folderIdxRoot = iota folderIdxWithSubFolder folderIdxWithParentFolder folderIdxWithFiles folderIdxInZip folderIdxForObjectFiles folderIdxWithImageFiles folderIdxWithGalleryFiles folderIdxWithSceneFiles totalFolders ) const ( fileIdxZip = iota fileIdxInZip fileIdxStartVideoFiles fileIdxStartImageFiles fileIdxStartGalleryFiles totalFiles ) const ( sceneIdxWithGroup = iota sceneIdxWithGallery sceneIdxWithPerformer sceneIdx1WithPerformer sceneIdx2WithPerformer sceneIdxWithTwoPerformers sceneIdxWithThreePerformers sceneIdxWithTag sceneIdxWithTwoTags sceneIdxWithThreeTags sceneIdxWithMarkerAndTag sceneIdxWithMarkerTwoTags sceneIdxWithStudio sceneIdx1WithStudio sceneIdx2WithStudio sceneIdxWithMarkers sceneIdxWithPerformerTag sceneIdxWithTwoPerformerTag sceneIdxWithPerformerTwoTags sceneIdxWithSpacedName sceneIdxWithStudioPerformer sceneIdx1WithTwoStudioPerformer sceneIdx2WithTwoStudioPerformer sceneIdxWithGrandChildStudio sceneIdxMissingPhash sceneIdxWithPerformerParentTag sceneIdxWithGroupWithParent // new indexes above lastSceneIdx totalScenes = lastSceneIdx + 3 ) const dupeScenePhashes = 2 const ( imageIdxWithGallery = iota imageIdx1WithGallery imageIdx2WithGallery imageIdxWithTwoGalleries imageIdxWithPerformer imageIdx1WithPerformer imageIdx2WithPerformer imageIdxWithTwoPerformers imageIdxWithThreePerformers imageIdxWithTag imageIdxWithTwoTags imageIdxWithThreeTags imageIdxWithStudio imageIdx1WithStudio imageIdx2WithStudio imageIdxWithStudioPerformer imageIdxInZip imageIdxWithPerformerTag imageIdxWithTwoPerformerTag imageIdxWithPerformerTwoTags imageIdxWithGrandChildStudio imageIdxWithPerformerParentTag // new indexes above totalImages ) const ( performerIdxWithScene = iota performerIdx1WithScene performerIdx2WithScene performerIdx3WithScene performerIdxWithTwoScenes performerIdxWithImage performerIdxWithTwoImages performerIdx1WithImage performerIdx2WithImage performerIdx3WithImage performerIdxWithTag performerIdx2WithTag performerIdxWithTwoTags performerIdxWithGallery performerIdxWithTwoGalleries performerIdx1WithGallery performerIdx2WithGallery performerIdx3WithGallery performerIdxWithSceneStudio performerIdxWithImageStudio performerIdxWithGalleryStudio performerIdxWithTwoSceneStudio performerIdxWithParentTag // new indexes above // performers with dup names start from the end performerIdx1WithDupName performerIdxWithDupName performersNameCase = performerIdx1WithDupName performersNameNoCase = 2 totalPerformers = performersNameCase + performersNameNoCase ) const ( groupIdxWithScene = iota groupIdxWithStudio groupIdxWithTag groupIdxWithTwoTags groupIdxWithThreeTags groupIdxWithGrandChild groupIdxWithChild groupIdxWithParentAndChild groupIdxWithParent groupIdxWithGrandParent groupIdxWithParentAndScene groupIdxWithChildWithScene // groups with dup names start from the end groupIdxWithDupName groupsNameCase = groupIdxWithDupName groupsNameNoCase = 1 ) const ( galleryIdxWithScene = iota galleryIdxWithChapters galleryIdxWithImage galleryIdx1WithImage galleryIdx2WithImage galleryIdxWithTwoImages galleryIdxWithPerformer galleryIdx1WithPerformer galleryIdx2WithPerformer galleryIdxWithTwoPerformers galleryIdxWithThreePerformers galleryIdxWithTag galleryIdxWithTwoTags galleryIdxWithThreeTags galleryIdxWithStudio galleryIdx1WithStudio galleryIdx2WithStudio galleryIdxWithPerformerTag galleryIdxWithTwoPerformerTag galleryIdxWithPerformerTwoTags galleryIdxWithStudioPerformer galleryIdxWithGrandChildStudio galleryIdxWithoutFile galleryIdxWithPerformerParentTag // new indexes above lastGalleryIdx totalGalleries = lastGalleryIdx + 1 ) const ( tagIdxWithScene = iota tagIdx1WithScene tagIdx2WithScene tagIdx3WithScene tagIdxWithPrimaryMarkers tagIdxWithMarkers tagIdxWithCoverImage tagIdxWithImage tagIdx1WithImage tagIdx2WithImage tagIdx3WithImage tagIdxWithPerformer tagIdx1WithPerformer tagIdx2WithPerformer tagIdxWithStudio tagIdx1WithStudio tagIdx2WithStudio tagIdxWithGallery tagIdx1WithGallery tagIdx2WithGallery tagIdx3WithGallery tagIdxWithChildTag tagIdxWithParentTag tagIdxWithGrandChild tagIdxWithParentAndChild tagIdxWithGrandParent tagIdx2WithMarkers tagIdxWithGroup tagIdx1WithGroup tagIdx2WithGroup tagIdx3WithGroup // new indexes above // tags with dup names start from the end tagIdx1WithDupName tagIdxWithDupName tagsNameNoCase = 2 tagsNameCase = tagIdx1WithDupName totalTags = tagsNameCase + tagsNameNoCase ) const ( studioIdxWithScene = iota studioIdxWithTwoScenes studioIdxWithGroup studioIdxWithChildStudio studioIdxWithParentStudio studioIdxWithImage studioIdxWithTwoImages studioIdxWithGallery studioIdxWithTwoGalleries studioIdxWithScenePerformer studioIdxWithImagePerformer studioIdxWithGalleryPerformer studioIdx1WithTwoScenePerformer studioIdx2WithTwoScenePerformer studioIdxWithTag studioIdx2WithTag studioIdxWithTwoTags studioIdxWithParentTag studioIdxWithGrandChild studioIdxWithParentAndChild studioIdxWithGrandParent // new indexes above // studios with dup names start from the end studioIdxWithDupName studiosNameCase = studioIdxWithDupName studiosNameNoCase = 1 totalStudios = studiosNameCase + studiosNameNoCase ) const ( markerIdxWithScene = iota markerIdxWithTag markerIdxWithSceneTag markerIdxWithDuration markerIdx2WithDuration totalMarkers ) const ( chapterIdxWithGallery = iota totalChapters ) const ( savedFilterIdxScene = iota savedFilterIdxImage // new indexes above totalSavedFilters ) const ( pathField = "Path" checksumField = "Checksum" titleField = "Title" detailsField = "Details" urlField = "URL" zipPath = "zipPath.zip" firstSavedFilterName = "firstSavedFilterName" ) var ( folderIDs []models.FolderID fileIDs []models.FileID sceneFileIDs []models.FileID imageFileIDs []models.FileID galleryFileIDs []models.FileID chapterIDs []int sceneIDs []int imageIDs []int performerIDs []int groupIDs []int galleryIDs []int tagIDs []int studioIDs []int markerIDs []int savedFilterIDs []int folderPaths []string tagNames []string studioNames []string groupNames []string performerNames []string ) type idAssociation struct { first int second int } type linkMap map[int][]int func (m linkMap) reverseLookup(idx int) []int { var result []int for k, v := range m { for _, vv := range v { if vv == idx { result = append(result, k) } } } return result } var ( folderParentFolders = map[int]int{ folderIdxWithSubFolder: folderIdxRoot, folderIdxForObjectFiles: folderIdxRoot, folderIdxWithParentFolder: folderIdxWithSubFolder, folderIdxWithSceneFiles: folderIdxForObjectFiles, folderIdxWithImageFiles: folderIdxForObjectFiles, folderIdxWithGalleryFiles: folderIdxForObjectFiles, } fileFolders = map[int]int{ fileIdxZip: folderIdxWithFiles, fileIdxInZip: folderIdxInZip, } folderZipFiles = map[int]int{ folderIdxInZip: fileIdxZip, } fileZipFiles = map[int]int{ fileIdxInZip: fileIdxZip, } ) var ( sceneTags = linkMap{ sceneIdxWithTag: {tagIdxWithScene}, sceneIdxWithTwoTags: {tagIdx1WithScene, tagIdx2WithScene}, sceneIdxWithThreeTags: {tagIdx1WithScene, tagIdx2WithScene, tagIdx3WithScene}, sceneIdxWithMarkerAndTag: {tagIdx3WithScene}, sceneIdxWithMarkerTwoTags: {tagIdx2WithScene, tagIdx3WithScene}, } scenePerformers = linkMap{ sceneIdxWithPerformer: {performerIdxWithScene}, sceneIdxWithTwoPerformers: {performerIdx1WithScene, performerIdx2WithScene}, sceneIdxWithThreePerformers: {performerIdx1WithScene, performerIdx2WithScene, performerIdx3WithScene}, sceneIdxWithPerformerTag: {performerIdxWithTag}, sceneIdxWithTwoPerformerTag: {performerIdxWithTag, performerIdx2WithTag}, sceneIdxWithPerformerTwoTags: {performerIdxWithTwoTags}, sceneIdx1WithPerformer: {performerIdxWithTwoScenes}, sceneIdx2WithPerformer: {performerIdxWithTwoScenes}, sceneIdxWithStudioPerformer: {performerIdxWithSceneStudio}, sceneIdx1WithTwoStudioPerformer: {performerIdxWithTwoSceneStudio}, sceneIdx2WithTwoStudioPerformer: {performerIdxWithTwoSceneStudio}, sceneIdxWithPerformerParentTag: {performerIdxWithParentTag}, } sceneGalleries = linkMap{ sceneIdxWithGallery: {galleryIdxWithScene}, } sceneGroups = linkMap{ sceneIdxWithGroup: {groupIdxWithScene}, sceneIdxWithGroupWithParent: {groupIdxWithParentAndScene}, } sceneStudios = map[int]int{ sceneIdxWithStudio: studioIdxWithScene, sceneIdx1WithStudio: studioIdxWithTwoScenes, sceneIdx2WithStudio: studioIdxWithTwoScenes, sceneIdxWithStudioPerformer: studioIdxWithScenePerformer, sceneIdx1WithTwoStudioPerformer: studioIdx1WithTwoScenePerformer, sceneIdx2WithTwoStudioPerformer: studioIdx2WithTwoScenePerformer, sceneIdxWithGrandChildStudio: studioIdxWithGrandParent, } ) type markerSpec struct { sceneIdx int primaryTagIdx int tagIdxs []int } var ( // indexed by marker markerSpecs = []markerSpec{ {sceneIdxWithMarkers, tagIdxWithPrimaryMarkers, nil}, {sceneIdxWithMarkers, tagIdxWithPrimaryMarkers, []int{tagIdxWithMarkers}}, {sceneIdxWithMarkers, tagIdxWithPrimaryMarkers, []int{tagIdx2WithMarkers}}, {sceneIdxWithMarkers, tagIdxWithPrimaryMarkers, []int{tagIdxWithMarkers, tagIdx2WithMarkers}}, {sceneIdxWithMarkerAndTag, tagIdxWithPrimaryMarkers, nil}, {sceneIdxWithMarkerTwoTags, tagIdxWithPrimaryMarkers, nil}, } ) type chapterSpec struct { galleryIdx int title string imageIndex int } var ( // indexed by chapter chapterSpecs = []chapterSpec{ {galleryIdxWithChapters, "Test1", 10}, } ) var ( imageGalleries = linkMap{ imageIdxWithGallery: {galleryIdxWithImage}, imageIdx1WithGallery: {galleryIdxWithTwoImages}, imageIdx2WithGallery: {galleryIdxWithTwoImages}, imageIdxWithTwoGalleries: {galleryIdx1WithImage, galleryIdx2WithImage}, } imageStudios = map[int]int{ imageIdxWithStudio: studioIdxWithImage, imageIdx1WithStudio: studioIdxWithTwoImages, imageIdx2WithStudio: studioIdxWithTwoImages, imageIdxWithStudioPerformer: studioIdxWithImagePerformer, imageIdxWithGrandChildStudio: studioIdxWithGrandParent, } imageTags = linkMap{ imageIdxWithTag: {tagIdxWithImage}, imageIdxWithTwoTags: {tagIdx1WithImage, tagIdx2WithImage}, imageIdxWithThreeTags: {tagIdx1WithImage, tagIdx2WithImage, tagIdx3WithImage}, } imagePerformers = linkMap{ imageIdxWithPerformer: {performerIdxWithImage}, imageIdxWithTwoPerformers: {performerIdx1WithImage, performerIdx2WithImage}, imageIdxWithThreePerformers: {performerIdx1WithImage, performerIdx2WithImage, performerIdx3WithImage}, imageIdxWithPerformerTag: {performerIdxWithTag}, imageIdxWithTwoPerformerTag: {performerIdxWithTag, performerIdx2WithTag}, imageIdxWithPerformerTwoTags: {performerIdxWithTwoTags}, imageIdx1WithPerformer: {performerIdxWithTwoImages}, imageIdx2WithPerformer: {performerIdxWithTwoImages}, imageIdxWithStudioPerformer: {performerIdxWithImageStudio}, imageIdxWithPerformerParentTag: {performerIdxWithParentTag}, } ) var ( galleryPerformers = linkMap{ galleryIdxWithPerformer: {performerIdxWithGallery}, galleryIdxWithTwoPerformers: {performerIdx1WithGallery, performerIdx2WithGallery}, galleryIdxWithThreePerformers: {performerIdx1WithGallery, performerIdx2WithGallery, performerIdx3WithGallery}, galleryIdxWithPerformerTag: {performerIdxWithTag}, galleryIdxWithTwoPerformerTag: {performerIdxWithTag, performerIdx2WithTag}, galleryIdxWithPerformerTwoTags: {performerIdxWithTwoTags}, galleryIdx1WithPerformer: {performerIdxWithTwoGalleries}, galleryIdx2WithPerformer: {performerIdxWithTwoGalleries}, galleryIdxWithStudioPerformer: {performerIdxWithGalleryStudio}, galleryIdxWithPerformerParentTag: {performerIdxWithParentTag}, } galleryStudios = map[int]int{ galleryIdxWithStudio: studioIdxWithGallery, galleryIdx1WithStudio: studioIdxWithTwoGalleries, galleryIdx2WithStudio: studioIdxWithTwoGalleries, galleryIdxWithStudioPerformer: studioIdxWithGalleryPerformer, galleryIdxWithGrandChildStudio: studioIdxWithGrandParent, } galleryTags = linkMap{ galleryIdxWithTag: {tagIdxWithGallery}, galleryIdxWithTwoTags: {tagIdx1WithGallery, tagIdx2WithGallery}, galleryIdxWithThreeTags: {tagIdx1WithGallery, tagIdx2WithGallery, tagIdx3WithGallery}, } ) var ( groupStudioLinks = [][2]int{ {groupIdxWithStudio, studioIdxWithGroup}, } groupTags = linkMap{ groupIdxWithTag: {tagIdxWithGroup}, groupIdxWithTwoTags: {tagIdx1WithGroup, tagIdx2WithGroup}, groupIdxWithThreeTags: {tagIdx1WithGroup, tagIdx2WithGroup, tagIdx3WithGroup}, } ) var ( studioParentLinks = [][2]int{ {studioIdxWithChildStudio, studioIdxWithParentStudio}, {studioIdxWithGrandChild, studioIdxWithParentAndChild}, {studioIdxWithParentAndChild, studioIdxWithGrandParent}, } ) var ( studioTags = linkMap{ studioIdxWithTag: {tagIdxWithStudio}, studioIdx2WithTag: {tagIdx2WithStudio}, studioIdxWithTwoTags: {tagIdx1WithStudio, tagIdx2WithStudio}, studioIdxWithParentTag: {tagIdxWithParentAndChild}, } ) var ( performerTags = linkMap{ performerIdxWithTag: {tagIdxWithPerformer}, performerIdx2WithTag: {tagIdx2WithPerformer}, performerIdxWithTwoTags: {tagIdx1WithPerformer, tagIdx2WithPerformer}, performerIdxWithParentTag: {tagIdxWithParentAndChild}, } ) var ( tagParentLinks = [][2]int{ {tagIdxWithChildTag, tagIdxWithParentTag}, {tagIdxWithGrandChild, tagIdxWithParentAndChild}, {tagIdxWithParentAndChild, tagIdxWithGrandParent}, } ) var ( groupParentLinks = [][2]int{ {groupIdxWithChild, groupIdxWithParent}, {groupIdxWithGrandChild, groupIdxWithParentAndChild}, {groupIdxWithParentAndChild, groupIdxWithGrandParent}, {groupIdxWithChildWithScene, groupIdxWithParentAndScene}, } ) func indexesToIDs(ids []int, indexes []int) []int { ret := make([]int, len(indexes)) for i, idx := range indexes { ret[i] = indexToID(ids, idx) } return ret } func indexToID(ids []int, idx int) int { if idx < 0 { return invalidID } return ids[idx] } func indexesToIDPtrs[T any](ids []T, indexes []int) []*T { ret := make([]*T, len(indexes)) for i, idx := range indexes { ret[i] = indexToIDPtr(ids, idx) } return ret } func indexToIDPtr[T any](ids []T, idx int) *T { if idx < 0 { return nil } return &ids[idx] } func indexFromID(ids []int, id int) int { for i, v := range ids { if v == id { return i } } return -1 } var db *sqlite.Database func TestMain(m *testing.M) { // initialise empty config - needed by some migrations _ = config.InitializeEmpty() ret := runTests(m) os.Exit(ret) } func withTxn(f func(ctx context.Context) error) error { return txn.WithTxn(context.Background(), db, f) } func withRollbackTxn(f func(ctx context.Context) error) error { var ret error withTxn(func(ctx context.Context) error { ret = f(ctx) return errors.New("fake error for rollback") }) return ret } func runWithRollbackTxn(t *testing.T, name string, f func(t *testing.T, ctx context.Context)) { withRollbackTxn(func(ctx context.Context) error { t.Run(name, func(t *testing.T) { f(t, ctx) }) return nil }) } func testTeardown(databaseFile string) { err := db.Close() if err != nil { panic(err) } err = os.Remove(databaseFile) if err != nil { panic(err) } } func runTests(m *testing.M) int { // create the database file f, err := os.CreateTemp("", "*.sqlite") if err != nil { panic(fmt.Sprintf("Could not create temporary file: %s", err.Error())) } f.Close() databaseFile := f.Name() db = sqlite.NewDatabase() db.SetBlobStoreOptions(sqlite.BlobStoreOptions{ UseDatabase: true, // don't use filesystem }) if err := db.Open(databaseFile); err != nil { panic(fmt.Sprintf("Could not initialize database: %s", err.Error())) } // defer close and delete the database defer testTeardown(databaseFile) err = populateDB() if err != nil { panic(fmt.Sprintf("Could not populate database: %s", err.Error())) } // run the tests return m.Run() } func populateDB() error { if err := withTxn(func(ctx context.Context) error { if err := createFolders(ctx); err != nil { return fmt.Errorf("creating folders: %w", err) } if err := createFiles(ctx); err != nil { return fmt.Errorf("creating files: %w", err) } if err := linkFoldersToZip(ctx); err != nil { return fmt.Errorf("linking folders to zip files: %w", err) } if err := createTags(ctx, db.Tag, tagsNameCase, tagsNameNoCase); err != nil { return fmt.Errorf("error creating tags: %s", err.Error()) } if err := createGroups(ctx, db.Group, groupsNameCase, groupsNameNoCase); err != nil { return fmt.Errorf("error creating groups: %s", err.Error()) } if err := createPerformers(ctx, performersNameCase, performersNameNoCase); err != nil { return fmt.Errorf("error creating performers: %s", err.Error()) } if err := createStudios(ctx, studiosNameCase, studiosNameNoCase); err != nil { return fmt.Errorf("error creating studios: %s", err.Error()) } if err := createGalleries(ctx, totalGalleries); err != nil { return fmt.Errorf("error creating galleries: %s", err.Error()) } if err := createScenes(ctx, totalScenes); err != nil { return fmt.Errorf("error creating scenes: %s", err.Error()) } if err := createImages(ctx, totalImages); err != nil { return fmt.Errorf("error creating images: %s", err.Error()) } if err := addTagImage(ctx, db.Tag, tagIdxWithCoverImage); err != nil { return fmt.Errorf("error adding tag image: %s", err.Error()) } if err := createSavedFilters(ctx, db.SavedFilter, totalSavedFilters); err != nil { return fmt.Errorf("error creating saved filters: %s", err.Error()) } if err := linkGroupStudios(ctx, db.Group); err != nil { return fmt.Errorf("error linking group studios: %s", err.Error()) } if err := linkStudiosParent(ctx); err != nil { return fmt.Errorf("error linking studios parent: %s", err.Error()) } if err := linkTagsParent(ctx, db.Tag); err != nil { return fmt.Errorf("error linking tags parent: %s", err.Error()) } if err := linkGroupsParent(ctx, db.Group); err != nil { return fmt.Errorf("error linking tags parent: %s", err.Error()) } for _, ms := range markerSpecs { if err := createMarker(ctx, db.SceneMarker, ms); err != nil { return fmt.Errorf("error creating scene marker: %s", err.Error()) } } for _, cs := range chapterSpecs { if err := createChapter(ctx, db.GalleryChapter, cs); err != nil { return fmt.Errorf("error creating gallery chapter: %s", err.Error()) } } return nil }); err != nil { return err } return nil } func getFolderPath(index int, parentFolderIdx *int) string { path := getPrefixedStringValue("folder", index, pathField) if parentFolderIdx != nil { return filepath.Join(folderPaths[*parentFolderIdx], path) } return path } func getFolderBasename(index int, parentFolderIdx *int) string { return filepath.Base(getFolderPath(index, parentFolderIdx)) } func getFolderModTime(index int) time.Time { return time.Date(2000, 1, (index%10)+1, 0, 0, 0, 0, time.UTC) } func makeFolder(i int) models.Folder { var folderID *models.FolderID var folderIdx *int if pidx, ok := folderParentFolders[i]; ok { folderIdx = &pidx v := folderIDs[pidx] folderID = &v } return models.Folder{ ParentFolderID: folderID, DirEntry: models.DirEntry{ // zip files have to be added after creating files ModTime: getFolderModTime(i), }, Path: getFolderPath(i, folderIdx), } } func createFolders(ctx context.Context) error { qb := db.Folder for i := 0; i < totalFolders; i++ { folder := makeFolder(i) if err := qb.Create(ctx, &folder); err != nil { return fmt.Errorf("Error creating folder [%d] %v+: %s", i, folder, err.Error()) } folderIDs = append(folderIDs, folder.ID) folderPaths = append(folderPaths, folder.Path) } return nil } func linkFoldersToZip(ctx context.Context) error { // link folders to zip files for folderIdx, fileIdx := range folderZipFiles { folderID := folderIDs[folderIdx] fileID := fileIDs[fileIdx] f, err := db.Folder.Find(ctx, folderID) if err != nil { return fmt.Errorf("Error finding folder [%d] to link to zip file [%d]", folderID, fileID) } f.ZipFileID = &fileID if err := db.Folder.Update(ctx, f); err != nil { return fmt.Errorf("Error linking folder [%d] to zip file [%d]: %s", folderIdx, fileIdx, err.Error()) } } return nil } func getFileBaseName(index int) string { return getPrefixedStringValue("file", index, "basename") } func getFileStringValue(index int, field string) string { return getPrefixedStringValue("file", index, field) } func getFileModTime(index int) time.Time { return getFolderModTime(index) } func getFilePhash(index int) int64 { return int64(index * 567) } func getFileFingerprints(index int) []models.Fingerprint { return []models.Fingerprint{ { Type: models.FingerprintTypeMD5, Fingerprint: getPrefixedStringValue("file", index, "md5"), }, { Type: models.FingerprintTypeOshash, Fingerprint: getPrefixedStringValue("file", index, "oshash"), }, { Type: models.FingerprintTypePhash, Fingerprint: getFilePhash(index), }, } } func getFileSize(index int) int64 { return int64(index) * 10 } func getFileDuration(index int) float64 { duration := (index % 4) + 1 duration = duration * 100 return float64(duration) + 0.432 } func makeFile(i int) models.File { folderID := folderIDs[fileFolders[i]] if folderID == 0 { folderID = folderIDs[folderIdxWithFiles] } var zipFileID *models.FileID if zipFileIndex, found := fileZipFiles[i]; found { zipFileID = &fileIDs[zipFileIndex] } var ret models.File baseFile := &models.BaseFile{ Basename: getFileBaseName(i), ParentFolderID: folderID, DirEntry: models.DirEntry{ // zip files have to be added after creating files ModTime: getFileModTime(i), ZipFileID: zipFileID, }, Fingerprints: getFileFingerprints(i), Size: getFileSize(i), } ret = baseFile if i >= fileIdxStartVideoFiles && i < fileIdxStartImageFiles { ret = &models.VideoFile{ BaseFile: baseFile, Format: getFileStringValue(i, "format"), Width: getWidth(i), Height: getHeight(i), Duration: getFileDuration(i), VideoCodec: getFileStringValue(i, "videoCodec"), AudioCodec: getFileStringValue(i, "audioCodec"), FrameRate: getFileDuration(i) * 2, BitRate: int64(getFileDuration(i)) * 3, } } else if i >= fileIdxStartImageFiles && i < fileIdxStartGalleryFiles { ret = &models.ImageFile{ BaseFile: baseFile, Format: getFileStringValue(i, "format"), Width: getWidth(i), Height: getHeight(i), } } return ret } func createFiles(ctx context.Context) error { qb := db.File for i := 0; i < totalFiles; i++ { file := makeFile(i) if err := qb.Create(ctx, file); err != nil { return fmt.Errorf("Error creating file [%d] %v+: %s", i, file, err.Error()) } fileIDs = append(fileIDs, file.Base().ID) } return nil } func getPrefixedStringValue(prefix string, index int, field string) string { return fmt.Sprintf("%s_%04d_%s", prefix, index, field) } func getPrefixedNullStringValue(prefix string, index int, field string) sql.NullString { if index > 0 && index%5 == 0 { return sql.NullString{} } if index > 0 && index%6 == 0 { return sql.NullString{ String: "", Valid: true, } } return sql.NullString{ String: getPrefixedStringValue(prefix, index, field), Valid: true, } } func getSceneStringValue(index int, field string) string { return getPrefixedStringValue("scene", index, field) } func getScenePhash(index int, field string) int64 { return int64(index % (totalScenes - dupeScenePhashes) * 1234) } func getSceneStringPtr(index int, field string) *string { v := getPrefixedStringValue("scene", index, field) return &v } func getSceneNullStringPtr(index int, field string) *string { return getStringPtrFromNullString(getPrefixedNullStringValue("scene", index, field)) } func getSceneEmptyString(index int, field string) string { v := getSceneNullStringPtr(index, field) if v == nil { return "" } return *v } func getSceneTitle(index int) string { switch index { case sceneIdxWithSpacedName: return spacedSceneTitle default: return getSceneStringValue(index, titleField) } } func getRating(index int) sql.NullInt64 { rating := index % 6 return sql.NullInt64{Int64: int64(rating * 20), Valid: rating > 0} } func getIntPtr(r sql.NullInt64) *int { if !r.Valid { return nil } v := int(r.Int64) return &v } func getStringPtrFromNullString(r sql.NullString) *string { if !r.Valid || r.String == "" { return nil } v := r.String return &v } func getStringPtr(r string) *string { if r == "" { return nil } return &r } func getEmptyStringFromPtr(v *string) string { if v == nil { return "" } return *v } func getOCounter(index int) int { return index % 3 } func getSceneDuration(index int) float64 { duration := index + 1 duration = duration * 100 return float64(duration) + 0.432 } func getHeight(index int) int { heights := []int{200, 240, 300, 480, 700, 720, 800, 1080, 1500, 2160, 3000} height := heights[index%len(heights)] return height } func getWidth(index int) int { height := getHeight(index) return height * 2 } func getObjectDate(index int) *models.Date { dates := []string{"null", "2000-01-01", "0001-01-01", "2001-02-03"} date := dates[index%len(dates)] if date == "null" { return nil } ret, _ := models.ParseDate(date) return &ret } func sceneStashIDs(i int) []models.StashID { if i%5 == 0 { return nil } return []models.StashID{sceneStashID(i)} } func sceneStashID(i int) models.StashID { return models.StashID{ StashID: getSceneStringValue(i, "stashid"), Endpoint: getSceneStringValue(0, "endpoint"), UpdatedAt: epochTime, } } func getSceneBasename(index int) string { return getSceneStringValue(index, pathField) } func makeSceneFile(i int) *models.VideoFile { fp := []models.Fingerprint{ { Type: models.FingerprintTypeMD5, Fingerprint: getSceneStringValue(i, checksumField), }, { Type: models.FingerprintTypeOshash, Fingerprint: getSceneStringValue(i, "oshash"), }, } if i != sceneIdxMissingPhash { fp = append(fp, models.Fingerprint{ Type: models.FingerprintTypePhash, Fingerprint: getScenePhash(i, "phash"), }) } return &models.VideoFile{ BaseFile: &models.BaseFile{ Path: getFilePath(folderIdxWithSceneFiles, getSceneBasename(i)), Basename: getSceneBasename(i), ParentFolderID: folderIDs[folderIdxWithSceneFiles], Fingerprints: fp, }, Duration: getSceneDuration(i), Height: getHeight(i), Width: getWidth(i), } } func getScenePlayDuration(index int) float64 { if index%5 == 0 { return 0 } return float64(index%5) * 123.4 } func getSceneResumeTime(index int) float64 { if index%5 == 0 { return 0 } return float64(index%5) * 1.2 } func makeScene(i int) *models.Scene { title := getSceneTitle(i) details := getSceneStringValue(i, "Details") var studioID *int if _, ok := sceneStudios[i]; ok { v := studioIDs[sceneStudios[i]] studioID = &v } gids := indexesToIDs(galleryIDs, sceneGalleries[i]) pids := indexesToIDs(performerIDs, scenePerformers[i]) tids := indexesToIDs(tagIDs, sceneTags[i]) mids := indexesToIDs(groupIDs, sceneGroups[i]) groups := make([]models.GroupsScenes, len(mids)) for i, m := range mids { groups[i] = models.GroupsScenes{ GroupID: m, } } rating := getRating(i) return &models.Scene{ Title: title, Details: details, URLs: models.NewRelatedStrings([]string{ getSceneEmptyString(i, urlField), }), Rating: getIntPtr(rating), Date: getObjectDate(i), StudioID: studioID, GalleryIDs: models.NewRelatedIDs(gids), PerformerIDs: models.NewRelatedIDs(pids), TagIDs: models.NewRelatedIDs(tids), Groups: models.NewRelatedGroups(groups), StashIDs: models.NewRelatedStashIDs(sceneStashIDs(i)), PlayDuration: getScenePlayDuration(i), ResumeTime: getSceneResumeTime(i), } } func getSceneCustomFields(index int) map[string]interface{} { if index%5 == 0 { return nil } return map[string]interface{}{ "string": getSceneStringValue(index, "custom"), "int": int64(index % 5), "real": float64(index) / 10, } } func createScenes(ctx context.Context, n int) error { sqb := db.Scene fqb := db.File for i := 0; i < n; i++ { f := makeSceneFile(i) if err := fqb.Create(ctx, f); err != nil { return fmt.Errorf("creating scene file: %w", err) } sceneFileIDs = append(sceneFileIDs, f.ID) scene := makeScene(i) if err := sqb.Create(ctx, scene, []models.FileID{f.ID}); err != nil { return fmt.Errorf("Error creating scene %v+: %s", scene, err.Error()) } if err := sqb.SetCustomFields(ctx, scene.ID, models.CustomFieldsInput{Full: getSceneCustomFields(i)}); err != nil { return fmt.Errorf("Error setting custom fields for scene %d: %s", scene.ID, err.Error()) } sceneIDs = append(sceneIDs, scene.ID) } return nil } func getImageStringValue(index int, field string) string { return fmt.Sprintf("image_%04d_%s", index, field) } func getImageNullStringPtr(index int, field string) *string { return getStringPtrFromNullString(getPrefixedNullStringValue("image", index, field)) } func getImageEmptyString(index int, field string) string { v := getImageNullStringPtr(index, field) if v == nil { return "" } return *v } func getImageBasename(index int) string { return getImageStringValue(index, pathField) } func getImageCustomFields(index int) map[string]interface{} { if index%5 == 0 { return nil } return map[string]interface{}{ "string": getImageStringValue(index, "custom"), "int": int64(index % 5), "real": float64(index) / 10, } } func makeImageFile(i int) *models.ImageFile { return &models.ImageFile{ BaseFile: &models.BaseFile{ Path: getFilePath(folderIdxWithImageFiles, getImageBasename(i)), Basename: getImageBasename(i), ParentFolderID: folderIDs[folderIdxWithImageFiles], Fingerprints: []models.Fingerprint{ { Type: models.FingerprintTypeMD5, Fingerprint: getImageStringValue(i, checksumField), }, }, }, Height: getHeight(i), Width: getWidth(i), } } func makeImage(i int) *models.Image { title := getImageStringValue(i, titleField) var studioID *int if _, ok := imageStudios[i]; ok { v := studioIDs[imageStudios[i]] studioID = &v } gids := indexesToIDs(galleryIDs, imageGalleries[i]) pids := indexesToIDs(performerIDs, imagePerformers[i]) tids := indexesToIDs(tagIDs, imageTags[i]) return &models.Image{ Title: title, Details: getImageStringValue(i, detailsField), Rating: getIntPtr(getRating(i)), Date: getObjectDate(i), URLs: models.NewRelatedStrings([]string{ getImageEmptyString(i, urlField), }), OCounter: getOCounter(i), StudioID: studioID, GalleryIDs: models.NewRelatedIDs(gids), PerformerIDs: models.NewRelatedIDs(pids), TagIDs: models.NewRelatedIDs(tids), } } func createImages(ctx context.Context, n int) error { qb := db.Image fqb := db.File for i := 0; i < n; i++ { f := makeImageFile(i) if i == imageIdxInZip { f.ZipFileID = &fileIDs[fileIdxZip] } if err := fqb.Create(ctx, f); err != nil { return fmt.Errorf("creating image file: %w", err) } imageFileIDs = append(imageFileIDs, f.ID) image := makeImage(i) err := qb.Create(ctx, &models.CreateImageInput{ Image: image, FileIDs: []models.FileID{f.ID}, CustomFields: getImageCustomFields(i), }) if err != nil { return fmt.Errorf("Error creating image %v+: %s", image, err.Error()) } imageIDs = append(imageIDs, image.ID) } return nil } func getGalleryStringValue(index int, field string) string { return getPrefixedStringValue("gallery", index, field) } func getGalleryNullStringValue(index int, field string) sql.NullString { return getPrefixedNullStringValue("gallery", index, field) } func getGalleryNullStringPtr(index int, field string) *string { return getStringPtrFromNullString(getPrefixedNullStringValue("gallery", index, field)) } func getGalleryEmptyString(index int, field string) string { v := getGalleryNullStringPtr(index, field) if v == nil { return "" } return *v } func getGalleryBasename(index int) string { return getGalleryStringValue(index, pathField) } func makeGalleryFile(i int) *models.BaseFile { return &models.BaseFile{ Path: getFilePath(folderIdxWithGalleryFiles, getGalleryBasename(i)), Basename: getGalleryBasename(i), ParentFolderID: folderIDs[folderIdxWithGalleryFiles], Fingerprints: []models.Fingerprint{ { Type: models.FingerprintTypeMD5, Fingerprint: getGalleryStringValue(i, checksumField), }, }, } } func makeGallery(i int, includeScenes bool) *models.Gallery { var studioID *int if _, ok := galleryStudios[i]; ok { v := studioIDs[galleryStudios[i]] studioID = &v } pids := indexesToIDs(performerIDs, galleryPerformers[i]) tids := indexesToIDs(tagIDs, galleryTags[i]) ret := &models.Gallery{ Title: getGalleryStringValue(i, titleField), URLs: models.NewRelatedStrings([]string{ getGalleryEmptyString(i, urlField), }), Rating: getIntPtr(getRating(i)), Date: getObjectDate(i), StudioID: studioID, PerformerIDs: models.NewRelatedIDs(pids), TagIDs: models.NewRelatedIDs(tids), } if includeScenes { ret.SceneIDs = models.NewRelatedIDs(indexesToIDs(sceneIDs, sceneGalleries.reverseLookup(i))) } return ret } func getGalleryCustomFields(index int) map[string]interface{} { if index%5 == 0 { return nil } return map[string]interface{}{ "string": getGalleryStringValue(index, "custom"), "int": int64(index % 5), "real": float64(index) / 10, } } func createGalleries(ctx context.Context, n int) error { gqb := db.Gallery fqb := db.File for i := 0; i < n; i++ { var fileIDs []models.FileID if i != galleryIdxWithoutFile { f := makeGalleryFile(i) if err := fqb.Create(ctx, f); err != nil { return fmt.Errorf("creating gallery file: %w", err) } galleryFileIDs = append(galleryFileIDs, f.ID) fileIDs = []models.FileID{f.ID} } else { galleryFileIDs = append(galleryFileIDs, 0) } // gallery relationship will be created with galleries const includeScenes = false gallery := makeGallery(i, includeScenes) err := gqb.Create(ctx, &models.CreateGalleryInput{ Gallery: gallery, FileIDs: fileIDs, CustomFields: getGalleryCustomFields(i), }) if err != nil { return fmt.Errorf("Error creating gallery %v+: %s", gallery, err.Error()) } galleryIDs = append(galleryIDs, gallery.ID) } return nil } func getGroupStringValue(index int, field string) string { return getPrefixedStringValue("group", index, field) } func getGroupNullStringValue(index int, field string) string { ret := getPrefixedNullStringValue("group", index, field) return ret.String } func getGroupEmptyString(index int, field string) string { v := getPrefixedNullStringValue("group", index, field) if !v.Valid { return "" } return v.String } func getGroupCustomFields(index int) map[string]interface{} { if index%5 == 0 { return nil } return map[string]interface{}{ "string": getGroupStringValue(index, "custom"), "int": int64(index % 5), "real": float64(index) / 10, } } // createGroups creates n groups with plain Name and o groups with camel cased NaMe included func createGroups(ctx context.Context, mqb models.GroupReaderWriter, n int, o int) error { const namePlain = "Name" const nameNoCase = "NaMe" for i := 0; i < n+o; i++ { index := i name := namePlain tids := indexesToIDs(tagIDs, groupTags[i]) if i >= n { // i=n groups get dup names if case is not checked index = n + o - (i + 1) // for the name to be the same the number (index) must be the same also } // so count backwards to 0 as needed // groups [ i ] and [ n + o - i - 1 ] should have similar names with only the Name!=NaMe part different name = getGroupStringValue(index, name) group := models.Group{ Name: name, URLs: models.NewRelatedStrings([]string{ getGroupEmptyString(i, urlField), }), TagIDs: models.NewRelatedIDs(tids), } err := mqb.Create(ctx, &group) if err != nil { return fmt.Errorf("Error creating group [%d] %v+: %s", i, group, err.Error()) } customFields := getGroupCustomFields(i) if customFields != nil { if err := mqb.SetCustomFields(ctx, group.ID, models.CustomFieldsInput{Full: customFields}); err != nil { return fmt.Errorf("Error setting custom fields for group %d: %s", group.ID, err.Error()) } } groupIDs = append(groupIDs, group.ID) groupNames = append(groupNames, group.Name) } return nil } func getPerformerStringValue(index int, field string) string { return getPrefixedStringValue("performer", index, field) } func getPerformerNullStringValue(index int, field string) string { ret := getPrefixedNullStringValue("performer", index, field) return ret.String } func getPerformerEmptyString(index int, field string) string { v := getPrefixedNullStringValue("performer", index, field) if !v.Valid { return "" } return v.String } func getPerformerBoolValue(index int) bool { index = index % 2 return index == 1 } func getPerformerBirthdate(index int) *models.Date { const minAge = 18 birthdate := time.Now() birthdate = birthdate.AddDate(-minAge-index, -1, -1) ret := models.Date{ Time: birthdate, } return &ret } func getPerformerDeathDate(index int) *models.Date { if index != 5 { return nil } deathDate := time.Now() deathDate = deathDate.AddDate(-index+1, -1, -1) ret := models.Date{ Time: deathDate, } return &ret } func getPerformerCareerStart(index int) *models.Date { if index%5 == 0 { return nil } date := models.DateFromYear(2000 + index) return &date } func getPerformerCareerEnd(index int) *models.Date { if index%5 == 0 { return nil } // only set career_end for even indices if index%2 == 0 { date := models.DateFromYear(2010 + index) return &date } return nil } func getPerformerPenisLength(index int) *float64 { if index%5 == 0 { return nil } ret := float64(index) return &ret } func getPerformerCircumcised(index int) *models.CircumcisedEnum { var ret models.CircumcisedEnum switch { case index%3 == 0: return nil case index%3 == 1: ret = models.CircumcisedEnumCut default: ret = models.CircumcisedEnumUncut } return &ret } func getIgnoreAutoTag(index int) bool { return index%5 == 0 } func performerStashID(i int) models.StashID { return models.StashID{ StashID: getPerformerStringValue(i, "stashid"), Endpoint: getPerformerStringValue(0, "endpoint"), } } func performerAliases(i int) []string { if i%5 == 0 { return []string{} } return []string{getPerformerStringValue(i, "alias")} } func getPerformerCustomFields(index int) map[string]interface{} { if index%5 == 0 { return nil } return map[string]interface{}{ "string": getPerformerStringValue(index, "custom"), "int": int64(index % 5), "real": float64(index) / 10, } } // createPerformers creates n performers with plain Name and o performers with camel cased NaMe included func createPerformers(ctx context.Context, n int, o int) error { pqb := db.Performer const namePlain = "Name" const nameNoCase = "NaMe" name := namePlain for i := 0; i < n+o; i++ { index := i if i >= n { // i=n performers get dup names if case is not checked index = n + o - (i + 1) // for the name to be the same the number (index) must be the same also } // so count backwards to 0 as needed // performers [ i ] and [ n + o - i - 1 ] should have similar names with only the Name!=NaMe part different tids := indexesToIDs(tagIDs, performerTags[i]) performer := models.Performer{ Name: getPerformerStringValue(index, name), Disambiguation: getPerformerStringValue(index, "disambiguation"), Aliases: models.NewRelatedStrings(performerAliases(index)), URLs: models.NewRelatedStrings([]string{ getPerformerEmptyString(i, urlField), }), Favorite: getPerformerBoolValue(i), Birthdate: getPerformerBirthdate(i), DeathDate: getPerformerDeathDate(i), Details: getPerformerStringValue(i, "Details"), Ethnicity: getPerformerStringValue(i, "Ethnicity"), PenisLength: getPerformerPenisLength(i), Circumcised: getPerformerCircumcised(i), Rating: getIntPtr(getRating(i)), IgnoreAutoTag: getIgnoreAutoTag(i), TagIDs: models.NewRelatedIDs(tids), } performer.CareerStart = getPerformerCareerStart(i) performer.CareerEnd = getPerformerCareerEnd(i) if (index+1)%5 != 0 { performer.StashIDs = models.NewRelatedStashIDs([]models.StashID{ performerStashID(i), }) } err := pqb.Create(ctx, &models.CreatePerformerInput{ Performer: &performer, CustomFields: getPerformerCustomFields(i), }) if err != nil { return fmt.Errorf("Error creating performer %v+: %s", performer, err.Error()) } performerIDs = append(performerIDs, performer.ID) performerNames = append(performerNames, performer.Name) } return nil } func getTagBoolValue(index int) bool { index = index % 2 return index == 1 } func getTagStringValue(index int, field string) string { return "tag_" + strconv.FormatInt(int64(index), 10) + "_" + field } func getTagSceneCount(id int) int { idx := indexFromID(tagIDs, id) return len(sceneTags.reverseLookup(idx)) } func getTagMarkerCount(id int) int { count := 0 idx := indexFromID(tagIDs, id) for _, s := range markerSpecs { if s.primaryTagIdx == idx || slices.Contains(s.tagIdxs, idx) { count++ } } return count } func getTagImageCount(id int) int { idx := indexFromID(tagIDs, id) return len(imageTags.reverseLookup(idx)) } func getTagGalleryCount(id int) int { idx := indexFromID(tagIDs, id) return len(galleryTags.reverseLookup(idx)) } func getTagPerformerCount(id int) int { idx := indexFromID(tagIDs, id) return len(performerTags.reverseLookup(idx)) } func getTagStudioCount(id int) int { idx := indexFromID(tagIDs, id) return len(studioTags.reverseLookup(idx)) } func getTagParentCount(id int) int { if id == tagIDs[tagIdxWithParentTag] || id == tagIDs[tagIdxWithGrandParent] || id == tagIDs[tagIdxWithParentAndChild] { return 1 } return 0 } func getTagChildCount(id int) int { if id == tagIDs[tagIdxWithChildTag] || id == tagIDs[tagIdxWithGrandChild] || id == tagIDs[tagIdxWithParentAndChild] { return 1 } return 0 } func tagStashID(i int) models.StashID { return models.StashID{ StashID: getTagStringValue(i, "stashid"), Endpoint: getTagStringValue(0, "endpoint"), } } func getTagCustomFields(index int) map[string]interface{} { if index%5 == 0 { return nil } return map[string]interface{}{ "string": getTagStringValue(index, "custom"), "int": int64(index % 5), "real": float64(index) / 10, } } // createTags creates n tags with plain Name and o tags with camel cased NaMe included func createTags(ctx context.Context, tqb models.TagReaderWriter, n int, o int) error { const namePlain = "Name" const nameNoCase = "NaMe" name := namePlain for i := 0; i < n+o; i++ { index := i if i >= n { // i=n tags get dup names if case is not checked index = n + o - (i + 1) // for the name to be the same the number (index) must be the same also } // so count backwards to 0 as needed // tags [ i ] and [ n + o - i - 1 ] should have similar names with only the Name!=NaMe part different tag := models.Tag{ Name: getTagStringValue(index, name), IgnoreAutoTag: getIgnoreAutoTag(i), } if (index+1)%5 != 0 { tag.StashIDs = models.NewRelatedStashIDs([]models.StashID{ tagStashID(i), }) } err := tqb.Create(ctx, &models.CreateTagInput{ Tag: &tag, CustomFields: getTagCustomFields(i), }) if err != nil { return fmt.Errorf("Error creating tag %v+: %s", tag, err.Error()) } // add alias alias := getTagStringValue(i, "Alias") if err := tqb.UpdateAliases(ctx, tag.ID, []string{alias}); err != nil { return fmt.Errorf("error setting tag alias: %s", err.Error()) } tagIDs = append(tagIDs, tag.ID) tagNames = append(tagNames, tag.Name) } return nil } func getStudioStringValue(index int, field string) string { return getPrefixedStringValue("studio", index, field) } func getStudioNullStringValue(index int, field string) string { ret := getPrefixedNullStringValue("studio", index, field) return ret.String } func getStudioCustomFields(index int) map[string]interface{} { if index%5 == 0 { return nil } return map[string]interface{}{ "string": getStudioStringValue(index, "custom"), "int": int64(index % 5), "real": float64(index) / 10, } } func createStudio(ctx context.Context, sqb *sqlite.StudioStore, name string, parentID *int, customFields map[string]interface{}) (*models.Studio, error) { studio := models.Studio{ Name: name, } if parentID != nil { studio.ParentID = parentID } err := createStudioFromModel(ctx, sqb, &studio, customFields) if err != nil { return nil, err } return &studio, nil } func createStudioFromModel(ctx context.Context, sqb *sqlite.StudioStore, studio *models.Studio, customFields map[string]interface{}) error { err := sqb.Create(ctx, &models.CreateStudioInput{ Studio: studio, CustomFields: customFields, }) if err != nil { return fmt.Errorf("Error creating studio %v+: %s", studio, err.Error()) } return nil } func getStudioBoolValue(index int) bool { index = index % 2 return index == 1 } func getStudioEmptyString(index int, field string) string { v := getPrefixedNullStringValue("studio", index, field) if !v.Valid { return "" } return v.String } func getStudioStringList(index int, field string) []string { v := getStudioEmptyString(index, field) if v == "" { return []string{} } return []string{v} } // createStudios creates n studios with plain Name and o studios with camel cased NaMe included func createStudios(ctx context.Context, n int, o int) error { sqb := db.Studio const namePlain = "Name" const nameNoCase = "NaMe" for i := 0; i < n+o; i++ { index := i name := namePlain if i >= n { // i=n studios get dup names if case is not checked index = n + o - (i + 1) // for the name to be the same the number (index) must be the same also } // so count backwards to 0 as needed // studios [ i ] and [ n + o - i - 1 ] should have similar names with only the Name!=NaMe part different name = getStudioStringValue(index, name) tids := indexesToIDs(tagIDs, studioTags[i]) studio := models.Studio{ Name: name, URLs: models.NewRelatedStrings(getStudioStringList(i, urlField)), Favorite: getStudioBoolValue(index), IgnoreAutoTag: getIgnoreAutoTag(i), TagIDs: models.NewRelatedIDs(tids), } // only add aliases for some scenes if i == studioIdxWithGroup || i%5 == 0 { alias := getStudioStringValue(i, "Alias") studio.Aliases = models.NewRelatedStrings([]string{alias}) } err := createStudioFromModel(ctx, sqb, &studio, getStudioCustomFields(i)) if err != nil { return err } studioIDs = append(studioIDs, studio.ID) studioNames = append(studioNames, studio.Name) } return nil } func getMarkerEndSeconds(index int) *float64 { if index != markerIdxWithDuration && index != markerIdx2WithDuration { return nil } ret := float64(index) return &ret } func createMarker(ctx context.Context, mqb models.SceneMarkerReaderWriter, markerSpec markerSpec) error { markerIdx := len(markerIDs) marker := models.SceneMarker{ SceneID: sceneIDs[markerSpec.sceneIdx], PrimaryTagID: tagIDs[markerSpec.primaryTagIdx], EndSeconds: getMarkerEndSeconds(markerIdx), } err := mqb.Create(ctx, &marker) if err != nil { return fmt.Errorf("error creating marker %v+: %w", marker, err) } markerIDs = append(markerIDs, marker.ID) if len(markerSpec.tagIdxs) > 0 { newTagIDs := []int{} for _, tagIdx := range markerSpec.tagIdxs { newTagIDs = append(newTagIDs, tagIDs[tagIdx]) } if err := mqb.UpdateTags(ctx, marker.ID, newTagIDs); err != nil { return fmt.Errorf("error creating marker/tag join: %w", err) } } return nil } func createChapter(ctx context.Context, mqb models.GalleryChapterReaderWriter, chapterSpec chapterSpec) error { chapter := models.GalleryChapter{ GalleryID: sceneIDs[chapterSpec.galleryIdx], Title: chapterSpec.title, ImageIndex: chapterSpec.imageIndex, } err := mqb.Create(ctx, &chapter) if err != nil { return fmt.Errorf("error creating chapter %v+: %w", chapter, err) } chapterIDs = append(chapterIDs, chapter.ID) return nil } func getSavedFilterMode(index int) models.FilterMode { switch index { case savedFilterIdxScene: return models.FilterModeScenes case savedFilterIdxImage: return models.FilterModeImages default: return models.FilterModeScenes } } func getSavedFilterName(index int) string { if index <= savedFilterIdxImage { // use the same name for the first two - should be possible return firstSavedFilterName } return getPrefixedStringValue("savedFilter", index, "Name") } func createSavedFilters(ctx context.Context, qb models.SavedFilterReaderWriter, n int) error { for i := 0; i < n; i++ { filterQ := "" filterPage := i filterPerPage := i * 40 filterSort := "date" filterDirection := models.SortDirectionEnumAsc findFilter := models.FindFilterType{ Q: &filterQ, Page: &filterPage, PerPage: &filterPerPage, Sort: &filterSort, Direction: &filterDirection, } savedFilter := models.SavedFilter{ Mode: getSavedFilterMode(i), Name: getSavedFilterName(i), FindFilter: &findFilter, ObjectFilter: map[string]interface{}{ "test": "object", }, UIOptions: map[string]interface{}{ "display_mode": 1, "zoom_index": 1, }, } err := qb.Create(ctx, &savedFilter) if err != nil { return fmt.Errorf("Error creating saved filter %v+: %s", savedFilter, err.Error()) } savedFilterIDs = append(savedFilterIDs, savedFilter.ID) } return nil } func doLinks(links [][2]int, fn func(idx1, idx2 int) error) error { for _, l := range links { if err := fn(l[0], l[1]); err != nil { return err } } return nil } func linkGroupStudios(ctx context.Context, mqb models.GroupWriter) error { return doLinks(groupStudioLinks, func(groupIndex, studioIndex int) error { group := models.GroupPartial{ StudioID: models.NewOptionalInt(studioIDs[studioIndex]), } _, err := mqb.UpdatePartial(ctx, groupIDs[groupIndex], group) return err }) } func linkStudiosParent(ctx context.Context) error { qb := db.Studio return doLinks(studioParentLinks, func(parentIndex, childIndex int) error { input := &models.StudioPartial{ ID: studioIDs[childIndex], ParentID: models.NewOptionalInt(studioIDs[parentIndex]), } _, err := qb.UpdatePartial(ctx, *input) return err }) } func linkTagsParent(ctx context.Context, qb models.TagReaderWriter) error { return doLinks(tagParentLinks, func(parentIndex, childIndex int) error { tagID := tagIDs[childIndex] parentTags, err := qb.FindByChildTagID(ctx, tagID) if err != nil { return err } var parentIDs []int for _, parentTag := range parentTags { parentIDs = append(parentIDs, parentTag.ID) } parentIDs = append(parentIDs, tagIDs[parentIndex]) return qb.UpdateParentTags(ctx, tagID, parentIDs) }) } func linkGroupsParent(ctx context.Context, qb models.GroupReaderWriter) error { return doLinks(groupParentLinks, func(parentIndex, childIndex int) error { groupID := groupIDs[childIndex] p := models.GroupPartial{ ContainingGroups: &models.UpdateGroupDescriptions{ Groups: []models.GroupIDDescription{ {GroupID: groupIDs[parentIndex]}, }, Mode: models.RelationshipUpdateModeAdd, }, } _, err := qb.UpdatePartial(ctx, groupID, p) return err }) } func addTagImage(ctx context.Context, qb models.TagWriter, tagIndex int) error { return qb.UpdateImage(ctx, tagIDs[tagIndex], []byte("image")) } ================================================ FILE: pkg/sqlite/sql.go ================================================ package sqlite import ( "fmt" "math/rand" "regexp" "strconv" "strings" "time" "github.com/stashapp/stash/pkg/models" ) func selectAll(tableName string) string { idColumn := getColumn(tableName, "*") return "SELECT " + idColumn + " FROM " + tableName + " " } func distinctIDs(qb *queryBuilder, tableName string) { qb.addColumn("DISTINCT " + getColumn(tableName, "id")) qb.from = tableName } func selectIDs(qb *queryBuilder, tableName string) { qb.addColumn(getColumn(tableName, "id")) qb.from = tableName } func getColumn(tableName string, columnName string) string { return tableName + "." + columnName } func getPagination(findFilter *models.FindFilterType) string { if findFilter == nil { panic("nil find filter for pagination") } if findFilter.IsGetAll() { return " " } return getPaginationSQL(findFilter.GetPage(), findFilter.GetPageSize()) } func getPaginationSQL(page int, perPage int) string { page = (page - 1) * perPage return " LIMIT " + strconv.Itoa(perPage) + " OFFSET " + strconv.Itoa(page) + " " } const randomSeedPrefix = "random_" // prefix for random sort type sortOptions []string func (o sortOptions) validateSort(sort string) error { if strings.HasPrefix(sort, randomSeedPrefix) { // seed as a parameter from the UI seedStr := sort[len(randomSeedPrefix):] _, err := strconv.ParseUint(seedStr, 10, 64) if err != nil { return fmt.Errorf("invalid random seed: %s", seedStr) } return nil } for _, v := range o { if v == sort { return nil } } return fmt.Errorf("invalid sort: %s", sort) } func validateIsMissing(isMissing string, allowed []string) error { for _, v := range allowed { if v == isMissing { return nil } } return fmt.Errorf("invalid is_missing field: %s", isMissing) } func getSortDirection(direction string) string { if direction != "ASC" && direction != "DESC" { return "ASC" } else { return direction } } func getSort(sort string, direction string, tableName string) string { direction = getSortDirection(direction) switch { case strings.HasSuffix(sort, "_count"): var relationTableName = strings.TrimSuffix(sort, "_count") // TODO: pluralize? colName := getColumn(relationTableName, "id") return " ORDER BY COUNT(distinct " + colName + ") " + direction case strings.Compare(sort, "filesize") == 0: colName := getColumn(tableName, "size") return " ORDER BY " + colName + " " + direction case strings.HasPrefix(sort, randomSeedPrefix): // seed as a parameter from the UI seedStr := sort[len(randomSeedPrefix):] seed, err := strconv.ParseUint(seedStr, 10, 64) if err != nil { // fallback to a random seed seed = rand.Uint64() } return getRandomSort(tableName, direction, seed) case strings.Compare(sort, "random") == 0: return getRandomSort(tableName, direction, rand.Uint64()) default: colName := getColumn(tableName, sort) if strings.Contains(sort, ".") { colName = sort } if strings.Compare(sort, "name") == 0 { return " ORDER BY " + colName + " COLLATE NATURAL_CI " + direction } if strings.Compare(sort, "title") == 0 { return " ORDER BY " + colName + " COLLATE NATURAL_CI " + direction } return " ORDER BY " + colName + " " + direction } } func getRandomSort(tableName string, direction string, seed uint64) string { // cap seed at 10^8 seed %= 1e8 colName := getColumn(tableName, "id") // https://stackoverflow.com/questions/21949795#comment33255354_21949859 // p1 := 52959209 // p2 := 1047483763 // p3 := 2147483647 // n := // ORDER BY ((n+seed)*(n+seed)*p1 + (n+seed)*p2) % p3 // since sqlite converts overflowing numbers to reals, a custom db function that uses uints with overflow should be faster, // however in practice the overhead of calling a custom function vastly outweighs the benefits return fmt.Sprintf(" ORDER BY mod((%[1]s + %[2]d) * (%[1]s + %[2]d) * 52959209 + (%[1]s + %[2]d) * 1047483763, 2147483647) %[3]s", colName, seed, direction) } func getCountSort(primaryTable, joinTable, primaryFK, direction string) string { return fmt.Sprintf(" ORDER BY (SELECT COUNT(*) FROM %s AS sort WHERE sort.%s = %s.id) %s", joinTable, primaryFK, primaryTable, getSortDirection(direction)) } // getStringSearchClause returns a sqlClause for searching strings in the provided columns. // It is used for includes and excludes string criteria. func getStringSearchClause(columns []string, q string, not bool) sqlClause { var likeClauses []string var args []interface{} notStr := "" binaryType := " OR " if not { notStr = " NOT" binaryType = " AND " } q = strings.TrimSpace(q) trimmedQuery := strings.Trim(q, "\"") if trimmedQuery == q { q = regexp.MustCompile(`\s+`).ReplaceAllString(q, " ") queryWords := strings.Split(q, " ") // Search for any word for _, word := range queryWords { for _, column := range columns { likeClauses = append(likeClauses, column+notStr+" LIKE ?") args = append(args, "%"+word+"%") } } } else { // Search the exact query for _, column := range columns { likeClauses = append(likeClauses, column+notStr+" LIKE ?") args = append(args, "%"+trimmedQuery+"%") } } likes := strings.Join(likeClauses, binaryType) return makeClause("("+likes+")", args...) } func getEnumSearchClause(column string, enumVals []string, not bool) sqlClause { var args []interface{} notStr := "" if not { notStr = " NOT" } clause := fmt.Sprintf("(%s%s IN %s)", column, notStr, getInBinding(len(enumVals))) for _, enumVal := range enumVals { args = append(args, enumVal) } return makeClause(clause, args...) } func getInBinding(length int) string { bindings := strings.Repeat("?, ", length) bindings = strings.TrimRight(bindings, ", ") return "(" + bindings + ")" } func getIntCriterionWhereClause(column string, input models.IntCriterionInput) (string, []interface{}) { return getIntWhereClause(column, input.Modifier, input.Value, input.Value2) } func getIntWhereClause(column string, modifier models.CriterionModifier, value int, upper *int) (string, []interface{}) { if upper == nil { u := 0 upper = &u } args := []interface{}{value, *upper} return getNumericWhereClause(column, modifier, args) } func getFloatCriterionWhereClause(column string, input models.FloatCriterionInput) (string, []interface{}) { return getFloatWhereClause(column, input.Modifier, input.Value, input.Value2) } func getFloatWhereClause(column string, modifier models.CriterionModifier, value float64, upper *float64) (string, []interface{}) { if upper == nil { u := 0.0 upper = &u } args := []interface{}{value, *upper} return getNumericWhereClause(column, modifier, args) } func getNumericWhereClause(column string, modifier models.CriterionModifier, args []interface{}) (string, []interface{}) { singleArgs := args[0:1] switch modifier { case models.CriterionModifierIsNull: return fmt.Sprintf("%s IS NULL", column), nil case models.CriterionModifierNotNull: return fmt.Sprintf("%s IS NOT NULL", column), nil case models.CriterionModifierEquals: return fmt.Sprintf("%s = ?", column), singleArgs case models.CriterionModifierNotEquals: return fmt.Sprintf("%s != ?", column), singleArgs case models.CriterionModifierBetween: return fmt.Sprintf("%s BETWEEN ? AND ?", column), args case models.CriterionModifierNotBetween: return fmt.Sprintf("%s NOT BETWEEN ? AND ?", column), args case models.CriterionModifierLessThan: return fmt.Sprintf("%s < ?", column), singleArgs case models.CriterionModifierGreaterThan: return fmt.Sprintf("%s > ?", column), singleArgs } panic("unsupported numeric modifier type " + modifier) } func getDateCriterionWhereClause(column string, input models.DateCriterionInput) (string, []interface{}) { return getDateWhereClause(column, input.Modifier, input.Value, input.Value2) } func getDateWhereClause(column string, modifier models.CriterionModifier, value string, upper *string) (string, []interface{}) { if upper == nil { u := time.Now().AddDate(0, 0, 1).Format(time.RFC3339) upper = &u } valueDate, _ := models.ParseDate(value) date := Date{Date: valueDate.Time} args := []interface{}{date} betweenArgs := []interface{}{date, *upper} switch modifier { case models.CriterionModifierIsNull: return fmt.Sprintf("(%s IS NULL OR %s = '')", column, column), nil case models.CriterionModifierNotNull: return fmt.Sprintf("(%s IS NOT NULL AND %s != '')", column, column), nil case models.CriterionModifierEquals: return fmt.Sprintf("%s = ?", column), args case models.CriterionModifierNotEquals: return fmt.Sprintf("%s != ?", column), args case models.CriterionModifierBetween: return fmt.Sprintf("%s BETWEEN ? AND ?", column), betweenArgs case models.CriterionModifierNotBetween: return fmt.Sprintf("%s NOT BETWEEN ? AND ?", column), betweenArgs case models.CriterionModifierLessThan: return fmt.Sprintf("%s < ?", column), args case models.CriterionModifierGreaterThan: return fmt.Sprintf("%s > ?", column), args } panic("unsupported date modifier type") } func getTimestampCriterionWhereClause(column string, input models.TimestampCriterionInput) (string, []interface{}) { return getTimestampWhereClause(column, input.Modifier, input.Value, input.Value2) } func getTimestampWhereClause(column string, modifier models.CriterionModifier, value string, upper *string) (string, []interface{}) { if upper == nil { u := time.Now().AddDate(0, 0, 1).Format(time.RFC3339) upper = &u } args := []interface{}{value} betweenArgs := []interface{}{value, *upper} switch modifier { case models.CriterionModifierIsNull: return fmt.Sprintf("%s IS NULL", column), nil case models.CriterionModifierNotNull: return fmt.Sprintf("%s IS NOT NULL", column), nil case models.CriterionModifierEquals: return fmt.Sprintf("%s = ?", column), args case models.CriterionModifierNotEquals: return fmt.Sprintf("%s != ?", column), args case models.CriterionModifierBetween: return fmt.Sprintf("%s BETWEEN ? AND ?", column), betweenArgs case models.CriterionModifierNotBetween: return fmt.Sprintf("%s NOT BETWEEN ? AND ?", column), betweenArgs case models.CriterionModifierLessThan: return fmt.Sprintf("%s < ?", column), args case models.CriterionModifierGreaterThan: return fmt.Sprintf("%s > ?", column), args } panic("unsupported date modifier type") } // returns where clause and having clause func getMultiCriterionClause(primaryTable, foreignTable, joinTable, primaryFK, foreignFK string, criterion *models.MultiCriterionInput) (string, string) { whereClause := "" havingClause := "" switch criterion.Modifier { case models.CriterionModifierIncludes: // includes any of the provided ids if joinTable != "" { whereClause = joinTable + "." + foreignFK + " IN " + getInBinding(len(criterion.Value)) } else { whereClause = foreignTable + ".id IN " + getInBinding(len(criterion.Value)) } case models.CriterionModifierIncludesAll: // includes all of the provided ids if joinTable != "" { whereClause = joinTable + "." + foreignFK + " IN " + getInBinding(len(criterion.Value)) havingClause = "count(distinct " + joinTable + "." + foreignFK + ") IS " + strconv.Itoa(len(criterion.Value)) } else { whereClause = foreignTable + ".id IN " + getInBinding(len(criterion.Value)) havingClause = "count(distinct " + foreignTable + ".id) IS " + strconv.Itoa(len(criterion.Value)) } case models.CriterionModifierExcludes: // excludes all of the provided ids if joinTable != "" { whereClause = primaryTable + ".id not in (select " + joinTable + "." + primaryFK + " from " + joinTable + " where " + joinTable + "." + foreignFK + " in " + getInBinding(len(criterion.Value)) + ")" } else { whereClause = "not exists (select s.id from " + primaryTable + " as s where s.id = " + primaryTable + ".id and s." + foreignFK + " in " + getInBinding(len(criterion.Value)) + ")" } } return whereClause, havingClause } func getCountCriterionClause(primaryTable, joinTable, primaryFK string, criterion models.IntCriterionInput) (string, []interface{}) { lhs := fmt.Sprintf("(SELECT COUNT(*) FROM %s s WHERE s.%s = %s.id)", joinTable, primaryFK, primaryTable) return getIntCriterionWhereClause(lhs, criterion) } func coalesce(column string) string { return fmt.Sprintf("COALESCE(%s, '')", column) } func like(v string) string { return "%" + v + "%" } type sqlTable string func (t sqlTable) Name() string { return string(t) } func (t sqlTable) Col(n string) string { return fmt.Sprintf("%s.%s", string(t), n) } ================================================ FILE: pkg/sqlite/stash_id_test.go ================================================ //go:build integration // +build integration package sqlite_test import ( "context" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stretchr/testify/assert" ) type stashIDReaderWriter interface { GetStashIDs(ctx context.Context, performerID int) ([]models.StashID, error) UpdateStashIDs(ctx context.Context, performerID int, stashIDs []models.StashID) error } func testStashIDReaderWriter(ctx context.Context, t *testing.T, r stashIDReaderWriter, id int) { // ensure no stash IDs to begin with testNoStashIDs(ctx, t, r, id) // ensure GetStashIDs with non-existing also returns none testNoStashIDs(ctx, t, r, -1) // add stash ids const stashIDStr = "stashID" const endpoint = "endpoint" stashID := models.StashID{ StashID: stashIDStr, Endpoint: endpoint, UpdatedAt: epochTime, } // update stash ids and ensure was updated if err := r.UpdateStashIDs(ctx, id, []models.StashID{stashID}); err != nil { t.Error(err.Error()) } testStashIDs(ctx, t, r, id, []models.StashID{stashID}) // update non-existing id - should return error if err := r.UpdateStashIDs(ctx, -1, []models.StashID{stashID}); err == nil { t.Error("expected error when updating non-existing id") } // remove stash ids and ensure was updated if err := r.UpdateStashIDs(ctx, id, []models.StashID{}); err != nil { t.Error(err.Error()) } testNoStashIDs(ctx, t, r, id) } func testNoStashIDs(ctx context.Context, t *testing.T, r stashIDReaderWriter, id int) { t.Helper() stashIDs, err := r.GetStashIDs(ctx, id) if err != nil { t.Error(err.Error()) return } assert.Len(t, stashIDs, 0) } func testStashIDs(ctx context.Context, t *testing.T, r stashIDReaderWriter, id int, expected []models.StashID) { t.Helper() stashIDs, err := r.GetStashIDs(ctx, id) if err != nil { t.Error(err.Error()) return } assert.Equal(t, stashIDs, expected) } ================================================ FILE: pkg/sqlite/studio.go ================================================ package sqlite import ( "context" "database/sql" "errors" "fmt" "slices" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4/zero" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/studio" "github.com/stashapp/stash/pkg/utils" ) const ( studioTable = "studios" studioIDColumn = "studio_id" studioURLsTable = "studio_urls" studioURLColumn = "url" studioAliasesTable = "studio_aliases" studioAliasColumn = "alias" studioParentIDColumn = "parent_id" studioNameColumn = "name" studioImageBlobColumn = "image_blob" studiosTagsTable = "studios_tags" ) type studioRow struct { ID int `db:"id" goqu:"skipinsert"` Name zero.String `db:"name"` ParentID null.Int `db:"parent_id,omitempty"` CreatedAt Timestamp `db:"created_at"` UpdatedAt Timestamp `db:"updated_at"` // expressed as 1-100 Rating null.Int `db:"rating"` Favorite bool `db:"favorite"` Details zero.String `db:"details"` IgnoreAutoTag bool `db:"ignore_auto_tag"` Organized bool `db:"organized"` // not used in resolutions or updates ImageBlob zero.String `db:"image_blob"` } func (r *studioRow) fromStudio(o models.Studio) { r.ID = o.ID r.Name = zero.StringFrom(o.Name) r.ParentID = intFromPtr(o.ParentID) r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} r.Rating = intFromPtr(o.Rating) r.Favorite = o.Favorite r.Details = zero.StringFrom(o.Details) r.IgnoreAutoTag = o.IgnoreAutoTag r.Organized = o.Organized } func (r *studioRow) resolve() *models.Studio { ret := &models.Studio{ ID: r.ID, Name: r.Name.String, ParentID: nullIntPtr(r.ParentID), CreatedAt: r.CreatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp, Rating: nullIntPtr(r.Rating), Favorite: r.Favorite, Details: r.Details.String, IgnoreAutoTag: r.IgnoreAutoTag, Organized: r.Organized, } return ret } type studioRowRecord struct { updateRecord } func (r *studioRowRecord) fromPartial(o models.StudioPartial) { r.setNullString("name", o.Name) r.setNullInt("parent_id", o.ParentID) r.setTimestamp("created_at", o.CreatedAt) r.setTimestamp("updated_at", o.UpdatedAt) r.setNullInt("rating", o.Rating) r.setBool("favorite", o.Favorite) r.setNullString("details", o.Details) r.setBool("ignore_auto_tag", o.IgnoreAutoTag) r.setBool("organized", o.Organized) } type studioRepositoryType struct { repository stashIDs stashIDRepository tags joinRepository scenes repository images repository galleries repository groups repository } var ( studioRepository = studioRepositoryType{ repository: repository{ tableName: studioTable, idColumn: idColumn, }, stashIDs: stashIDRepository{ repository{ tableName: "studio_stash_ids", idColumn: studioIDColumn, }, }, scenes: repository{ tableName: sceneTable, idColumn: studioIDColumn, }, images: repository{ tableName: imageTable, idColumn: studioIDColumn, }, galleries: repository{ tableName: galleryTable, idColumn: studioIDColumn, }, groups: repository{ tableName: groupTable, idColumn: studioIDColumn, }, tags: joinRepository{ repository: repository{ tableName: studiosTagsTable, idColumn: studioIDColumn, }, fkColumn: tagIDColumn, foreignTable: tagTable, orderBy: tagTableSortSQL, }, } ) type StudioStore struct { blobJoinQueryBuilder customFieldsStore tagRelationshipStore tableMgr *table } func NewStudioStore(blobStore *BlobStore) *StudioStore { return &StudioStore{ blobJoinQueryBuilder: blobJoinQueryBuilder{ blobStore: blobStore, joinTable: studioTable, }, customFieldsStore: customFieldsStore{ table: studiosCustomFieldsTable, fk: studiosCustomFieldsTable.Col(studioIDColumn), }, tagRelationshipStore: tagRelationshipStore{ idRelationshipStore: idRelationshipStore{ joinTable: studiosTagsTableMgr, }, }, tableMgr: studioTableMgr, } } func (qb *StudioStore) table() exp.IdentifierExpression { return qb.tableMgr.table } func (qb *StudioStore) selectDataset() *goqu.SelectDataset { return dialect.From(qb.table()).Select(qb.table().All()) } func (qb *StudioStore) Create(ctx context.Context, newObject *models.CreateStudioInput) error { var err error var r studioRow r.fromStudio(*newObject.Studio) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { return err } if newObject.Aliases.Loaded() { if err := studio.ValidateAliases(ctx, id, newObject.Aliases.List(), qb); err != nil { return err } if err := studiosAliasesTableMgr.insertJoins(ctx, id, newObject.Aliases.List()); err != nil { return err } } if newObject.URLs.Loaded() { const startPos = 0 if err := studiosURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { return err } } if err := qb.tagRelationshipStore.createRelationships(ctx, id, newObject.TagIDs); err != nil { return err } if newObject.StashIDs.Loaded() { if err := studiosStashIDsTableMgr.insertJoins(ctx, id, newObject.StashIDs.List()); err != nil { return err } } const partial = false if err := qb.setCustomFields(ctx, id, newObject.CustomFields, partial); err != nil { return err } updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } *newObject.Studio = *updated return nil } func (qb *StudioStore) UpdatePartial(ctx context.Context, input models.StudioPartial) (*models.Studio, error) { r := studioRowRecord{ updateRecord{ Record: make(exp.Record), }, } r.fromPartial(input) if len(r.Record) > 0 { if err := qb.tableMgr.updateByID(ctx, input.ID, r.Record); err != nil { return nil, err } } if input.Aliases != nil { if err := studiosAliasesTableMgr.modifyJoins(ctx, input.ID, input.Aliases.Values, input.Aliases.Mode); err != nil { return nil, err } } if input.URLs != nil { if err := studiosURLsTableMgr.modifyJoins(ctx, input.ID, input.URLs.Values, input.URLs.Mode); err != nil { return nil, err } } if err := qb.tagRelationshipStore.modifyRelationships(ctx, input.ID, input.TagIDs); err != nil { return nil, err } if input.StashIDs != nil { if err := studiosStashIDsTableMgr.modifyJoins(ctx, input.ID, input.StashIDs.StashIDs, input.StashIDs.Mode); err != nil { return nil, err } } if err := qb.SetCustomFields(ctx, input.ID, input.CustomFields); err != nil { return nil, err } return qb.find(ctx, input.ID) } // This is only used by the Import/Export functionality func (qb *StudioStore) Update(ctx context.Context, updatedObject *models.UpdateStudioInput) error { var r studioRow r.fromStudio(*updatedObject.Studio) if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { return err } if updatedObject.Aliases.Loaded() { if err := studiosAliasesTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.Aliases.List()); err != nil { return err } } if updatedObject.URLs.Loaded() { if err := studiosURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { return err } } if err := qb.tagRelationshipStore.replaceRelationships(ctx, updatedObject.ID, updatedObject.TagIDs); err != nil { return err } if updatedObject.StashIDs.Loaded() { if err := studiosStashIDsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.StashIDs.List()); err != nil { return err } } if err := qb.SetCustomFields(ctx, updatedObject.ID, updatedObject.CustomFields); err != nil { return err } return nil } func (qb *StudioStore) Destroy(ctx context.Context, id int) error { // must handle image checksums manually if err := qb.destroyImage(ctx, id); err != nil { return err } return studioRepository.destroyExisting(ctx, []int{id}) } // returns nil, nil if not found func (qb *StudioStore) Find(ctx context.Context, id int) (*models.Studio, error) { ret, err := qb.find(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } return ret, err } func (qb *StudioStore) FindMany(ctx context.Context, ids []int) ([]*models.Studio, error) { ret := make([]*models.Studio, len(ids)) table := qb.table() if err := batchExec(ids, defaultBatchSize, func(batch []int) error { q := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(batch)) unsorted, err := qb.getMany(ctx, q) if err != nil { return err } for _, s := range unsorted { i := slices.Index(ids, s.ID) ret[i] = s } return nil }); err != nil { return nil, err } for i := range ret { if ret[i] == nil { return nil, fmt.Errorf("studio with id %d not found", ids[i]) } } return ret, nil } // returns nil, sql.ErrNoRows if not found func (qb *StudioStore) find(ctx context.Context, id int) (*models.Studio, error) { q := qb.selectDataset().Where(qb.tableMgr.byID(id)) ret, err := qb.get(ctx, q) if err != nil { return nil, err } return ret, nil } // returns nil, sql.ErrNoRows if not found func (qb *StudioStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Studio, error) { ret, err := qb.getMany(ctx, q) if err != nil { return nil, err } if len(ret) == 0 { return nil, sql.ErrNoRows } return ret[0], nil } func (qb *StudioStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Studio, error) { const single = false var ret []*models.Studio if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { var f studioRow if err := r.StructScan(&f); err != nil { return err } s := f.resolve() ret = append(ret, s) return nil }); err != nil { return nil, err } return ret, nil } func (qb *StudioStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]*models.Studio, error) { table := qb.table() q := qb.selectDataset().Where( table.Col(idColumn).Eq( sq, ), ) return qb.getMany(ctx, q) } func (qb *StudioStore) FindChildren(ctx context.Context, id int) ([]*models.Studio, error) { // SELECT studios.* FROM studios WHERE studios.parent_id = ? table := qb.table() sq := qb.selectDataset().Where(table.Col(studioParentIDColumn).Eq(id)) ret, err := qb.getMany(ctx, sq) if err != nil { return nil, err } return ret, nil } func (qb *StudioStore) FindBySceneID(ctx context.Context, sceneID int) (*models.Studio, error) { // SELECT studios.* FROM studios JOIN scenes ON studios.id = scenes.studio_id WHERE scenes.id = ? LIMIT 1 table := qb.table() scenes := sceneTableMgr.table sq := qb.selectDataset().Join( scenes, goqu.On(table.Col(idColumn), scenes.Col(studioIDColumn)), ).Where( scenes.Col(idColumn), ).Limit(1) ret, err := qb.get(ctx, sq) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, err } return ret, nil } func (qb *StudioStore) FindByName(ctx context.Context, name string, nocase bool) (*models.Studio, error) { // query := "SELECT * FROM studios WHERE name = ?" // if nocase { // query += " COLLATE NOCASE" // } // query += " LIMIT 1" where := "name = ?" if nocase { where += " COLLATE NOCASE" } sq := qb.selectDataset().Prepared(true).Where(goqu.L(where, name)).Limit(1) ret, err := qb.get(ctx, sq) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, err } return ret, nil } func (qb *StudioStore) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Studio, error) { sq := dialect.From(studiosStashIDsJoinTable).Select(studiosStashIDsJoinTable.Col(studioIDColumn)).Where( studiosStashIDsJoinTable.Col("stash_id").Eq(stashID.StashID), studiosStashIDsJoinTable.Col("endpoint").Eq(stashID.Endpoint), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting studios for stash ID %s: %w", stashID.StashID, err) } return ret, nil } func (qb *StudioStore) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Studio, error) { table := qb.table() sq := dialect.From(table).LeftJoin( studiosStashIDsJoinTable, goqu.On(table.Col(idColumn).Eq(studiosStashIDsJoinTable.Col(studioIDColumn))), ).Select(table.Col(idColumn)) if hasStashID { sq = sq.Where( studiosStashIDsJoinTable.Col("stash_id").IsNotNull(), studiosStashIDsJoinTable.Col("endpoint").Eq(stashboxEndpoint), ) } else { sq = sq.Where( studiosStashIDsJoinTable.Col("stash_id").IsNull(), ) } ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting studios for stash-box endpoint %s: %w", stashboxEndpoint, err) } return ret, nil } func (qb *StudioStore) Count(ctx context.Context) (int, error) { q := dialect.Select(goqu.COUNT("*")).From(qb.table()) return count(ctx, q) } func (qb *StudioStore) All(ctx context.Context) ([]*models.Studio, error) { table := qb.table() return qb.getMany(ctx, qb.selectDataset().Order(table.Col(studioNameColumn).Asc())) } func (qb *StudioStore) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Studio, error) { // TODO - Query needs to be changed to support queries of this type, and // this method should be removed table := qb.table() sq := dialect.From(table).Select(table.Col(idColumn)).LeftJoin( studiosAliasesJoinTable, goqu.On(studiosAliasesJoinTable.Col(studioIDColumn).Eq(table.Col(idColumn))), ) var whereClauses []exp.Expression for _, w := range words { whereClauses = append(whereClauses, table.Col(studioNameColumn).Like(w+"%")) whereClauses = append(whereClauses, studiosAliasesJoinTable.Col("alias").Like(w+"%")) } sq = sq.Where( goqu.Or(whereClauses...), table.Col("ignore_auto_tag").Eq(0), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting studios for autotag: %w", err) } return ret, nil } func (qb *StudioStore) makeQuery(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if studioFilter == nil { studioFilter = &models.StudioFilterType{} } if findFilter == nil { findFilter = &models.FindFilterType{} } query := studioRepository.newQuery() distinctIDs(&query, studioTable) if q := findFilter.Q; q != nil && *q != "" { query.join(studioAliasesTable, "", "studio_aliases.studio_id = studios.id") searchColumns := []string{"studios.name", "studio_aliases.alias"} query.parseQueryString(searchColumns, *q) } filter := filterBuilderFromHandler(ctx, &studioFilterHandler{ studioFilter: studioFilter, }) if err := query.addFilter(filter); err != nil { return nil, err } var err error query.sortAndPagination, err = qb.getStudioSort(findFilter) if err != nil { return nil, err } query.sortAndPagination += getPagination(findFilter) return &query, nil } func (qb *StudioStore) Query(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) ([]*models.Studio, int, error) { query, err := qb.makeQuery(ctx, studioFilter, findFilter) if err != nil { return nil, 0, err } idsResult, countResult, err := query.executeFind(ctx) if err != nil { return nil, 0, err } studios, err := qb.FindMany(ctx, idsResult) if err != nil { return nil, 0, err } return studios, countResult, nil } func (qb *StudioStore) QueryCount(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) (int, error) { query, err := qb.makeQuery(ctx, studioFilter, findFilter) if err != nil { return 0, err } return query.executeCount(ctx) } func (qb *StudioStore) sortByScenesDuration(direction string) string { return fmt.Sprintf(` ORDER BY ( SELECT COALESCE(SUM(video_files.duration), 0) FROM %s LEFT JOIN %s ON %s.%s = %s.id LEFT JOIN video_files ON video_files.file_id = %s.file_id WHERE %s.%s = %s.id ) %s`, sceneTable, scenesFilesTable, scenesFilesTable, sceneIDColumn, sceneTable, scenesFilesTable, sceneTable, studioIDColumn, studioTable, getSortDirection(direction)) } func (qb *StudioStore) sortByScenesSize(direction string) string { return fmt.Sprintf(` ORDER BY ( SELECT COALESCE(SUM(%s.size), 0) FROM %s LEFT JOIN %s ON %s.%s = %s.id LEFT JOIN %s ON %s.id = %s.file_id WHERE %s.%s = %s.id ) %s`, fileTable, sceneTable, scenesFilesTable, scenesFilesTable, sceneIDColumn, sceneTable, fileTable, fileTable, scenesFilesTable, sceneTable, studioIDColumn, studioTable, getSortDirection(direction)) } // used for sorting on performer latest scene var selectStudioLatestSceneSQL = utils.StrFormat( "SELECT MAX(date) FROM ("+ "SELECT {date} FROM {scenes} s "+ "WHERE s.{studio_id} = {studios}.id"+ ")", map[string]interface{}{ "scenes": sceneTable, "studios": studioTable, "studio_id": studioIDColumn, "date": sceneDateColumn, }, ) func (qb *StudioStore) sortByLatestScene(direction string) string { // need to get the latest date from scenes return " ORDER BY (" + selectStudioLatestSceneSQL + ") " + direction } var studioSortOptions = sortOptions{ "child_count", "created_at", "galleries_count", "id", "images_count", "latest_scene", "name", "scenes_count", "scenes_duration", "scenes_size", "random", "rating", "tag_count", "updated_at", } func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) (string, error) { var sort string var direction string if findFilter == nil { sort = "name" direction = "ASC" } else { sort = findFilter.GetSort("name") direction = findFilter.GetDirection() } // CVE-2024-32231 - ensure sort is in the list of allowed sorts if err := studioSortOptions.validateSort(sort); err != nil { return "", err } sortQuery := "" switch sort { case "tag_count": sortQuery += getCountSort(studioTable, studiosTagsTable, studioIDColumn, direction) case "scenes_count": sortQuery += getCountSort(studioTable, sceneTable, studioIDColumn, direction) case "scenes_duration": sortQuery += qb.sortByScenesDuration(direction) case "scenes_size": sortQuery += qb.sortByScenesSize(direction) case "images_count": sortQuery += getCountSort(studioTable, imageTable, studioIDColumn, direction) case "galleries_count": sortQuery += getCountSort(studioTable, galleryTable, studioIDColumn, direction) case "child_count": sortQuery += getCountSort(studioTable, studioTable, studioParentIDColumn, direction) case "latest_scene": sortQuery += qb.sortByLatestScene(direction) default: sortQuery += getSort(sort, direction, "studios") } // Whatever the sorting, always use name/id as a final sort sortQuery += ", COALESCE(studios.name, studios.id) COLLATE NATURAL_CI ASC" return sortQuery, nil } func (qb *StudioStore) GetImage(ctx context.Context, studioID int) ([]byte, error) { return qb.blobJoinQueryBuilder.GetImage(ctx, studioID, studioImageBlobColumn) } func (qb *StudioStore) HasImage(ctx context.Context, studioID int) (bool, error) { return qb.blobJoinQueryBuilder.HasImage(ctx, studioID, studioImageBlobColumn) } func (qb *StudioStore) UpdateImage(ctx context.Context, studioID int, image []byte) error { return qb.blobJoinQueryBuilder.UpdateImage(ctx, studioID, studioImageBlobColumn, image) } func (qb *StudioStore) destroyImage(ctx context.Context, studioID int) error { return qb.blobJoinQueryBuilder.DestroyImage(ctx, studioID, studioImageBlobColumn) } func (qb *StudioStore) GetStashIDs(ctx context.Context, studioID int) ([]models.StashID, error) { return studiosStashIDsTableMgr.get(ctx, studioID) } func (qb *StudioStore) GetAliases(ctx context.Context, studioID int) ([]string, error) { return studiosAliasesTableMgr.get(ctx, studioID) } func (qb *StudioStore) GetURLs(ctx context.Context, studioID int) ([]string, error) { return studiosURLsTableMgr.get(ctx, studioID) } ================================================ FILE: pkg/sqlite/studio_filter.go ================================================ package sqlite import ( "context" "github.com/stashapp/stash/pkg/models" ) type studioFilterHandler struct { studioFilter *models.StudioFilterType } func (qb *studioFilterHandler) validate() error { studioFilter := qb.studioFilter if studioFilter == nil { return nil } if err := validateFilterCombination(studioFilter.OperatorFilter); err != nil { return err } if subFilter := studioFilter.SubFilter(); subFilter != nil { sqb := &studioFilterHandler{studioFilter: subFilter} if err := sqb.validate(); err != nil { return err } } return nil } func (qb *studioFilterHandler) handle(ctx context.Context, f *filterBuilder) { studioFilter := qb.studioFilter if studioFilter == nil { return } if err := qb.validate(); err != nil { f.setError(err) return } sf := studioFilter.SubFilter() if sf != nil { sub := &studioFilterHandler{sf} handleSubFilter(ctx, sub, f, studioFilter.OperatorFilter) } f.handleCriterion(ctx, qb.criterionHandler()) } func (qb *studioFilterHandler) criterionHandler() criterionHandler { studioFilter := qb.studioFilter return compoundHandler{ stringCriterionHandler(studioFilter.Name, studioTable+".name"), stringCriterionHandler(studioFilter.Details, studioTable+".details"), qb.urlsCriterionHandler(studioFilter.URL), intCriterionHandler(studioFilter.Rating100, studioTable+".rating", nil), boolCriterionHandler(studioFilter.Favorite, studioTable+".favorite", nil), boolCriterionHandler(studioFilter.IgnoreAutoTag, studioTable+".ignore_auto_tag", nil), boolCriterionHandler(studioFilter.Organized, studioTable+".organized", nil), criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if studioFilter.StashID != nil { studioRepository.stashIDs.join(f, "studio_stash_ids", "studios.id") stringCriterionHandler(studioFilter.StashID, "studio_stash_ids.stash_id")(ctx, f) } }), &stashIDCriterionHandler{ c: studioFilter.StashIDEndpoint, stashIDRepository: &studioRepository.stashIDs, stashIDTableAs: "studio_stash_ids", parentIDCol: "studios.id", }, &stashIDsCriterionHandler{ c: studioFilter.StashIDsEndpoint, stashIDRepository: &studioRepository.stashIDs, stashIDTableAs: "studio_stash_ids", parentIDCol: "studios.id", }, qb.isMissingCriterionHandler(studioFilter.IsMissing), qb.tagCountCriterionHandler(studioFilter.TagCount), qb.sceneCountCriterionHandler(studioFilter.SceneCount), qb.imageCountCriterionHandler(studioFilter.ImageCount), qb.galleryCountCriterionHandler(studioFilter.GalleryCount), qb.groupCountCriterionHandler(studioFilter.GroupCount), qb.parentCriterionHandler(studioFilter.Parents), qb.aliasCriterionHandler(studioFilter.Aliases), qb.tagsCriterionHandler(studioFilter.Tags), qb.childCountCriterionHandler(studioFilter.ChildCount), ×tampCriterionHandler{studioFilter.CreatedAt, studioTable + ".created_at", nil}, ×tampCriterionHandler{studioFilter.UpdatedAt, studioTable + ".updated_at", nil}, &relatedFilterHandler{ relatedIDCol: "scenes.id", relatedRepo: sceneRepository.repository, relatedHandler: &sceneFilterHandler{studioFilter.ScenesFilter}, joinFn: func(f *filterBuilder) { studioRepository.scenes.innerJoin(f, "", "studios.id") }, }, &relatedFilterHandler{ relatedIDCol: "images.id", relatedRepo: imageRepository.repository, relatedHandler: &imageFilterHandler{studioFilter.ImagesFilter}, joinFn: func(f *filterBuilder) { studioRepository.images.innerJoin(f, "", "studios.id") }, }, &relatedFilterHandler{ relatedIDCol: "galleries.id", relatedRepo: galleryRepository.repository, relatedHandler: &galleryFilterHandler{studioFilter.GalleriesFilter}, joinFn: func(f *filterBuilder) { studioRepository.galleries.innerJoin(f, "", "studios.id") }, }, &relatedFilterHandler{ relatedIDCol: "groups.id", relatedRepo: groupRepository.repository, relatedHandler: &groupFilterHandler{studioFilter.GroupsFilter}, joinFn: func(f *filterBuilder) { studioRepository.groups.innerJoin(f, "", "studios.id") }, }, &customFieldsFilterHandler{ table: studiosCustomFieldsTable.GetTable(), fkCol: studioIDColumn, c: studioFilter.CustomFields, idCol: "studios.id", }, } } func (qb *studioFilterHandler) isMissingCriterionHandler(isMissing *string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if isMissing != nil && *isMissing != "" { switch *isMissing { case "url": studiosURLsTableMgr.join(f, "", "studios.id") f.addWhere("studio_urls.url IS NULL") case "image": f.addWhere("studios.image_blob IS NULL") case "stash_id": studioRepository.stashIDs.join(f, "studio_stash_ids", "studios.id") f.addWhere("studio_stash_ids.studio_id IS NULL") case "aliases": studiosAliasesTableMgr.join(f, "", "studios.id") f.addWhere("studio_aliases.alias IS NULL") case "tags": f.addLeftJoin(studiosTagsTable, "tags_join", "tags_join.studio_id = studios.id") f.addWhere("tags_join.studio_id IS NULL") default: if err := validateIsMissing(*isMissing, []string{ "details", "rating", }); err != nil { f.setError(err) return } f.addWhere("(studios." + *isMissing + " IS NULL OR TRIM(studios." + *isMissing + ") = '')") } } } } func (qb *studioFilterHandler) sceneCountCriterionHandler(sceneCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if sceneCount != nil { f.addLeftJoin("scenes", "", "scenes.studio_id = studios.id") clause, args := getIntCriterionWhereClause("count(distinct scenes.id)", *sceneCount) f.addHaving(clause, args...) } } } func (qb *studioFilterHandler) imageCountCriterionHandler(imageCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if imageCount != nil { f.addLeftJoin("images", "", "images.studio_id = studios.id") clause, args := getIntCriterionWhereClause("count(distinct images.id)", *imageCount) f.addHaving(clause, args...) } } } func (qb *studioFilterHandler) galleryCountCriterionHandler(galleryCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if galleryCount != nil { f.addLeftJoin("galleries", "", "galleries.studio_id = studios.id") clause, args := getIntCriterionWhereClause("count(distinct galleries.id)", *galleryCount) f.addHaving(clause, args...) } } } func (qb *studioFilterHandler) groupCountCriterionHandler(groupCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if groupCount != nil { f.addLeftJoin("groups", "", "groups.studio_id = studios.id") clause, args := getIntCriterionWhereClause("count(distinct groups.id)", *groupCount) f.addHaving(clause, args...) } } } func (qb *studioFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: studioTable, joinTable: studiosTagsTable, primaryFK: studioIDColumn, } return h.handler(tagCount) } func (qb *studioFilterHandler) parentCriterionHandler(parents *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { f.addLeftJoin("studios", "parent_studio", "parent_studio.id = studios.parent_id") } h := multiCriterionHandlerBuilder{ primaryTable: studioTable, foreignTable: "parent_studio", joinTable: "", primaryFK: studioIDColumn, foreignFK: "parent_id", addJoinsFunc: addJoinsFunc, } return h.handler(parents) } func (qb *studioFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ primaryTable: studioTable, primaryFK: studioIDColumn, joinTable: studioAliasesTable, stringColumn: studioAliasColumn, addJoinTable: func(f *filterBuilder) { studiosAliasesTableMgr.join(f, "", "studios.id") }, } return h.handler(alias) } func (qb *studioFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ primaryTable: studioTable, primaryFK: studioIDColumn, joinTable: studioURLsTable, stringColumn: studioURLColumn, addJoinTable: func(f *filterBuilder) { studiosURLsTableMgr.join(f, "", "studios.id") }, } return h.handler(url) } func (qb *studioFilterHandler) childCountCriterionHandler(childCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if childCount != nil { f.addLeftJoin("studios", "children_count", "children_count.parent_id = studios.id") clause, args := getIntCriterionWhereClause("count(distinct children_count.id)", *childCount) f.addHaving(clause, args...) } } } func (qb *studioFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { h := joinedHierarchicalMultiCriterionHandlerBuilder{ primaryTable: studioTable, foreignTable: tagTable, foreignFK: "tag_id", relationsTable: "tags_relations", joinTable: studiosTagsTable, joinAs: "studio_tag", primaryFK: studioIDColumn, } return h.handler(tags) } ================================================ FILE: pkg/sqlite/studio_test.go ================================================ //go:build integration // +build integration package sqlite_test import ( "context" "errors" "fmt" "math" "strconv" "strings" "testing" "time" "github.com/stashapp/stash/pkg/models" "github.com/stretchr/testify/assert" ) func TestStudioFindByName(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Studio name := studioNames[studioIdxWithScene] // find a studio by name studio, err := sqb.FindByName(ctx, name, false) if err != nil { t.Errorf("Error finding studios: %s", err.Error()) } assert.Equal(t, studioNames[studioIdxWithScene], studio.Name) name = studioNames[studioIdxWithDupName] // find a studio by name nocase studio, err = sqb.FindByName(ctx, name, true) if err != nil { t.Errorf("Error finding studios: %s", err.Error()) } // studioIdxWithDupName and studioIdxWithScene should have similar names ( only diff should be Name vs NaMe) //studio.Name should match with studioIdxWithScene since its ID is before studioIdxWithDupName assert.Equal(t, studioNames[studioIdxWithScene], studio.Name) //studio.Name should match with studioIdxWithDupName if the check is not case sensitive assert.Equal(t, strings.ToLower(studioNames[studioIdxWithDupName]), strings.ToLower(studio.Name)) return nil }) } func loadStudioRelationships(ctx context.Context, expected models.Studio, actual *models.Studio) error { if expected.Aliases.Loaded() { if err := actual.LoadAliases(ctx, db.Studio); err != nil { return err } } if expected.URLs.Loaded() { if err := actual.LoadURLs(ctx, db.Studio); err != nil { return err } } if expected.TagIDs.Loaded() { if err := actual.LoadTagIDs(ctx, db.Studio); err != nil { return err } } if expected.StashIDs.Loaded() { if err := actual.LoadStashIDs(ctx, db.Studio); err != nil { return err } } return nil } func Test_StudioStore_Create(t *testing.T) { var ( name = "name" details = "details" url = "url" rating = 3 aliases = []string{"alias1", "alias2"} ignoreAutoTag = true organized = true favorite = true endpoint1 = "endpoint1" endpoint2 = "endpoint2" stashID1 = "stashid1" stashID2 = "stashid2" createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) ) tests := []struct { name string newObject models.CreateStudioInput wantErr bool }{ { "full", models.CreateStudioInput{ Studio: &models.Studio{ Name: name, URLs: models.NewRelatedStrings([]string{url}), Favorite: favorite, Rating: &rating, Details: details, IgnoreAutoTag: ignoreAutoTag, Organized: organized, TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithStudio], tagIDs[tagIdx1WithDupName]}), Aliases: models.NewRelatedStrings(aliases), StashIDs: models.NewRelatedStashIDs([]models.StashID{ { StashID: stashID1, Endpoint: endpoint1, UpdatedAt: epochTime, }, { StashID: stashID2, Endpoint: endpoint2, UpdatedAt: epochTime, }, }), CreatedAt: createdAt, UpdatedAt: updatedAt, }, CustomFields: testCustomFields, }, false, }, { "invalid tag id", models.CreateStudioInput{ Studio: &models.Studio{ Name: name, TagIDs: models.NewRelatedIDs([]int{invalidID}), }, }, true, }, } qb := db.Studio for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) p := tt.newObject if err := qb.Create(ctx, &p); (err != nil) != tt.wantErr { t.Errorf("StudioStore.Create() error = %v, wantErr = %v", err, tt.wantErr) } if tt.wantErr { assert.Zero(p.ID) return } assert.NotZero(p.ID) copy := *tt.newObject.Studio copy.ID = p.ID // load relationships if err := loadStudioRelationships(ctx, copy, p.Studio); err != nil { t.Errorf("loadStudioRelationships() error = %v", err) return } assert.Equal(copy, *p.Studio) // ensure can find the Studio found, err := qb.Find(ctx, p.ID) if err != nil { t.Errorf("StudioStore.Find() error = %v", err) } if !assert.NotNil(found) { return } // load relationships if err := loadStudioRelationships(ctx, copy, found); err != nil { t.Errorf("loadStudioRelationships() error = %v", err) return } assert.Equal(copy, *found) // ensure custom fields are set cf, err := qb.GetCustomFields(ctx, p.ID) if err != nil { t.Errorf("StudioStore.GetCustomFields() error = %v", err) return } assert.Equal(tt.newObject.CustomFields, cf) return }) } } func Test_StudioStore_Update(t *testing.T) { var ( name = "name" details = "details" url = "url" rating = 3 aliases = []string{"aliasX", "aliasY"} ignoreAutoTag = true organized = true favorite = true endpoint1 = "endpoint1" endpoint2 = "endpoint2" stashID1 = "stashid1" stashID2 = "stashid2" createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) ) tests := []struct { name string updatedObject models.UpdateStudioInput wantErr bool }{ { "full", models.UpdateStudioInput{ Studio: &models.Studio{ ID: studioIDs[studioIdxWithGallery], Name: name, URLs: models.NewRelatedStrings([]string{url}), Favorite: favorite, Rating: &rating, Details: details, IgnoreAutoTag: ignoreAutoTag, Organized: organized, Aliases: models.NewRelatedStrings(aliases), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithStudio]}), StashIDs: models.NewRelatedStashIDs([]models.StashID{ { StashID: stashID1, Endpoint: endpoint1, UpdatedAt: epochTime, }, { StashID: stashID2, Endpoint: endpoint2, UpdatedAt: epochTime, }, }), CreatedAt: createdAt, UpdatedAt: updatedAt, }, }, false, }, { "clear nullables", models.UpdateStudioInput{ Studio: &models.Studio{ ID: studioIDs[studioIdxWithGallery], Name: name, // name is mandatory URLs: models.NewRelatedStrings([]string{}), Aliases: models.NewRelatedStrings([]string{}), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), }, }, false, }, { "clear tag ids", models.UpdateStudioInput{ Studio: &models.Studio{ ID: studioIDs[sceneIdxWithTag], Name: name, // name is mandatory TagIDs: models.NewRelatedIDs([]int{}), }, }, false, }, { "set custom fields", models.UpdateStudioInput{ Studio: &models.Studio{ ID: studioIDs[studioIdxWithGallery], Name: name, // name is mandatory }, CustomFields: models.CustomFieldsInput{ Full: testCustomFields, }, }, false, }, { "clear custom fields", models.UpdateStudioInput{ Studio: &models.Studio{ ID: studioIDs[studioIdxWithGallery], Name: name, // name is mandatory }, CustomFields: models.CustomFieldsInput{ Full: map[string]interface{}{}, }, }, false, }, { "invalid tag id", models.UpdateStudioInput{ Studio: &models.Studio{ ID: studioIDs[sceneIdxWithGallery], Name: name, // name is mandatory TagIDs: models.NewRelatedIDs([]int{invalidID}), }, }, true, }, } qb := db.Studio for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) copy := *tt.updatedObject.Studio if err := qb.Update(ctx, &tt.updatedObject); (err != nil) != tt.wantErr { t.Errorf("StudioStore.Update() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { return } s, err := qb.Find(ctx, tt.updatedObject.ID) if err != nil { t.Errorf("StudioStore.Find() error = %v", err) } // load relationships if err := loadStudioRelationships(ctx, copy, s); err != nil { t.Errorf("loadStudioRelationships() error = %v", err) return } assert.Equal(copy, *s) // ensure custom fields are correct if tt.updatedObject.CustomFields.Full != nil { cf, err := qb.GetCustomFields(ctx, tt.updatedObject.ID) if err != nil { t.Errorf("StudioStore.GetCustomFields() error = %v", err) return } assert.Equal(tt.updatedObject.CustomFields.Full, cf) } }) } } func clearStudioPartial() models.StudioPartial { nullString := models.OptionalString{Set: true, Null: true} nullInt := models.OptionalInt{Set: true, Null: true} // leave mandatory fields return models.StudioPartial{ URLs: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet}, Aliases: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet}, Rating: nullInt, Details: nullString, TagIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet}, StashIDs: &models.UpdateStashIDs{Mode: models.RelationshipUpdateModeSet}, } } func Test_StudioStore_UpdatePartial(t *testing.T) { var ( name = "name" details = "details" url = "url" aliases = []string{"aliasX", "aliasY"} rating = 3 ignoreAutoTag = true organized = true favorite = true endpoint1 = "endpoint1" endpoint2 = "endpoint2" stashID1 = "stashid1" stashID2 = "stashid2" createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) ) tests := []struct { name string id int partial models.StudioPartial want models.Studio wantErr bool }{ { "full", studioIDs[studioIdxWithDupName], models.StudioPartial{ Name: models.NewOptionalString(name), URLs: &models.UpdateStrings{ Values: []string{url}, Mode: models.RelationshipUpdateModeSet, }, Aliases: &models.UpdateStrings{ Values: aliases, Mode: models.RelationshipUpdateModeSet, }, Favorite: models.NewOptionalBool(favorite), Rating: models.NewOptionalInt(rating), Details: models.NewOptionalString(details), IgnoreAutoTag: models.NewOptionalBool(ignoreAutoTag), Organized: models.NewOptionalBool(organized), TagIDs: &models.UpdateIDs{ IDs: []int{tagIDs[tagIdx1WithStudio], tagIDs[tagIdx1WithDupName]}, Mode: models.RelationshipUpdateModeSet, }, StashIDs: &models.UpdateStashIDs{ StashIDs: []models.StashID{ { StashID: stashID1, Endpoint: endpoint1, UpdatedAt: epochTime, }, { StashID: stashID2, Endpoint: endpoint2, UpdatedAt: epochTime, }, }, Mode: models.RelationshipUpdateModeSet, }, CreatedAt: models.NewOptionalTime(createdAt), UpdatedAt: models.NewOptionalTime(updatedAt), }, models.Studio{ ID: studioIDs[studioIdxWithDupName], Name: name, URLs: models.NewRelatedStrings([]string{url}), Aliases: models.NewRelatedStrings(aliases), Favorite: favorite, Rating: &rating, Details: details, IgnoreAutoTag: ignoreAutoTag, Organized: organized, TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithStudio]}), StashIDs: models.NewRelatedStashIDs([]models.StashID{ { StashID: stashID1, Endpoint: endpoint1, UpdatedAt: epochTime, }, { StashID: stashID2, Endpoint: endpoint2, UpdatedAt: epochTime, }, }), CreatedAt: createdAt, UpdatedAt: updatedAt, }, false, }, { "clear all", studioIDs[studioIdxWithTwoTags], clearStudioPartial(), models.Studio{ ID: studioIDs[studioIdxWithTwoTags], Name: getStudioStringValue(studioIdxWithTwoTags, "Name"), Favorite: getStudioBoolValue(studioIdxWithTwoTags), Aliases: models.NewRelatedStrings([]string{}), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), IgnoreAutoTag: getIgnoreAutoTag(studioIdxWithTwoTags), }, false, }, { "invalid id", invalidID, models.StudioPartial{Name: models.NewOptionalString(name)}, models.Studio{}, true, }, } for _, tt := range tests { qb := db.Studio runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) tt.partial.ID = tt.id got, err := qb.UpdatePartial(ctx, tt.partial) if (err != nil) != tt.wantErr { t.Errorf("StudioStore.UpdatePartial() error = %v, wantErr %v", err, tt.wantErr) return } if tt.wantErr { return } if err := loadStudioRelationships(ctx, tt.want, got); err != nil { t.Errorf("loadStudioRelationships() error = %v", err) return } assert.Equal(tt.want, *got) s, err := qb.Find(ctx, tt.id) if err != nil { t.Errorf("StudioStore.Find() error = %v", err) } // load relationships if err := loadStudioRelationships(ctx, tt.want, s); err != nil { t.Errorf("loadStudioRelationships() error = %v", err) return } assert.Equal(tt.want, *s) }) } } func Test_StudioStore_UpdatePartialCustomFields(t *testing.T) { tests := []struct { name string id int partial models.StudioPartial expected map[string]interface{} // nil to use the partial }{ { "set custom fields", studioIDs[studioIdxWithGallery], models.StudioPartial{ CustomFields: models.CustomFieldsInput{ Full: testCustomFields, }, }, nil, }, { "clear custom fields", studioIDs[studioIdxWithGallery], models.StudioPartial{ CustomFields: models.CustomFieldsInput{ Full: map[string]interface{}{}, }, }, nil, }, { "partial custom fields", studioIDs[studioIdxWithGallery], models.StudioPartial{ CustomFields: models.CustomFieldsInput{ Partial: map[string]interface{}{ "string": "bbb", "new_field": "new", }, }, }, map[string]interface{}{ "int": int64(2), "real": 0.7, "string": "bbb", "new_field": "new", }, }, } for _, tt := range tests { qb := db.Studio runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) tt.partial.ID = tt.id _, err := qb.UpdatePartial(ctx, tt.partial) if err != nil { t.Errorf("StudioStore.UpdatePartial() error = %v", err) return } // ensure custom fields are correct cf, err := qb.GetCustomFields(ctx, tt.id) if err != nil { t.Errorf("StudioStore.GetCustomFields() error = %v", err) return } if tt.expected == nil { assert.Equal(tt.partial.CustomFields.Full, cf) } else { assert.Equal(tt.expected, cf) } }) } } func TestStudioQueryNameOr(t *testing.T) { const studio1Idx = 1 const studio2Idx = 2 studio1Name := getStudioStringValue(studio1Idx, "Name") studio2Name := getStudioStringValue(studio2Idx, "Name") studioFilter := models.StudioFilterType{ Name: &models.StringCriterionInput{ Value: studio1Name, Modifier: models.CriterionModifierEquals, }, OperatorFilter: models.OperatorFilter[models.StudioFilterType]{ Or: &models.StudioFilterType{ Name: &models.StringCriterionInput{ Value: studio2Name, Modifier: models.CriterionModifierEquals, }, }, }, } withTxn(func(ctx context.Context) error { sqb := db.Studio studios := queryStudio(ctx, t, sqb, &studioFilter, nil) assert.Len(t, studios, 2) assert.Equal(t, studio1Name, studios[0].Name) assert.Equal(t, studio2Name, studios[1].Name) return nil }) } func TestStudioQueryNameAndUrl(t *testing.T) { const studioIdx = 1 studioName := getStudioStringValue(studioIdx, "Name") studioUrl := getStudioNullStringValue(studioIdx, urlField) studioFilter := models.StudioFilterType{ Name: &models.StringCriterionInput{ Value: studioName, Modifier: models.CriterionModifierEquals, }, OperatorFilter: models.OperatorFilter[models.StudioFilterType]{ And: &models.StudioFilterType{ URL: &models.StringCriterionInput{ Value: studioUrl, Modifier: models.CriterionModifierEquals, }, }, }, } withTxn(func(ctx context.Context) error { sqb := db.Studio studios := queryStudio(ctx, t, sqb, &studioFilter, nil) if !assert.Len(t, studios, 1) { return nil } if err := studios[0].LoadURLs(ctx, db.Studio); err != nil { t.Errorf("Error loading studio relationships: %v", err) } assert.Equal(t, studioName, studios[0].Name) assert.Equal(t, []string{studioUrl}, studios[0].URLs.List()) return nil }) } func TestStudioQueryNameNotUrl(t *testing.T) { const studioIdx = 1 studioUrl := getStudioNullStringValue(studioIdx, urlField) nameCriterion := models.StringCriterionInput{ Value: "studio_.*1_Name", Modifier: models.CriterionModifierMatchesRegex, } urlCriterion := models.StringCriterionInput{ Value: studioUrl, Modifier: models.CriterionModifierEquals, } studioFilter := models.StudioFilterType{ Name: &nameCriterion, OperatorFilter: models.OperatorFilter[models.StudioFilterType]{ Not: &models.StudioFilterType{ URL: &urlCriterion, }, }, } withTxn(func(ctx context.Context) error { sqb := db.Studio studios := queryStudio(ctx, t, sqb, &studioFilter, nil) for _, studio := range studios { if err := studio.LoadURLs(ctx, db.Studio); err != nil { t.Errorf("Error loading studio relationships: %v", err) } verifyString(t, studio.Name, nameCriterion) urlCriterion.Modifier = models.CriterionModifierNotEquals verifyStringList(t, studio.URLs.List(), urlCriterion) } return nil }) } func TestStudioIllegalQuery(t *testing.T) { assert := assert.New(t) const studioIdx = 1 subFilter := models.StudioFilterType{ Name: &models.StringCriterionInput{ Value: getStudioStringValue(studioIdx, "Name"), Modifier: models.CriterionModifierEquals, }, } studioFilter := &models.StudioFilterType{ OperatorFilter: models.OperatorFilter[models.StudioFilterType]{ And: &subFilter, Or: &subFilter, }, } withTxn(func(ctx context.Context) error { sqb := db.Studio _, _, err := sqb.Query(ctx, studioFilter, nil) assert.NotNil(err) studioFilter.Or = nil studioFilter.Not = &subFilter _, _, err = sqb.Query(ctx, studioFilter, nil) assert.NotNil(err) studioFilter.And = nil studioFilter.Or = &subFilter _, _, err = sqb.Query(ctx, studioFilter, nil) assert.NotNil(err) return nil }) } func TestStudioQueryIgnoreAutoTag(t *testing.T) { withTxn(func(ctx context.Context) error { ignoreAutoTag := true studioFilter := models.StudioFilterType{ IgnoreAutoTag: &ignoreAutoTag, } sqb := db.Studio studios := queryStudio(ctx, t, sqb, &studioFilter, nil) assert.Len(t, studios, int(math.Ceil(float64(totalStudios)/5))) for _, s := range studios { assert.True(t, s.IgnoreAutoTag) } return nil }) } func TestStudioQueryForAutoTag(t *testing.T) { withTxn(func(ctx context.Context) error { tqb := db.Studio name := studioNames[studioIdxWithGroup] // find a studio by name studios, err := tqb.QueryForAutoTag(ctx, []string{name}) if err != nil { t.Errorf("Error finding studios: %s", err.Error()) } assert.Len(t, studios, 1) assert.Equal(t, strings.ToLower(studioNames[studioIdxWithGroup]), strings.ToLower(studios[0].Name)) name = getStudioStringValue(studioIdxWithGroup, "Alias") studios, err = tqb.QueryForAutoTag(ctx, []string{name}) if err != nil { t.Errorf("Error finding studios: %s", err.Error()) } if assert.Len(t, studios, 1) { assert.Equal(t, studioIDs[studioIdxWithGroup], studios[0].ID) } return nil }) } func TestStudioQueryParent(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Studio studioCriterion := models.MultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithChildStudio]), }, Modifier: models.CriterionModifierIncludes, } studioFilter := models.StudioFilterType{ Parents: &studioCriterion, } studios, _, err := sqb.Query(ctx, &studioFilter, nil) if err != nil { t.Errorf("Error querying studio: %s", err.Error()) } assert.Len(t, studios, 1) // ensure id is correct assert.Equal(t, sceneIDs[studioIdxWithParentStudio], studios[0].ID) studioCriterion = models.MultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithChildStudio]), }, Modifier: models.CriterionModifierExcludes, } q := getStudioStringValue(studioIdxWithParentStudio, titleField) findFilter := models.FindFilterType{ Q: &q, } studios, _, err = sqb.Query(ctx, &studioFilter, &findFilter) if err != nil { t.Errorf("Error querying studio: %s", err.Error()) } assert.Len(t, studios, 0) return nil }) } func TestStudioDestroyParent(t *testing.T) { const parentName = "parent" const childName = "child" // create parent and child studios if err := withTxn(func(ctx context.Context) error { createdParent, err := createStudio(ctx, db.Studio, parentName, nil, nil) if err != nil { return fmt.Errorf("Error creating parent studio: %s", err.Error()) } parentID := createdParent.ID createdChild, err := createStudio(ctx, db.Studio, childName, &parentID, nil) if err != nil { return fmt.Errorf("Error creating child studio: %s", err.Error()) } sqb := db.Studio // destroy the parent err = sqb.Destroy(ctx, createdParent.ID) if err != nil { return fmt.Errorf("Error destroying parent studio: %s", err.Error()) } // destroy the child err = sqb.Destroy(ctx, createdChild.ID) if err != nil { return fmt.Errorf("Error destroying child studio: %s", err.Error()) } return nil }); err != nil { t.Error(err.Error()) } } func TestStudioFindChildren(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Studio studios, err := sqb.FindChildren(ctx, studioIDs[studioIdxWithChildStudio]) if err != nil { t.Errorf("error calling FindChildren: %s", err.Error()) } assert.Len(t, studios, 1) assert.Equal(t, studioIDs[studioIdxWithParentStudio], studios[0].ID) studios, err = sqb.FindChildren(ctx, 0) if err != nil { t.Errorf("error calling FindChildren: %s", err.Error()) } assert.Len(t, studios, 0) return nil }) } func TestStudioUpdateClearParent(t *testing.T) { const parentName = "clearParent_parent" const childName = "clearParent_child" // create parent and child studios if err := withTxn(func(ctx context.Context) error { createdParent, err := createStudio(ctx, db.Studio, parentName, nil, nil) if err != nil { return fmt.Errorf("Error creating parent studio: %s", err.Error()) } parentID := createdParent.ID createdChild, err := createStudio(ctx, db.Studio, childName, &parentID, nil) if err != nil { return fmt.Errorf("Error creating child studio: %s", err.Error()) } sqb := db.Studio // clear the parent id from the child input := models.StudioPartial{ ID: createdChild.ID, ParentID: models.NewOptionalIntPtr(nil), } updatedStudio, err := sqb.UpdatePartial(ctx, input) if err != nil { return fmt.Errorf("Error updated studio: %s", err.Error()) } if updatedStudio.ParentID != nil { return errors.New("updated studio has parent ID set") } return nil }); err != nil { t.Error(err.Error()) } } func TestStudioUpdateStudioImage(t *testing.T) { if err := withTxn(func(ctx context.Context) error { qb := db.Studio // create studio to test against const name = "TestStudioUpdateStudioImage" created, err := createStudio(ctx, db.Studio, name, nil, nil) if err != nil { return fmt.Errorf("Error creating studio: %s", err.Error()) } return testUpdateImage(t, ctx, created.ID, qb.UpdateImage, qb.GetImage) }); err != nil { t.Error(err.Error()) } } func TestStudioQuerySceneCount(t *testing.T) { const sceneCount = 1 sceneCountCriterion := models.IntCriterionInput{ Value: sceneCount, Modifier: models.CriterionModifierEquals, } verifyStudiosSceneCount(t, sceneCountCriterion) sceneCountCriterion.Modifier = models.CriterionModifierNotEquals verifyStudiosSceneCount(t, sceneCountCriterion) sceneCountCriterion.Modifier = models.CriterionModifierGreaterThan verifyStudiosSceneCount(t, sceneCountCriterion) sceneCountCriterion.Modifier = models.CriterionModifierLessThan verifyStudiosSceneCount(t, sceneCountCriterion) } func verifyStudiosSceneCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Studio studioFilter := models.StudioFilterType{ SceneCount: &sceneCountCriterion, } studios := queryStudio(ctx, t, sqb, &studioFilter, nil) assert.Greater(t, len(studios), 0) for _, studio := range studios { sceneCount, err := db.Scene.CountByStudioID(ctx, studio.ID) if err != nil { return err } verifyInt(t, sceneCount, sceneCountCriterion) } return nil }) } func TestStudioQueryImageCount(t *testing.T) { const imageCount = 1 imageCountCriterion := models.IntCriterionInput{ Value: imageCount, Modifier: models.CriterionModifierEquals, } verifyStudiosImageCount(t, imageCountCriterion) imageCountCriterion.Modifier = models.CriterionModifierNotEquals verifyStudiosImageCount(t, imageCountCriterion) imageCountCriterion.Modifier = models.CriterionModifierGreaterThan verifyStudiosImageCount(t, imageCountCriterion) imageCountCriterion.Modifier = models.CriterionModifierLessThan verifyStudiosImageCount(t, imageCountCriterion) } func verifyStudiosImageCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Studio studioFilter := models.StudioFilterType{ ImageCount: &imageCountCriterion, } studios := queryStudio(ctx, t, sqb, &studioFilter, nil) assert.Greater(t, len(studios), 0) for _, studio := range studios { pp := 0 result, err := db.Image.Query(ctx, models.ImageQueryOptions{ QueryOptions: models.QueryOptions{ FindFilter: &models.FindFilterType{ PerPage: &pp, }, Count: true, }, ImageFilter: &models.ImageFilterType{ Studios: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(studio.ID)}, Modifier: models.CriterionModifierIncludes, }, }, }) if err != nil { return err } verifyInt(t, result.Count, imageCountCriterion) } return nil }) } func TestStudioQueryGalleryCount(t *testing.T) { const galleryCount = 1 galleryCountCriterion := models.IntCriterionInput{ Value: galleryCount, Modifier: models.CriterionModifierEquals, } verifyStudiosGalleryCount(t, galleryCountCriterion) galleryCountCriterion.Modifier = models.CriterionModifierNotEquals verifyStudiosGalleryCount(t, galleryCountCriterion) galleryCountCriterion.Modifier = models.CriterionModifierGreaterThan verifyStudiosGalleryCount(t, galleryCountCriterion) galleryCountCriterion.Modifier = models.CriterionModifierLessThan verifyStudiosGalleryCount(t, galleryCountCriterion) } func verifyStudiosGalleryCount(t *testing.T, galleryCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Studio studioFilter := models.StudioFilterType{ GalleryCount: &galleryCountCriterion, } studios := queryStudio(ctx, t, sqb, &studioFilter, nil) assert.Greater(t, len(studios), 0) for _, studio := range studios { pp := 0 _, count, err := db.Gallery.Query(ctx, &models.GalleryFilterType{ Studios: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(studio.ID)}, Modifier: models.CriterionModifierIncludes, }, }, &models.FindFilterType{ PerPage: &pp, }) if err != nil { return err } verifyInt(t, count, galleryCountCriterion) } return nil }) } func TestStudioStashIDs(t *testing.T) { if err := withRollbackTxn(func(ctx context.Context) error { qb := db.Studio // create studio to test against const name = "TestStudioStashIDs" created, err := createStudio(ctx, db.Studio, name, nil, nil) if err != nil { return fmt.Errorf("Error creating studio: %s", err.Error()) } studio, err := qb.Find(ctx, created.ID) if err != nil { return fmt.Errorf("Error getting studio: %s", err.Error()) } if err := studio.LoadStashIDs(ctx, qb); err != nil { return err } testStudioStashIDs(ctx, t, studio) return nil }); err != nil { t.Error(err.Error()) } } func testStudioStashIDs(ctx context.Context, t *testing.T, s *models.Studio) { qb := db.Studio if err := s.LoadStashIDs(ctx, qb); err != nil { t.Error(err.Error()) return } // ensure no stash IDs to begin with assert.Len(t, s.StashIDs.List(), 0) // add stash ids const stashIDStr = "stashID" const endpoint = "endpoint" stashID := models.StashID{ StashID: stashIDStr, Endpoint: endpoint, } // update stash ids and ensure was updated input := models.StudioPartial{ ID: s.ID, StashIDs: &models.UpdateStashIDs{ StashIDs: []models.StashID{stashID}, Mode: models.RelationshipUpdateModeSet, }, } var err error s, err = qb.UpdatePartial(ctx, input) if err != nil { t.Error(err.Error()) } if err := s.LoadStashIDs(ctx, qb); err != nil { t.Error(err.Error()) return } // #5563 - set the UpdatedAt field to epoch stashID.UpdatedAt = epochTime assert.Equal(t, []models.StashID{stashID}, s.StashIDs.List()) // remove stash ids and ensure was updated input = models.StudioPartial{ ID: s.ID, StashIDs: &models.UpdateStashIDs{ StashIDs: []models.StashID{stashID}, Mode: models.RelationshipUpdateModeRemove, }, } s, err = qb.UpdatePartial(ctx, input) if err != nil { t.Error(err.Error()) } if err := s.LoadStashIDs(ctx, qb); err != nil { t.Error(err.Error()) return } assert.Len(t, s.StashIDs.List(), 0) } func TestStudioQueryURL(t *testing.T) { const sceneIdx = 1 studioURL := getStudioStringValue(sceneIdx, urlField) urlCriterion := models.StringCriterionInput{ Value: studioURL, Modifier: models.CriterionModifierEquals, } filter := models.StudioFilterType{ URL: &urlCriterion, } verifyFn := func(ctx context.Context, g *models.Studio) { t.Helper() if err := g.LoadURLs(ctx, db.Studio); err != nil { t.Errorf("Error loading studio relationships: %v", err) return } verifyStringList(t, g.URLs.List(), urlCriterion) } verifyStudioQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierNotEquals verifyStudioQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierMatchesRegex urlCriterion.Value = "studio_.*1_URL" verifyStudioQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierNotMatchesRegex verifyStudioQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierIsNull urlCriterion.Value = "" verifyStudioQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierNotNull verifyStudioQuery(t, filter, verifyFn) } func TestStudioQueryRating(t *testing.T) { const rating = 60 ratingCriterion := models.IntCriterionInput{ Value: rating, Modifier: models.CriterionModifierEquals, } verifyStudiosRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierNotEquals verifyStudiosRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierGreaterThan verifyStudiosRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierLessThan verifyStudiosRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierIsNull verifyStudiosRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierNotNull verifyStudiosRating(t, ratingCriterion) } func queryStudios(ctx context.Context, t *testing.T, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) []*models.Studio { t.Helper() studios, _, err := db.Studio.Query(ctx, studioFilter, findFilter) if err != nil { t.Errorf("Error querying studio: %s", err.Error()) } return studios } func TestStudioQueryTags(t *testing.T) { withTxn(func(ctx context.Context) error { tagCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithStudio]), strconv.Itoa(tagIDs[tagIdx1WithStudio]), }, Modifier: models.CriterionModifierIncludes, } studioFilter := models.StudioFilterType{ Tags: &tagCriterion, } // ensure ids are correct studios := queryStudios(ctx, t, &studioFilter, nil) assert.Len(t, studios, 2) for _, studio := range studios { assert.True(t, studio.ID == studioIDs[studioIdxWithTag] || studio.ID == studioIDs[studioIdxWithTwoTags]) } tagCriterion = models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdx1WithStudio]), strconv.Itoa(tagIDs[tagIdx2WithStudio]), }, Modifier: models.CriterionModifierIncludesAll, } studios = queryStudios(ctx, t, &studioFilter, nil) assert.Len(t, studios, 1) assert.Equal(t, sceneIDs[studioIdxWithTwoTags], studios[0].ID) tagCriterion = models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdx1WithStudio]), }, Modifier: models.CriterionModifierExcludes, } q := getSceneStringValue(studioIdxWithTwoTags, titleField) findFilter := models.FindFilterType{ Q: &q, } studios = queryStudios(ctx, t, &studioFilter, &findFilter) assert.Len(t, studios, 0) return nil }) } func TestStudioQueryTagCount(t *testing.T) { const tagCount = 1 tagCountCriterion := models.IntCriterionInput{ Value: tagCount, Modifier: models.CriterionModifierEquals, } verifyStudiosTagCount(t, tagCountCriterion) tagCountCriterion.Modifier = models.CriterionModifierNotEquals verifyStudiosTagCount(t, tagCountCriterion) tagCountCriterion.Modifier = models.CriterionModifierGreaterThan verifyStudiosTagCount(t, tagCountCriterion) tagCountCriterion.Modifier = models.CriterionModifierLessThan verifyStudiosTagCount(t, tagCountCriterion) } func verifyStudiosTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Studio studioFilter := models.StudioFilterType{ TagCount: &tagCountCriterion, } studios := queryStudios(ctx, t, &studioFilter, nil) assert.Greater(t, len(studios), 0) for _, studio := range studios { ids, err := sqb.GetTagIDs(ctx, studio.ID) if err != nil { return err } verifyInt(t, len(ids), tagCountCriterion) } return nil }) } func verifyStudioQuery(t *testing.T, filter models.StudioFilterType, verifyFn func(ctx context.Context, s *models.Studio)) { withTxn(func(ctx context.Context) error { t.Helper() sqb := db.Studio studios := queryStudio(ctx, t, sqb, &filter, nil) // assume it should find at least one assert.Greater(t, len(studios), 0) for _, studio := range studios { verifyFn(ctx, studio) } return nil }) } func verifyStudiosRating(t *testing.T, ratingCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Studio studioFilter := models.StudioFilterType{ Rating100: &ratingCriterion, } studios, _, err := sqb.Query(ctx, &studioFilter, nil) if err != nil { t.Errorf("Error querying studio: %s", err.Error()) } for _, studio := range studios { verifyIntPtr(t, studio.Rating, ratingCriterion) } return nil }) } func TestStudioQueryIsMissingRating(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Studio isMissing := "rating" studioFilter := models.StudioFilterType{ IsMissing: &isMissing, } studios, _, err := sqb.Query(ctx, &studioFilter, nil) if err != nil { t.Errorf("Error querying studio: %s", err.Error()) } assert.True(t, len(studios) > 0) for _, studio := range studios { assert.Nil(t, studio.Rating) } return nil }) } func queryStudio(ctx context.Context, t *testing.T, sqb models.StudioReader, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) []*models.Studio { studios, _, err := sqb.Query(ctx, studioFilter, findFilter) if err != nil { t.Errorf("Error querying studio: %s", err.Error()) } return studios } func TestStudioQueryName(t *testing.T) { const studioIdx = 1 studioName := getStudioStringValue(studioIdx, "Name") nameCriterion := &models.StringCriterionInput{ Value: studioName, Modifier: models.CriterionModifierEquals, } studioFilter := models.StudioFilterType{ Name: nameCriterion, } verifyFn := func(ctx context.Context, studio *models.Studio) { verifyString(t, studio.Name, *nameCriterion) } verifyStudioQuery(t, studioFilter, verifyFn) nameCriterion.Modifier = models.CriterionModifierNotEquals verifyStudioQuery(t, studioFilter, verifyFn) nameCriterion.Modifier = models.CriterionModifierMatchesRegex nameCriterion.Value = "studio_.*1_Name" verifyStudioQuery(t, studioFilter, verifyFn) nameCriterion.Modifier = models.CriterionModifierNotMatchesRegex verifyStudioQuery(t, studioFilter, verifyFn) } func TestStudioQueryAlias(t *testing.T) { const studioIdx = studioIdxWithGroup studioName := getStudioStringValue(studioIdx, "Alias") aliasCriterion := &models.StringCriterionInput{ Value: studioName, Modifier: models.CriterionModifierEquals, } studioFilter := models.StudioFilterType{ Aliases: aliasCriterion, } verifyFn := func(ctx context.Context, studio *models.Studio) { t.Helper() aliases, err := db.Studio.GetAliases(ctx, studio.ID) if err != nil { t.Errorf("Error querying studios: %s", err.Error()) } var alias string if len(aliases) > 0 { alias = aliases[0] } verifyString(t, alias, *aliasCriterion) } verifyStudioQuery(t, studioFilter, verifyFn) aliasCriterion.Modifier = models.CriterionModifierNotEquals verifyStudioQuery(t, studioFilter, verifyFn) aliasCriterion.Modifier = models.CriterionModifierMatchesRegex aliasCriterion.Value = "studio_.*2_Alias" verifyStudioQuery(t, studioFilter, verifyFn) aliasCriterion.Modifier = models.CriterionModifierNotMatchesRegex verifyStudioQuery(t, studioFilter, verifyFn) aliasCriterion.Modifier = models.CriterionModifierIsNull aliasCriterion.Value = "" verifyStudioQuery(t, studioFilter, verifyFn) aliasCriterion.Modifier = models.CriterionModifierNotNull verifyStudioQuery(t, studioFilter, verifyFn) } func TestStudioAlias(t *testing.T) { if err := withRollbackTxn(func(ctx context.Context) error { qb := db.Studio // create studio to test against const name = "TestStudioAlias" created, err := createStudio(ctx, db.Studio, name, nil, nil) if err != nil { return fmt.Errorf("Error creating studio: %s", err.Error()) } studio, err := qb.Find(ctx, created.ID) if err != nil { return fmt.Errorf("Error getting studio: %s", err.Error()) } if err := studio.LoadStashIDs(ctx, qb); err != nil { return err } testStudioAlias(ctx, t, studio) return nil }); err != nil { t.Error(err.Error()) } } func testStudioAlias(ctx context.Context, t *testing.T, s *models.Studio) { qb := db.Studio if err := s.LoadAliases(ctx, qb); err != nil { t.Error(err.Error()) return } // ensure no alias to begin with assert.Len(t, s.Aliases.List(), 0) aliases := []string{"alias1", "alias2"} // update alias and ensure was updated input := models.StudioPartial{ ID: s.ID, Aliases: &models.UpdateStrings{ Values: aliases, Mode: models.RelationshipUpdateModeSet, }, } var err error s, err = qb.UpdatePartial(ctx, input) if err != nil { t.Error(err.Error()) } if err := s.LoadAliases(ctx, qb); err != nil { t.Error(err.Error()) return } assert.Equal(t, aliases, s.Aliases.List()) // remove alias and ensure was updated input = models.StudioPartial{ ID: s.ID, Aliases: &models.UpdateStrings{ Values: aliases, Mode: models.RelationshipUpdateModeRemove, }, } s, err = qb.UpdatePartial(ctx, input) if err != nil { t.Error(err.Error()) } if err := s.LoadAliases(ctx, qb); err != nil { t.Error(err.Error()) return } assert.Len(t, s.Aliases.List(), 0) } // TestStudioQueryFast does a quick test for major errors, no result verification func TestStudioQueryFast(t *testing.T) { tsString := "test" tsInt := 1 testStringCriterion := models.StringCriterionInput{ Value: tsString, Modifier: models.CriterionModifierEquals, } testIncludesMultiCriterion := models.MultiCriterionInput{ Value: []string{tsString}, Modifier: models.CriterionModifierIncludes, } testIntCriterion := models.IntCriterionInput{ Value: tsInt, Modifier: models.CriterionModifierEquals, } nameFilter := models.StudioFilterType{ Name: &testStringCriterion, } aliasesFilter := models.StudioFilterType{ Aliases: &testStringCriterion, } stashIDFilter := models.StudioFilterType{ StashID: &testStringCriterion, } urlFilter := models.StudioFilterType{ URL: &testStringCriterion, } ratingFilter := models.StudioFilterType{ Rating100: &testIntCriterion, } sceneCountFilter := models.StudioFilterType{ SceneCount: &testIntCriterion, } imageCountFilter := models.StudioFilterType{ SceneCount: &testIntCriterion, } parentsFilter := models.StudioFilterType{ Parents: &testIncludesMultiCriterion, } filters := []models.StudioFilterType{nameFilter, aliasesFilter, stashIDFilter, urlFilter, ratingFilter, sceneCountFilter, imageCountFilter, parentsFilter} missingStrings := []string{"image", "stash_id", "details"} for _, m := range missingStrings { filters = append(filters, models.StudioFilterType{ IsMissing: &m, }) } sortbyStrings := []string{"scenes_count", "images_count", "galleries_count", "created_at", "updated_at", "name", "random_26819649", "rating"} var findFilters []models.FindFilterType for _, sb := range sortbyStrings { findFilters = append(findFilters, models.FindFilterType{ Q: &tsString, Page: &tsInt, PerPage: &tsInt, Sort: &sb, }) } withTxn(func(ctx context.Context) error { sqb := db.Studio for _, f := range filters { for _, ff := range findFilters { _, _, err := sqb.Query(ctx, &f, &ff) if err != nil { t.Errorf("Error querying studio: %s", err.Error()) } } } return nil }) } func studiesToIDs(i []*models.Studio) []int { ret := make([]int, len(i)) for i, v := range i { ret[i] = v.ID } return ret } func TestStudioQueryCustomFields(t *testing.T) { tests := []struct { name string filter *models.StudioFilterType includeIdxs []int excludeIdxs []int wantErr bool }{ { "equals", &models.StudioFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierEquals, Value: []any{getStudioStringValue(studioIdxWithTwoScenes, "custom")}, }, }, }, []int{studioIdxWithTwoScenes}, nil, false, }, { "not equals", &models.StudioFilterType{ Name: &models.StringCriterionInput{ Value: getStudioStringValue(studioIdxWithTwoScenes, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotEquals, Value: []any{getStudioStringValue(studioIdxWithTwoScenes, "custom")}, }, }, }, nil, []int{studioIdxWithTwoScenes}, false, }, { "includes", &models.StudioFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierIncludes, Value: []any{getStudioStringValue(studioIdxWithTwoScenes, "custom")[9:]}, }, }, }, []int{studioIdxWithTwoScenes}, nil, false, }, { "excludes", &models.StudioFilterType{ Name: &models.StringCriterionInput{ Value: getStudioStringValue(studioIdxWithTwoScenes, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierExcludes, Value: []any{getStudioStringValue(studioIdxWithTwoScenes, "custom")[9:]}, }, }, }, nil, []int{studioIdxWithTwoScenes}, false, }, { "regex", &models.StudioFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierMatchesRegex, Value: []any{".*1_custom"}, }, }, }, []int{studioIdxWithTwoScenes}, nil, false, }, { "invalid regex", &models.StudioFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierMatchesRegex, Value: []any{"["}, }, }, }, nil, nil, true, }, { "not matches regex", &models.StudioFilterType{ Name: &models.StringCriterionInput{ Value: getStudioStringValue(studioIdxWithTwoScenes, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotMatchesRegex, Value: []any{".*1_custom"}, }, }, }, nil, []int{studioIdxWithTwoScenes}, false, }, { "invalid not matches regex", &models.StudioFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotMatchesRegex, Value: []any{"["}, }, }, }, nil, nil, true, }, { "null", &models.StudioFilterType{ Name: &models.StringCriterionInput{ Value: getStudioStringValue(studioIdxWithTwoScenes, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "not existing", Modifier: models.CriterionModifierIsNull, }, }, }, []int{studioIdxWithTwoScenes}, nil, false, }, { "not null", &models.StudioFilterType{ Name: &models.StringCriterionInput{ Value: getStudioStringValue(studioIdxWithTwoScenes, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotNull, }, }, }, []int{studioIdxWithTwoScenes}, nil, false, }, { "between", &models.StudioFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "real", Modifier: models.CriterionModifierBetween, Value: []any{0.15, 0.25}, }, }, }, []int{studioIdxWithGroup}, nil, false, }, { "not between", &models.StudioFilterType{ Name: &models.StringCriterionInput{ Value: getStudioStringValue(studioIdxWithGroup, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "real", Modifier: models.CriterionModifierNotBetween, Value: []any{0.15, 0.25}, }, }, }, nil, []int{studioIdxWithGroup}, false, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) studios, _, err := db.Studio.Query(ctx, tt.filter, nil) if (err != nil) != tt.wantErr { t.Errorf("StudioStore.Query() error = %v, wantErr %v", err, tt.wantErr) return } ids := studiesToIDs(studios) include := indexesToIDs(studioIDs, tt.includeIdxs) exclude := indexesToIDs(studioIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(ids, i) } for _, e := range exclude { assert.NotContains(ids, e) } }) } } // TODO Create // TODO Update // TODO Destroy // TODO Find // TODO FindBySceneID // TODO Count // TODO All // TODO AllSlim // TODO Query ================================================ FILE: pkg/sqlite/table.go ================================================ package sqlite import ( "context" "database/sql" "errors" "fmt" "time" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "gopkg.in/guregu/null.v4" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil" ) type table struct { table exp.IdentifierExpression idColumn exp.IdentifierExpression } type NotFoundError struct { ID int Table string } func (e *NotFoundError) Error() string { return fmt.Sprintf("id %d does not exist in %s", e.ID, e.Table) } func (t *table) insert(ctx context.Context, o interface{}) (sql.Result, error) { q := dialect.Insert(t.table).Prepared(true).Rows(o) ret, err := exec(ctx, q) if err != nil { return nil, fmt.Errorf("inserting into %s: %w", t.table.GetTable(), err) } return ret, nil } func (t *table) insertID(ctx context.Context, o interface{}) (int, error) { result, err := t.insert(ctx, o) if err != nil { return 0, err } ret, err := result.LastInsertId() if err != nil { return 0, err } return int(ret), nil } func (t *table) updateByID(ctx context.Context, id interface{}, o interface{}) error { q := dialect.Update(t.table).Prepared(true).Set(o).Where(t.byID(id)) if _, err := exec(ctx, q); err != nil { return fmt.Errorf("updating %s: %w", t.table.GetTable(), err) } return nil } func (t *table) byID(id interface{}) exp.Expression { return t.idColumn.Eq(id) } func (t *table) byIDInts(ids ...int) exp.Expression { ii := make([]interface{}, len(ids)) for i, id := range ids { ii[i] = id } return t.idColumn.In(ii...) } func (t *table) idExists(ctx context.Context, id interface{}) (bool, error) { q := dialect.Select(goqu.COUNT("*")).From(t.table).Where(t.byID(id)) var count int if err := querySimple(ctx, q, &count); err != nil { return false, err } return count == 1, nil } func (t *table) checkIDExists(ctx context.Context, id int) error { exists, err := t.idExists(ctx, id) if err != nil { return err } if !exists { return &NotFoundError{ID: id, Table: t.table.GetTable()} } return nil } func (t *table) destroyExisting(ctx context.Context, ids []int) error { for _, id := range ids { exists, err := t.idExists(ctx, id) if err != nil { return err } if !exists { return &NotFoundError{ ID: id, Table: t.table.GetTable(), } } } return t.destroy(ctx, ids) } func (t *table) destroy(ctx context.Context, ids []int) error { q := dialect.Delete(t.table).Where(t.idColumn.In(ids)) if _, err := exec(ctx, q); err != nil { return fmt.Errorf("destroying %s: %w", t.table.GetTable(), err) } return nil } func (t *table) join(j joiner, as string, parentIDCol string) { tableName := t.table.GetTable() tt := tableName if as != "" { tt = as } j.addLeftJoin(tableName, as, fmt.Sprintf("%s.%s = %s", tt, t.idColumn.GetCol(), parentIDCol)) } // func (t *table) get(ctx context.Context, q *goqu.SelectDataset, dest interface{}) error { // tx, err := getTx(ctx) // if err != nil { // return err // } // sql, args, err := q.ToSQL() // if err != nil { // return fmt.Errorf("generating sql: %w", err) // } // return tx.GetContext(ctx, dest, sql, args...) // } type joinTable struct { table fkColumn exp.IdentifierExpression // required for ordering foreignTable *table orderBy exp.OrderedExpression } func (t *joinTable) invert() *joinTable { return &joinTable{ table: table{ table: t.table.table, idColumn: t.fkColumn, }, fkColumn: t.table.idColumn, foreignTable: t.foreignTable, orderBy: t.orderBy, } } func (t *joinTable) get(ctx context.Context, id int) ([]int, error) { q := dialect.Select(t.fkColumn).From(t.table.table).Where(t.idColumn.Eq(id)) if t.orderBy != nil { if t.foreignTable != nil { q = q.InnerJoin(t.foreignTable.table, goqu.On(t.foreignTable.idColumn.Eq(t.fkColumn))) } q = q.Order(t.orderBy) } const single = false var ret []int if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { var fk int if err := rows.Scan(&fk); err != nil { return err } ret = append(ret, fk) return nil }); err != nil { return nil, fmt.Errorf("getting foreign keys from %s: %w", t.table.table.GetTable(), err) } return ret, nil } func (t *joinTable) insertJoins(ctx context.Context, id int, foreignIDs []int) error { // manually create SQL so that we can prepare once // ignore duplicates q := fmt.Sprintf("INSERT INTO %s (%s, %s) VALUES (?, ?) ON CONFLICT (%[2]s, %s) DO NOTHING", t.table.table.GetTable(), t.idColumn.GetCol(), t.fkColumn.GetCol()) stmt, err := dbWrapper.Prepare(ctx, q) if err != nil { return err } defer stmt.Close() // eliminate duplicates foreignIDs = sliceutil.AppendUniques(nil, foreignIDs) for _, fk := range foreignIDs { if _, err := dbWrapper.ExecStmt(ctx, stmt, id, fk); err != nil { return err } } return nil } func (t *joinTable) replaceJoins(ctx context.Context, id int, foreignIDs []int) error { if err := t.destroy(ctx, []int{id}); err != nil { return err } return t.insertJoins(ctx, id, foreignIDs) } func (t *joinTable) addJoins(ctx context.Context, id int, foreignIDs []int) error { // get existing foreign keys fks, err := t.get(ctx, id) if err != nil { return err } // only add foreign keys that are not already present foreignIDs = sliceutil.Exclude(foreignIDs, fks) return t.insertJoins(ctx, id, foreignIDs) } func (t *joinTable) destroyJoins(ctx context.Context, id int, foreignIDs []int) error { q := dialect.Delete(t.table.table).Where( t.idColumn.Eq(id), t.fkColumn.In(foreignIDs), ) if _, err := exec(ctx, q); err != nil { return fmt.Errorf("destroying %s: %w", t.table.table.GetTable(), err) } return nil } func (t *joinTable) modifyJoins(ctx context.Context, id int, foreignIDs []int, mode models.RelationshipUpdateMode) error { switch mode { case models.RelationshipUpdateModeSet: return t.replaceJoins(ctx, id, foreignIDs) case models.RelationshipUpdateModeAdd: return t.addJoins(ctx, id, foreignIDs) case models.RelationshipUpdateModeRemove: return t.destroyJoins(ctx, id, foreignIDs) } return nil } type stashIDTable struct { table } type stashIDRow struct { StashID null.String `db:"stash_id"` Endpoint null.String `db:"endpoint"` UpdatedAt Timestamp `db:"updated_at"` } func (r *stashIDRow) resolve() models.StashID { return models.StashID{ StashID: r.StashID.String, Endpoint: r.Endpoint.String, UpdatedAt: r.UpdatedAt.Timestamp, } } func (t *stashIDTable) get(ctx context.Context, id int) ([]models.StashID, error) { q := dialect.Select("endpoint", "stash_id", "updated_at").From(t.table.table).Where(t.idColumn.Eq(id)) const single = false var ret []models.StashID if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { var v stashIDRow if err := rows.StructScan(&v); err != nil { return err } ret = append(ret, v.resolve()) return nil }); err != nil { return nil, fmt.Errorf("getting stash ids from %s: %w", t.table.table.GetTable(), err) } return ret, nil } var epochTime = time.Unix(0, 0).UTC() func (t *stashIDTable) insertJoin(ctx context.Context, id int, v models.StashID) (sql.Result, error) { // #5563 - it's possible that zero-value updated at timestamps are provided via import // replace them with the epoch time if v.UpdatedAt.IsZero() { v.UpdatedAt = epochTime } var q = dialect.Insert(t.table.table).Cols(t.idColumn.GetCol(), "endpoint", "stash_id", "updated_at").Vals( goqu.Vals{id, v.Endpoint, v.StashID, v.UpdatedAt}, ) ret, err := exec(ctx, q) if err != nil { return nil, fmt.Errorf("inserting into %s: %w", t.table.table.GetTable(), err) } return ret, nil } func (t *stashIDTable) insertJoins(ctx context.Context, id int, v []models.StashID) error { for _, fk := range v { if _, err := t.insertJoin(ctx, id, fk); err != nil { return err } } return nil } func (t *stashIDTable) replaceJoins(ctx context.Context, id int, v []models.StashID) error { if err := t.destroy(ctx, []int{id}); err != nil { return err } return t.insertJoins(ctx, id, v) } func (t *stashIDTable) addJoins(ctx context.Context, id int, v []models.StashID) error { // get existing foreign keys fks, err := t.get(ctx, id) if err != nil { return err } // only add values that are not already present var filtered []models.StashID for _, vv := range v { for _, e := range fks { if vv.Endpoint == e.Endpoint { continue } filtered = append(filtered, vv) } } return t.insertJoins(ctx, id, filtered) } func (t *stashIDTable) destroyJoins(ctx context.Context, id int, v []models.StashID) error { for _, vv := range v { q := dialect.Delete(t.table.table).Where( t.idColumn.Eq(id), t.table.table.Col("endpoint").Eq(vv.Endpoint), t.table.table.Col("stash_id").Eq(vv.StashID), ) if _, err := exec(ctx, q); err != nil { return fmt.Errorf("destroying %s: %w", t.table.table.GetTable(), err) } } return nil } func (t *stashIDTable) modifyJoins(ctx context.Context, id int, v []models.StashID, mode models.RelationshipUpdateMode) error { switch mode { case models.RelationshipUpdateModeSet: return t.replaceJoins(ctx, id, v) case models.RelationshipUpdateModeAdd: return t.addJoins(ctx, id, v) case models.RelationshipUpdateModeRemove: return t.destroyJoins(ctx, id, v) } return nil } type stringTable struct { table stringColumn exp.IdentifierExpression } func (t *stringTable) get(ctx context.Context, id int) ([]string, error) { q := dialect.Select(t.stringColumn).From(t.table.table).Where(t.idColumn.Eq(id)) const single = false var ret []string if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { var v string if err := rows.Scan(&v); err != nil { return err } ret = append(ret, v) return nil }); err != nil { return nil, fmt.Errorf("getting stash ids from %s: %w", t.table.table.GetTable(), err) } return ret, nil } func (t *stringTable) insertJoin(ctx context.Context, id int, v string) (sql.Result, error) { q := dialect.Insert(t.table.table).Cols(t.idColumn.GetCol(), t.stringColumn.GetCol()).Vals( goqu.Vals{id, v}, ) ret, err := exec(ctx, q) if err != nil { return nil, fmt.Errorf("inserting into %s: %w", t.table.table.GetTable(), err) } return ret, nil } func (t *stringTable) insertJoins(ctx context.Context, id int, v []string) error { for _, fk := range v { if _, err := t.insertJoin(ctx, id, fk); err != nil { return err } } return nil } func (t *stringTable) replaceJoins(ctx context.Context, id int, v []string) error { if err := t.destroy(ctx, []int{id}); err != nil { return err } return t.insertJoins(ctx, id, v) } func (t *stringTable) addJoins(ctx context.Context, id int, v []string) error { // get existing foreign keys existing, err := t.get(ctx, id) if err != nil { return err } // only add values that are not already present filtered := sliceutil.Exclude(v, existing) return t.insertJoins(ctx, id, filtered) } func (t *stringTable) destroyJoins(ctx context.Context, id int, v []string) error { for _, vv := range v { q := dialect.Delete(t.table.table).Where( t.idColumn.Eq(id), t.stringColumn.Eq(vv), ) if _, err := exec(ctx, q); err != nil { return fmt.Errorf("destroying %s: %w", t.table.table.GetTable(), err) } } return nil } func (t *stringTable) modifyJoins(ctx context.Context, id int, v []string, mode models.RelationshipUpdateMode) error { switch mode { case models.RelationshipUpdateModeSet: return t.replaceJoins(ctx, id, v) case models.RelationshipUpdateModeAdd: return t.addJoins(ctx, id, v) case models.RelationshipUpdateModeRemove: return t.destroyJoins(ctx, id, v) } return nil } type orderedValueTable[T comparable] struct { table valueColumn exp.IdentifierExpression } func (t *orderedValueTable[T]) positionColumn() exp.IdentifierExpression { const positionColumn = "position" return t.table.table.Col(positionColumn) } func (t *orderedValueTable[T]) get(ctx context.Context, id int) ([]T, error) { q := dialect.Select(t.valueColumn).From(t.table.table).Where(t.idColumn.Eq(id)).Order(t.positionColumn().Asc()) const single = false var ret []T if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { var v T if err := rows.Scan(&v); err != nil { return err } ret = append(ret, v) return nil }); err != nil { return nil, fmt.Errorf("getting stash ids from %s: %w", t.table.table.GetTable(), err) } return ret, nil } func (t *orderedValueTable[T]) insertJoin(ctx context.Context, id int, position int, v T) (sql.Result, error) { q := dialect.Insert(t.table.table).Cols(t.idColumn.GetCol(), t.positionColumn().GetCol(), t.valueColumn.GetCol()).Vals( goqu.Vals{id, position, v}, ) ret, err := exec(ctx, q) if err != nil { return nil, fmt.Errorf("inserting into %s: %w", t.table.table.GetTable(), err) } return ret, nil } func (t *orderedValueTable[T]) insertJoins(ctx context.Context, id int, startPos int, v []T) error { for i, fk := range v { if _, err := t.insertJoin(ctx, id, i+startPos, fk); err != nil { return err } } return nil } func (t *orderedValueTable[T]) replaceJoins(ctx context.Context, id int, v []T) error { if err := t.destroy(ctx, []int{id}); err != nil { return err } const startPos = 0 return t.insertJoins(ctx, id, startPos, v) } func (t *orderedValueTable[T]) addJoins(ctx context.Context, id int, v []T) error { // get existing foreign keys existing, err := t.get(ctx, id) if err != nil { return err } // only add values that are not already present filtered := sliceutil.Exclude(v, existing) if len(filtered) == 0 { return nil } startPos := len(existing) return t.insertJoins(ctx, id, startPos, filtered) } func (t *orderedValueTable[T]) destroyJoins(ctx context.Context, id int, v []T) error { existing, err := t.get(ctx, id) if err != nil { return fmt.Errorf("getting existing %s: %w", t.table.table.GetTable(), err) } newValue := sliceutil.Exclude(existing, v) if len(newValue) == len(existing) { return nil } return t.replaceJoins(ctx, id, newValue) } func (t *orderedValueTable[T]) modifyJoins(ctx context.Context, id int, v []T, mode models.RelationshipUpdateMode) error { switch mode { case models.RelationshipUpdateModeSet: return t.replaceJoins(ctx, id, v) case models.RelationshipUpdateModeAdd: return t.addJoins(ctx, id, v) case models.RelationshipUpdateModeRemove: return t.destroyJoins(ctx, id, v) } return nil } type scenesGroupsTable struct { table } type groupsScenesRow struct { SceneID null.Int `db:"scene_id"` GroupID null.Int `db:"group_id"` SceneIndex null.Int `db:"scene_index"` } func (r groupsScenesRow) resolve(sceneID int) models.GroupsScenes { return models.GroupsScenes{ GroupID: int(r.GroupID.Int64), SceneIndex: nullIntPtr(r.SceneIndex), } } func (t *scenesGroupsTable) get(ctx context.Context, id int) ([]models.GroupsScenes, error) { q := dialect.Select("group_id", "scene_index").From(t.table.table).Where(t.idColumn.Eq(id)) const single = false var ret []models.GroupsScenes if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { var v groupsScenesRow if err := rows.StructScan(&v); err != nil { return err } ret = append(ret, v.resolve(id)) return nil }); err != nil { return nil, fmt.Errorf("getting scene groups from %s: %w", t.table.table.GetTable(), err) } return ret, nil } func (t *scenesGroupsTable) insertJoin(ctx context.Context, id int, v models.GroupsScenes) (sql.Result, error) { q := dialect.Insert(t.table.table).Cols(t.idColumn.GetCol(), "group_id", "scene_index").Vals( goqu.Vals{id, v.GroupID, intFromPtr(v.SceneIndex)}, ) ret, err := exec(ctx, q) if err != nil { return nil, fmt.Errorf("inserting into %s: %w", t.table.table.GetTable(), err) } return ret, nil } func (t *scenesGroupsTable) insertJoins(ctx context.Context, id int, v []models.GroupsScenes) error { for _, fk := range v { if _, err := t.insertJoin(ctx, id, fk); err != nil { return err } } return nil } func (t *scenesGroupsTable) replaceJoins(ctx context.Context, id int, v []models.GroupsScenes) error { if err := t.destroy(ctx, []int{id}); err != nil { return err } return t.insertJoins(ctx, id, v) } func (t *scenesGroupsTable) addJoins(ctx context.Context, id int, v []models.GroupsScenes) error { // get existing foreign keys fks, err := t.get(ctx, id) if err != nil { return err } // only add values that are not already present var filtered []models.GroupsScenes for _, vv := range v { found := false for _, e := range fks { if vv.GroupID == e.GroupID { found = true break } } if !found { filtered = append(filtered, vv) } } return t.insertJoins(ctx, id, filtered) } func (t *scenesGroupsTable) destroyJoins(ctx context.Context, id int, v []models.GroupsScenes) error { for _, vv := range v { q := dialect.Delete(t.table.table).Where( t.idColumn.Eq(id), t.table.table.Col("group_id").Eq(vv.GroupID), ) if _, err := exec(ctx, q); err != nil { return fmt.Errorf("destroying %s: %w", t.table.table.GetTable(), err) } } return nil } func (t *scenesGroupsTable) modifyJoins(ctx context.Context, id int, v []models.GroupsScenes, mode models.RelationshipUpdateMode) error { switch mode { case models.RelationshipUpdateModeSet: return t.replaceJoins(ctx, id, v) case models.RelationshipUpdateModeAdd: return t.addJoins(ctx, id, v) case models.RelationshipUpdateModeRemove: return t.destroyJoins(ctx, id, v) } return nil } type imageGalleriesTable struct { joinTable } func (t *imageGalleriesTable) setCover(ctx context.Context, id int, galleryID int) error { if err := t.resetCover(ctx, galleryID); err != nil { return err } table := t.table.table q := dialect.Update(table).Prepared(true).Set(goqu.Record{ "cover": true, }).Where(t.idColumn.Eq(id), table.Col(galleryIDColumn).Eq(galleryID)) if _, err := exec(ctx, q); err != nil { return fmt.Errorf("setting cover flag in %s: %w", t.table.table.GetTable(), err) } return nil } func (t *imageGalleriesTable) resetCover(ctx context.Context, galleryID int) error { table := t.table.table q := dialect.Update(table).Prepared(true).Set(goqu.Record{ "cover": false, }).Where( table.Col(galleryIDColumn).Eq(galleryID), table.Col("cover").Eq(true), ) if _, err := exec(ctx, q); err != nil { return fmt.Errorf("unsetting cover flags in %s: %w", t.table.table.GetTable(), err) } return nil } type relatedFilesTable struct { table } // type scenesFilesRow struct { // SceneID int `db:"scene_id"` // Primary bool `db:"primary"` // FileID models.FileID `db:"file_id"` // } // get returns the file IDs related to the provided scene ID // the primary file is returned first func (t *relatedFilesTable) get(ctx context.Context, id int) ([]models.FileID, error) { q := dialect.Select("file_id").From(t.table.table).Where(t.idColumn.Eq(id)).Order(t.table.table.Col("primary").Desc()) const single = false var ret []models.FileID if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { var v models.FileID if err := rows.Scan(&v); err != nil { return err } ret = append(ret, v) return nil }); err != nil { return nil, fmt.Errorf("getting related files from %s: %w", t.table.table.GetTable(), err) } return ret, nil } func (t *relatedFilesTable) insertJoin(ctx context.Context, id int, primary bool, fileID models.FileID) error { q := dialect.Insert(t.table.table).Cols(t.idColumn.GetCol(), "primary", "file_id").Vals( goqu.Vals{id, primary, fileID}, ) _, err := exec(ctx, q) if err != nil { return fmt.Errorf("inserting into %s: %w", t.table.table.GetTable(), err) } return nil } func (t *relatedFilesTable) insertJoins(ctx context.Context, id int, firstPrimary bool, fileIDs []models.FileID) error { for i, fk := range fileIDs { if err := t.insertJoin(ctx, id, firstPrimary && i == 0, fk); err != nil { return err } } return nil } func (t *relatedFilesTable) replaceJoins(ctx context.Context, id int, fileIDs []models.FileID) error { if err := t.destroy(ctx, []int{id}); err != nil { return err } const firstPrimary = true return t.insertJoins(ctx, id, firstPrimary, fileIDs) } // destroyJoins destroys all entries in the table with the provided fileIDs func (t *relatedFilesTable) destroyJoins(ctx context.Context, fileIDs []models.FileID) error { q := dialect.Delete(t.table.table).Where(t.table.table.Col("file_id").In(fileIDs)) if _, err := exec(ctx, q); err != nil { return fmt.Errorf("destroying file joins in %s: %w", t.table.table.GetTable(), err) } return nil } func (t *relatedFilesTable) setPrimary(ctx context.Context, id int, fileID models.FileID) error { table := t.table.table q := dialect.Update(table).Prepared(true).Set(goqu.Record{ "primary": 0, }).Where(t.idColumn.Eq(id), table.Col(fileIDColumn).Neq(fileID)) if _, err := exec(ctx, q); err != nil { return fmt.Errorf("unsetting primary flags in %s: %w", t.table.table.GetTable(), err) } q = dialect.Update(table).Prepared(true).Set(goqu.Record{ "primary": 1, }).Where(t.idColumn.Eq(id), table.Col(fileIDColumn).Eq(fileID)) if _, err := exec(ctx, q); err != nil { return fmt.Errorf("setting primary flag in %s: %w", t.table.table.GetTable(), err) } return nil } type viewHistoryTable struct { table dateColumn exp.IdentifierExpression } func (t *viewHistoryTable) getDates(ctx context.Context, id int) ([]time.Time, error) { table := t.table.table q := dialect.Select( t.dateColumn, ).From(table).Where( t.idColumn.Eq(id), ).Order(t.dateColumn.Desc()) const single = false var ret []time.Time if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { var date Timestamp if err := rows.Scan(&date); err != nil { return err } ret = append(ret, date.Timestamp) return nil }); err != nil { return nil, err } return ret, nil } func (t *viewHistoryTable) getManyDates(ctx context.Context, ids []int) ([][]time.Time, error) { table := t.table.table q := dialect.Select( t.idColumn, t.dateColumn, ).From(table).Where( t.idColumn.In(ids), ).Order(t.dateColumn.Desc()) ret := make([][]time.Time, len(ids)) idToIndex := idToIndexMap(ids) const single = false if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { var id int var date Timestamp if err := rows.Scan(&id, &date); err != nil { return err } idx := idToIndex[id] ret[idx] = append(ret[idx], date.Timestamp) return nil }); err != nil { return nil, err } return ret, nil } func (t *viewHistoryTable) getLastDate(ctx context.Context, id int) (*time.Time, error) { table := t.table.table q := dialect.Select(t.dateColumn).From(table).Where( t.idColumn.Eq(id), ).Order(t.dateColumn.Desc()).Limit(1) var date NullTimestamp if err := querySimple(ctx, q, &date); err != nil { return nil, err } return date.TimePtr(), nil } func (t *viewHistoryTable) getManyLastDate(ctx context.Context, ids []int) ([]*time.Time, error) { table := t.table.table q := dialect.Select( t.idColumn, goqu.MAX(t.dateColumn), ).From(table).Where( t.idColumn.In(ids), ).GroupBy(t.idColumn) ret := make([]*time.Time, len(ids)) idToIndex := idToIndexMap(ids) const single = false if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { var id int // MAX appears to return a string, so handle it manually var dateString string if err := rows.Scan(&id, &dateString); err != nil { return err } t, err := time.Parse(TimestampFormat, dateString) if err != nil { return fmt.Errorf("parsing date %v: %w", dateString, err) } idx := idToIndex[id] ret[idx] = &t return nil }); err != nil { return nil, err } return ret, nil } func (t *viewHistoryTable) getCount(ctx context.Context, id int) (int, error) { table := t.table.table q := dialect.Select(goqu.COUNT("*")).From(table).Where(t.idColumn.Eq(id)) const single = true var ret int if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { if err := rows.Scan(&ret); err != nil { return err } return nil }); err != nil { return 0, err } return ret, nil } func (t *viewHistoryTable) getManyCount(ctx context.Context, ids []int) ([]int, error) { table := t.table.table q := dialect.Select( t.idColumn, goqu.COUNT(t.dateColumn), ).From(table).Where( t.idColumn.In(ids), ).GroupBy(t.idColumn) ret := make([]int, len(ids)) idToIndex := idToIndexMap(ids) const single = false if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { var id int var count int if err := rows.Scan(&id, &count); err != nil { return err } idx := idToIndex[id] ret[idx] = count return nil }); err != nil { return nil, err } return ret, nil } func (t *viewHistoryTable) getAllCount(ctx context.Context) (int, error) { table := t.table.table q := dialect.Select(goqu.COUNT("*")).From(table) const single = true var ret int if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { if err := rows.Scan(&ret); err != nil { return err } return nil }); err != nil { return 0, err } return ret, nil } func (t *viewHistoryTable) getUniqueCount(ctx context.Context) (int, error) { table := t.table.table q := dialect.Select(goqu.COUNT(goqu.DISTINCT(t.idColumn))).From(table) const single = true var ret int if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { if err := rows.Scan(&ret); err != nil { return err } return nil }); err != nil { return 0, err } return ret, nil } func (t *viewHistoryTable) addDates(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) { table := t.table.table if len(dates) == 0 { dates = []time.Time{time.Now()} } for _, d := range dates { q := dialect.Insert(table).Cols(t.idColumn.GetCol(), t.dateColumn.GetCol()).Vals( // convert all dates to UTC goqu.Vals{id, UTCTimestamp{Timestamp{d}}}, ) if _, err := exec(ctx, q); err != nil { return nil, fmt.Errorf("inserting into %s: %w", table.GetTable(), err) } } return t.getDates(ctx, id) } func (t *viewHistoryTable) deleteDates(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) { table := t.table.table mostRecent := false if len(dates) == 0 { mostRecent = true dates = []time.Time{time.Now()} } for _, date := range dates { var subquery *goqu.SelectDataset if mostRecent { // delete the most recent subquery = dialect.Select("rowid").From(table).Where( t.idColumn.Eq(id), ).Order(t.dateColumn.Desc()).Limit(1) } else { subquery = dialect.Select("rowid").From(table).Where( t.idColumn.Eq(id), t.dateColumn.Eq(UTCTimestamp{Timestamp{date}}), ).Limit(1) } q := dialect.Delete(table).Where(goqu.I("rowid").Eq(subquery)) if _, err := exec(ctx, q); err != nil { return nil, fmt.Errorf("deleting from %s: %w", table.GetTable(), err) } } return t.getDates(ctx, id) } func (t *viewHistoryTable) deleteAllDates(ctx context.Context, id int) (int, error) { table := t.table.table q := dialect.Delete(table).Where(t.idColumn.Eq(id)) if _, err := exec(ctx, q); err != nil { return 0, fmt.Errorf("resetting dates for id %v: %w", id, err) } return t.getCount(ctx, id) } type sqler interface { ToSQL() (sql string, params []interface{}, err error) } func exec(ctx context.Context, stmt sqler) (sql.Result, error) { tx, err := getTx(ctx) if err != nil { return nil, err } sql, args, err := stmt.ToSQL() if err != nil { return nil, fmt.Errorf("generating sql: %w", err) } logger.Tracef("SQL: %s [%v]", sql, args) ret, err := tx.ExecContext(ctx, sql, args...) if err != nil { return nil, fmt.Errorf("executing `%s` [%v]: %w", sql, args, err) } return ret, nil } func count(ctx context.Context, q *goqu.SelectDataset) (int, error) { var count int if err := querySimple(ctx, q, &count); err != nil { return 0, err } return count, nil } func queryFunc(ctx context.Context, query *goqu.SelectDataset, single bool, f func(rows *sqlx.Rows) error) error { q, args, err := query.ToSQL() if err != nil { return err } rows, err := dbWrapper.QueryxContext(ctx, q, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("querying `%s` [%v]: %w", q, args, err) } defer rows.Close() for rows.Next() { if err := f(rows); err != nil { return err } if single { break } } if err := rows.Err(); err != nil { return err } return nil } func querySimple(ctx context.Context, query *goqu.SelectDataset, out interface{}) error { q, args, err := query.ToSQL() if err != nil { return err } rows, err := dbWrapper.QueryxContext(ctx, q, args...) if err != nil { return fmt.Errorf("querying `%s` [%v]: %w", q, args, err) } defer rows.Close() if rows.Next() { if err := rows.Scan(out); err != nil { return err } } if err := rows.Err(); err != nil { return err } return nil } // func cols(table exp.IdentifierExpression, cols []string) []interface{} { // var ret []interface{} // for _, c := range cols { // ret = append(ret, table.Col(c)) // } // return ret // } ================================================ FILE: pkg/sqlite/tables.go ================================================ package sqlite import ( "github.com/doug-martin/goqu/v9" _ "github.com/doug-martin/goqu/v9/dialect/sqlite3" ) var dialect = goqu.Dialect("sqlite3") var ( galleriesImagesJoinTable = goqu.T(galleriesImagesTable) imagesTagsJoinTable = goqu.T(imagesTagsTable) performersImagesJoinTable = goqu.T(performersImagesTable) imagesFilesJoinTable = goqu.T(imagesFilesTable) imagesURLsJoinTable = goqu.T(imagesURLsTable) imagesCustomFieldsTable = goqu.T("image_custom_fields") galleriesFilesJoinTable = goqu.T(galleriesFilesTable) galleriesTagsJoinTable = goqu.T(galleriesTagsTable) performersGalleriesJoinTable = goqu.T(performersGalleriesTable) galleriesScenesJoinTable = goqu.T(galleriesScenesTable) galleriesURLsJoinTable = goqu.T(galleriesURLsTable) galleriesCustomFieldsTable = goqu.T("gallery_custom_fields") scenesFilesJoinTable = goqu.T(scenesFilesTable) scenesTagsJoinTable = goqu.T(scenesTagsTable) scenesPerformersJoinTable = goqu.T(performersScenesTable) scenesStashIDsJoinTable = goqu.T("scene_stash_ids") scenesGroupsJoinTable = goqu.T(groupsScenesTable) scenesURLsJoinTable = goqu.T(scenesURLsTable) scenesCustomFieldsTable = goqu.T("scene_custom_fields") sceneMarkersTagsJoinTable = goqu.T(sceneMarkersTagsTable) performersAliasesJoinTable = goqu.T(performersAliasesTable) performersURLsJoinTable = goqu.T(performerURLsTable) performersTagsJoinTable = goqu.T(performersTagsTable) performersStashIDsJoinTable = goqu.T("performer_stash_ids") performersCustomFieldsTable = goqu.T("performer_custom_fields") studiosAliasesJoinTable = goqu.T(studioAliasesTable) studiosURLsJoinTable = goqu.T(studioURLsTable) studiosTagsJoinTable = goqu.T(studiosTagsTable) studiosStashIDsJoinTable = goqu.T("studio_stash_ids") studiosCustomFieldsTable = goqu.T("studio_custom_fields") groupsURLsJoinTable = goqu.T(groupURLsTable) groupsTagsJoinTable = goqu.T(groupsTagsTable) groupRelationsJoinTable = goqu.T(groupRelationsTable) groupsCustomFieldsTable = goqu.T("group_custom_fields") tagsAliasesJoinTable = goqu.T(tagAliasesTable) tagRelationsJoinTable = goqu.T(tagRelationsTable) tagsStashIDsJoinTable = goqu.T("tag_stash_ids") tagsCustomFieldsTable = goqu.T("tag_custom_fields") ) var ( imageTableMgr = &table{ table: goqu.T(imageTable), idColumn: goqu.T(imageTable).Col(idColumn), } imagesFilesTableMgr = &relatedFilesTable{ table: table{ table: imagesFilesJoinTable, idColumn: imagesFilesJoinTable.Col(imageIDColumn), }, } imageGalleriesTableMgr = &imageGalleriesTable{ joinTable: joinTable{ table: table{ table: galleriesImagesJoinTable, idColumn: galleriesImagesJoinTable.Col(imageIDColumn), }, fkColumn: galleriesImagesJoinTable.Col(galleryIDColumn), }, } imagesTagsTableMgr = &joinTable{ table: table{ table: imagesTagsJoinTable, idColumn: imagesTagsJoinTable.Col(imageIDColumn), }, fkColumn: imagesTagsJoinTable.Col(tagIDColumn), foreignTable: tagTableMgr, orderBy: tagTableSort, } imagesPerformersTableMgr = &joinTable{ table: table{ table: performersImagesJoinTable, idColumn: performersImagesJoinTable.Col(imageIDColumn), }, fkColumn: performersImagesJoinTable.Col(performerIDColumn), } imagesURLsTableMgr = &orderedValueTable[string]{ table: table{ table: imagesURLsJoinTable, idColumn: imagesURLsJoinTable.Col(imageIDColumn), }, valueColumn: imagesURLsJoinTable.Col(imageURLColumn), } ) var ( galleryTableMgr = &table{ table: goqu.T(galleryTable), idColumn: goqu.T(galleryTable).Col(idColumn), } galleriesFilesTableMgr = &relatedFilesTable{ table: table{ table: galleriesFilesJoinTable, idColumn: galleriesFilesJoinTable.Col(galleryIDColumn), }, } galleriesTagsTableMgr = &joinTable{ table: table{ table: galleriesTagsJoinTable, idColumn: galleriesTagsJoinTable.Col(galleryIDColumn), }, fkColumn: galleriesTagsJoinTable.Col(tagIDColumn), foreignTable: tagTableMgr, orderBy: tagTableSort, } galleriesPerformersTableMgr = &joinTable{ table: table{ table: performersGalleriesJoinTable, idColumn: performersGalleriesJoinTable.Col(galleryIDColumn), }, fkColumn: performersGalleriesJoinTable.Col(performerIDColumn), } galleriesScenesTableMgr = &joinTable{ table: table{ table: galleriesScenesJoinTable, idColumn: galleriesScenesJoinTable.Col(galleryIDColumn), }, fkColumn: galleriesScenesJoinTable.Col(sceneIDColumn), } galleriesChaptersTableMgr = &table{ table: goqu.T(galleriesChaptersTable), idColumn: goqu.T(galleriesChaptersTable).Col(idColumn), } galleriesURLsTableMgr = &orderedValueTable[string]{ table: table{ table: galleriesURLsJoinTable, idColumn: galleriesURLsJoinTable.Col(galleryIDColumn), }, valueColumn: galleriesURLsJoinTable.Col(galleriesURLColumn), } ) var ( sceneTableMgr = &table{ table: goqu.T(sceneTable), idColumn: goqu.T(sceneTable).Col(idColumn), } sceneMarkerTableMgr = &table{ table: goqu.T(sceneMarkerTable), idColumn: goqu.T(sceneMarkerTable).Col(idColumn), } sceneMarkersTagsTableMgr = &joinTable{ table: table{ table: sceneMarkersTagsJoinTable, idColumn: sceneMarkersTagsJoinTable.Col(sceneMarkerIDColumn), }, fkColumn: sceneMarkersTagsJoinTable.Col(tagIDColumn), foreignTable: tagTableMgr, orderBy: tagTableSort, } scenesFilesTableMgr = &relatedFilesTable{ table: table{ table: scenesFilesJoinTable, idColumn: scenesFilesJoinTable.Col(sceneIDColumn), }, } scenesTagsTableMgr = &joinTable{ table: table{ table: scenesTagsJoinTable, idColumn: scenesTagsJoinTable.Col(sceneIDColumn), }, fkColumn: scenesTagsJoinTable.Col(tagIDColumn), foreignTable: tagTableMgr, orderBy: tagTableSort, } scenesPerformersTableMgr = &joinTable{ table: table{ table: scenesPerformersJoinTable, idColumn: scenesPerformersJoinTable.Col(sceneIDColumn), }, fkColumn: scenesPerformersJoinTable.Col(performerIDColumn), } scenesGalleriesTableMgr = galleriesScenesTableMgr.invert() scenesStashIDsTableMgr = &stashIDTable{ table: table{ table: scenesStashIDsJoinTable, idColumn: scenesStashIDsJoinTable.Col(sceneIDColumn), }, } scenesGroupsTableMgr = &scenesGroupsTable{ table: table{ table: scenesGroupsJoinTable, idColumn: scenesGroupsJoinTable.Col(sceneIDColumn), }, } scenesURLsTableMgr = &orderedValueTable[string]{ table: table{ table: scenesURLsJoinTable, idColumn: scenesURLsJoinTable.Col(sceneIDColumn), }, valueColumn: scenesURLsJoinTable.Col(sceneURLColumn), } scenesViewTableMgr = &viewHistoryTable{ table: table{ table: goqu.T(scenesViewDatesTable), idColumn: goqu.T(scenesViewDatesTable).Col(sceneIDColumn), }, dateColumn: goqu.T(scenesViewDatesTable).Col(sceneViewDateColumn), } scenesOTableMgr = &viewHistoryTable{ table: table{ table: goqu.T(scenesODatesTable), idColumn: goqu.T(scenesODatesTable).Col(sceneIDColumn), }, dateColumn: goqu.T(scenesODatesTable).Col(sceneODateColumn), } ) var ( fileTableMgr = &table{ table: goqu.T(fileTable), idColumn: goqu.T(fileTable).Col(idColumn), } videoFileTableMgr = &table{ table: goqu.T(videoFileTable), idColumn: goqu.T(videoFileTable).Col(fileIDColumn), } imageFileTableMgr = &table{ table: goqu.T(imageFileTable), idColumn: goqu.T(imageFileTable).Col(fileIDColumn), } folderTableMgr = &table{ table: goqu.T(folderTable), idColumn: goqu.T(folderTable).Col(idColumn), } fingerprintTableMgr = &table{ table: goqu.T(fingerprintTable), idColumn: goqu.T(fingerprintTable).Col(idColumn), } ) var ( performerTableMgr = &table{ table: goqu.T(performerTable), idColumn: goqu.T(performerTable).Col(idColumn), } performersAliasesTableMgr = &stringTable{ table: table{ table: performersAliasesJoinTable, idColumn: performersAliasesJoinTable.Col(performerIDColumn), }, stringColumn: performersAliasesJoinTable.Col(performerAliasColumn), } performersURLsTableMgr = &orderedValueTable[string]{ table: table{ table: performersURLsJoinTable, idColumn: performersURLsJoinTable.Col(performerIDColumn), }, valueColumn: performersURLsJoinTable.Col(performerURLColumn), } performersTagsTableMgr = &joinTable{ table: table{ table: performersTagsJoinTable, idColumn: performersTagsJoinTable.Col(performerIDColumn), }, fkColumn: performersTagsJoinTable.Col(tagIDColumn), foreignTable: tagTableMgr, orderBy: tagTableSort, } performersStashIDsTableMgr = &stashIDTable{ table: table{ table: performersStashIDsJoinTable, idColumn: performersStashIDsJoinTable.Col(performerIDColumn), }, } ) var ( studioTableMgr = &table{ table: goqu.T(studioTable), idColumn: goqu.T(studioTable).Col(idColumn), } studiosAliasesTableMgr = &stringTable{ table: table{ table: studiosAliasesJoinTable, idColumn: studiosAliasesJoinTable.Col(studioIDColumn), }, stringColumn: studiosAliasesJoinTable.Col(studioAliasColumn), } studiosURLsTableMgr = &orderedValueTable[string]{ table: table{ table: studiosURLsJoinTable, idColumn: studiosURLsJoinTable.Col(studioIDColumn), }, valueColumn: studiosURLsJoinTable.Col(studioURLColumn), } studiosTagsTableMgr = &joinTable{ table: table{ table: studiosTagsJoinTable, idColumn: studiosTagsJoinTable.Col(studioIDColumn), }, fkColumn: studiosTagsJoinTable.Col(tagIDColumn), foreignTable: tagTableMgr, orderBy: tagTableSort, } studiosStashIDsTableMgr = &stashIDTable{ table: table{ table: studiosStashIDsJoinTable, idColumn: studiosStashIDsJoinTable.Col(studioIDColumn), }, } ) var ( tagTableMgr = &table{ table: goqu.T(tagTable), idColumn: goqu.T(tagTable).Col(idColumn), } // formerly: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc() tagTableSort = goqu.L("COALESCE(tags.sort_name, tags.name) COLLATE NATURAL_CI").Asc() tagTableSortSQL = "COALESCE(tags.sort_name, tags.name) COLLATE NATURAL_CI ASC" tagsAliasesTableMgr = &stringTable{ table: table{ table: tagsAliasesJoinTable, idColumn: tagsAliasesJoinTable.Col(tagIDColumn), }, stringColumn: tagsAliasesJoinTable.Col(tagAliasColumn), } tagsParentTagsTableMgr = &joinTable{ table: table{ table: tagRelationsJoinTable, idColumn: tagRelationsJoinTable.Col(tagChildIDColumn), }, fkColumn: tagRelationsJoinTable.Col(tagParentIDColumn), foreignTable: tagTableMgr, orderBy: tagTableSort, } tagsChildTagsTableMgr = *tagsParentTagsTableMgr.invert() tagsStashIDsTableMgr = &stashIDTable{ table: table{ table: tagsStashIDsJoinTable, idColumn: tagsStashIDsJoinTable.Col(tagIDColumn), }, } ) var ( groupTableMgr = &table{ table: goqu.T(groupTable), idColumn: goqu.T(groupTable).Col(idColumn), } groupsURLsTableMgr = &orderedValueTable[string]{ table: table{ table: groupsURLsJoinTable, idColumn: groupsURLsJoinTable.Col(groupIDColumn), }, valueColumn: groupsURLsJoinTable.Col(groupURLColumn), } groupsTagsTableMgr = &joinTable{ table: table{ table: groupsTagsJoinTable, idColumn: groupsTagsJoinTable.Col(groupIDColumn), }, fkColumn: groupsTagsJoinTable.Col(tagIDColumn), foreignTable: tagTableMgr, orderBy: tagTableSort, } groupRelationshipTableMgr = &table{ table: groupRelationsJoinTable, } ) var ( blobTableMgr = &table{ table: goqu.T(blobTable), idColumn: goqu.T(blobTable).Col(blobChecksumColumn), } ) var ( savedFilterTableMgr = &table{ table: goqu.T(savedFilterTable), idColumn: goqu.T(savedFilterTable).Col(idColumn), } ) ================================================ FILE: pkg/sqlite/tag.go ================================================ package sqlite import ( "context" "database/sql" "errors" "fmt" "slices" "strings" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4/zero" "github.com/stashapp/stash/pkg/models" ) const ( tagTable = "tags" tagIDColumn = "tag_id" tagAliasesTable = "tag_aliases" tagAliasColumn = "alias" tagImageBlobColumn = "image_blob" tagRelationsTable = "tags_relations" tagParentIDColumn = "parent_id" tagChildIDColumn = "child_id" ) type tagRow struct { ID int `db:"id" goqu:"skipinsert"` Name null.String `db:"name"` // TODO: make schema non-nullable SortName zero.String `db:"sort_name"` Favorite bool `db:"favorite"` Description zero.String `db:"description"` IgnoreAutoTag bool `db:"ignore_auto_tag"` CreatedAt Timestamp `db:"created_at"` UpdatedAt Timestamp `db:"updated_at"` // not used in resolutions or updates ImageBlob zero.String `db:"image_blob"` } func (r *tagRow) fromTag(o models.Tag) { r.ID = o.ID r.Name = null.StringFrom(o.Name) r.SortName = zero.StringFrom((o.SortName)) r.Favorite = o.Favorite r.Description = zero.StringFrom(o.Description) r.IgnoreAutoTag = o.IgnoreAutoTag r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} } func (r *tagRow) resolve() *models.Tag { ret := &models.Tag{ ID: r.ID, Name: r.Name.String, SortName: r.SortName.String, Favorite: r.Favorite, Description: r.Description.String, IgnoreAutoTag: r.IgnoreAutoTag, CreatedAt: r.CreatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp, } return ret } type tagPathRow struct { tagRow Path string `db:"path"` } func (r *tagPathRow) resolve() *models.TagPath { ret := &models.TagPath{ Tag: *r.tagRow.resolve(), Path: r.Path, } return ret } type tagRowRecord struct { updateRecord } func (r *tagRowRecord) fromPartial(o models.TagPartial) { r.setString("name", o.Name) r.setNullString("sort_name", o.SortName) r.setNullString("description", o.Description) r.setBool("favorite", o.Favorite) r.setBool("ignore_auto_tag", o.IgnoreAutoTag) r.setTimestamp("created_at", o.CreatedAt) r.setTimestamp("updated_at", o.UpdatedAt) } type tagRepositoryType struct { repository aliases stringRepository stashIDs stashIDRepository scenes joinRepository images joinRepository galleries joinRepository groups joinRepository performers joinRepository studios joinRepository } var ( tagRepository = tagRepositoryType{ repository: repository{ tableName: tagTable, idColumn: idColumn, }, aliases: stringRepository{ repository: repository{ tableName: tagAliasesTable, idColumn: tagIDColumn, }, stringColumn: tagAliasColumn, }, stashIDs: stashIDRepository{ repository{ tableName: "tag_stash_ids", idColumn: tagIDColumn, }, }, scenes: joinRepository{ repository: repository{ tableName: scenesTagsTable, idColumn: tagIDColumn, }, fkColumn: sceneIDColumn, foreignTable: sceneTable, }, images: joinRepository{ repository: repository{ tableName: imagesTagsTable, idColumn: tagIDColumn, }, fkColumn: imageIDColumn, foreignTable: imageTable, }, galleries: joinRepository{ repository: repository{ tableName: galleriesTagsTable, idColumn: tagIDColumn, }, fkColumn: galleryIDColumn, foreignTable: galleryTable, }, groups: joinRepository{ repository: repository{ tableName: groupsTagsTable, idColumn: tagIDColumn, }, fkColumn: groupIDColumn, foreignTable: groupTable, }, performers: joinRepository{ repository: repository{ tableName: performersTagsTable, idColumn: tagIDColumn, }, fkColumn: performerIDColumn, foreignTable: performerTable, }, studios: joinRepository{ repository: repository{ tableName: studiosTagsTable, idColumn: tagIDColumn, }, fkColumn: studioIDColumn, foreignTable: studioTable, }, } ) type TagStore struct { blobJoinQueryBuilder customFieldsStore tableMgr *table } func NewTagStore(blobStore *BlobStore) *TagStore { return &TagStore{ blobJoinQueryBuilder: blobJoinQueryBuilder{ blobStore: blobStore, joinTable: tagTable, }, customFieldsStore: customFieldsStore{ table: tagsCustomFieldsTable, fk: tagsCustomFieldsTable.Col(tagIDColumn), }, tableMgr: tagTableMgr, } } func (qb *TagStore) table() exp.IdentifierExpression { return qb.tableMgr.table } func (qb *TagStore) selectDataset() *goqu.SelectDataset { return dialect.From(qb.table()).Select(qb.table().All()) } func (qb *TagStore) Create(ctx context.Context, newObject *models.CreateTagInput) error { var r tagRow r.fromTag(*newObject.Tag) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { return err } if newObject.Aliases.Loaded() { if err := tagsAliasesTableMgr.insertJoins(ctx, id, newObject.Aliases.List()); err != nil { return err } } if newObject.ParentIDs.Loaded() { if err := tagsParentTagsTableMgr.insertJoins(ctx, id, newObject.ParentIDs.List()); err != nil { return err } } if newObject.ChildIDs.Loaded() { if err := tagsChildTagsTableMgr.insertJoins(ctx, id, newObject.ChildIDs.List()); err != nil { return err } } if newObject.StashIDs.Loaded() { if err := tagsStashIDsTableMgr.insertJoins(ctx, id, newObject.StashIDs.List()); err != nil { return err } } const partial = false if err := qb.setCustomFields(ctx, id, newObject.CustomFields, partial); err != nil { return err } updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } *newObject.Tag = *updated return nil } func (qb *TagStore) UpdatePartial(ctx context.Context, id int, partial models.TagPartial) (*models.Tag, error) { r := tagRowRecord{ updateRecord{ Record: make(exp.Record), }, } r.fromPartial(partial) if len(r.Record) > 0 { if err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil { return nil, err } } if partial.Aliases != nil { if err := tagsAliasesTableMgr.modifyJoins(ctx, id, partial.Aliases.Values, partial.Aliases.Mode); err != nil { return nil, err } } if partial.ParentIDs != nil { if err := tagsParentTagsTableMgr.modifyJoins(ctx, id, partial.ParentIDs.IDs, partial.ParentIDs.Mode); err != nil { return nil, err } } if partial.ChildIDs != nil { if err := tagsChildTagsTableMgr.modifyJoins(ctx, id, partial.ChildIDs.IDs, partial.ChildIDs.Mode); err != nil { return nil, err } } if partial.StashIDs != nil { if err := tagsStashIDsTableMgr.modifyJoins(ctx, id, partial.StashIDs.StashIDs, partial.StashIDs.Mode); err != nil { return nil, err } } if err := qb.SetCustomFields(ctx, id, partial.CustomFields); err != nil { return nil, err } return qb.find(ctx, id) } func (qb *TagStore) Update(ctx context.Context, updatedObject *models.UpdateTagInput) error { var r tagRow r.fromTag(*updatedObject.Tag) if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { return err } if updatedObject.Aliases.Loaded() { if err := tagsAliasesTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.Aliases.List()); err != nil { return err } } if updatedObject.ParentIDs.Loaded() { if err := tagsParentTagsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.ParentIDs.List()); err != nil { return err } } if updatedObject.ChildIDs.Loaded() { if err := tagsChildTagsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.ChildIDs.List()); err != nil { return err } } if updatedObject.StashIDs.Loaded() { if err := tagsStashIDsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.StashIDs.List()); err != nil { return err } } if err := qb.SetCustomFields(ctx, updatedObject.ID, updatedObject.CustomFields); err != nil { return err } return nil } func (qb *TagStore) Destroy(ctx context.Context, id int) error { // must handle image checksums manually if err := qb.destroyImage(ctx, id); err != nil { return err } // cannot unset primary_tag_id in scene_markers because it is not nullable countQuery := "SELECT COUNT(*) as count FROM scene_markers where primary_tag_id = ?" args := []interface{}{id} primaryMarkers, err := tagRepository.runCountQuery(ctx, countQuery, args) if err != nil { return err } if primaryMarkers > 0 { return errors.New("cannot delete tag used as a primary tag in scene markers") } return tagRepository.destroyExisting(ctx, []int{id}) } // returns nil, nil if not found func (qb *TagStore) Find(ctx context.Context, id int) (*models.Tag, error) { ret, err := qb.find(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } return ret, err } func (qb *TagStore) FindMany(ctx context.Context, ids []int) ([]*models.Tag, error) { ret := make([]*models.Tag, len(ids)) table := qb.table() if err := batchExec(ids, defaultBatchSize, func(batch []int) error { q := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(batch)) unsorted, err := qb.getMany(ctx, q) if err != nil { return err } for _, s := range unsorted { i := slices.Index(ids, s.ID) ret[i] = s } return nil }); err != nil { return nil, err } for i := range ret { if ret[i] == nil { return nil, fmt.Errorf("tag with id %d not found", ids[i]) } } return ret, nil } // returns nil, sql.ErrNoRows if not found func (qb *TagStore) find(ctx context.Context, id int) (*models.Tag, error) { q := qb.selectDataset().Where(qb.tableMgr.byID(id)) ret, err := qb.get(ctx, q) if err != nil { return nil, err } return ret, nil } // returns nil, sql.ErrNoRows if not found func (qb *TagStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Tag, error) { ret, err := qb.getMany(ctx, q) if err != nil { return nil, err } if len(ret) == 0 { return nil, sql.ErrNoRows } return ret[0], nil } func (qb *TagStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Tag, error) { const single = false var ret []*models.Tag if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { var f tagRow if err := r.StructScan(&f); err != nil { return err } s := f.resolve() ret = append(ret, s) return nil }); err != nil { return nil, err } return ret, nil } func (qb *TagStore) FindBySceneID(ctx context.Context, sceneID int) ([]*models.Tag, error) { query := ` SELECT tags.* FROM tags LEFT JOIN scenes_tags as scenes_join on scenes_join.tag_id = tags.id WHERE scenes_join.scene_id = ? GROUP BY tags.id ` query += qb.getDefaultTagSort() args := []interface{}{sceneID} return qb.queryTags(ctx, query, args) } func (qb *TagStore) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Tag, error) { query := ` SELECT tags.* FROM tags LEFT JOIN performers_tags as performers_join on performers_join.tag_id = tags.id WHERE performers_join.performer_id = ? GROUP BY tags.id ` query += qb.getDefaultTagSort() args := []interface{}{performerID} return qb.queryTags(ctx, query, args) } func (qb *TagStore) FindByImageID(ctx context.Context, imageID int) ([]*models.Tag, error) { query := ` SELECT tags.* FROM tags LEFT JOIN images_tags as images_join on images_join.tag_id = tags.id WHERE images_join.image_id = ? GROUP BY tags.id ` query += qb.getDefaultTagSort() args := []interface{}{imageID} return qb.queryTags(ctx, query, args) } func (qb *TagStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Tag, error) { query := ` SELECT tags.* FROM tags LEFT JOIN galleries_tags as galleries_join on galleries_join.tag_id = tags.id WHERE galleries_join.gallery_id = ? GROUP BY tags.id ` query += qb.getDefaultTagSort() args := []interface{}{galleryID} return qb.queryTags(ctx, query, args) } func (qb *TagStore) FindByGroupID(ctx context.Context, groupID int) ([]*models.Tag, error) { query := ` SELECT tags.* FROM tags LEFT JOIN groups_tags as groups_join on groups_join.tag_id = tags.id WHERE groups_join.group_id = ? GROUP BY tags.id ` query += qb.getDefaultTagSort() args := []interface{}{groupID} return qb.queryTags(ctx, query, args) } func (qb *TagStore) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*models.Tag, error) { query := ` SELECT tags.* FROM tags LEFT JOIN scene_markers_tags as scene_markers_join on scene_markers_join.tag_id = tags.id WHERE scene_markers_join.scene_marker_id = ? GROUP BY tags.id ` query += qb.getDefaultTagSort() args := []interface{}{sceneMarkerID} return qb.queryTags(ctx, query, args) } func (qb *TagStore) FindByStudioID(ctx context.Context, studioID int) ([]*models.Tag, error) { query := ` SELECT tags.* FROM tags LEFT JOIN studios_tags as studios_join on studios_join.tag_id = tags.id WHERE studios_join.studio_id = ? GROUP BY tags.id ` query += qb.getDefaultTagSort() args := []interface{}{studioID} return qb.queryTags(ctx, query, args) } func (qb *TagStore) FindByName(ctx context.Context, name string, nocase bool) (*models.Tag, error) { // query := "SELECT * FROM tags WHERE name = ?" // if nocase { // query += " COLLATE NOCASE" // } // query += " LIMIT 1" where := "name = ?" if nocase { where += " COLLATE NOCASE" } sq := qb.selectDataset().Prepared(true).Where(goqu.L(where, name)).Limit(1) ret, err := qb.get(ctx, sq) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, err } return ret, nil } func (qb *TagStore) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Tag, error) { // query := "SELECT * FROM tags WHERE name" // if nocase { // query += " COLLATE NOCASE" // } // query += " IN " + getInBinding(len(names)) where := "name" if nocase { where += " COLLATE NOCASE" } where += " IN " + getInBinding(len(names)) var args []interface{} for _, name := range names { args = append(args, name) } sq := qb.selectDataset().Prepared(true).Where(goqu.L(where, args...)) ret, err := qb.getMany(ctx, sq) if err != nil { return nil, err } return ret, nil } func (qb *TagStore) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Tag, error) { sq := dialect.From(tagsStashIDsJoinTable).Select(tagsStashIDsJoinTable.Col(tagIDColumn)).Where( tagsStashIDsJoinTable.Col("stash_id").Eq(stashID.StashID), tagsStashIDsJoinTable.Col("endpoint").Eq(stashID.Endpoint), ) idsQuery := qb.selectDataset().Where( qb.table().Col(idColumn).In(sq), ) ret, err := qb.getMany(ctx, idsQuery) if err != nil { return nil, fmt.Errorf("getting tags for stash ID %s: %w", stashID.StashID, err) } return ret, nil } func (qb *TagStore) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Tag, error) { table := qb.table() sq := dialect.From(table).LeftJoin( tagsStashIDsJoinTable, goqu.On(table.Col(idColumn).Eq(tagsStashIDsJoinTable.Col(tagIDColumn))), ).Select(table.Col(idColumn)) if hasStashID { sq = sq.Where( tagsStashIDsJoinTable.Col("stash_id").IsNotNull(), tagsStashIDsJoinTable.Col("endpoint").Eq(stashboxEndpoint), ) } else { sq = sq.Where( tagsStashIDsJoinTable.Col("stash_id").IsNull(), ) } idsQuery := qb.selectDataset().Where( table.Col(idColumn).In(sq), ) ret, err := qb.getMany(ctx, idsQuery) if err != nil { return nil, fmt.Errorf("getting tags for stash-box endpoint %s: %w", stashboxEndpoint, err) } return ret, nil } func (qb *TagStore) GetParentIDs(ctx context.Context, relatedID int) ([]int, error) { return tagsParentTagsTableMgr.get(ctx, relatedID) } func (qb *TagStore) GetChildIDs(ctx context.Context, relatedID int) ([]int, error) { return tagsChildTagsTableMgr.get(ctx, relatedID) } func (qb *TagStore) FindByParentTagID(ctx context.Context, parentID int) ([]*models.Tag, error) { query := ` SELECT tags.* FROM tags INNER JOIN tags_relations ON tags_relations.child_id = tags.id WHERE tags_relations.parent_id = ? ` query += qb.getDefaultTagSort() args := []interface{}{parentID} return qb.queryTags(ctx, query, args) } func (qb *TagStore) FindByChildTagID(ctx context.Context, parentID int) ([]*models.Tag, error) { query := ` SELECT tags.* FROM tags INNER JOIN tags_relations ON tags_relations.parent_id = tags.id WHERE tags_relations.child_id = ? ` query += qb.getDefaultTagSort() args := []interface{}{parentID} return qb.queryTags(ctx, query, args) } func (qb *TagStore) CountByParentTagID(ctx context.Context, parentID int) (int, error) { q := dialect.Select(goqu.COUNT("*")).From(goqu.T("tags")). InnerJoin(goqu.T("tags_relations"), goqu.On(goqu.I("tags_relations.parent_id").Eq(goqu.I("tags.id")))). Where(goqu.I("tags_relations.child_id").Eq(goqu.V(parentID))) // Pass the parentID here return count(ctx, q) } func (qb *TagStore) CountByChildTagID(ctx context.Context, childID int) (int, error) { q := dialect.Select(goqu.COUNT("*")).From(goqu.T("tags")). InnerJoin(goqu.T("tags_relations"), goqu.On(goqu.I("tags_relations.child_id").Eq(goqu.I("tags.id")))). Where(goqu.I("tags_relations.parent_id").Eq(goqu.V(childID))) // Pass the childID here return count(ctx, q) } func (qb *TagStore) Count(ctx context.Context) (int, error) { q := dialect.Select(goqu.COUNT("*")).From(qb.table()) return count(ctx, q) } func (qb *TagStore) All(ctx context.Context) ([]*models.Tag, error) { table := qb.table() return qb.getMany(ctx, qb.selectDataset().Order( goqu.L("COALESCE(tags.sort_name, tags.name) COLLATE NATURAL_CI").Asc(), table.Col(idColumn).Asc(), )) } func (qb *TagStore) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Tag, error) { // TODO - Query needs to be changed to support queries of this type, and // this method should be removed query := selectAll(tagTable) query += " LEFT JOIN tag_aliases ON tag_aliases.tag_id = tags.id" var whereClauses []string var args []interface{} for _, w := range words { ww := w + "%" whereClauses = append(whereClauses, "tags.name like ?") args = append(args, ww) // include aliases whereClauses = append(whereClauses, "tag_aliases.alias like ?") args = append(args, ww) } whereOr := "(" + strings.Join(whereClauses, " OR ") + ")" where := strings.Join([]string{ "tags.ignore_auto_tag = 0", whereOr, }, " AND ") return qb.queryTags(ctx, query+" WHERE "+where, args) } func (qb *TagStore) Query(ctx context.Context, tagFilter *models.TagFilterType, findFilter *models.FindFilterType) ([]*models.Tag, int, error) { if tagFilter == nil { tagFilter = &models.TagFilterType{} } if findFilter == nil { findFilter = &models.FindFilterType{} } query := tagRepository.newQuery() distinctIDs(&query, tagTable) if q := findFilter.Q; q != nil && *q != "" { query.join(tagAliasesTable, "", "tag_aliases.tag_id = tags.id") searchColumns := []string{"tags.name", "tag_aliases.alias", "tags.sort_name"} query.parseQueryString(searchColumns, *q) } filter := filterBuilderFromHandler(ctx, &tagFilterHandler{ tagFilter: tagFilter, }) if err := query.addFilter(filter); err != nil { return nil, 0, err } var err error query.sortAndPagination, err = qb.getTagSort(&query, findFilter) if err != nil { return nil, 0, err } query.sortAndPagination += getPagination(findFilter) idsResult, countResult, err := query.executeFind(ctx) if err != nil { return nil, 0, err } tags, err := qb.FindMany(ctx, idsResult) if err != nil { return nil, 0, err } return tags, countResult, nil } var tagSortOptions = sortOptions{ "created_at", "galleries_count", "groups_count", "id", "images_count", "movies_count", "studios_count", "name", "performers_count", "random", "scene_markers_count", "scenes_count", "scenes_duration", "scenes_size", "updated_at", } func (qb *TagStore) sortByScenesDuration(direction string) string { return fmt.Sprintf(` ORDER BY ( SELECT COALESCE(SUM(video_files.duration), 0) FROM %s LEFT JOIN %s ON %s.id = %s.%s LEFT JOIN %s ON %s.%s = %s.id LEFT JOIN video_files ON video_files.file_id = %s.file_id WHERE %s.%s = %s.id ) %s`, scenesTagsTable, sceneTable, sceneTable, scenesTagsTable, sceneIDColumn, scenesFilesTable, scenesFilesTable, sceneIDColumn, sceneTable, scenesFilesTable, scenesTagsTable, tagIDColumn, tagTable, getSortDirection(direction)) } func (qb *TagStore) sortByScenesSize(direction string) string { return fmt.Sprintf(` ORDER BY ( SELECT COALESCE(SUM(%s.size), 0) FROM %s LEFT JOIN %s ON %s.id = %s.%s LEFT JOIN %s ON %s.%s = %s.id LEFT JOIN %s ON %s.id = %s.file_id WHERE %s.%s = %s.id ) %s`, fileTable, scenesTagsTable, sceneTable, sceneTable, scenesTagsTable, sceneIDColumn, scenesFilesTable, scenesFilesTable, sceneIDColumn, sceneTable, fileTable, fileTable, scenesFilesTable, scenesTagsTable, tagIDColumn, tagTable, getSortDirection(direction)) } func (qb *TagStore) getDefaultTagSort() string { return getSort("name", "ASC", "tags") } func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilterType) (string, error) { var sort string var direction string if findFilter == nil { sort = "name" direction = "ASC" } else { sort = findFilter.GetSort("name") direction = findFilter.GetDirection() } // CVE-2024-32231 - ensure sort is in the list of allowed sorts if err := tagSortOptions.validateSort(sort); err != nil { return "", err } sortQuery := "" switch sort { case "name": sortQuery += fmt.Sprintf(" ORDER BY COALESCE(tags.sort_name, tags.name) COLLATE NATURAL_CI %s", getSortDirection(direction)) case "scenes_count": sortQuery += getCountSort(tagTable, scenesTagsTable, tagIDColumn, direction) case "scenes_duration": sortQuery += qb.sortByScenesDuration(direction) case "scenes_size": sortQuery += qb.sortByScenesSize(direction) case "scene_markers_count": sortQuery += fmt.Sprintf(" ORDER BY (SELECT COUNT(*) FROM scene_markers_tags WHERE tags.id = scene_markers_tags.tag_id)+(SELECT COUNT(*) FROM scene_markers WHERE tags.id = scene_markers.primary_tag_id) %s", getSortDirection(direction)) case "images_count": sortQuery += getCountSort(tagTable, imagesTagsTable, tagIDColumn, direction) case "galleries_count": sortQuery += getCountSort(tagTable, galleriesTagsTable, tagIDColumn, direction) case "performers_count": sortQuery += getCountSort(tagTable, performersTagsTable, tagIDColumn, direction) case "studios_count": sortQuery += getCountSort(tagTable, studiosTagsTable, tagIDColumn, direction) case "movies_count", "groups_count": sortQuery += getCountSort(tagTable, groupsTagsTable, tagIDColumn, direction) default: sortQuery += getSort(sort, direction, "tags") } // Whatever the sorting, always use sort_name/name/id as a final sort sortQuery += ", COALESCE(tags.sort_name, tags.name, tags.id) COLLATE NATURAL_CI ASC" return sortQuery, nil } func (qb *TagStore) queryTags(ctx context.Context, query string, args []interface{}) ([]*models.Tag, error) { const single = false var ret []*models.Tag if err := tagRepository.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { var f tagRow if err := r.StructScan(&f); err != nil { return err } s := f.resolve() ret = append(ret, s) return nil }); err != nil { return nil, err } return ret, nil } func (qb *TagStore) queryTagPaths(ctx context.Context, query string, args []interface{}) ([]*models.TagPath, error) { const single = false var ret []*models.TagPath if err := tagRepository.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { var f tagPathRow if err := r.StructScan(&f); err != nil { return err } t := f.resolve() ret = append(ret, t) return nil }); err != nil { return nil, err } return ret, nil } func (qb *TagStore) GetImage(ctx context.Context, tagID int) ([]byte, error) { return qb.blobJoinQueryBuilder.GetImage(ctx, tagID, tagImageBlobColumn) } func (qb *TagStore) HasImage(ctx context.Context, tagID int) (bool, error) { return qb.blobJoinQueryBuilder.HasImage(ctx, tagID, tagImageBlobColumn) } func (qb *TagStore) UpdateImage(ctx context.Context, tagID int, image []byte) error { return qb.blobJoinQueryBuilder.UpdateImage(ctx, tagID, tagImageBlobColumn, image) } func (qb *TagStore) destroyImage(ctx context.Context, tagID int) error { return qb.blobJoinQueryBuilder.DestroyImage(ctx, tagID, tagImageBlobColumn) } func (qb *TagStore) GetAliases(ctx context.Context, tagID int) ([]string, error) { return tagRepository.aliases.get(ctx, tagID) } func (qb *TagStore) UpdateAliases(ctx context.Context, tagID int, aliases []string) error { return tagRepository.aliases.replace(ctx, tagID, aliases) } func (qb *TagStore) GetStashIDs(ctx context.Context, tagID int) ([]models.StashID, error) { return tagsStashIDsTableMgr.get(ctx, tagID) } func (qb *TagStore) UpdateStashIDs(ctx context.Context, tagID int, stashIDs []models.StashID) error { return tagsStashIDsTableMgr.replaceJoins(ctx, tagID, stashIDs) } func (qb *TagStore) Merge(ctx context.Context, source []int, destination int) error { if len(source) == 0 { return nil } inBinding := getInBinding(len(source)) args := []interface{}{destination} srcArgs := make([]interface{}, len(source)) for i, id := range source { if id == destination { return errors.New("cannot merge where source == destination") } srcArgs[i] = id } args = append(args, srcArgs...) tagTables := map[string]string{ scenesTagsTable: sceneIDColumn, "scene_markers_tags": "scene_marker_id", galleriesTagsTable: galleryIDColumn, imagesTagsTable: imageIDColumn, "performers_tags": "performer_id", "studios_tags": "studio_id", groupsTagsTable: "group_id", } args = append(args, destination) // for each table, update source tag ids to destination tag id, ignoring duplicates for table, idColumn := range tagTables { _, err := dbWrapper.Exec(ctx, `UPDATE OR IGNORE `+table+` SET tag_id = ? WHERE tag_id IN `+inBinding+` AND NOT EXISTS(SELECT 1 FROM `+table+` o WHERE o.`+idColumn+` = `+table+`.`+idColumn+` AND o.tag_id = ?)`, args..., ) if err != nil { return err } // delete source tag ids from the table where they couldn't be set if _, err := dbWrapper.Exec(ctx, `DELETE FROM `+table+` WHERE tag_id IN `+inBinding, srcArgs...); err != nil { return err } } _, err := dbWrapper.Exec(ctx, "UPDATE "+sceneMarkerTable+" SET primary_tag_id = ? WHERE primary_tag_id IN "+inBinding, args...) if err != nil { return err } _, err = dbWrapper.Exec(ctx, "INSERT INTO "+tagAliasesTable+" (tag_id, alias) SELECT ?, name FROM "+tagTable+" WHERE id IN "+inBinding, args...) if err != nil { return err } _, err = dbWrapper.Exec(ctx, "UPDATE "+tagAliasesTable+" SET tag_id = ? WHERE tag_id IN "+inBinding, args...) if err != nil { return err } // Merge StashIDs - move all source StashIDs to destination (ignoring duplicates) _, err = dbWrapper.Exec(ctx, `UPDATE OR IGNORE `+"tag_stash_ids"+` SET tag_id = ? WHERE tag_id IN `+inBinding, args...) if err != nil { return err } // Delete remaining source StashIDs that couldn't be moved (duplicates) if _, err := dbWrapper.Exec(ctx, `DELETE FROM tag_stash_ids WHERE tag_id IN `+inBinding, srcArgs...); err != nil { return err } for _, id := range source { err = qb.Destroy(ctx, id) if err != nil { return err } } return nil } func (qb *TagStore) UpdateParentTags(ctx context.Context, tagID int, parentIDs []int) error { if _, err := dbWrapper.Exec(ctx, "DELETE FROM tags_relations WHERE child_id = ?", tagID); err != nil { return err } if len(parentIDs) > 0 { var args []interface{} var values []string for _, parentID := range parentIDs { values = append(values, "(? , ?)") args = append(args, parentID, tagID) } query := "INSERT INTO tags_relations (parent_id, child_id) VALUES " + strings.Join(values, ", ") if _, err := dbWrapper.Exec(ctx, query, args...); err != nil { return err } } return nil } func (qb *TagStore) UpdateChildTags(ctx context.Context, tagID int, childIDs []int) error { if _, err := dbWrapper.Exec(ctx, "DELETE FROM tags_relations WHERE parent_id = ?", tagID); err != nil { return err } if len(childIDs) > 0 { var args []interface{} var values []string for _, childID := range childIDs { values = append(values, "(? , ?)") args = append(args, tagID, childID) } query := "INSERT INTO tags_relations (parent_id, child_id) VALUES " + strings.Join(values, ", ") if _, err := dbWrapper.Exec(ctx, query, args...); err != nil { return err } } return nil } // FindAllAncestors returns a slice of TagPath objects, representing all // ancestors of the tag with the provided id. func (qb *TagStore) FindAllAncestors(ctx context.Context, tagID int, excludeIDs []int) ([]*models.TagPath, error) { inBinding := getInBinding(len(excludeIDs) + 1) query := `WITH RECURSIVE parents AS ( SELECT t.id AS parent_id, t.id AS child_id, t.name as path FROM tags t WHERE t.id = ? UNION SELECT tr.parent_id, tr.child_id, t.name || '->' || p.path as path FROM tags_relations tr INNER JOIN parents p ON p.parent_id = tr.child_id JOIN tags t ON t.id = tr.parent_id WHERE tr.parent_id NOT IN` + inBinding + ` ) SELECT t.*, p.path FROM tags t INNER JOIN parents p ON t.id = p.parent_id ` excludeArgs := []interface{}{tagID} for _, excludeID := range excludeIDs { excludeArgs = append(excludeArgs, excludeID) } args := []interface{}{tagID} args = append(args, append(append(excludeArgs, excludeArgs...), excludeArgs...)...) return qb.queryTagPaths(ctx, query, args) } // FindAllDescendants returns a slice of TagPath objects, representing all // descendants of the tag with the provided id. func (qb *TagStore) FindAllDescendants(ctx context.Context, tagID int, excludeIDs []int) ([]*models.TagPath, error) { inBinding := getInBinding(len(excludeIDs) + 1) query := `WITH RECURSIVE children AS ( SELECT t.id AS parent_id, t.id AS child_id, t.name as path FROM tags t WHERE t.id = ? UNION SELECT tr.parent_id, tr.child_id, c.path || '->' || t.name as path FROM tags_relations tr INNER JOIN children c ON c.child_id = tr.parent_id JOIN tags t ON t.id = tr.child_id WHERE tr.child_id NOT IN` + inBinding + ` ) SELECT t.*, c.path FROM tags t INNER JOIN children c ON t.id = c.child_id ` excludeArgs := []interface{}{tagID} for _, excludeID := range excludeIDs { excludeArgs = append(excludeArgs, excludeID) } args := []interface{}{tagID} args = append(args, append(append(excludeArgs, excludeArgs...), excludeArgs...)...) return qb.queryTagPaths(ctx, query, args) } type tagRelationshipStore struct { idRelationshipStore } func (s *tagRelationshipStore) CountByTagID(ctx context.Context, tagID int) (int, error) { joinTable := s.joinTable.table.table q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col(tagIDColumn).Eq(tagID)) return count(ctx, q) } func (s *tagRelationshipStore) GetTagIDs(ctx context.Context, id int) ([]int, error) { return s.joinTable.get(ctx, id) } ================================================ FILE: pkg/sqlite/tag_filter.go ================================================ package sqlite import ( "context" "github.com/stashapp/stash/pkg/models" ) type tagFilterHandler struct { tagFilter *models.TagFilterType } func (qb *tagFilterHandler) validate() error { tagFilter := qb.tagFilter if tagFilter == nil { return nil } if err := validateFilterCombination(tagFilter.OperatorFilter); err != nil { return err } if subFilter := tagFilter.SubFilter(); subFilter != nil { sqb := &tagFilterHandler{tagFilter: subFilter} if err := sqb.validate(); err != nil { return err } } return nil } func (qb *tagFilterHandler) handle(ctx context.Context, f *filterBuilder) { tagFilter := qb.tagFilter if tagFilter == nil { return } if err := qb.validate(); err != nil { f.setError(err) return } sf := tagFilter.SubFilter() if sf != nil { sub := &tagFilterHandler{sf} handleSubFilter(ctx, sub, f, tagFilter.OperatorFilter) } f.handleCriterion(ctx, qb.criterionHandler()) } var tagHierarchyHandler = hierarchicalRelationshipHandler{ primaryTable: tagTable, relationTable: tagRelationsTable, aliasPrefix: tagTable, parentIDCol: "parent_id", childIDCol: "child_id", } func (qb *tagFilterHandler) criterionHandler() criterionHandler { tagFilter := qb.tagFilter return compoundHandler{ stringCriterionHandler(tagFilter.Name, tagTable+".name"), stringCriterionHandler(tagFilter.SortName, tagTable+".sort_name"), qb.aliasCriterionHandler(tagFilter.Aliases), boolCriterionHandler(tagFilter.Favorite, tagTable+".favorite", nil), stringCriterionHandler(tagFilter.Description, tagTable+".description"), boolCriterionHandler(tagFilter.IgnoreAutoTag, tagTable+".ignore_auto_tag", nil), qb.isMissingCriterionHandler(tagFilter.IsMissing), qb.sceneCountCriterionHandler(tagFilter.SceneCount), qb.imageCountCriterionHandler(tagFilter.ImageCount), qb.galleryCountCriterionHandler(tagFilter.GalleryCount), qb.performerCountCriterionHandler(tagFilter.PerformerCount), qb.studioCountCriterionHandler(tagFilter.StudioCount), qb.groupCountCriterionHandler(tagFilter.GroupCount), qb.groupCountCriterionHandler(tagFilter.MovieCount), qb.markerCountCriterionHandler(tagFilter.MarkerCount), tagHierarchyHandler.ParentsCriterionHandler(tagFilter.Parents), tagHierarchyHandler.ChildrenCriterionHandler(tagFilter.Children), tagHierarchyHandler.ParentCountCriterionHandler(tagFilter.ParentCount), tagHierarchyHandler.ChildCountCriterionHandler(tagFilter.ChildCount), &stashIDCriterionHandler{ c: tagFilter.StashIDEndpoint, stashIDRepository: &tagRepository.stashIDs, stashIDTableAs: "tag_stash_ids", parentIDCol: "tags.id", }, &stashIDsCriterionHandler{ c: tagFilter.StashIDsEndpoint, stashIDRepository: &tagRepository.stashIDs, stashIDTableAs: "tag_stash_ids", parentIDCol: "tags.id", }, ×tampCriterionHandler{tagFilter.CreatedAt, "tags.created_at", nil}, ×tampCriterionHandler{tagFilter.UpdatedAt, "tags.updated_at", nil}, &customFieldsFilterHandler{ table: tagsCustomFieldsTable.GetTable(), fkCol: tagIDColumn, c: tagFilter.CustomFields, idCol: "tags.id", }, &relatedFilterHandler{ relatedIDCol: "scenes_tags.scene_id", relatedRepo: sceneRepository.repository, relatedHandler: &sceneFilterHandler{tagFilter.ScenesFilter}, joinFn: func(f *filterBuilder) { tagRepository.scenes.innerJoin(f, "", "tags.id") }, }, &relatedFilterHandler{ relatedIDCol: "images_tags.image_id", relatedRepo: imageRepository.repository, relatedHandler: &imageFilterHandler{tagFilter.ImagesFilter}, joinFn: func(f *filterBuilder) { tagRepository.images.innerJoin(f, "", "tags.id") }, }, &relatedFilterHandler{ relatedIDCol: "galleries_tags.gallery_id", relatedRepo: galleryRepository.repository, relatedHandler: &galleryFilterHandler{tagFilter.GalleriesFilter}, joinFn: func(f *filterBuilder) { tagRepository.galleries.innerJoin(f, "", "tags.id") }, }, &relatedFilterHandler{ relatedIDCol: "groups_tags.group_id", relatedRepo: groupRepository.repository, relatedHandler: &groupFilterHandler{tagFilter.GroupsFilter}, joinFn: func(f *filterBuilder) { tagRepository.groups.innerJoin(f, "", "tags.id") }, }, &relatedFilterHandler{ relatedIDCol: "performers_tags.performer_id", relatedRepo: performerRepository.repository, relatedHandler: &performerFilterHandler{tagFilter.PerformersFilter}, joinFn: func(f *filterBuilder) { tagRepository.performers.innerJoin(f, "", "tags.id") }, }, &relatedFilterHandler{ relatedIDCol: "studios_tags.studio_id", relatedRepo: studioRepository.repository, relatedHandler: &studioFilterHandler{tagFilter.StudiosFilter}, joinFn: func(f *filterBuilder) { tagRepository.studios.innerJoin(f, "", "tags.id") }, }, &relatedFilterHandler{ relatedIDCol: "markers_tags.marker_id", relatedRepo: sceneMarkerRepository.repository, relatedHandler: &sceneMarkerFilterHandler{tagFilter.MarkersFilter}, joinFn: func(f *filterBuilder) { f.addWith(`markers_tags AS ( SELECT mt.scene_marker_id AS marker_id, mt.tag_id AS tag_id FROM scene_markers_tags mt UNION SELECT m.id, m.primary_tag_id FROM scene_markers m )`) f.addInnerJoin("markers_tags", "", "markers_tags.tag_id = tags.id") }, }, } } func (qb *tagFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ primaryTable: tagTable, primaryFK: tagIDColumn, joinTable: tagAliasesTable, stringColumn: tagAliasColumn, addJoinTable: func(f *filterBuilder) { tagRepository.aliases.join(f, "", "tags.id") }, } return h.handler(alias) } func (qb *tagFilterHandler) isMissingCriterionHandler(isMissing *string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if isMissing != nil && *isMissing != "" { switch *isMissing { case "image": f.addWhere("tags.image_blob IS NULL") case "aliases": tagRepository.aliases.join(f, "", "tags.id") f.addWhere("tag_aliases.alias IS NULL") case "stash_id": tagRepository.stashIDs.join(f, "tag_stash_ids", "tags.id") f.addWhere("tag_stash_ids.tag_id IS NULL") default: if err := validateIsMissing(*isMissing, []string{ "description", }); err != nil { f.setError(err) return } f.addWhere("(tags." + *isMissing + " IS NULL OR TRIM(tags." + *isMissing + ") = '')") } } } } func (qb *tagFilterHandler) sceneCountCriterionHandler(sceneCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if sceneCount != nil { f.addLeftJoin("scenes_tags", "", "scenes_tags.tag_id = tags.id") clause, args := getIntCriterionWhereClause("count(distinct scenes_tags.scene_id)", *sceneCount) f.addHaving(clause, args...) } } } func (qb *tagFilterHandler) imageCountCriterionHandler(imageCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if imageCount != nil { f.addLeftJoin("images_tags", "", "images_tags.tag_id = tags.id") clause, args := getIntCriterionWhereClause("count(distinct images_tags.image_id)", *imageCount) f.addHaving(clause, args...) } } } func (qb *tagFilterHandler) galleryCountCriterionHandler(galleryCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if galleryCount != nil { f.addLeftJoin("galleries_tags", "", "galleries_tags.tag_id = tags.id") clause, args := getIntCriterionWhereClause("count(distinct galleries_tags.gallery_id)", *galleryCount) f.addHaving(clause, args...) } } } func (qb *tagFilterHandler) performerCountCriterionHandler(performerCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if performerCount != nil { f.addLeftJoin("performers_tags", "", "performers_tags.tag_id = tags.id") clause, args := getIntCriterionWhereClause("count(distinct performers_tags.performer_id)", *performerCount) f.addHaving(clause, args...) } } } func (qb *tagFilterHandler) studioCountCriterionHandler(studioCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if studioCount != nil { f.addLeftJoin("studios_tags", "", "studios_tags.tag_id = tags.id") clause, args := getIntCriterionWhereClause("count(distinct studios_tags.studio_id)", *studioCount) f.addHaving(clause, args...) } } } func (qb *tagFilterHandler) groupCountCriterionHandler(groupCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if groupCount != nil { f.addLeftJoin("groups_tags", "", "groups_tags.tag_id = tags.id") clause, args := getIntCriterionWhereClause("count(distinct groups_tags.group_id)", *groupCount) f.addHaving(clause, args...) } } } func (qb *tagFilterHandler) markerCountCriterionHandler(markerCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if markerCount != nil { f.addLeftJoin("scene_markers_tags", "", "scene_markers_tags.tag_id = tags.id") f.addLeftJoin("scene_markers", "", "scene_markers_tags.scene_marker_id = scene_markers.id OR scene_markers.primary_tag_id = tags.id") clause, args := getIntCriterionWhereClause("count(distinct scene_markers.id)", *markerCount) f.addHaving(clause, args...) } } } ================================================ FILE: pkg/sqlite/tag_test.go ================================================ //go:build integration // +build integration package sqlite_test import ( "context" "fmt" "math" "strconv" "strings" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stretchr/testify/assert" ) func TestMarkerFindBySceneMarkerID(t *testing.T) { withTxn(func(ctx context.Context) error { tqb := db.Tag markerID := markerIDs[markerIdxWithTag] tags, err := tqb.FindBySceneMarkerID(ctx, markerID) if err != nil { t.Errorf("Error finding tags: %s", err.Error()) } assert.Len(t, tags, 1) assert.Equal(t, tagIDs[tagIdxWithMarkers], tags[0].ID) tags, err = tqb.FindBySceneMarkerID(ctx, 0) if err != nil { t.Errorf("Error finding tags: %s", err.Error()) } assert.Len(t, tags, 0) return nil }) } func TestTagFindByGroupID(t *testing.T) { withTxn(func(ctx context.Context) error { tqb := db.Tag groupID := groupIDs[groupIdxWithTag] tags, err := tqb.FindByGroupID(ctx, groupID) if err != nil { t.Errorf("Error finding tags: %s", err.Error()) } assert.Len(t, tags, 1) assert.Equal(t, tagIDs[tagIdxWithGroup], tags[0].ID) tags, err = tqb.FindByGroupID(ctx, 0) if err != nil { t.Errorf("Error finding tags: %s", err.Error()) } assert.Len(t, tags, 0) return nil }) } func TestTagFindByName(t *testing.T) { withTxn(func(ctx context.Context) error { tqb := db.Tag name := tagNames[tagIdxWithScene] // find a tag by name tag, err := tqb.FindByName(ctx, name, false) if err != nil { t.Errorf("Error finding tags: %s", err.Error()) } assert.Equal(t, tagNames[tagIdxWithScene], tag.Name) name = tagNames[tagIdxWithDupName] // find a tag by name nocase tag, err = tqb.FindByName(ctx, name, true) if err != nil { t.Errorf("Error finding tags: %s", err.Error()) } // tagIdxWithDupName and tagIdxWithScene should have similar names ( only diff should be Name vs NaMe) //tag.Name should match with tagIdxWithScene since its ID is before tagIdxWithDupName assert.Equal(t, tagNames[tagIdxWithScene], tag.Name) //tag.Name should match with tagIdxWithDupName if the check is not case sensitive assert.Equal(t, strings.ToLower(tagNames[tagIdxWithDupName]), strings.ToLower(tag.Name)) return nil }) } func TestTagQueryIgnoreAutoTag(t *testing.T) { withTxn(func(ctx context.Context) error { ignoreAutoTag := true tagFilter := models.TagFilterType{ IgnoreAutoTag: &ignoreAutoTag, } sqb := db.Tag tags := queryTags(ctx, t, sqb, &tagFilter, nil) assert.Len(t, tags, int(math.Ceil(float64(totalTags)/5))) for _, s := range tags { assert.True(t, s.IgnoreAutoTag) } return nil }) } func TestTagQueryForAutoTag(t *testing.T) { withTxn(func(ctx context.Context) error { tqb := db.Tag name := tagNames[tagIdx1WithScene] // find a tag by name tags, err := tqb.QueryForAutoTag(ctx, []string{name}) if err != nil { t.Errorf("Error finding tags: %s", err.Error()) } assert.Len(t, tags, 2) lcName := tagNames[tagIdx1WithScene] assert.Equal(t, strings.ToLower(lcName), strings.ToLower(tags[0].Name)) assert.Equal(t, strings.ToLower(lcName), strings.ToLower(tags[1].Name)) // find by alias name = getTagStringValue(tagIdx1WithScene, "Alias") tags, err = tqb.QueryForAutoTag(ctx, []string{name}) if err != nil { t.Errorf("Error finding tags: %s", err.Error()) } assert.Len(t, tags, 1) assert.Equal(t, tagIDs[tagIdx1WithScene], tags[0].ID) return nil }) } func TestTagFindByNames(t *testing.T) { var names []string withTxn(func(ctx context.Context) error { tqb := db.Tag names = append(names, tagNames[tagIdxWithScene]) // find tags by names tags, err := tqb.FindByNames(ctx, names, false) if err != nil { t.Errorf("Error finding tags: %s", err.Error()) } assert.Len(t, tags, 1) assert.Equal(t, tagNames[tagIdxWithScene], tags[0].Name) tags, err = tqb.FindByNames(ctx, names, true) // find tags by names nocase if err != nil { t.Errorf("Error finding tags: %s", err.Error()) } assert.Len(t, tags, 2) // tagIdxWithScene and tagIdxWithDupName assert.Equal(t, strings.ToLower(tagNames[tagIdxWithScene]), strings.ToLower(tags[0].Name)) assert.Equal(t, strings.ToLower(tagNames[tagIdxWithScene]), strings.ToLower(tags[1].Name)) names = append(names, tagNames[tagIdx1WithScene]) // find tags by names ( 2 names ) tags, err = tqb.FindByNames(ctx, names, false) if err != nil { t.Errorf("Error finding tags: %s", err.Error()) } assert.Len(t, tags, 2) // tagIdxWithScene and tagIdx1WithScene assert.Equal(t, tagNames[tagIdxWithScene], tags[0].Name) assert.Equal(t, tagNames[tagIdx1WithScene], tags[1].Name) tags, err = tqb.FindByNames(ctx, names, true) // find tags by names ( 2 names nocase) if err != nil { t.Errorf("Error finding tags: %s", err.Error()) } assert.Len(t, tags, 4) // tagIdxWithScene and tagIdxWithDupName , tagIdx1WithScene and tagIdx1WithDupName assert.Equal(t, tagNames[tagIdxWithScene], tags[0].Name) assert.Equal(t, tagNames[tagIdx1WithScene], tags[1].Name) assert.Equal(t, tagNames[tagIdx1WithDupName], tags[2].Name) assert.Equal(t, tagNames[tagIdxWithDupName], tags[3].Name) return nil }) } func TestTagQuerySort(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Tag sortBy := "scenes_count" dir := models.SortDirectionEnumDesc findFilter := &models.FindFilterType{ Sort: &sortBy, Direction: &dir, } tags := queryTags(ctx, t, sqb, nil, findFilter) assert := assert.New(t) assert.Equal(tagIDs[tagIdx2WithScene], tags[0].ID) sortBy = "scene_markers_count" tags = queryTags(ctx, t, sqb, nil, findFilter) assert.Equal(tagIDs[tagIdxWithPrimaryMarkers], tags[0].ID) sortBy = "images_count" tags = queryTags(ctx, t, sqb, nil, findFilter) assert.Equal(tagIDs[tagIdx1WithImage], tags[0].ID) sortBy = "galleries_count" tags = queryTags(ctx, t, sqb, nil, findFilter) assert.Equal(tagIDs[tagIdx1WithGallery], tags[0].ID) sortBy = "performers_count" tags = queryTags(ctx, t, sqb, nil, findFilter) assert.Equal(tagIDs[tagIdx2WithPerformer], tags[0].ID) sortBy = "studios_count" tags = queryTags(ctx, t, sqb, nil, findFilter) assert.Equal(tagIDs[tagIdx2WithStudio], tags[0].ID) sortBy = "movies_count" tags = queryTags(ctx, t, sqb, nil, findFilter) assert.Equal(tagIDs[tagIdx1WithGroup], tags[0].ID) return nil }) } func TestTagQueryName(t *testing.T) { const tagIdx = 1 tagName := getSceneStringValue(tagIdx, "Name") nameCriterion := &models.StringCriterionInput{ Value: tagName, Modifier: models.CriterionModifierEquals, } tagFilter := &models.TagFilterType{ Name: nameCriterion, } verifyFn := func(ctx context.Context, tag *models.Tag) { verifyString(t, tag.Name, *nameCriterion) } verifyTagQuery(t, tagFilter, nil, verifyFn) nameCriterion.Modifier = models.CriterionModifierNotEquals verifyTagQuery(t, tagFilter, nil, verifyFn) nameCriterion.Modifier = models.CriterionModifierMatchesRegex nameCriterion.Value = "tag_.*1_Name" verifyTagQuery(t, tagFilter, nil, verifyFn) nameCriterion.Modifier = models.CriterionModifierNotMatchesRegex verifyTagQuery(t, tagFilter, nil, verifyFn) } func TestTagQueryAlias(t *testing.T) { const tagIdx = 1 tagName := getSceneStringValue(tagIdx, "Alias") aliasCriterion := &models.StringCriterionInput{ Value: tagName, Modifier: models.CriterionModifierEquals, } tagFilter := &models.TagFilterType{ Aliases: aliasCriterion, } verifyFn := func(ctx context.Context, tag *models.Tag) { aliases, err := db.Tag.GetAliases(ctx, tag.ID) if err != nil { t.Errorf("Error querying tags: %s", err.Error()) } var alias string if len(aliases) > 0 { alias = aliases[0] } verifyString(t, alias, *aliasCriterion) } verifyTagQuery(t, tagFilter, nil, verifyFn) aliasCriterion.Modifier = models.CriterionModifierNotEquals verifyTagQuery(t, tagFilter, nil, verifyFn) aliasCriterion.Modifier = models.CriterionModifierMatchesRegex aliasCriterion.Value = "tag_.*1_Alias" verifyTagQuery(t, tagFilter, nil, verifyFn) aliasCriterion.Modifier = models.CriterionModifierNotMatchesRegex verifyTagQuery(t, tagFilter, nil, verifyFn) aliasCriterion.Modifier = models.CriterionModifierIsNull aliasCriterion.Value = "" verifyTagQuery(t, tagFilter, nil, verifyFn) aliasCriterion.Modifier = models.CriterionModifierNotNull verifyTagQuery(t, tagFilter, nil, verifyFn) } func verifyTagQuery(t *testing.T, tagFilter *models.TagFilterType, findFilter *models.FindFilterType, verifyFn func(ctx context.Context, t *models.Tag)) { withTxn(func(ctx context.Context) error { sqb := db.Tag tags := queryTags(ctx, t, sqb, tagFilter, findFilter) for _, tag := range tags { verifyFn(ctx, tag) } return nil }) } func queryTags(ctx context.Context, t *testing.T, qb models.TagReader, tagFilter *models.TagFilterType, findFilter *models.FindFilterType) []*models.Tag { t.Helper() tags, _, err := qb.Query(ctx, tagFilter, findFilter) if err != nil { t.Errorf("Error querying tags: %s", err.Error()) } return tags } func tagsToIDs(i []*models.Tag) []int { ret := make([]int, len(i)) for i, v := range i { ret[i] = v.ID } return ret } func TestTagQuery(t *testing.T) { var ( endpoint = tagStashID(tagIdxWithPerformer).Endpoint stashID = tagStashID(tagIdxWithPerformer).StashID stashID2 = tagStashID(tagIdx1WithPerformer).StashID stashIDs = []*string{&stashID, &stashID2} ) tests := []struct { name string findFilter *models.FindFilterType filter *models.TagFilterType includeIdxs []int excludeIdxs []int wantErr bool }{ { "stash id with endpoint", nil, &models.TagFilterType{ StashIDEndpoint: &models.StashIDCriterionInput{ Endpoint: &endpoint, StashID: &stashID, Modifier: models.CriterionModifierEquals, }, }, []int{tagIdxWithPerformer}, nil, false, }, { "exclude stash id with endpoint", nil, &models.TagFilterType{ StashIDEndpoint: &models.StashIDCriterionInput{ Endpoint: &endpoint, StashID: &stashID, Modifier: models.CriterionModifierNotEquals, }, }, nil, []int{tagIdxWithPerformer}, false, }, { "null stash id with endpoint", nil, &models.TagFilterType{ StashIDEndpoint: &models.StashIDCriterionInput{ Endpoint: &endpoint, Modifier: models.CriterionModifierIsNull, }, }, nil, []int{tagIdxWithPerformer}, false, }, { "not null stash id with endpoint", nil, &models.TagFilterType{ StashIDEndpoint: &models.StashIDCriterionInput{ Endpoint: &endpoint, Modifier: models.CriterionModifierNotNull, }, }, []int{tagIdxWithPerformer}, nil, false, }, { "stash ids with endpoint", nil, &models.TagFilterType{ StashIDsEndpoint: &models.StashIDsCriterionInput{ Endpoint: &endpoint, StashIDs: stashIDs, Modifier: models.CriterionModifierEquals, }, }, []int{tagIdxWithPerformer, tagIdx1WithPerformer}, nil, false, }, { "exclude stash ids with endpoint", nil, &models.TagFilterType{ StashIDsEndpoint: &models.StashIDsCriterionInput{ Endpoint: &endpoint, StashIDs: stashIDs, Modifier: models.CriterionModifierNotEquals, }, }, nil, []int{tagIdxWithPerformer, tagIdx1WithPerformer}, false, }, { "null stash ids with endpoint", nil, &models.TagFilterType{ StashIDsEndpoint: &models.StashIDsCriterionInput{ Endpoint: &endpoint, Modifier: models.CriterionModifierIsNull, }, }, nil, []int{tagIdxWithPerformer, tagIdx1WithPerformer}, false, }, { "not null stash ids with endpoint", nil, &models.TagFilterType{ StashIDsEndpoint: &models.StashIDsCriterionInput{ Endpoint: &endpoint, Modifier: models.CriterionModifierNotNull, }, }, []int{tagIdxWithPerformer, tagIdx1WithPerformer}, nil, false, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) tags, _, err := db.Tag.Query(ctx, tt.filter, tt.findFilter) if (err != nil) != tt.wantErr { t.Errorf("PerformerStore.Query() error = %v, wantErr %v", err, tt.wantErr) return } ids := tagsToIDs(tags) include := indexesToIDs(tagIDs, tt.includeIdxs) exclude := indexesToIDs(tagIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(ids, i) } for _, e := range exclude { assert.NotContains(ids, e) } }) } } func TestTagQueryIsMissingImage(t *testing.T) { withTxn(func(ctx context.Context) error { qb := db.Tag isMissing := "image" tagFilter := models.TagFilterType{ IsMissing: &isMissing, } q := getTagStringValue(tagIdxWithCoverImage, "name") findFilter := models.FindFilterType{ Q: &q, } tags, _, err := qb.Query(ctx, &tagFilter, &findFilter) if err != nil { t.Errorf("Error querying tag: %s", err.Error()) } assert.Len(t, tags, 0) findFilter.Q = nil tags, _, err = qb.Query(ctx, &tagFilter, &findFilter) if err != nil { t.Errorf("Error querying tag: %s", err.Error()) } // ensure non of the ids equal the one with image for _, tag := range tags { assert.NotEqual(t, tagIDs[tagIdxWithCoverImage], tag.ID) } return nil }) } func TestTagQuerySceneCount(t *testing.T) { countCriterion := models.IntCriterionInput{ Value: 1, Modifier: models.CriterionModifierEquals, } verifyTagSceneCount(t, countCriterion) countCriterion.Modifier = models.CriterionModifierNotEquals verifyTagSceneCount(t, countCriterion) countCriterion.Modifier = models.CriterionModifierLessThan verifyTagSceneCount(t, countCriterion) countCriterion.Value = 0 countCriterion.Modifier = models.CriterionModifierGreaterThan verifyTagSceneCount(t, countCriterion) } func verifyTagSceneCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { qb := db.Tag tagFilter := models.TagFilterType{ SceneCount: &sceneCountCriterion, } tags, _, err := qb.Query(ctx, &tagFilter, nil) if err != nil { t.Errorf("Error querying tag: %s", err.Error()) } for _, tag := range tags { verifyInt(t, getTagSceneCount(tag.ID), sceneCountCriterion) } return nil }) } func TestTagQueryMarkerCount(t *testing.T) { countCriterion := models.IntCriterionInput{ Value: 1, Modifier: models.CriterionModifierEquals, } verifyTagMarkerCount(t, countCriterion) countCriterion.Modifier = models.CriterionModifierNotEquals verifyTagMarkerCount(t, countCriterion) countCriterion.Modifier = models.CriterionModifierLessThan verifyTagMarkerCount(t, countCriterion) countCriterion.Value = 0 countCriterion.Modifier = models.CriterionModifierGreaterThan verifyTagMarkerCount(t, countCriterion) } func verifyTagMarkerCount(t *testing.T, markerCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { qb := db.Tag tagFilter := models.TagFilterType{ MarkerCount: &markerCountCriterion, } tags, _, err := qb.Query(ctx, &tagFilter, nil) if err != nil { t.Errorf("Error querying tag: %s", err.Error()) } for _, tag := range tags { verifyInt(t, getTagMarkerCount(tag.ID), markerCountCriterion) } return nil }) } func TestTagQueryImageCount(t *testing.T) { countCriterion := models.IntCriterionInput{ Value: 1, Modifier: models.CriterionModifierEquals, } verifyTagImageCount(t, countCriterion) countCriterion.Modifier = models.CriterionModifierNotEquals verifyTagImageCount(t, countCriterion) countCriterion.Modifier = models.CriterionModifierLessThan verifyTagImageCount(t, countCriterion) countCriterion.Value = 0 countCriterion.Modifier = models.CriterionModifierGreaterThan verifyTagImageCount(t, countCriterion) } func verifyTagImageCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { qb := db.Tag tagFilter := models.TagFilterType{ ImageCount: &imageCountCriterion, } tags, _, err := qb.Query(ctx, &tagFilter, nil) if err != nil { t.Errorf("Error querying tag: %s", err.Error()) } for _, tag := range tags { verifyInt(t, getTagImageCount(tag.ID), imageCountCriterion) } return nil }) } func TestTagQueryGalleryCount(t *testing.T) { countCriterion := models.IntCriterionInput{ Value: 1, Modifier: models.CriterionModifierEquals, } verifyTagGalleryCount(t, countCriterion) countCriterion.Modifier = models.CriterionModifierNotEquals verifyTagGalleryCount(t, countCriterion) countCriterion.Modifier = models.CriterionModifierLessThan verifyTagGalleryCount(t, countCriterion) countCriterion.Value = 0 countCriterion.Modifier = models.CriterionModifierGreaterThan verifyTagGalleryCount(t, countCriterion) } func verifyTagGalleryCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { qb := db.Tag tagFilter := models.TagFilterType{ GalleryCount: &imageCountCriterion, } tags, _, err := qb.Query(ctx, &tagFilter, nil) if err != nil { t.Errorf("Error querying tag: %s", err.Error()) } for _, tag := range tags { verifyInt(t, getTagGalleryCount(tag.ID), imageCountCriterion) } return nil }) } func TestTagQueryPerformerCount(t *testing.T) { countCriterion := models.IntCriterionInput{ Value: 1, Modifier: models.CriterionModifierEquals, } verifyTagPerformerCount(t, countCriterion) countCriterion.Modifier = models.CriterionModifierNotEquals verifyTagPerformerCount(t, countCriterion) countCriterion.Modifier = models.CriterionModifierLessThan verifyTagPerformerCount(t, countCriterion) countCriterion.Value = 0 countCriterion.Modifier = models.CriterionModifierGreaterThan verifyTagPerformerCount(t, countCriterion) } func verifyTagPerformerCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { qb := db.Tag tagFilter := models.TagFilterType{ PerformerCount: &imageCountCriterion, } tags, _, err := qb.Query(ctx, &tagFilter, nil) if err != nil { t.Errorf("Error querying tag: %s", err.Error()) } for _, tag := range tags { verifyInt(t, getTagPerformerCount(tag.ID), imageCountCriterion) } return nil }) } func TestTagQueryStudioCount(t *testing.T) { countCriterion := models.IntCriterionInput{ Value: 1, Modifier: models.CriterionModifierEquals, } verifyTagStudioCount(t, countCriterion) countCriterion.Modifier = models.CriterionModifierNotEquals verifyTagStudioCount(t, countCriterion) countCriterion.Modifier = models.CriterionModifierLessThan verifyTagStudioCount(t, countCriterion) countCriterion.Value = 0 countCriterion.Modifier = models.CriterionModifierGreaterThan verifyTagStudioCount(t, countCriterion) } func verifyTagStudioCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { qb := db.Tag tagFilter := models.TagFilterType{ StudioCount: &imageCountCriterion, } tags, _, err := qb.Query(ctx, &tagFilter, nil) if err != nil { t.Errorf("Error querying tag: %s", err.Error()) } for _, tag := range tags { verifyInt(t, getTagStudioCount(tag.ID), imageCountCriterion) } return nil }) } func TestTagQueryParentCount(t *testing.T) { countCriterion := models.IntCriterionInput{ Value: 1, Modifier: models.CriterionModifierEquals, } verifyTagParentCount(t, countCriterion) countCriterion.Modifier = models.CriterionModifierNotEquals verifyTagParentCount(t, countCriterion) countCriterion.Modifier = models.CriterionModifierLessThan verifyTagParentCount(t, countCriterion) countCriterion.Value = 0 countCriterion.Modifier = models.CriterionModifierGreaterThan verifyTagParentCount(t, countCriterion) } func verifyTagParentCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { qb := db.Tag tagFilter := models.TagFilterType{ ParentCount: &sceneCountCriterion, } tags := queryTags(ctx, t, qb, &tagFilter, nil) if len(tags) == 0 { t.Error("Expected at least one tag") } for _, tag := range tags { verifyInt(t, getTagParentCount(tag.ID), sceneCountCriterion) } return nil }) } func TestTagQueryChildCount(t *testing.T) { countCriterion := models.IntCriterionInput{ Value: 1, Modifier: models.CriterionModifierEquals, } verifyTagChildCount(t, countCriterion) countCriterion.Modifier = models.CriterionModifierNotEquals verifyTagChildCount(t, countCriterion) countCriterion.Modifier = models.CriterionModifierLessThan verifyTagChildCount(t, countCriterion) countCriterion.Value = 0 countCriterion.Modifier = models.CriterionModifierGreaterThan verifyTagChildCount(t, countCriterion) } func verifyTagChildCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { qb := db.Tag tagFilter := models.TagFilterType{ ChildCount: &sceneCountCriterion, } tags := queryTags(ctx, t, qb, &tagFilter, nil) if len(tags) == 0 { t.Error("Expected at least one tag") } for _, tag := range tags { verifyInt(t, getTagChildCount(tag.ID), sceneCountCriterion) } return nil }) } func TestTagQueryParent(t *testing.T) { withTxn(func(ctx context.Context) error { const nameField = "Name" sqb := db.Tag tagCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithChildTag]), }, Modifier: models.CriterionModifierIncludes, } tagFilter := models.TagFilterType{ Parents: &tagCriterion, } tags := queryTags(ctx, t, sqb, &tagFilter, nil) assert.Len(t, tags, 1) // ensure id is correct assert.Equal(t, tagIDs[tagIdxWithParentTag], tags[0].ID) tagCriterion.Modifier = models.CriterionModifierExcludes q := getTagStringValue(tagIdxWithParentTag, nameField) findFilter := models.FindFilterType{ Q: &q, } tags = queryTags(ctx, t, sqb, &tagFilter, &findFilter) assert.Len(t, tags, 0) depth := -1 tagCriterion = models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithGrandChild]), }, Modifier: models.CriterionModifierIncludes, Depth: &depth, } tags = queryTags(ctx, t, sqb, &tagFilter, nil) assert.Len(t, tags, 2) depth = 1 tags = queryTags(ctx, t, sqb, &tagFilter, nil) assert.Len(t, tags, 2) tagCriterion = models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIsNull, } q = getTagStringValue(tagIdxWithGallery, nameField) tags = queryTags(ctx, t, sqb, &tagFilter, &findFilter) assert.Len(t, tags, 1) assert.Equal(t, tagIDs[tagIdxWithGallery], tags[0].ID) q = getTagStringValue(tagIdxWithParentTag, nameField) tags = queryTags(ctx, t, sqb, &tagFilter, &findFilter) assert.Len(t, tags, 0) tagCriterion.Modifier = models.CriterionModifierNotNull tags = queryTags(ctx, t, sqb, &tagFilter, &findFilter) assert.Len(t, tags, 1) assert.Equal(t, tagIDs[tagIdxWithParentTag], tags[0].ID) q = getTagStringValue(tagIdxWithGallery, nameField) tags = queryTags(ctx, t, sqb, &tagFilter, &findFilter) assert.Len(t, tags, 0) return nil }) } func TestTagQueryChild(t *testing.T) { withTxn(func(ctx context.Context) error { const nameField = "Name" sqb := db.Tag tagCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithParentTag]), }, Modifier: models.CriterionModifierIncludes, } tagFilter := models.TagFilterType{ Children: &tagCriterion, } tags := queryTags(ctx, t, sqb, &tagFilter, nil) assert.Len(t, tags, 1) // ensure id is correct assert.Equal(t, sceneIDs[tagIdxWithChildTag], tags[0].ID) tagCriterion.Modifier = models.CriterionModifierExcludes q := getTagStringValue(tagIdxWithChildTag, nameField) findFilter := models.FindFilterType{ Q: &q, } tags = queryTags(ctx, t, sqb, &tagFilter, &findFilter) assert.Len(t, tags, 0) depth := -1 tagCriterion = models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithGrandParent]), }, Modifier: models.CriterionModifierIncludes, Depth: &depth, } tags = queryTags(ctx, t, sqb, &tagFilter, nil) assert.Len(t, tags, 2) depth = 1 tags = queryTags(ctx, t, sqb, &tagFilter, nil) assert.Len(t, tags, 2) tagCriterion = models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIsNull, } q = getTagStringValue(tagIdxWithGallery, nameField) tags = queryTags(ctx, t, sqb, &tagFilter, &findFilter) assert.Len(t, tags, 1) assert.Equal(t, tagIDs[tagIdxWithGallery], tags[0].ID) q = getTagStringValue(tagIdxWithChildTag, nameField) tags = queryTags(ctx, t, sqb, &tagFilter, &findFilter) assert.Len(t, tags, 0) tagCriterion.Modifier = models.CriterionModifierNotNull tags = queryTags(ctx, t, sqb, &tagFilter, &findFilter) assert.Len(t, tags, 1) assert.Equal(t, tagIDs[tagIdxWithChildTag], tags[0].ID) q = getTagStringValue(tagIdxWithGallery, nameField) tags = queryTags(ctx, t, sqb, &tagFilter, &findFilter) assert.Len(t, tags, 0) return nil }) } func TestTagUpdateTagImage(t *testing.T) { if err := withTxn(func(ctx context.Context) error { qb := db.Tag // create tag to test against const name = "TestTagUpdateTagImage" tag := models.CreateTagInput{ Tag: &models.Tag{ Name: name, }, } err := qb.Create(ctx, &tag) if err != nil { return fmt.Errorf("Error creating tag: %s", err.Error()) } return testUpdateImage(t, ctx, tag.ID, qb.UpdateImage, qb.GetImage) }); err != nil { t.Error(err.Error()) } } func TestTagUpdateAlias(t *testing.T) { if err := withTxn(func(ctx context.Context) error { qb := db.Tag // create tag to test against const name = "TestTagUpdateAlias" tag := models.CreateTagInput{ Tag: &models.Tag{ Name: name, }, } err := qb.Create(ctx, &tag) if err != nil { return fmt.Errorf("Error creating tag: %s", err.Error()) } aliases := []string{"updatedAlias1", "updatedAlias2"} err = qb.UpdateAliases(ctx, tag.ID, aliases) if err != nil { return fmt.Errorf("Error updating tag aliases: %s", err.Error()) } // ensure aliases set storedAliases, err := qb.GetAliases(ctx, tag.ID) if err != nil { return fmt.Errorf("Error getting aliases: %s", err.Error()) } assert.Equal(t, aliases, storedAliases) return nil }); err != nil { t.Error(err.Error()) } } func TestTagStashIDs(t *testing.T) { if err := withTxn(func(ctx context.Context) error { qb := db.Tag // create tag to test against const name = "TestTagStashIDs" tag := models.CreateTagInput{ Tag: &models.Tag{ Name: name, }, } err := qb.Create(ctx, &tag) if err != nil { return fmt.Errorf("Error creating tag: %s", err.Error()) } testStashIDReaderWriter(ctx, t, qb, tag.ID) return nil }); err != nil { t.Error(err.Error()) } } func TestTagFindByStashID(t *testing.T) { withTxn(func(ctx context.Context) error { qb := db.Tag // create tag to test against const name = "TestTagFindByStashID" const stashID = "stashid" const endpoint = "endpoint" tag := models.CreateTagInput{ Tag: &models.Tag{ Name: name, StashIDs: models.NewRelatedStashIDs([]models.StashID{{StashID: stashID, Endpoint: endpoint}}), }, } err := qb.Create(ctx, &tag) if err != nil { return fmt.Errorf("Error creating tag: %s", err.Error()) } // find by stash ID tags, err := qb.FindByStashID(ctx, models.StashID{StashID: stashID, Endpoint: endpoint}) if err != nil { return fmt.Errorf("Error finding by stash ID: %s", err.Error()) } assert.Len(t, tags, 1) assert.Equal(t, tag.ID, tags[0].ID) // find by non-existent stash ID tags, err = qb.FindByStashID(ctx, models.StashID{StashID: "nonexistent", Endpoint: endpoint}) if err != nil { return fmt.Errorf("Error finding by stash ID: %s", err.Error()) } assert.Len(t, tags, 0) return nil }) } func TestTagMerge(t *testing.T) { assert := assert.New(t) // merge tests - perform these in a transaction that we'll rollback if err := withRollbackTxn(func(ctx context.Context) error { qb := db.Tag mqb := db.SceneMarker // try merging into same tag err := qb.Merge(ctx, []int{tagIDs[tagIdx1WithScene]}, tagIDs[tagIdx1WithScene]) assert.NotNil(err) // merge everything into tagIdxWithScene srcIdxs := []int{ tagIdx1WithScene, tagIdx2WithScene, tagIdxWithPrimaryMarkers, tagIdxWithMarkers, tagIdxWithCoverImage, tagIdxWithImage, tagIdx1WithImage, tagIdx2WithImage, tagIdxWithPerformer, tagIdx1WithPerformer, tagIdx2WithPerformer, tagIdxWithStudio, tagIdx1WithStudio, tagIdx2WithStudio, tagIdxWithGallery, tagIdx1WithGallery, tagIdx2WithGallery, tagIdx1WithGroup, tagIdx2WithGroup, } var srcIDs []int for _, idx := range srcIdxs { srcIDs = append(srcIDs, tagIDs[idx]) } destID := tagIDs[tagIdxWithScene] if err = qb.Merge(ctx, srcIDs, destID); err != nil { return err } // ensure other tags are deleted for _, tagId := range srcIDs { t, err := qb.Find(ctx, tagId) if err != nil { return err } assert.Nil(t) } // ensure aliases are set on the destination destAliases, err := qb.GetAliases(ctx, destID) if err != nil { return err } for _, tagIdx := range srcIdxs { assert.Contains(destAliases, getTagStringValue(tagIdx, "Name")) } // ensure scene points to new tag s, err := db.Scene.Find(ctx, sceneIDs[sceneIdxWithTwoTags]) if err != nil { return err } if err := s.LoadTagIDs(ctx, db.Scene); err != nil { return err } sceneTagIDs := s.TagIDs.List() assert.Contains(sceneTagIDs, destID) // ensure marker points to new tag marker, err := mqb.Find(ctx, markerIDs[markerIdxWithTag]) if err != nil { return err } assert.Equal(destID, marker.PrimaryTagID) markerTagIDs, err := mqb.GetTagIDs(ctx, marker.ID) if err != nil { return err } assert.Contains(markerTagIDs, destID) // ensure image points to new tag imageTagIDs, err := db.Image.GetTagIDs(ctx, imageIDs[imageIdxWithTwoTags]) if err != nil { return err } assert.Contains(imageTagIDs, destID) g, err := db.Gallery.Find(ctx, galleryIDs[galleryIdxWithTwoTags]) if err != nil { return err } if err := g.LoadTagIDs(ctx, db.Gallery); err != nil { return err } // ensure gallery points to new tag assert.Contains(g.TagIDs.List(), destID) // ensure performer points to new tag performerTagIDs, err := db.Performer.GetTagIDs(ctx, performerIDs[performerIdxWithTwoTags]) if err != nil { return err } assert.Contains(performerTagIDs, destID) // ensure studio points to new tag studioTagIDs, err := db.Studio.GetTagIDs(ctx, studioIDs[studioIdxWithTwoTags]) if err != nil { return err } assert.Contains(studioTagIDs, destID) // ensure group points to new tag group, err := db.Group.Find(ctx, groupIDs[groupIdxWithTwoTags]) if err != nil { return err } if err := group.LoadTagIDs(ctx, db.Group); err != nil { return err } groupTagIDs := group.TagIDs.List() assert.Contains(groupTagIDs, destID) return nil }); err != nil { t.Error(err.Error()) } } func loadTagRelationships(ctx context.Context, expected models.Tag, actual *models.Tag) error { if expected.Aliases.Loaded() { if err := actual.LoadAliases(ctx, db.Tag); err != nil { return err } } if expected.ParentIDs.Loaded() { if err := actual.LoadParentIDs(ctx, db.Tag); err != nil { return err } } if expected.ChildIDs.Loaded() { if err := actual.LoadChildIDs(ctx, db.Tag); err != nil { return err } } if expected.StashIDs.Loaded() { if err := actual.LoadStashIDs(ctx, db.Tag); err != nil { return err } } return nil } func Test_TagStore_Create(t *testing.T) { var ( name = "name" sortName = "sortName" description = "description" favorite = true ignoreAutoTag = true aliases = []string{"alias1", "alias2"} endpoint1 = "endpoint1" endpoint2 = "endpoint2" stashID1 = "stashid1" stashID2 = "stashid2" createdAt = epochTime updatedAt = epochTime ) tests := []struct { name string newObject models.CreateTagInput wantErr bool }{ { "full", models.CreateTagInput{ Tag: &models.Tag{ Name: name, SortName: sortName, Description: description, Favorite: favorite, IgnoreAutoTag: ignoreAutoTag, Aliases: models.NewRelatedStrings(aliases), ParentIDs: models.NewRelatedIDs([]int{tagIDs[tagIdxWithScene]}), ChildIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithScene]}), StashIDs: models.NewRelatedStashIDs([]models.StashID{ { StashID: stashID1, Endpoint: endpoint1, UpdatedAt: epochTime, }, { StashID: stashID2, Endpoint: endpoint2, UpdatedAt: epochTime, }, }), CreatedAt: createdAt, UpdatedAt: updatedAt, }, CustomFields: testCustomFields, }, false, }, { "invalid parent id", models.CreateTagInput{ Tag: &models.Tag{ Name: name, ParentIDs: models.NewRelatedIDs([]int{invalidID}), }, }, true, }, { "invalid child id", models.CreateTagInput{ Tag: &models.Tag{ Name: name, ChildIDs: models.NewRelatedIDs([]int{invalidID}), }, }, true, }, } qb := db.Tag for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) p := tt.newObject if err := qb.Create(ctx, &p); (err != nil) != tt.wantErr { t.Errorf("TagStore.Create() error = %v, wantErr = %v", err, tt.wantErr) } if tt.wantErr { assert.Zero(p.ID) return } assert.NotZero(p.ID) copy := *tt.newObject.Tag copy.ID = p.ID // load relationships if err := loadTagRelationships(ctx, copy, p.Tag); err != nil { t.Errorf("loadTagRelationships() error = %v", err) return } assert.Equal(copy, *p.Tag) // ensure can find the tag found, err := qb.Find(ctx, p.ID) if err != nil { t.Errorf("TagStore.Find() error = %v", err) } if !assert.NotNil(found) { return } // load relationships if err := loadTagRelationships(ctx, copy, found); err != nil { t.Errorf("loadTagRelationships() error = %v", err) return } assert.Equal(copy, *found) // ensure custom fields are set cf, err := qb.GetCustomFields(ctx, p.ID) if err != nil { t.Errorf("TagStore.GetCustomFields() error = %v", err) return } assert.Equal(tt.newObject.CustomFields, cf) return }) } } func Test_TagStore_Update(t *testing.T) { var ( name = "name" sortName = "sortName" description = "description" favorite = true ignoreAutoTag = true aliases = []string{"alias1", "alias2"} endpoint1 = "endpoint1" endpoint2 = "endpoint2" stashID1 = "stashid1" stashID2 = "stashid2" createdAt = epochTime updatedAt = epochTime ) tests := []struct { name string updatedObject models.UpdateTagInput wantErr bool }{ { "full", models.UpdateTagInput{ Tag: &models.Tag{ ID: tagIDs[tagIdxWithGallery], Name: name, SortName: sortName, Description: description, Favorite: favorite, IgnoreAutoTag: ignoreAutoTag, Aliases: models.NewRelatedStrings(aliases), ParentIDs: models.NewRelatedIDs([]int{tagIDs[tagIdxWithScene]}), ChildIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithScene]}), StashIDs: models.NewRelatedStashIDs([]models.StashID{ { StashID: stashID1, Endpoint: endpoint1, UpdatedAt: epochTime, }, { StashID: stashID2, Endpoint: endpoint2, UpdatedAt: epochTime, }, }), CreatedAt: createdAt, UpdatedAt: updatedAt, }, CustomFields: models.CustomFieldsInput{ Full: map[string]interface{}{ "string": "updated", "int": int64(999), "real": 9.99, }, }, }, false, }, { "set custom fields", models.UpdateTagInput{ Tag: &models.Tag{ ID: tagIDs[tagIdxWithGallery], Name: tagNames[tagIdxWithGallery], }, CustomFields: models.CustomFieldsInput{ Full: testCustomFields, }, }, false, }, { "clear custom fields", models.UpdateTagInput{ Tag: &models.Tag{ ID: tagIDs[tagIdxWithGallery], Name: tagNames[tagIdxWithGallery], }, CustomFields: models.CustomFieldsInput{ Full: map[string]interface{}{}, }, }, false, }, { "invalid parent id", models.UpdateTagInput{ Tag: &models.Tag{ ID: tagIDs[tagIdxWithGallery], Name: tagNames[tagIdxWithGallery], ParentIDs: models.NewRelatedIDs([]int{invalidID}), }, }, true, }, { "invalid child id", models.UpdateTagInput{ Tag: &models.Tag{ ID: tagIDs[tagIdxWithGallery], Name: tagNames[tagIdxWithGallery], ChildIDs: models.NewRelatedIDs([]int{invalidID}), }, }, true, }, } qb := db.Tag for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) p := tt.updatedObject if err := qb.Update(ctx, &p); (err != nil) != tt.wantErr { t.Errorf("TagStore.Update() error = %v, wantErr = %v", err, tt.wantErr) } if tt.wantErr { return } s, err := qb.Find(ctx, tt.updatedObject.ID) if err != nil { t.Errorf("TagStore.Find() error = %v", err) return } // load relationships if err := loadTagRelationships(ctx, *tt.updatedObject.Tag, s); err != nil { t.Errorf("loadTagRelationships() error = %v", err) return } assert.Equal(*tt.updatedObject.Tag, *s) // ensure custom fields are correct if tt.updatedObject.CustomFields.Full != nil { cf, err := qb.GetCustomFields(ctx, tt.updatedObject.ID) if err != nil { t.Errorf("TagStore.GetCustomFields() error = %v", err) return } assert.Equal(tt.updatedObject.CustomFields.Full, cf) } }) } } func Test_TagStore_UpdatePartialCustomFields(t *testing.T) { tests := []struct { name string id int partial models.TagPartial expected map[string]interface{} // nil to use the partial }{ { "set custom fields", tagIDs[tagIdxWithGallery], models.TagPartial{ CustomFields: models.CustomFieldsInput{ Full: testCustomFields, }, }, nil, }, { "clear custom fields", tagIDs[tagIdxWithGallery], models.TagPartial{ CustomFields: models.CustomFieldsInput{ Full: map[string]interface{}{}, }, }, nil, }, { "partial custom fields", tagIDs[tagIdxWithGallery], models.TagPartial{ CustomFields: models.CustomFieldsInput{ Partial: map[string]interface{}{ "string": "bbb", "new_field": "new", }, }, }, map[string]interface{}{ "int": int64(2), "real": float64(1.7), "string": "bbb", "new_field": "new", }, }, } for _, tt := range tests { qb := db.Tag runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) _, err := qb.UpdatePartial(ctx, tt.id, tt.partial) if err != nil { t.Errorf("TagStore.UpdatePartial() error = %v", err) return } // ensure custom fields are correct cf, err := qb.GetCustomFields(ctx, tt.id) if err != nil { t.Errorf("TagStore.GetCustomFields() error = %v", err) return } if tt.expected == nil { assert.Equal(tt.partial.CustomFields.Full, cf) } else { assert.Equal(tt.expected, cf) } }) } } func TestTagQueryCustomFields(t *testing.T) { tests := []struct { name string filter *models.TagFilterType includeIdxs []int excludeIdxs []int wantErr bool }{ { "equals", &models.TagFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierEquals, Value: []any{getTagStringValue(tagIdxWithGallery, "custom")}, }, }, }, []int{tagIdxWithGallery}, nil, false, }, { "not equals", &models.TagFilterType{ Name: &models.StringCriterionInput{ Value: getTagStringValue(tagIdxWithGallery, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotEquals, Value: []any{getTagStringValue(tagIdxWithGallery, "custom")}, }, }, }, nil, []int{tagIdxWithGallery}, false, }, { "includes", &models.TagFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierIncludes, Value: []any{getTagStringValue(tagIdxWithGallery, "custom")[9:]}, }, }, }, []int{tagIdxWithGallery}, nil, false, }, { "excludes", &models.TagFilterType{ Name: &models.StringCriterionInput{ Value: getTagStringValue(tagIdxWithGallery, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierExcludes, Value: []any{getTagStringValue(tagIdxWithGallery, "custom")[9:]}, }, }, }, nil, []int{tagIdxWithGallery}, false, }, { "regex", &models.TagFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierMatchesRegex, Value: []any{".*17_custom"}, }, }, }, []int{tagIdxWithGallery}, nil, false, }, { "invalid regex", &models.TagFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierMatchesRegex, Value: []any{"["}, }, }, }, nil, nil, true, }, { "not matches regex", &models.TagFilterType{ Name: &models.StringCriterionInput{ Value: getTagStringValue(tagIdxWithGallery, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotMatchesRegex, Value: []any{".*17_custom"}, }, }, }, nil, []int{tagIdxWithGallery}, false, }, { "invalid not matches regex", &models.TagFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotMatchesRegex, Value: []any{"["}, }, }, }, nil, nil, true, }, { "null", &models.TagFilterType{ Name: &models.StringCriterionInput{ Value: getTagStringValue(tagIdxWithGallery, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "not existing", Modifier: models.CriterionModifierIsNull, }, }, }, []int{tagIdxWithGallery}, nil, false, }, { "not null", &models.TagFilterType{ Name: &models.StringCriterionInput{ Value: getTagStringValue(tagIdxWithGallery, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierNotNull, }, }, }, []int{tagIdxWithGallery}, nil, false, }, { "between", &models.TagFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "real", Modifier: models.CriterionModifierBetween, Value: []any{0.15, 0.25}, }, }, }, []int{tagIdx2WithScene}, nil, false, }, { "not between", &models.TagFilterType{ Name: &models.StringCriterionInput{ Value: getTagStringValue(tagIdx2WithScene, "Name"), Modifier: models.CriterionModifierEquals, }, CustomFields: []models.CustomFieldCriterionInput{ { Field: "real", Modifier: models.CriterionModifierNotBetween, Value: []any{0.15, 0.25}, }, }, }, nil, []int{tagIdx2WithScene}, false, }, } for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) tags, _, err := db.Tag.Query(ctx, tt.filter, nil) if (err != nil) != tt.wantErr { t.Errorf("TagStore.Query() error = %v, wantErr %v", err, tt.wantErr) return } ids := tagsToIDs(tags) include := indexesToIDs(tagIDs, tt.includeIdxs) exclude := indexesToIDs(tagIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(ids, i) } for _, e := range exclude { assert.NotContains(ids, e) } }) } // Test combining text search (findFilter.Q) with custom field filters. // This verifies that positional args are bound in the correct order // when JOINs (from custom fields) and WHERE (from text search) both // have parameterized placeholders. runWithRollbackTxn(t, "equals with text search", func(t *testing.T, ctx context.Context) { assert := assert.New(t) tagName := getTagStringValue(tagIdxWithGallery, "Name") q := tagName findFilter := &models.FindFilterType{Q: &q} tagFilter := &models.TagFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "string", Modifier: models.CriterionModifierEquals, Value: []any{getTagStringValue(tagIdxWithGallery, "custom")}, }, }, } tags, _, err := db.Tag.Query(ctx, tagFilter, findFilter) if err != nil { t.Errorf("TagStore.Query() error = %v", err) return } ids := tagsToIDs(tags) assert.Contains(ids, tagIDs[tagIdxWithGallery]) assert.Len(tags, 1) }) runWithRollbackTxn(t, "is_null with text search", func(t *testing.T, ctx context.Context) { assert := assert.New(t) tagName := getTagStringValue(tagIdxWithGallery, "Name") q := tagName findFilter := &models.FindFilterType{Q: &q} tagFilter := &models.TagFilterType{ CustomFields: []models.CustomFieldCriterionInput{ { Field: "not existing", Modifier: models.CriterionModifierIsNull, }, }, } tags, _, err := db.Tag.Query(ctx, tagFilter, findFilter) if err != nil { t.Errorf("TagStore.Query() error = %v", err) return } ids := tagsToIDs(tags) assert.Contains(ids, tagIDs[tagIdxWithGallery]) assert.Len(tags, 1) }) } // TODO Destroy // TODO Find // TODO FindBySceneID // TODO FindBySceneMarkerID // TODO Count // TODO All // TODO AllSlim // TODO Query ================================================ FILE: pkg/sqlite/timestamp.go ================================================ package sqlite import ( "database/sql/driver" "time" ) const TimestampFormat = time.RFC3339 // Timestamp represents a time stored in RFC3339 format. type Timestamp struct { Timestamp time.Time } // Scan implements the Scanner interface. func (t *Timestamp) Scan(value interface{}) error { t.Timestamp = value.(time.Time) return nil } // Value implements the driver Valuer interface. func (t Timestamp) Value() (driver.Value, error) { return t.Timestamp.Format(TimestampFormat), nil } // UTCTimestamp stores a time in UTC. // TODO - Timestamp should use UTC by default type UTCTimestamp struct { Timestamp } // Value implements the driver Valuer interface. func (t UTCTimestamp) Value() (driver.Value, error) { return t.Timestamp.Timestamp.UTC().Format(TimestampFormat), nil } // NullTimestamp represents a nullable time stored in RFC3339 format. type NullTimestamp struct { Timestamp time.Time Valid bool } // Scan implements the Scanner interface. func (t *NullTimestamp) Scan(value interface{}) error { var ok bool t.Timestamp, ok = value.(time.Time) if !ok { t.Timestamp = time.Time{} t.Valid = false return nil } t.Valid = true return nil } // Value implements the driver Valuer interface. func (t NullTimestamp) Value() (driver.Value, error) { if !t.Valid { return nil, nil } return t.Timestamp.Format(TimestampFormat), nil } func (t NullTimestamp) TimePtr() *time.Time { if !t.Valid { return nil } timestamp := t.Timestamp return ×tamp } func NullTimestampFromTimePtr(t *time.Time) NullTimestamp { if t == nil { return NullTimestamp{Valid: false} } return NullTimestamp{Timestamp: *t, Valid: true} } ================================================ FILE: pkg/sqlite/transaction.go ================================================ package sqlite import ( "context" "errors" "fmt" "runtime/debug" "github.com/jmoiron/sqlx" "github.com/mattn/go-sqlite3" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) type key int const ( txnKey key = iota + 1 dbKey writableKey ) func (db *Database) WithDatabase(ctx context.Context) (context.Context, error) { // if we are already in a transaction or have a database already, just use it if tx, _ := getDBReader(ctx); tx != nil { return ctx, nil } return context.WithValue(ctx, dbKey, db.readDB), nil } func (db *Database) Begin(ctx context.Context, writable bool) (context.Context, error) { if tx, _ := getTx(ctx); tx != nil { // log the stack trace so we can see logger.Error(string(debug.Stack())) return nil, fmt.Errorf("already in transaction") } dbtx := db.readDB if writable { dbtx = db.writeDB } tx, err := dbtx.BeginTxx(ctx, nil) if err != nil { return nil, fmt.Errorf("beginning transaction: %w", err) } ctx = context.WithValue(ctx, writableKey, writable) return context.WithValue(ctx, txnKey, tx), nil } func (db *Database) Commit(ctx context.Context) error { tx, err := getTx(ctx) if err != nil { return err } defer db.txnComplete(ctx) if err := tx.Commit(); err != nil { return err } return nil } func (db *Database) Rollback(ctx context.Context) error { tx, err := getTx(ctx) if err != nil { return err } defer db.txnComplete(ctx) if err := tx.Rollback(); err != nil { return err } return nil } func (db *Database) txnComplete(ctx context.Context) { } func getTx(ctx context.Context) (*sqlx.Tx, error) { tx, ok := ctx.Value(txnKey).(*sqlx.Tx) if !ok || tx == nil { return nil, fmt.Errorf("not in transaction") } return tx, nil } func getDBReader(ctx context.Context) (dbReader, error) { // get transaction first if present tx, ok := ctx.Value(txnKey).(*sqlx.Tx) if !ok || tx == nil { // try to get database if present db, ok := ctx.Value(dbKey).(*sqlx.DB) if !ok || db == nil { return nil, fmt.Errorf("not in transaction") } return db, nil } return tx, nil } func (db *Database) IsLocked(err error) bool { var sqliteError sqlite3.Error if errors.As(err, &sqliteError) { return sqliteError.Code == sqlite3.ErrBusy } return false } func (db *Database) Repository() models.Repository { return models.Repository{ TxnManager: db, Blob: db.Blobs, File: db.File, Folder: db.Folder, Gallery: db.Gallery, GalleryChapter: db.GalleryChapter, Image: db.Image, Group: db.Group, Performer: db.Performer, Scene: db.Scene, SceneMarker: db.SceneMarker, Studio: db.Studio, Tag: db.Tag, SavedFilter: db.SavedFilter, } } ================================================ FILE: pkg/sqlite/transaction_test.go ================================================ //go:build integration // +build integration package sqlite_test import ( "context" "errors" "sync" "testing" "time" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/txn" ) // this test is left commented out as it is not deterministic. // func TestConcurrentExclusiveTxn(t *testing.T) { // const ( // workers = 8 // loops = 100 // innerLoops = 10 // sleepTime = 2 * time.Millisecond // ) // ctx := context.Background() // var wg sync.WaitGroup // for k := 0; k < workers; k++ { // wg.Add(1) // go func(wk int) { // for l := 0; l < loops; l++ { // // change this to WithReadTxn to see locked database error // if err := txn.WithTxn(ctx, db, func(ctx context.Context) error { // for ll := 0; ll < innerLoops; ll++ { // scene := &models.Scene{ // Title: "test", // } // if err := db.Scene.Create(ctx, scene, nil); err != nil { // return err // } // if err := db.Scene.Destroy(ctx, scene.ID); err != nil { // return err // } // } // time.Sleep(sleepTime) // return nil // }); err != nil { // t.Errorf("worker %d loop %d: %v", wk, l, err) // } // } // wg.Done() // }(k) // } // wg.Wait() // } func signalOtherThread(c chan struct{}) error { select { case c <- struct{}{}: return nil case <-time.After(10 * time.Second): return errors.New("timed out signalling other thread") } } func waitForOtherThread(c chan struct{}) error { select { case <-c: return nil case <-time.After(10 * time.Second): return errors.New("timed out waiting for other thread") } } // this test is left commented as it's no longer possible to write to the database // with a read-only transaction. // func TestConcurrentReadTxn(t *testing.T) { // var wg sync.WaitGroup // ctx := context.Background() // c := make(chan struct{}) // // first thread // wg.Add(2) // go func() { // defer wg.Done() // if err := txn.WithReadTxn(ctx, db, func(ctx context.Context) error { // scene := &models.Scene{ // Title: "test", // } // if err := db.Scene.Create(ctx, scene, nil); err != nil { // return err // } // // wait for other thread to start // if err := signalOtherThread(c); err != nil { // return err // } // if err := waitForOtherThread(c); err != nil { // return err // } // if err := db.Scene.Destroy(ctx, scene.ID); err != nil { // return err // } // return nil // }); err != nil { // t.Errorf("unexpected error in first thread: %v", err) // } // }() // // second thread // go func() { // defer wg.Done() // _ = txn.WithReadTxn(ctx, db, func(ctx context.Context) error { // // wait for first thread // if err := waitForOtherThread(c); err != nil { // t.Errorf(err.Error()) // return err // } // defer func() { // if err := signalOtherThread(c); err != nil { // t.Errorf(err.Error()) // } // }() // scene := &models.Scene{ // Title: "test", // } // // expect error when we try to do this, as the other thread has already // // modified this table // // this takes time to fail, so we need to wait for it // if err := db.Scene.Create(ctx, scene, nil); err != nil { // if !db.IsLocked(err) { // t.Errorf("unexpected error: %v", err) // } // return err // } else { // t.Errorf("expected locked error in second thread") // } // return nil // }) // }() // wg.Wait() // } func TestConcurrentExclusiveAndReadTxn(t *testing.T) { var wg sync.WaitGroup ctx := context.Background() c := make(chan struct{}) // first thread wg.Add(2) go func() { defer wg.Done() if err := txn.WithTxn(ctx, db, func(ctx context.Context) error { scene := &models.Scene{ Title: "test", } if err := db.Scene.Create(ctx, scene, nil); err != nil { return err } // wait for other thread to start if err := signalOtherThread(c); err != nil { return err } if err := waitForOtherThread(c); err != nil { return err } if err := db.Scene.Destroy(ctx, scene.ID); err != nil { return err } return nil }); err != nil { t.Errorf("unexpected error in first thread: %v", err) } }() // second thread go func() { defer wg.Done() _ = txn.WithReadTxn(ctx, db, func(ctx context.Context) error { // wait for first thread if err := waitForOtherThread(c); err != nil { t.Error(err.Error()) return err } defer func() { if err := signalOtherThread(c); err != nil { t.Error(err.Error()) } }() if _, err := db.Scene.Find(ctx, sceneIDs[sceneIdx1WithPerformer]); err != nil { t.Errorf("unexpected error: %v", err) return err } return nil }) }() wg.Wait() } // this test is left commented out as it is not deterministic. // func TestConcurrentExclusiveAndReadTxns(t *testing.T) { // const ( // writeWorkers = 4 // readWorkers = 4 // loops = 200 // innerLoops = 10 // sleepTime = 1 * time.Millisecond // ) // ctx := context.Background() // var wg sync.WaitGroup // for k := 0; k < writeWorkers; k++ { // wg.Add(1) // go func(wk int) { // for l := 0; l < loops; l++ { // if err := txn.WithTxn(ctx, db, func(ctx context.Context) error { // for ll := 0; ll < innerLoops; ll++ { // scene := &models.Scene{ // Title: "test", // } // if err := db.Scene.Create(ctx, scene, nil); err != nil { // return err // } // if err := db.Scene.Destroy(ctx, scene.ID); err != nil { // return err // } // } // time.Sleep(sleepTime) // return nil // }); err != nil { // t.Errorf("write worker %d loop %d: %v", wk, l, err) // } // } // wg.Done() // }(k) // } // for k := 0; k < readWorkers; k++ { // wg.Add(1) // go func(wk int) { // for l := 0; l < loops; l++ { // if err := txn.WithReadTxn(ctx, db, func(ctx context.Context) error { // for ll := 0; ll < innerLoops; ll++ { // if _, err := db.Scene.Find(ctx, sceneIDs[ll%totalScenes]); err != nil { // return err // } // } // time.Sleep(sleepTime) // return nil // }); err != nil { // t.Errorf("read worker %d loop %d: %v", wk, l, err) // } // } // wg.Done() // }(k) // } // wg.Wait() // } ================================================ FILE: pkg/sqlite/tx.go ================================================ package sqlite import ( "context" "database/sql" "fmt" "time" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/logger" ) const ( slowLogTime = time.Millisecond * 200 ) type dbReader interface { Get(dest interface{}, query string, args ...interface{}) error GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) } type stmt struct { *sql.Stmt query string } func logSQL(start time.Time, query string, args ...interface{}) { since := time.Since(start) if since >= slowLogTime { logger.Debugf("SLOW SQL [%v]: %s, args: %v", since, query, args) } else { logger.Tracef("SQL [%v]: %s, args: %v", since, query, args) } } type dbWrapperType struct{} var dbWrapper = dbWrapperType{} func sqlError(err error, sql string, args ...interface{}) error { if err == nil { return nil } return fmt.Errorf("error executing `%s` [%v]: %w", sql, args, err) } func (*dbWrapperType) Get(ctx context.Context, dest interface{}, query string, args ...interface{}) error { tx, err := getDBReader(ctx) if err != nil { return sqlError(err, query, args...) } start := time.Now() err = tx.GetContext(ctx, dest, query, args...) logSQL(start, query, args...) return sqlError(err, query, args...) } func (*dbWrapperType) Select(ctx context.Context, dest interface{}, query string, args ...interface{}) error { tx, err := getDBReader(ctx) if err != nil { return sqlError(err, query, args...) } start := time.Now() err = tx.SelectContext(ctx, dest, query, args...) logSQL(start, query, args...) return sqlError(err, query, args...) } func (*dbWrapperType) Queryx(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) { tx, err := getDBReader(ctx) if err != nil { return nil, sqlError(err, query, args...) } start := time.Now() ret, err := tx.QueryxContext(ctx, query, args...) logSQL(start, query, args...) return ret, sqlError(err, query, args...) } func (*dbWrapperType) QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) { return dbWrapper.Queryx(ctx, query, args...) } func (*dbWrapperType) NamedExec(ctx context.Context, query string, arg interface{}) (sql.Result, error) { tx, err := getTx(ctx) if err != nil { return nil, sqlError(err, query, arg) } start := time.Now() ret, err := tx.NamedExecContext(ctx, query, arg) logSQL(start, query, arg) return ret, sqlError(err, query, arg) } func (*dbWrapperType) Exec(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { tx, err := getTx(ctx) if err != nil { return nil, sqlError(err, query, args...) } start := time.Now() ret, err := tx.ExecContext(ctx, query, args...) logSQL(start, query, args...) return ret, sqlError(err, query, args...) } // Prepare creates a prepared statement. func (*dbWrapperType) Prepare(ctx context.Context, query string, args ...interface{}) (*stmt, error) { tx, err := getTx(ctx) if err != nil { return nil, sqlError(err, query, args...) } // nolint:sqlclosecheck ret, err := tx.PrepareContext(ctx, query) if err != nil { return nil, sqlError(err, query, args...) } return &stmt{ query: query, Stmt: ret, }, nil } func (*dbWrapperType) ExecStmt(ctx context.Context, stmt *stmt, args ...interface{}) (sql.Result, error) { _, err := getTx(ctx) if err != nil { return nil, sqlError(err, stmt.query, args...) } start := time.Now() ret, err := stmt.ExecContext(ctx, args...) logSQL(start, stmt.query, args...) return ret, sqlError(err, stmt.query, args...) } ================================================ FILE: pkg/sqlite/values.go ================================================ package sqlite import ( "gopkg.in/guregu/null.v4" "github.com/stashapp/stash/pkg/models" ) // null package does not provide methods to convert null.Int to int pointer func intFromPtr(i *int) null.Int { if i == nil { return null.NewInt(0, false) } return null.IntFrom(int64(*i)) } func nullIntPtr(i null.Int) *int { if !i.Valid { return nil } v := int(i.Int64) return &v } func nullFloatPtr(i null.Float) *float64 { if !i.Valid { return nil } v := float64(i.Float64) return &v } func nullIntFolderIDPtr(i null.Int) *models.FolderID { if !i.Valid { return nil } v := models.FolderID(i.Int64) return &v } func nullIntFileIDPtr(i null.Int) *models.FileID { if !i.Valid { return nil } v := models.FileID(i.Int64) return &v } func nullIntFromFileIDPtr(i *models.FileID) null.Int { if i == nil { return null.NewInt(0, false) } return null.IntFrom(int64(*i)) } func nullIntFromFolderIDPtr(i *models.FolderID) null.Int { if i == nil { return null.NewInt(0, false) } return null.IntFrom(int64(*i)) } ================================================ FILE: pkg/stashbox/client.go ================================================ // Package stashbox provides a client interface to a stash-box server instance. package stashbox import ( "context" "net/http" "regexp" "github.com/Yamashou/gqlgenc/clientv2" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scraper" "github.com/stashapp/stash/pkg/stashbox/graphql" "golang.org/x/time/rate" ) // DefaultMaxRequestsPerMinute is the default maximum number of requests per minute. const DefaultMaxRequestsPerMinute = 240 // Client represents the client interface to a stash-box server instance. type Client struct { client *graphql.Client httpClient *http.Client box models.StashBox maxRequestsPerMinute int // tag patterns to be excluded excludeTagRE []*regexp.Regexp } type ClientOption func(*Client) func ExcludeTagPatterns(patterns []string) ClientOption { return func(c *Client) { c.excludeTagRE = scraper.CompileExclusionRegexps(patterns) } } func MaxRequestsPerMinute(n int) ClientOption { return func(c *Client) { if n > 0 { c.maxRequestsPerMinute = n } } } func setApiKeyHeader(apiKey string) clientv2.RequestInterceptor { return func(ctx context.Context, req *http.Request, gqlInfo *clientv2.GQLRequestInfo, res interface{}, next clientv2.RequestInterceptorFunc) error { req.Header.Set("ApiKey", apiKey) return next(ctx, req, gqlInfo, res) } } func rateLimit(n int) clientv2.RequestInterceptor { perSec := float64(n) / 60 limiter := rate.NewLimiter(rate.Limit(perSec), 1) return func(ctx context.Context, req *http.Request, gqlInfo *clientv2.GQLRequestInfo, res interface{}, next clientv2.RequestInterceptorFunc) error { if err := limiter.Wait(ctx); err != nil { // should only happen if the context is canceled return err } return next(ctx, req, gqlInfo, res) } } // NewClient returns a new instance of a stash-box client. func NewClient(box models.StashBox, options ...ClientOption) *Client { ret := &Client{ box: box, maxRequestsPerMinute: DefaultMaxRequestsPerMinute, httpClient: http.DefaultClient, } if box.MaxRequestsPerMinute > 0 { ret.maxRequestsPerMinute = box.MaxRequestsPerMinute } for _, option := range options { option(ret) } authHeader := setApiKeyHeader(box.APIKey) limitRequests := rateLimit(ret.maxRequestsPerMinute) client := &graphql.Client{ Client: clientv2.NewClient(ret.httpClient, box.Endpoint, nil, authHeader, limitRequests), } ret.client = client return ret } func (c Client) GetUser(ctx context.Context) (*graphql.Me, error) { return c.client.Me(ctx) } ================================================ FILE: pkg/stashbox/draft.go ================================================ package stashbox import ( "bytes" "context" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "github.com/Yamashou/gqlgenc/clientv2" "github.com/Yamashou/gqlgenc/graphqljson" ) func (c *Client) submitDraft(ctx context.Context, query string, input interface{}, image io.Reader, ret interface{}) error { vars := map[string]interface{}{ "input": input, } r := &clientv2.Request{ Query: query, Variables: vars, OperationName: "", } requestBody, err := json.Marshal(r) if err != nil { return fmt.Errorf("encode: %w", err) } body := &bytes.Buffer{} writer := multipart.NewWriter(body) if err := writer.WriteField("operations", string(requestBody)); err != nil { return err } if image != nil { if err := writer.WriteField("map", "{ \"0\": [\"variables.input.image\"] }"); err != nil { return err } part, _ := writer.CreateFormFile("0", "draft") if _, err := io.Copy(part, image); err != nil { return err } } else if err := writer.WriteField("map", "{}"); err != nil { return err } writer.Close() req, _ := http.NewRequestWithContext(ctx, "POST", c.box.Endpoint, body) req.Header.Add("Content-Type", writer.FormDataContentType()) req.Header.Set("ApiKey", c.box.APIKey) httpClient := c.client.Client.Client resp, err := httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() responseBytes, err := io.ReadAll(resp.Body) if err != nil { return err } type response struct { Data json.RawMessage `json:"data"` Errors json.RawMessage `json:"errors"` } var respGQL response if err := json.Unmarshal(responseBytes, &respGQL); err != nil { return fmt.Errorf("failed to decode data %s: %w", string(responseBytes), err) } if len(respGQL.Errors) > 0 { // try to parse standard graphql error errors := &clientv2.GqlErrorList{} if e := json.Unmarshal(responseBytes, errors); e != nil { return fmt.Errorf("failed to parse graphql errors. Response content %s - %w ", string(responseBytes), e) } return errors } if err := graphqljson.UnmarshalData(respGQL.Data, ret); err != nil { return err } return err } // we can't currently use this due to https://github.com/Yamashou/gqlgenc/issues/109 // func uploadImage(image io.Reader) client.HTTPRequestOption { // return func(req *http.Request) { // if image == nil { // // return without changing anything // return // } // // we can't handle errors in here, so if one happens, just return // // without changing anything. // // repackage the request to include the image // bodyBytes, err := ioutil.ReadAll(req.Body) // if err != nil { // return // } // newBody := &bytes.Buffer{} // writer := multipart.NewWriter(newBody) // _ = writer.WriteField("operations", string(bodyBytes)) // if err := writer.WriteField("map", "{ \"0\": [\"variables.input.image\"] }"); err != nil { // return // } // part, _ := writer.CreateFormFile("0", "draft") // if _, err := io.Copy(part, image); err != nil { // return // } // writer.Close() // // now set the request body to this new body // req.Body = io.NopCloser(newBody) // req.ContentLength = int64(newBody.Len()) // req.Header.Set("Content-Type", writer.FormDataContentType()) // } // } ================================================ FILE: pkg/stashbox/graphql/generated_client.go ================================================ // Code generated by github.com/Yamashou/gqlgenc, DO NOT EDIT. package graphql import ( "context" "github.com/Yamashou/gqlgenc/clientv2" ) type StashBoxGraphQLClient interface { FindScenesBySceneFingerprints(ctx context.Context, fingerprints [][]*FingerprintQueryInput, interceptors ...clientv2.RequestInterceptor) (*FindScenesBySceneFingerprints, error) SearchScene(ctx context.Context, term string, interceptors ...clientv2.RequestInterceptor) (*SearchScene, error) SearchPerformer(ctx context.Context, term string, interceptors ...clientv2.RequestInterceptor) (*SearchPerformer, error) FindPerformerByID(ctx context.Context, id string, interceptors ...clientv2.RequestInterceptor) (*FindPerformerByID, error) FindSceneByID(ctx context.Context, id string, interceptors ...clientv2.RequestInterceptor) (*FindSceneByID, error) FindStudio(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindStudio, error) FindTag(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindTag, error) QueryTags(ctx context.Context, input TagQueryInput, interceptors ...clientv2.RequestInterceptor) (*QueryTags, error) SubmitFingerprint(ctx context.Context, input FingerprintSubmission, interceptors ...clientv2.RequestInterceptor) (*SubmitFingerprint, error) Me(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*Me, error) SubmitSceneDraft(ctx context.Context, input SceneDraftInput, interceptors ...clientv2.RequestInterceptor) (*SubmitSceneDraft, error) SubmitPerformerDraft(ctx context.Context, input PerformerDraftInput, interceptors ...clientv2.RequestInterceptor) (*SubmitPerformerDraft, error) } type Client struct { Client *clientv2.Client } func NewClient(cli clientv2.HttpClient, baseURL string, options *clientv2.Options, interceptors ...clientv2.RequestInterceptor) StashBoxGraphQLClient { return &Client{Client: clientv2.NewClient(cli, baseURL, options, interceptors...)} } type URLFragment struct { URL string "json:\"url\" graphql:\"url\"" Type string "json:\"type\" graphql:\"type\"" } func (t *URLFragment) GetURL() string { if t == nil { t = &URLFragment{} } return t.URL } func (t *URLFragment) GetType() string { if t == nil { t = &URLFragment{} } return t.Type } type ImageFragment struct { ID string "json:\"id\" graphql:\"id\"" URL string "json:\"url\" graphql:\"url\"" Width int "json:\"width\" graphql:\"width\"" Height int "json:\"height\" graphql:\"height\"" } func (t *ImageFragment) GetID() string { if t == nil { t = &ImageFragment{} } return t.ID } func (t *ImageFragment) GetURL() string { if t == nil { t = &ImageFragment{} } return t.URL } func (t *ImageFragment) GetWidth() int { if t == nil { t = &ImageFragment{} } return t.Width } func (t *ImageFragment) GetHeight() int { if t == nil { t = &ImageFragment{} } return t.Height } type StudioFragment struct { Name string "json:\"name\" graphql:\"name\"" ID string "json:\"id\" graphql:\"id\"" Aliases []string "json:\"aliases\" graphql:\"aliases\"" Urls []*URLFragment "json:\"urls\" graphql:\"urls\"" Parent *StudioFragment_Parent "json:\"parent,omitempty\" graphql:\"parent\"" Images []*ImageFragment "json:\"images\" graphql:\"images\"" } func (t *StudioFragment) GetName() string { if t == nil { t = &StudioFragment{} } return t.Name } func (t *StudioFragment) GetID() string { if t == nil { t = &StudioFragment{} } return t.ID } func (t *StudioFragment) GetAliases() []string { if t == nil { t = &StudioFragment{} } return t.Aliases } func (t *StudioFragment) GetUrls() []*URLFragment { if t == nil { t = &StudioFragment{} } return t.Urls } func (t *StudioFragment) GetParent() *StudioFragment_Parent { if t == nil { t = &StudioFragment{} } return t.Parent } func (t *StudioFragment) GetImages() []*ImageFragment { if t == nil { t = &StudioFragment{} } return t.Images } type TagFragment struct { Name string "json:\"name\" graphql:\"name\"" ID string "json:\"id\" graphql:\"id\"" Description *string "json:\"description,omitempty\" graphql:\"description\"" Aliases []string "json:\"aliases\" graphql:\"aliases\"" Category *TagFragment_Category "json:\"category,omitempty\" graphql:\"category\"" } func (t *TagFragment) GetName() string { if t == nil { t = &TagFragment{} } return t.Name } func (t *TagFragment) GetID() string { if t == nil { t = &TagFragment{} } return t.ID } func (t *TagFragment) GetDescription() *string { if t == nil { t = &TagFragment{} } return t.Description } func (t *TagFragment) GetAliases() []string { if t == nil { t = &TagFragment{} } return t.Aliases } func (t *TagFragment) GetCategory() *TagFragment_Category { if t == nil { t = &TagFragment{} } return t.Category } type MeasurementsFragment struct { BandSize *int "json:\"band_size,omitempty\" graphql:\"band_size\"" CupSize *string "json:\"cup_size,omitempty\" graphql:\"cup_size\"" Waist *int "json:\"waist,omitempty\" graphql:\"waist\"" Hip *int "json:\"hip,omitempty\" graphql:\"hip\"" } func (t *MeasurementsFragment) GetBandSize() *int { if t == nil { t = &MeasurementsFragment{} } return t.BandSize } func (t *MeasurementsFragment) GetCupSize() *string { if t == nil { t = &MeasurementsFragment{} } return t.CupSize } func (t *MeasurementsFragment) GetWaist() *int { if t == nil { t = &MeasurementsFragment{} } return t.Waist } func (t *MeasurementsFragment) GetHip() *int { if t == nil { t = &MeasurementsFragment{} } return t.Hip } type BodyModificationFragment struct { Location string "json:\"location\" graphql:\"location\"" Description *string "json:\"description,omitempty\" graphql:\"description\"" } func (t *BodyModificationFragment) GetLocation() string { if t == nil { t = &BodyModificationFragment{} } return t.Location } func (t *BodyModificationFragment) GetDescription() *string { if t == nil { t = &BodyModificationFragment{} } return t.Description } type PerformerFragment struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" Disambiguation *string "json:\"disambiguation,omitempty\" graphql:\"disambiguation\"" Aliases []string "json:\"aliases\" graphql:\"aliases\"" Gender *GenderEnum "json:\"gender,omitempty\" graphql:\"gender\"" MergedIds []string "json:\"merged_ids\" graphql:\"merged_ids\"" Deleted bool "json:\"deleted\" graphql:\"deleted\"" MergedIntoID *string "json:\"merged_into_id,omitempty\" graphql:\"merged_into_id\"" Urls []*URLFragment "json:\"urls\" graphql:\"urls\"" Images []*ImageFragment "json:\"images\" graphql:\"images\"" BirthDate *string "json:\"birth_date,omitempty\" graphql:\"birth_date\"" DeathDate *string "json:\"death_date,omitempty\" graphql:\"death_date\"" Ethnicity *EthnicityEnum "json:\"ethnicity,omitempty\" graphql:\"ethnicity\"" Country *string "json:\"country,omitempty\" graphql:\"country\"" EyeColor *EyeColorEnum "json:\"eye_color,omitempty\" graphql:\"eye_color\"" HairColor *HairColorEnum "json:\"hair_color,omitempty\" graphql:\"hair_color\"" Height *int "json:\"height,omitempty\" graphql:\"height\"" Measurements *MeasurementsFragment "json:\"measurements\" graphql:\"measurements\"" BreastType *BreastTypeEnum "json:\"breast_type,omitempty\" graphql:\"breast_type\"" CareerStartYear *int "json:\"career_start_year,omitempty\" graphql:\"career_start_year\"" CareerEndYear *int "json:\"career_end_year,omitempty\" graphql:\"career_end_year\"" Tattoos []*BodyModificationFragment "json:\"tattoos,omitempty\" graphql:\"tattoos\"" Piercings []*BodyModificationFragment "json:\"piercings,omitempty\" graphql:\"piercings\"" } func (t *PerformerFragment) GetID() string { if t == nil { t = &PerformerFragment{} } return t.ID } func (t *PerformerFragment) GetName() string { if t == nil { t = &PerformerFragment{} } return t.Name } func (t *PerformerFragment) GetDisambiguation() *string { if t == nil { t = &PerformerFragment{} } return t.Disambiguation } func (t *PerformerFragment) GetAliases() []string { if t == nil { t = &PerformerFragment{} } return t.Aliases } func (t *PerformerFragment) GetGender() *GenderEnum { if t == nil { t = &PerformerFragment{} } return t.Gender } func (t *PerformerFragment) GetMergedIds() []string { if t == nil { t = &PerformerFragment{} } return t.MergedIds } func (t *PerformerFragment) GetDeleted() bool { if t == nil { t = &PerformerFragment{} } return t.Deleted } func (t *PerformerFragment) GetMergedIntoID() *string { if t == nil { t = &PerformerFragment{} } return t.MergedIntoID } func (t *PerformerFragment) GetUrls() []*URLFragment { if t == nil { t = &PerformerFragment{} } return t.Urls } func (t *PerformerFragment) GetImages() []*ImageFragment { if t == nil { t = &PerformerFragment{} } return t.Images } func (t *PerformerFragment) GetBirthDate() *string { if t == nil { t = &PerformerFragment{} } return t.BirthDate } func (t *PerformerFragment) GetDeathDate() *string { if t == nil { t = &PerformerFragment{} } return t.DeathDate } func (t *PerformerFragment) GetEthnicity() *EthnicityEnum { if t == nil { t = &PerformerFragment{} } return t.Ethnicity } func (t *PerformerFragment) GetCountry() *string { if t == nil { t = &PerformerFragment{} } return t.Country } func (t *PerformerFragment) GetEyeColor() *EyeColorEnum { if t == nil { t = &PerformerFragment{} } return t.EyeColor } func (t *PerformerFragment) GetHairColor() *HairColorEnum { if t == nil { t = &PerformerFragment{} } return t.HairColor } func (t *PerformerFragment) GetHeight() *int { if t == nil { t = &PerformerFragment{} } return t.Height } func (t *PerformerFragment) GetMeasurements() *MeasurementsFragment { if t == nil { t = &PerformerFragment{} } return t.Measurements } func (t *PerformerFragment) GetBreastType() *BreastTypeEnum { if t == nil { t = &PerformerFragment{} } return t.BreastType } func (t *PerformerFragment) GetCareerStartYear() *int { if t == nil { t = &PerformerFragment{} } return t.CareerStartYear } func (t *PerformerFragment) GetCareerEndYear() *int { if t == nil { t = &PerformerFragment{} } return t.CareerEndYear } func (t *PerformerFragment) GetTattoos() []*BodyModificationFragment { if t == nil { t = &PerformerFragment{} } return t.Tattoos } func (t *PerformerFragment) GetPiercings() []*BodyModificationFragment { if t == nil { t = &PerformerFragment{} } return t.Piercings } type PerformerAppearanceFragment struct { As *string "json:\"as,omitempty\" graphql:\"as\"" Performer *PerformerFragment "json:\"performer\" graphql:\"performer\"" } func (t *PerformerAppearanceFragment) GetAs() *string { if t == nil { t = &PerformerAppearanceFragment{} } return t.As } func (t *PerformerAppearanceFragment) GetPerformer() *PerformerFragment { if t == nil { t = &PerformerAppearanceFragment{} } return t.Performer } type FingerprintFragment struct { Algorithm FingerprintAlgorithm "json:\"algorithm\" graphql:\"algorithm\"" Hash string "json:\"hash\" graphql:\"hash\"" Duration int "json:\"duration\" graphql:\"duration\"" } func (t *FingerprintFragment) GetAlgorithm() *FingerprintAlgorithm { if t == nil { t = &FingerprintFragment{} } return &t.Algorithm } func (t *FingerprintFragment) GetHash() string { if t == nil { t = &FingerprintFragment{} } return t.Hash } func (t *FingerprintFragment) GetDuration() int { if t == nil { t = &FingerprintFragment{} } return t.Duration } type SceneFragment struct { ID string "json:\"id\" graphql:\"id\"" Title *string "json:\"title,omitempty\" graphql:\"title\"" Code *string "json:\"code,omitempty\" graphql:\"code\"" Details *string "json:\"details,omitempty\" graphql:\"details\"" Director *string "json:\"director,omitempty\" graphql:\"director\"" Duration *int "json:\"duration,omitempty\" graphql:\"duration\"" Date *string "json:\"date,omitempty\" graphql:\"date\"" Urls []*URLFragment "json:\"urls\" graphql:\"urls\"" Images []*ImageFragment "json:\"images\" graphql:\"images\"" Studio *StudioFragment "json:\"studio,omitempty\" graphql:\"studio\"" Tags []*TagFragment "json:\"tags\" graphql:\"tags\"" Performers []*PerformerAppearanceFragment "json:\"performers\" graphql:\"performers\"" Fingerprints []*FingerprintFragment "json:\"fingerprints\" graphql:\"fingerprints\"" } func (t *SceneFragment) GetID() string { if t == nil { t = &SceneFragment{} } return t.ID } func (t *SceneFragment) GetTitle() *string { if t == nil { t = &SceneFragment{} } return t.Title } func (t *SceneFragment) GetCode() *string { if t == nil { t = &SceneFragment{} } return t.Code } func (t *SceneFragment) GetDetails() *string { if t == nil { t = &SceneFragment{} } return t.Details } func (t *SceneFragment) GetDirector() *string { if t == nil { t = &SceneFragment{} } return t.Director } func (t *SceneFragment) GetDuration() *int { if t == nil { t = &SceneFragment{} } return t.Duration } func (t *SceneFragment) GetDate() *string { if t == nil { t = &SceneFragment{} } return t.Date } func (t *SceneFragment) GetUrls() []*URLFragment { if t == nil { t = &SceneFragment{} } return t.Urls } func (t *SceneFragment) GetImages() []*ImageFragment { if t == nil { t = &SceneFragment{} } return t.Images } func (t *SceneFragment) GetStudio() *StudioFragment { if t == nil { t = &SceneFragment{} } return t.Studio } func (t *SceneFragment) GetTags() []*TagFragment { if t == nil { t = &SceneFragment{} } return t.Tags } func (t *SceneFragment) GetPerformers() []*PerformerAppearanceFragment { if t == nil { t = &SceneFragment{} } return t.Performers } func (t *SceneFragment) GetFingerprints() []*FingerprintFragment { if t == nil { t = &SceneFragment{} } return t.Fingerprints } type StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" } func (t *StudioFragment_Parent) GetID() string { if t == nil { t = &StudioFragment_Parent{} } return t.ID } func (t *StudioFragment_Parent) GetName() string { if t == nil { t = &StudioFragment_Parent{} } return t.Name } type TagFragment_Category struct { Description *string "json:\"description,omitempty\" graphql:\"description\"" ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" } func (t *TagFragment_Category) GetDescription() *string { if t == nil { t = &TagFragment_Category{} } return t.Description } func (t *TagFragment_Category) GetID() string { if t == nil { t = &TagFragment_Category{} } return t.ID } func (t *TagFragment_Category) GetName() string { if t == nil { t = &TagFragment_Category{} } return t.Name } type SceneFragment_Studio_StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" } func (t *SceneFragment_Studio_StudioFragment_Parent) GetID() string { if t == nil { t = &SceneFragment_Studio_StudioFragment_Parent{} } return t.ID } func (t *SceneFragment_Studio_StudioFragment_Parent) GetName() string { if t == nil { t = &SceneFragment_Studio_StudioFragment_Parent{} } return t.Name } type SceneFragment_Tags_TagFragment_Category struct { Description *string "json:\"description,omitempty\" graphql:\"description\"" ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" } func (t *SceneFragment_Tags_TagFragment_Category) GetDescription() *string { if t == nil { t = &SceneFragment_Tags_TagFragment_Category{} } return t.Description } func (t *SceneFragment_Tags_TagFragment_Category) GetID() string { if t == nil { t = &SceneFragment_Tags_TagFragment_Category{} } return t.ID } func (t *SceneFragment_Tags_TagFragment_Category) GetName() string { if t == nil { t = &SceneFragment_Tags_TagFragment_Category{} } return t.Name } type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" } func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetID() string { if t == nil { t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent{} } return t.ID } func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetName() string { if t == nil { t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent{} } return t.Name } type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category struct { Description *string "json:\"description,omitempty\" graphql:\"description\"" ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" } func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetDescription() *string { if t == nil { t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{} } return t.Description } func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetID() string { if t == nil { t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{} } return t.ID } func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetName() string { if t == nil { t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{} } return t.Name } type SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" } func (t *SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent) GetID() string { if t == nil { t = &SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent{} } return t.ID } func (t *SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent) GetName() string { if t == nil { t = &SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent{} } return t.Name } type SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category struct { Description *string "json:\"description,omitempty\" graphql:\"description\"" ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" } func (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetDescription() *string { if t == nil { t = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{} } return t.Description } func (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetID() string { if t == nil { t = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{} } return t.ID } func (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetName() string { if t == nil { t = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{} } return t.Name } type FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" } func (t *FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent) GetID() string { if t == nil { t = &FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent{} } return t.ID } func (t *FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent) GetName() string { if t == nil { t = &FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent{} } return t.Name } type FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category struct { Description *string "json:\"description,omitempty\" graphql:\"description\"" ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" } func (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetDescription() *string { if t == nil { t = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{} } return t.Description } func (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetID() string { if t == nil { t = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{} } return t.ID } func (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetName() string { if t == nil { t = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{} } return t.Name } type FindStudio_FindStudio_StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" } func (t *FindStudio_FindStudio_StudioFragment_Parent) GetID() string { if t == nil { t = &FindStudio_FindStudio_StudioFragment_Parent{} } return t.ID } func (t *FindStudio_FindStudio_StudioFragment_Parent) GetName() string { if t == nil { t = &FindStudio_FindStudio_StudioFragment_Parent{} } return t.Name } type FindTag_FindTag_TagFragment_Category struct { Description *string "json:\"description,omitempty\" graphql:\"description\"" ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" } func (t *FindTag_FindTag_TagFragment_Category) GetDescription() *string { if t == nil { t = &FindTag_FindTag_TagFragment_Category{} } return t.Description } func (t *FindTag_FindTag_TagFragment_Category) GetID() string { if t == nil { t = &FindTag_FindTag_TagFragment_Category{} } return t.ID } func (t *FindTag_FindTag_TagFragment_Category) GetName() string { if t == nil { t = &FindTag_FindTag_TagFragment_Category{} } return t.Name } type QueryTags_QueryTags_Tags_TagFragment_Category struct { Description *string "json:\"description,omitempty\" graphql:\"description\"" ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" } func (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetDescription() *string { if t == nil { t = &QueryTags_QueryTags_Tags_TagFragment_Category{} } return t.Description } func (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetID() string { if t == nil { t = &QueryTags_QueryTags_Tags_TagFragment_Category{} } return t.ID } func (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetName() string { if t == nil { t = &QueryTags_QueryTags_Tags_TagFragment_Category{} } return t.Name } type QueryTags_QueryTags struct { Count int "json:\"count\" graphql:\"count\"" Tags []*TagFragment "json:\"tags\" graphql:\"tags\"" } func (t *QueryTags_QueryTags) GetCount() int { if t == nil { t = &QueryTags_QueryTags{} } return t.Count } func (t *QueryTags_QueryTags) GetTags() []*TagFragment { if t == nil { t = &QueryTags_QueryTags{} } return t.Tags } type Me_Me struct { Name string "json:\"name\" graphql:\"name\"" } func (t *Me_Me) GetName() string { if t == nil { t = &Me_Me{} } return t.Name } type SubmitSceneDraft_SubmitSceneDraft struct { ID *string "json:\"id,omitempty\" graphql:\"id\"" } func (t *SubmitSceneDraft_SubmitSceneDraft) GetID() *string { if t == nil { t = &SubmitSceneDraft_SubmitSceneDraft{} } return t.ID } type SubmitPerformerDraft_SubmitPerformerDraft struct { ID *string "json:\"id,omitempty\" graphql:\"id\"" } func (t *SubmitPerformerDraft_SubmitPerformerDraft) GetID() *string { if t == nil { t = &SubmitPerformerDraft_SubmitPerformerDraft{} } return t.ID } type FindScenesBySceneFingerprints struct { FindScenesBySceneFingerprints [][]*SceneFragment "json:\"findScenesBySceneFingerprints\" graphql:\"findScenesBySceneFingerprints\"" } func (t *FindScenesBySceneFingerprints) GetFindScenesBySceneFingerprints() [][]*SceneFragment { if t == nil { t = &FindScenesBySceneFingerprints{} } return t.FindScenesBySceneFingerprints } type SearchScene struct { SearchScene []*SceneFragment "json:\"searchScene\" graphql:\"searchScene\"" } func (t *SearchScene) GetSearchScene() []*SceneFragment { if t == nil { t = &SearchScene{} } return t.SearchScene } type SearchPerformer struct { SearchPerformer []*PerformerFragment "json:\"searchPerformer\" graphql:\"searchPerformer\"" } func (t *SearchPerformer) GetSearchPerformer() []*PerformerFragment { if t == nil { t = &SearchPerformer{} } return t.SearchPerformer } type FindPerformerByID struct { FindPerformer *PerformerFragment "json:\"findPerformer,omitempty\" graphql:\"findPerformer\"" } func (t *FindPerformerByID) GetFindPerformer() *PerformerFragment { if t == nil { t = &FindPerformerByID{} } return t.FindPerformer } type FindSceneByID struct { FindScene *SceneFragment "json:\"findScene,omitempty\" graphql:\"findScene\"" } func (t *FindSceneByID) GetFindScene() *SceneFragment { if t == nil { t = &FindSceneByID{} } return t.FindScene } type FindStudio struct { FindStudio *StudioFragment "json:\"findStudio,omitempty\" graphql:\"findStudio\"" } func (t *FindStudio) GetFindStudio() *StudioFragment { if t == nil { t = &FindStudio{} } return t.FindStudio } type FindTag struct { FindTag *TagFragment "json:\"findTag,omitempty\" graphql:\"findTag\"" } func (t *FindTag) GetFindTag() *TagFragment { if t == nil { t = &FindTag{} } return t.FindTag } type QueryTags struct { QueryTags QueryTags_QueryTags "json:\"queryTags\" graphql:\"queryTags\"" } func (t *QueryTags) GetQueryTags() *QueryTags_QueryTags { if t == nil { t = &QueryTags{} } return &t.QueryTags } type SubmitFingerprint struct { SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\"" } func (t *SubmitFingerprint) GetSubmitFingerprint() bool { if t == nil { t = &SubmitFingerprint{} } return t.SubmitFingerprint } type Me struct { Me *Me_Me "json:\"me,omitempty\" graphql:\"me\"" } func (t *Me) GetMe() *Me_Me { if t == nil { t = &Me{} } return t.Me } type SubmitSceneDraft struct { SubmitSceneDraft SubmitSceneDraft_SubmitSceneDraft "json:\"submitSceneDraft\" graphql:\"submitSceneDraft\"" } func (t *SubmitSceneDraft) GetSubmitSceneDraft() *SubmitSceneDraft_SubmitSceneDraft { if t == nil { t = &SubmitSceneDraft{} } return &t.SubmitSceneDraft } type SubmitPerformerDraft struct { SubmitPerformerDraft SubmitPerformerDraft_SubmitPerformerDraft "json:\"submitPerformerDraft\" graphql:\"submitPerformerDraft\"" } func (t *SubmitPerformerDraft) GetSubmitPerformerDraft() *SubmitPerformerDraft_SubmitPerformerDraft { if t == nil { t = &SubmitPerformerDraft{} } return &t.SubmitPerformerDraft } const FindScenesBySceneFingerprintsDocument = `query FindScenesBySceneFingerprints ($fingerprints: [[FingerprintQueryInput!]!]!) { findScenesBySceneFingerprints(fingerprints: $fingerprints) { ... SceneFragment } } fragment SceneFragment on Scene { id title code details director duration date urls { ... URLFragment } images { ... ImageFragment } studio { ... StudioFragment } tags { ... TagFragment } performers { ... PerformerAppearanceFragment } fingerprints { ... FingerprintFragment } } fragment URLFragment on URL { url type } fragment ImageFragment on Image { id url width height } fragment StudioFragment on Studio { name id aliases urls { ... URLFragment } parent { name id } images { ... ImageFragment } } fragment TagFragment on Tag { name id description aliases category { id name description } } fragment PerformerAppearanceFragment on PerformerAppearance { as performer { ... PerformerFragment } } fragment PerformerFragment on Performer { id name disambiguation aliases gender merged_ids deleted merged_into_id urls { ... URLFragment } images { ... ImageFragment } birth_date death_date ethnicity country eye_color hair_color height measurements { ... MeasurementsFragment } breast_type career_start_year career_end_year tattoos { ... BodyModificationFragment } piercings { ... BodyModificationFragment } } fragment MeasurementsFragment on Measurements { band_size cup_size waist hip } fragment BodyModificationFragment on BodyModification { location description } fragment FingerprintFragment on Fingerprint { algorithm hash duration } ` func (c *Client) FindScenesBySceneFingerprints(ctx context.Context, fingerprints [][]*FingerprintQueryInput, interceptors ...clientv2.RequestInterceptor) (*FindScenesBySceneFingerprints, error) { vars := map[string]any{ "fingerprints": fingerprints, } var res FindScenesBySceneFingerprints if err := c.Client.Post(ctx, "FindScenesBySceneFingerprints", FindScenesBySceneFingerprintsDocument, &res, vars, interceptors...); err != nil { if c.Client.ParseDataWhenErrors { return &res, err } return nil, err } return &res, nil } const SearchSceneDocument = `query SearchScene ($term: String!) { searchScene(term: $term) { ... SceneFragment } } fragment SceneFragment on Scene { id title code details director duration date urls { ... URLFragment } images { ... ImageFragment } studio { ... StudioFragment } tags { ... TagFragment } performers { ... PerformerAppearanceFragment } fingerprints { ... FingerprintFragment } } fragment URLFragment on URL { url type } fragment ImageFragment on Image { id url width height } fragment StudioFragment on Studio { name id aliases urls { ... URLFragment } parent { name id } images { ... ImageFragment } } fragment TagFragment on Tag { name id description aliases category { id name description } } fragment PerformerAppearanceFragment on PerformerAppearance { as performer { ... PerformerFragment } } fragment PerformerFragment on Performer { id name disambiguation aliases gender merged_ids deleted merged_into_id urls { ... URLFragment } images { ... ImageFragment } birth_date death_date ethnicity country eye_color hair_color height measurements { ... MeasurementsFragment } breast_type career_start_year career_end_year tattoos { ... BodyModificationFragment } piercings { ... BodyModificationFragment } } fragment MeasurementsFragment on Measurements { band_size cup_size waist hip } fragment BodyModificationFragment on BodyModification { location description } fragment FingerprintFragment on Fingerprint { algorithm hash duration } ` func (c *Client) SearchScene(ctx context.Context, term string, interceptors ...clientv2.RequestInterceptor) (*SearchScene, error) { vars := map[string]any{ "term": term, } var res SearchScene if err := c.Client.Post(ctx, "SearchScene", SearchSceneDocument, &res, vars, interceptors...); err != nil { if c.Client.ParseDataWhenErrors { return &res, err } return nil, err } return &res, nil } const SearchPerformerDocument = `query SearchPerformer ($term: String!) { searchPerformer(term: $term) { ... PerformerFragment } } fragment PerformerFragment on Performer { id name disambiguation aliases gender merged_ids deleted merged_into_id urls { ... URLFragment } images { ... ImageFragment } birth_date death_date ethnicity country eye_color hair_color height measurements { ... MeasurementsFragment } breast_type career_start_year career_end_year tattoos { ... BodyModificationFragment } piercings { ... BodyModificationFragment } } fragment URLFragment on URL { url type } fragment ImageFragment on Image { id url width height } fragment MeasurementsFragment on Measurements { band_size cup_size waist hip } fragment BodyModificationFragment on BodyModification { location description } ` func (c *Client) SearchPerformer(ctx context.Context, term string, interceptors ...clientv2.RequestInterceptor) (*SearchPerformer, error) { vars := map[string]any{ "term": term, } var res SearchPerformer if err := c.Client.Post(ctx, "SearchPerformer", SearchPerformerDocument, &res, vars, interceptors...); err != nil { if c.Client.ParseDataWhenErrors { return &res, err } return nil, err } return &res, nil } const FindPerformerByIDDocument = `query FindPerformerByID ($id: ID!) { findPerformer(id: $id) { ... PerformerFragment } } fragment PerformerFragment on Performer { id name disambiguation aliases gender merged_ids deleted merged_into_id urls { ... URLFragment } images { ... ImageFragment } birth_date death_date ethnicity country eye_color hair_color height measurements { ... MeasurementsFragment } breast_type career_start_year career_end_year tattoos { ... BodyModificationFragment } piercings { ... BodyModificationFragment } } fragment URLFragment on URL { url type } fragment ImageFragment on Image { id url width height } fragment MeasurementsFragment on Measurements { band_size cup_size waist hip } fragment BodyModificationFragment on BodyModification { location description } ` func (c *Client) FindPerformerByID(ctx context.Context, id string, interceptors ...clientv2.RequestInterceptor) (*FindPerformerByID, error) { vars := map[string]any{ "id": id, } var res FindPerformerByID if err := c.Client.Post(ctx, "FindPerformerByID", FindPerformerByIDDocument, &res, vars, interceptors...); err != nil { if c.Client.ParseDataWhenErrors { return &res, err } return nil, err } return &res, nil } const FindSceneByIDDocument = `query FindSceneByID ($id: ID!) { findScene(id: $id) { ... SceneFragment } } fragment SceneFragment on Scene { id title code details director duration date urls { ... URLFragment } images { ... ImageFragment } studio { ... StudioFragment } tags { ... TagFragment } performers { ... PerformerAppearanceFragment } fingerprints { ... FingerprintFragment } } fragment URLFragment on URL { url type } fragment ImageFragment on Image { id url width height } fragment StudioFragment on Studio { name id aliases urls { ... URLFragment } parent { name id } images { ... ImageFragment } } fragment TagFragment on Tag { name id description aliases category { id name description } } fragment PerformerAppearanceFragment on PerformerAppearance { as performer { ... PerformerFragment } } fragment PerformerFragment on Performer { id name disambiguation aliases gender merged_ids deleted merged_into_id urls { ... URLFragment } images { ... ImageFragment } birth_date death_date ethnicity country eye_color hair_color height measurements { ... MeasurementsFragment } breast_type career_start_year career_end_year tattoos { ... BodyModificationFragment } piercings { ... BodyModificationFragment } } fragment MeasurementsFragment on Measurements { band_size cup_size waist hip } fragment BodyModificationFragment on BodyModification { location description } fragment FingerprintFragment on Fingerprint { algorithm hash duration } ` func (c *Client) FindSceneByID(ctx context.Context, id string, interceptors ...clientv2.RequestInterceptor) (*FindSceneByID, error) { vars := map[string]any{ "id": id, } var res FindSceneByID if err := c.Client.Post(ctx, "FindSceneByID", FindSceneByIDDocument, &res, vars, interceptors...); err != nil { if c.Client.ParseDataWhenErrors { return &res, err } return nil, err } return &res, nil } const FindStudioDocument = `query FindStudio ($id: ID, $name: String) { findStudio(id: $id, name: $name) { ... StudioFragment } } fragment StudioFragment on Studio { name id aliases urls { ... URLFragment } parent { name id } images { ... ImageFragment } } fragment URLFragment on URL { url type } fragment ImageFragment on Image { id url width height } ` func (c *Client) FindStudio(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindStudio, error) { vars := map[string]any{ "id": id, "name": name, } var res FindStudio if err := c.Client.Post(ctx, "FindStudio", FindStudioDocument, &res, vars, interceptors...); err != nil { if c.Client.ParseDataWhenErrors { return &res, err } return nil, err } return &res, nil } const FindTagDocument = `query FindTag ($id: ID, $name: String) { findTag(id: $id, name: $name) { ... TagFragment } } fragment TagFragment on Tag { name id description aliases category { id name description } } ` func (c *Client) FindTag(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindTag, error) { vars := map[string]any{ "id": id, "name": name, } var res FindTag if err := c.Client.Post(ctx, "FindTag", FindTagDocument, &res, vars, interceptors...); err != nil { if c.Client.ParseDataWhenErrors { return &res, err } return nil, err } return &res, nil } const QueryTagsDocument = `query QueryTags ($input: TagQueryInput!) { queryTags(input: $input) { count tags { ... TagFragment } } } fragment TagFragment on Tag { name id description aliases category { id name description } } ` func (c *Client) QueryTags(ctx context.Context, input TagQueryInput, interceptors ...clientv2.RequestInterceptor) (*QueryTags, error) { vars := map[string]any{ "input": input, } var res QueryTags if err := c.Client.Post(ctx, "QueryTags", QueryTagsDocument, &res, vars, interceptors...); err != nil { if c.Client.ParseDataWhenErrors { return &res, err } return nil, err } return &res, nil } const SubmitFingerprintDocument = `mutation SubmitFingerprint ($input: FingerprintSubmission!) { submitFingerprint(input: $input) } ` func (c *Client) SubmitFingerprint(ctx context.Context, input FingerprintSubmission, interceptors ...clientv2.RequestInterceptor) (*SubmitFingerprint, error) { vars := map[string]any{ "input": input, } var res SubmitFingerprint if err := c.Client.Post(ctx, "SubmitFingerprint", SubmitFingerprintDocument, &res, vars, interceptors...); err != nil { if c.Client.ParseDataWhenErrors { return &res, err } return nil, err } return &res, nil } const MeDocument = `query Me { me { name } } ` func (c *Client) Me(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*Me, error) { vars := map[string]any{} var res Me if err := c.Client.Post(ctx, "Me", MeDocument, &res, vars, interceptors...); err != nil { if c.Client.ParseDataWhenErrors { return &res, err } return nil, err } return &res, nil } const SubmitSceneDraftDocument = `mutation SubmitSceneDraft ($input: SceneDraftInput!) { submitSceneDraft(input: $input) { id } } ` func (c *Client) SubmitSceneDraft(ctx context.Context, input SceneDraftInput, interceptors ...clientv2.RequestInterceptor) (*SubmitSceneDraft, error) { vars := map[string]any{ "input": input, } var res SubmitSceneDraft if err := c.Client.Post(ctx, "SubmitSceneDraft", SubmitSceneDraftDocument, &res, vars, interceptors...); err != nil { if c.Client.ParseDataWhenErrors { return &res, err } return nil, err } return &res, nil } const SubmitPerformerDraftDocument = `mutation SubmitPerformerDraft ($input: PerformerDraftInput!) { submitPerformerDraft(input: $input) { id } } ` func (c *Client) SubmitPerformerDraft(ctx context.Context, input PerformerDraftInput, interceptors ...clientv2.RequestInterceptor) (*SubmitPerformerDraft, error) { vars := map[string]any{ "input": input, } var res SubmitPerformerDraft if err := c.Client.Post(ctx, "SubmitPerformerDraft", SubmitPerformerDraftDocument, &res, vars, interceptors...); err != nil { if c.Client.ParseDataWhenErrors { return &res, err } return nil, err } return &res, nil } var DocumentOperationNames = map[string]string{ FindScenesBySceneFingerprintsDocument: "FindScenesBySceneFingerprints", SearchSceneDocument: "SearchScene", SearchPerformerDocument: "SearchPerformer", FindPerformerByIDDocument: "FindPerformerByID", FindSceneByIDDocument: "FindSceneByID", FindStudioDocument: "FindStudio", FindTagDocument: "FindTag", QueryTagsDocument: "QueryTags", SubmitFingerprintDocument: "SubmitFingerprint", MeDocument: "Me", SubmitSceneDraftDocument: "SubmitSceneDraft", SubmitPerformerDraftDocument: "SubmitPerformerDraft", } ================================================ FILE: pkg/stashbox/graphql/generated_models.go ================================================ // Code generated by github.com/99designs/gqlgen, DO NOT EDIT. package graphql import ( "bytes" "fmt" "io" "strconv" "time" "github.com/99designs/gqlgen/graphql" ) type DraftData interface { IsDraftData() } type EditDetails interface { IsEditDetails() } type EditTarget interface { IsEditTarget() } type NotificationData interface { IsNotificationData() } type SceneDraftPerformer interface { IsSceneDraftPerformer() } type SceneDraftStudio interface { IsSceneDraftStudio() } type SceneDraftTag interface { IsSceneDraftTag() } type ActivateNewUserInput struct { Name string `json:"name"` ActivationKey string `json:"activation_key"` Password string `json:"password"` } type ApplyEditInput struct { ID string `json:"id"` } type BodyModification struct { Location string `json:"location"` Description *string `json:"description,omitempty"` } type BodyModificationCriterionInput struct { Location *string `json:"location,omitempty"` Description *string `json:"description,omitempty"` Modifier CriterionModifier `json:"modifier"` } type BodyModificationInput struct { Location string `json:"location"` Description *string `json:"description,omitempty"` } type BreastTypeCriterionInput struct { Value *BreastTypeEnum `json:"value,omitempty"` Modifier CriterionModifier `json:"modifier"` } type CancelEditInput struct { ID string `json:"id"` } type CommentCommentedEdit struct { Comment *EditComment `json:"comment"` } func (CommentCommentedEdit) IsNotificationData() {} type CommentOwnEdit struct { Comment *EditComment `json:"comment"` } func (CommentOwnEdit) IsNotificationData() {} type CommentVotedEdit struct { Comment *EditComment `json:"comment"` } func (CommentVotedEdit) IsNotificationData() {} type DateCriterionInput struct { Value string `json:"value"` Modifier CriterionModifier `json:"modifier"` } type DownvoteOwnEdit struct { Edit *Edit `json:"edit"` } func (DownvoteOwnEdit) IsNotificationData() {} type Draft struct { ID string `json:"id"` Created time.Time `json:"created"` Expires time.Time `json:"expires"` Data DraftData `json:"data"` } type DraftEntity struct { Name string `json:"name"` ID *string `json:"id,omitempty"` } func (DraftEntity) IsSceneDraftPerformer() {} func (DraftEntity) IsSceneDraftStudio() {} func (DraftEntity) IsSceneDraftTag() {} type DraftEntityInput struct { Name string `json:"name"` ID *string `json:"id,omitempty"` } type DraftFingerprint struct { Hash string `json:"hash"` Algorithm FingerprintAlgorithm `json:"algorithm"` Duration int `json:"duration"` } type DraftSubmissionStatus struct { ID *string `json:"id,omitempty"` } type Edit struct { ID string `json:"id"` User *User `json:"user,omitempty"` // Object being edited - null if creating a new object Target EditTarget `json:"target,omitempty"` TargetType TargetTypeEnum `json:"target_type"` // Objects to merge with the target. Only applicable to merges MergeSources []EditTarget `json:"merge_sources"` Operation OperationEnum `json:"operation"` Bot bool `json:"bot"` Details EditDetails `json:"details,omitempty"` // Previous state of fields being modified - null if operation is create or delete. OldDetails EditDetails `json:"old_details,omitempty"` // Entity specific options Options *PerformerEditOptions `json:"options,omitempty"` Comments []*EditComment `json:"comments"` Votes []*EditVote `json:"votes"` // = Accepted - Rejected VoteCount int `json:"vote_count"` // Is the edit considered destructive. Destructive bool `json:"destructive"` Status VoteStatusEnum `json:"status"` Applied bool `json:"applied"` UpdateCount int `json:"update_count"` Updatable bool `json:"updatable"` Created time.Time `json:"created"` Updated *time.Time `json:"updated,omitempty"` Closed *time.Time `json:"closed,omitempty"` Expires *time.Time `json:"expires,omitempty"` } type EditComment struct { ID string `json:"id"` User *User `json:"user,omitempty"` Date time.Time `json:"date"` Comment string `json:"comment"` Edit *Edit `json:"edit"` } type EditCommentInput struct { ID string `json:"id"` Comment string `json:"comment"` } type EditInput struct { // Not required for create type ID *string `json:"id,omitempty"` Operation OperationEnum `json:"operation"` // Only required for merge type MergeSourceIds []string `json:"merge_source_ids,omitempty"` Comment *string `json:"comment,omitempty"` // Edit submitted by an automated script. Requires bot permission Bot *bool `json:"bot,omitempty"` } type EditQueryInput struct { // Filter by user id UserID *string `json:"user_id,omitempty"` // Filter by status Status *VoteStatusEnum `json:"status,omitempty"` // Filter by operation Operation *OperationEnum `json:"operation,omitempty"` // Filter by vote count VoteCount *IntCriterionInput `json:"vote_count,omitempty"` // Filter by applied status Applied *bool `json:"applied,omitempty"` // Filter by target type TargetType *TargetTypeEnum `json:"target_type,omitempty"` // Filter by target id TargetID *string `json:"target_id,omitempty"` // Filter by favorite status IsFavorite *bool `json:"is_favorite,omitempty"` // Filter by user voted status Voted *UserVotedFilterEnum `json:"voted,omitempty"` // Filter to bot edits only IsBot *bool `json:"is_bot,omitempty"` // Filter out user's own edits IncludeUserSubmitted *bool `json:"include_user_submitted,omitempty"` Page int `json:"page"` PerPage int `json:"per_page"` Direction SortDirectionEnum `json:"direction"` Sort EditSortEnum `json:"sort"` } type EditVote struct { User *User `json:"user,omitempty"` Date time.Time `json:"date"` Vote VoteTypeEnum `json:"vote"` } type EditVoteInput struct { ID string `json:"id"` Vote VoteTypeEnum `json:"vote"` } type EyeColorCriterionInput struct { Value *EyeColorEnum `json:"value,omitempty"` Modifier CriterionModifier `json:"modifier"` } type FailedOwnEdit struct { Edit *Edit `json:"edit"` } func (FailedOwnEdit) IsNotificationData() {} type FavoritePerformerEdit struct { Edit *Edit `json:"edit"` } func (FavoritePerformerEdit) IsNotificationData() {} type FavoritePerformerScene struct { Scene *Scene `json:"scene"` } func (FavoritePerformerScene) IsNotificationData() {} type FavoriteStudioEdit struct { Edit *Edit `json:"edit"` } func (FavoriteStudioEdit) IsNotificationData() {} type FavoriteStudioScene struct { Scene *Scene `json:"scene"` } func (FavoriteStudioScene) IsNotificationData() {} type Fingerprint struct { Hash string `json:"hash"` Algorithm FingerprintAlgorithm `json:"algorithm"` Duration int `json:"duration"` // number of times this fingerprint has been submitted (excluding reports) Submissions int `json:"submissions"` // number of times this fingerprint has been reported Reports int `json:"reports"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` // true if the current user submitted this fingerprint UserSubmitted bool `json:"user_submitted"` // true if the current user reported this fingerprint UserReported bool `json:"user_reported"` } type FingerprintEditInput struct { UserIds []string `json:"user_ids,omitempty"` Hash string `json:"hash"` Algorithm FingerprintAlgorithm `json:"algorithm"` Duration int `json:"duration"` Created time.Time `json:"created"` Submissions *int `json:"submissions,omitempty"` Updated *time.Time `json:"updated,omitempty"` } type FingerprintInput struct { // assumes current user if omitted. Ignored for non-modify Users UserIds []string `json:"user_ids,omitempty"` Hash string `json:"hash"` Algorithm FingerprintAlgorithm `json:"algorithm"` Duration int `json:"duration"` } type FingerprintQueryInput struct { Hash string `json:"hash"` Algorithm FingerprintAlgorithm `json:"algorithm"` } type FingerprintSubmission struct { SceneID string `json:"scene_id"` Fingerprint *FingerprintInput `json:"fingerprint"` Unmatch *bool `json:"unmatch,omitempty"` Vote *FingerprintSubmissionType `json:"vote,omitempty"` } type FingerprintedSceneEdit struct { Edit *Edit `json:"edit"` } func (FingerprintedSceneEdit) IsNotificationData() {} type FuzzyDate struct { Date string `json:"date"` Accuracy DateAccuracyEnum `json:"accuracy"` } type GenerateInviteCodeInput struct { Keys *int `json:"keys,omitempty"` Uses *int `json:"uses,omitempty"` TTL *int `json:"ttl,omitempty"` } type GrantInviteInput struct { UserID string `json:"user_id"` Amount int `json:"amount"` } type HairColorCriterionInput struct { Value *HairColorEnum `json:"value,omitempty"` Modifier CriterionModifier `json:"modifier"` } type IDCriterionInput struct { Value []string `json:"value"` Modifier CriterionModifier `json:"modifier"` } type Image struct { ID string `json:"id"` URL string `json:"url"` Width int `json:"width"` Height int `json:"height"` } type ImageCreateInput struct { URL *string `json:"url,omitempty"` File *graphql.Upload `json:"file,omitempty"` } type ImageDestroyInput struct { ID string `json:"id"` } type ImageUpdateInput struct { ID string `json:"id"` URL *string `json:"url,omitempty"` } type IntCriterionInput struct { Value int `json:"value"` Modifier CriterionModifier `json:"modifier"` } type InviteKey struct { ID string `json:"id"` Uses *int `json:"uses,omitempty"` Expires *time.Time `json:"expires,omitempty"` } type MarkNotificationReadInput struct { Type NotificationEnum `json:"type"` ID string `json:"id"` } type Measurements struct { CupSize *string `json:"cup_size,omitempty"` BandSize *int `json:"band_size,omitempty"` Waist *int `json:"waist,omitempty"` Hip *int `json:"hip,omitempty"` } type MultiIDCriterionInput struct { Value []string `json:"value,omitempty"` Modifier CriterionModifier `json:"modifier"` } type MultiStringCriterionInput struct { Value []string `json:"value"` Modifier CriterionModifier `json:"modifier"` } type Mutation struct { } type NewUserInput struct { Email string `json:"email"` InviteKey *string `json:"invite_key,omitempty"` } type Notification struct { Created time.Time `json:"created"` Read bool `json:"read"` Data NotificationData `json:"data"` } type Performer struct { ID string `json:"id"` Name string `json:"name"` Disambiguation *string `json:"disambiguation,omitempty"` Aliases []string `json:"aliases"` Gender *GenderEnum `json:"gender,omitempty"` Urls []*URL `json:"urls"` Birthdate *FuzzyDate `json:"birthdate,omitempty"` BirthDate *string `json:"birth_date,omitempty"` DeathDate *string `json:"death_date,omitempty"` Age *int `json:"age,omitempty"` Ethnicity *EthnicityEnum `json:"ethnicity,omitempty"` Country *string `json:"country,omitempty"` EyeColor *EyeColorEnum `json:"eye_color,omitempty"` HairColor *HairColorEnum `json:"hair_color,omitempty"` // Height in cm Height *int `json:"height,omitempty"` Measurements *Measurements `json:"measurements"` CupSize *string `json:"cup_size,omitempty"` BandSize *int `json:"band_size,omitempty"` WaistSize *int `json:"waist_size,omitempty"` HipSize *int `json:"hip_size,omitempty"` BreastType *BreastTypeEnum `json:"breast_type,omitempty"` CareerStartYear *int `json:"career_start_year,omitempty"` CareerEndYear *int `json:"career_end_year,omitempty"` Tattoos []*BodyModification `json:"tattoos,omitempty"` Piercings []*BodyModification `json:"piercings,omitempty"` Images []*Image `json:"images"` Deleted bool `json:"deleted"` Edits []*Edit `json:"edits"` SceneCount int `json:"scene_count"` Scenes []*Scene `json:"scenes"` // IDs of performers that were merged into this one MergedIds []string `json:"merged_ids"` // ID of performer that replaces this one MergedIntoID *string `json:"merged_into_id,omitempty"` Studios []*PerformerStudio `json:"studios"` IsFavorite bool `json:"is_favorite"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` } func (Performer) IsEditTarget() {} func (Performer) IsSceneDraftPerformer() {} type PerformerAppearance struct { Performer *Performer `json:"performer"` // Performing as alias As *string `json:"as,omitempty"` } type PerformerAppearanceInput struct { PerformerID string `json:"performer_id"` // Performing as alias As *string `json:"as,omitempty"` } type PerformerCreateInput struct { Name string `json:"name"` Disambiguation *string `json:"disambiguation,omitempty"` Aliases []string `json:"aliases,omitempty"` Gender *GenderEnum `json:"gender,omitempty"` Urls []*URLInput `json:"urls,omitempty"` Birthdate *string `json:"birthdate,omitempty"` Deathdate *string `json:"deathdate,omitempty"` Ethnicity *EthnicityEnum `json:"ethnicity,omitempty"` Country *string `json:"country,omitempty"` EyeColor *EyeColorEnum `json:"eye_color,omitempty"` HairColor *HairColorEnum `json:"hair_color,omitempty"` Height *int `json:"height,omitempty"` CupSize *string `json:"cup_size,omitempty"` BandSize *int `json:"band_size,omitempty"` WaistSize *int `json:"waist_size,omitempty"` HipSize *int `json:"hip_size,omitempty"` BreastType *BreastTypeEnum `json:"breast_type,omitempty"` CareerStartYear *int `json:"career_start_year,omitempty"` CareerEndYear *int `json:"career_end_year,omitempty"` Tattoos []*BodyModificationInput `json:"tattoos,omitempty"` Piercings []*BodyModificationInput `json:"piercings,omitempty"` ImageIds []string `json:"image_ids,omitempty"` DraftID *string `json:"draft_id,omitempty"` } type PerformerDestroyInput struct { ID string `json:"id"` } type PerformerDraft struct { ID *string `json:"id,omitempty"` Name string `json:"name"` Disambiguation *string `json:"disambiguation,omitempty"` Aliases *string `json:"aliases,omitempty"` Gender *string `json:"gender,omitempty"` Birthdate *string `json:"birthdate,omitempty"` Deathdate *string `json:"deathdate,omitempty"` Urls []string `json:"urls,omitempty"` Ethnicity *string `json:"ethnicity,omitempty"` Country *string `json:"country,omitempty"` EyeColor *string `json:"eye_color,omitempty"` HairColor *string `json:"hair_color,omitempty"` Height *string `json:"height,omitempty"` Measurements *string `json:"measurements,omitempty"` BreastType *string `json:"breast_type,omitempty"` Tattoos *string `json:"tattoos,omitempty"` Piercings *string `json:"piercings,omitempty"` CareerStartYear *int `json:"career_start_year,omitempty"` CareerEndYear *int `json:"career_end_year,omitempty"` Image *Image `json:"image,omitempty"` } func (PerformerDraft) IsDraftData() {} type PerformerDraftInput struct { ID *string `json:"id,omitempty"` Disambiguation *string `json:"disambiguation,omitempty"` Name string `json:"name"` Aliases *string `json:"aliases,omitempty"` Gender *string `json:"gender,omitempty"` Birthdate *string `json:"birthdate,omitempty"` Deathdate *string `json:"deathdate,omitempty"` Urls []string `json:"urls,omitempty"` Ethnicity *string `json:"ethnicity,omitempty"` Country *string `json:"country,omitempty"` EyeColor *string `json:"eye_color,omitempty"` HairColor *string `json:"hair_color,omitempty"` Height *string `json:"height,omitempty"` Measurements *string `json:"measurements,omitempty"` BreastType *string `json:"breast_type,omitempty"` Tattoos *string `json:"tattoos,omitempty"` Piercings *string `json:"piercings,omitempty"` CareerStartYear *int `json:"career_start_year,omitempty"` CareerEndYear *int `json:"career_end_year,omitempty"` Image *graphql.Upload `json:"image,omitempty"` } type PerformerEdit struct { Name *string `json:"name,omitempty"` Disambiguation *string `json:"disambiguation,omitempty"` AddedAliases []string `json:"added_aliases,omitempty"` RemovedAliases []string `json:"removed_aliases,omitempty"` Gender *GenderEnum `json:"gender,omitempty"` AddedUrls []*URL `json:"added_urls,omitempty"` RemovedUrls []*URL `json:"removed_urls,omitempty"` Birthdate *string `json:"birthdate,omitempty"` Deathdate *string `json:"deathdate,omitempty"` Ethnicity *EthnicityEnum `json:"ethnicity,omitempty"` Country *string `json:"country,omitempty"` EyeColor *EyeColorEnum `json:"eye_color,omitempty"` HairColor *HairColorEnum `json:"hair_color,omitempty"` // Height in cm Height *int `json:"height,omitempty"` CupSize *string `json:"cup_size,omitempty"` BandSize *int `json:"band_size,omitempty"` WaistSize *int `json:"waist_size,omitempty"` HipSize *int `json:"hip_size,omitempty"` BreastType *BreastTypeEnum `json:"breast_type,omitempty"` CareerStartYear *int `json:"career_start_year,omitempty"` CareerEndYear *int `json:"career_end_year,omitempty"` AddedTattoos []*BodyModification `json:"added_tattoos,omitempty"` RemovedTattoos []*BodyModification `json:"removed_tattoos,omitempty"` AddedPiercings []*BodyModification `json:"added_piercings,omitempty"` RemovedPiercings []*BodyModification `json:"removed_piercings,omitempty"` AddedImages []*Image `json:"added_images,omitempty"` RemovedImages []*Image `json:"removed_images,omitempty"` DraftID *string `json:"draft_id,omitempty"` Aliases []string `json:"aliases"` Urls []*URL `json:"urls"` Images []*Image `json:"images"` Tattoos []*BodyModification `json:"tattoos"` Piercings []*BodyModification `json:"piercings"` } func (PerformerEdit) IsEditDetails() {} type PerformerEditDetailsInput struct { Name *string `json:"name,omitempty"` Disambiguation *string `json:"disambiguation,omitempty"` Aliases []string `json:"aliases,omitempty"` Gender *GenderEnum `json:"gender,omitempty"` Urls []*URLInput `json:"urls,omitempty"` Birthdate *string `json:"birthdate,omitempty"` Deathdate *string `json:"deathdate,omitempty"` Ethnicity *EthnicityEnum `json:"ethnicity,omitempty"` Country *string `json:"country,omitempty"` EyeColor *EyeColorEnum `json:"eye_color,omitempty"` HairColor *HairColorEnum `json:"hair_color,omitempty"` Height *int `json:"height,omitempty"` CupSize *string `json:"cup_size,omitempty"` BandSize *int `json:"band_size,omitempty"` WaistSize *int `json:"waist_size,omitempty"` HipSize *int `json:"hip_size,omitempty"` BreastType *BreastTypeEnum `json:"breast_type,omitempty"` CareerStartYear *int `json:"career_start_year,omitempty"` CareerEndYear *int `json:"career_end_year,omitempty"` Tattoos []*BodyModificationInput `json:"tattoos,omitempty"` Piercings []*BodyModificationInput `json:"piercings,omitempty"` ImageIds []string `json:"image_ids,omitempty"` DraftID *string `json:"draft_id,omitempty"` } type PerformerEditInput struct { Edit *EditInput `json:"edit"` // Not required for destroy type Details *PerformerEditDetailsInput `json:"details,omitempty"` // Controls aliases modification for merges and name modifications Options *PerformerEditOptionsInput `json:"options,omitempty"` } type PerformerEditOptions struct { // Set performer alias on scenes without alias to old name if name is changed SetModifyAliases bool `json:"set_modify_aliases"` // Set performer alias on scenes attached to merge sources to old name SetMergeAliases bool `json:"set_merge_aliases"` } type PerformerEditOptionsInput struct { // Set performer alias on scenes without alias to old name if name is changed SetModifyAliases *bool `json:"set_modify_aliases,omitempty"` // Set performer alias on scenes attached to merge sources to old name SetMergeAliases *bool `json:"set_merge_aliases,omitempty"` } type PerformerQueryInput struct { // Searches name and disambiguation - assumes like query unless quoted Names *string `json:"names,omitempty"` // Searches name only - assumes like query unless quoted Name *string `json:"name,omitempty"` // Search aliases only - assumes like query unless quoted Alias *string `json:"alias,omitempty"` Disambiguation *StringCriterionInput `json:"disambiguation,omitempty"` Gender *GenderFilterEnum `json:"gender,omitempty"` // Filter to search urls - assumes like query unless quoted URL *string `json:"url,omitempty"` Birthdate *DateCriterionInput `json:"birthdate,omitempty"` Deathdate *DateCriterionInput `json:"deathdate,omitempty"` BirthYear *IntCriterionInput `json:"birth_year,omitempty"` Age *IntCriterionInput `json:"age,omitempty"` Ethnicity *EthnicityFilterEnum `json:"ethnicity,omitempty"` Country *StringCriterionInput `json:"country,omitempty"` EyeColor *EyeColorCriterionInput `json:"eye_color,omitempty"` HairColor *HairColorCriterionInput `json:"hair_color,omitempty"` Height *IntCriterionInput `json:"height,omitempty"` CupSize *StringCriterionInput `json:"cup_size,omitempty"` BandSize *IntCriterionInput `json:"band_size,omitempty"` WaistSize *IntCriterionInput `json:"waist_size,omitempty"` HipSize *IntCriterionInput `json:"hip_size,omitempty"` BreastType *BreastTypeCriterionInput `json:"breast_type,omitempty"` CareerStartYear *IntCriterionInput `json:"career_start_year,omitempty"` CareerEndYear *IntCriterionInput `json:"career_end_year,omitempty"` Tattoos *BodyModificationCriterionInput `json:"tattoos,omitempty"` Piercings *BodyModificationCriterionInput `json:"piercings,omitempty"` // Filter by performerfavorite status for the current user IsFavorite *bool `json:"is_favorite,omitempty"` // Filter by a performer they have performed in scenes with PerformedWith *string `json:"performed_with,omitempty"` // Filter by a studio StudioID *string `json:"studio_id,omitempty"` Page int `json:"page"` PerPage int `json:"per_page"` Direction SortDirectionEnum `json:"direction"` Sort PerformerSortEnum `json:"sort"` } type PerformerScenesInput struct { // Filter by another performer that also performs in the scenes PerformedWith *string `json:"performed_with,omitempty"` // Filter by a studio StudioID *string `json:"studio_id,omitempty"` // Filter by tags Tags *MultiIDCriterionInput `json:"tags,omitempty"` } type PerformerStudio struct { Studio *Studio `json:"studio"` SceneCount int `json:"scene_count"` } type PerformerUpdateInput struct { ID string `json:"id"` Name *string `json:"name,omitempty"` Disambiguation *string `json:"disambiguation,omitempty"` Aliases []string `json:"aliases,omitempty"` Gender *GenderEnum `json:"gender,omitempty"` Urls []*URLInput `json:"urls,omitempty"` Birthdate *string `json:"birthdate,omitempty"` Deathdate *string `json:"deathdate,omitempty"` Ethnicity *EthnicityEnum `json:"ethnicity,omitempty"` Country *string `json:"country,omitempty"` EyeColor *EyeColorEnum `json:"eye_color,omitempty"` HairColor *HairColorEnum `json:"hair_color,omitempty"` Height *int `json:"height,omitempty"` CupSize *string `json:"cup_size,omitempty"` BandSize *int `json:"band_size,omitempty"` WaistSize *int `json:"waist_size,omitempty"` HipSize *int `json:"hip_size,omitempty"` BreastType *BreastTypeEnum `json:"breast_type,omitempty"` CareerStartYear *int `json:"career_start_year,omitempty"` CareerEndYear *int `json:"career_end_year,omitempty"` Tattoos []*BodyModificationInput `json:"tattoos,omitempty"` Piercings []*BodyModificationInput `json:"piercings,omitempty"` ImageIds []string `json:"image_ids,omitempty"` } // The query root for this schema type Query struct { } type QueryEditsResultType struct { Count int `json:"count"` Edits []*Edit `json:"edits"` } type QueryExistingPerformerInput struct { Name *string `json:"name,omitempty"` Disambiguation *string `json:"disambiguation,omitempty"` Urls []string `json:"urls"` } type QueryExistingPerformerResult struct { Edits []*Edit `json:"edits"` Performers []*Performer `json:"performers"` } type QueryExistingSceneInput struct { Title *string `json:"title,omitempty"` StudioID *string `json:"studio_id,omitempty"` Fingerprints []*FingerprintInput `json:"fingerprints"` } type QueryExistingSceneResult struct { Edits []*Edit `json:"edits"` Scenes []*Scene `json:"scenes"` } type QueryNotificationsInput struct { Page int `json:"page"` PerPage int `json:"per_page"` Type *NotificationEnum `json:"type,omitempty"` UnreadOnly *bool `json:"unread_only,omitempty"` } type QueryNotificationsResult struct { Count int `json:"count"` Notifications []*Notification `json:"notifications"` } type QueryPerformersResultType struct { Count int `json:"count"` Performers []*Performer `json:"performers"` } type QueryScenesResultType struct { Count int `json:"count"` Scenes []*Scene `json:"scenes"` } type QuerySitesResultType struct { Count int `json:"count"` Sites []*Site `json:"sites"` } type QueryStudiosResultType struct { Count int `json:"count"` Studios []*Studio `json:"studios"` } type QueryTagCategoriesResultType struct { Count int `json:"count"` TagCategories []*TagCategory `json:"tag_categories"` } type QueryTagsResultType struct { Count int `json:"count"` Tags []*Tag `json:"tags"` } type QueryUsersResultType struct { Count int `json:"count"` Users []*User `json:"users"` } type ResetPasswordInput struct { Email string `json:"email"` } type RevokeInviteInput struct { UserID string `json:"user_id"` Amount int `json:"amount"` } type RoleCriterionInput struct { Value []RoleEnum `json:"value"` Modifier CriterionModifier `json:"modifier"` } type Scene struct { ID string `json:"id"` Title *string `json:"title,omitempty"` Details *string `json:"details,omitempty"` Date *string `json:"date,omitempty"` ReleaseDate *string `json:"release_date,omitempty"` ProductionDate *string `json:"production_date,omitempty"` Urls []*URL `json:"urls"` Studio *Studio `json:"studio,omitempty"` Tags []*Tag `json:"tags"` Images []*Image `json:"images"` Performers []*PerformerAppearance `json:"performers"` Fingerprints []*Fingerprint `json:"fingerprints"` Duration *int `json:"duration,omitempty"` Director *string `json:"director,omitempty"` Code *string `json:"code,omitempty"` Deleted bool `json:"deleted"` Edits []*Edit `json:"edits"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` } func (Scene) IsEditTarget() {} type SceneCreateInput struct { Title *string `json:"title,omitempty"` Details *string `json:"details,omitempty"` Urls []*URLInput `json:"urls,omitempty"` Date string `json:"date"` ProductionDate *string `json:"production_date,omitempty"` StudioID *string `json:"studio_id,omitempty"` Performers []*PerformerAppearanceInput `json:"performers,omitempty"` TagIds []string `json:"tag_ids,omitempty"` ImageIds []string `json:"image_ids,omitempty"` Fingerprints []*FingerprintEditInput `json:"fingerprints"` Duration *int `json:"duration,omitempty"` Director *string `json:"director,omitempty"` Code *string `json:"code,omitempty"` } type SceneDestroyInput struct { ID string `json:"id"` } type SceneDraft struct { ID *string `json:"id,omitempty"` Title *string `json:"title,omitempty"` Code *string `json:"code,omitempty"` Details *string `json:"details,omitempty"` Director *string `json:"director,omitempty"` Urls []string `json:"urls,omitempty"` Date *string `json:"date,omitempty"` ProductionDate *string `json:"production_date,omitempty"` Studio SceneDraftStudio `json:"studio,omitempty"` Performers []SceneDraftPerformer `json:"performers"` Tags []SceneDraftTag `json:"tags,omitempty"` Image *Image `json:"image,omitempty"` Fingerprints []*DraftFingerprint `json:"fingerprints"` } func (SceneDraft) IsDraftData() {} type SceneDraftInput struct { ID *string `json:"id,omitempty"` Title *string `json:"title,omitempty"` Code *string `json:"code,omitempty"` Details *string `json:"details,omitempty"` Director *string `json:"director,omitempty"` URL *string `json:"url,omitempty"` Urls []string `json:"urls,omitempty"` Date *string `json:"date,omitempty"` ProductionDate *string `json:"production_date,omitempty"` Studio *DraftEntityInput `json:"studio,omitempty"` Performers []*DraftEntityInput `json:"performers"` Tags []*DraftEntityInput `json:"tags,omitempty"` Image *graphql.Upload `json:"image,omitempty"` Fingerprints []*FingerprintInput `json:"fingerprints"` } type SceneEdit struct { Title *string `json:"title,omitempty"` Details *string `json:"details,omitempty"` AddedUrls []*URL `json:"added_urls,omitempty"` RemovedUrls []*URL `json:"removed_urls,omitempty"` Date *string `json:"date,omitempty"` ProductionDate *string `json:"production_date,omitempty"` Studio *Studio `json:"studio,omitempty"` // Added or modified performer appearance entries AddedPerformers []*PerformerAppearance `json:"added_performers,omitempty"` RemovedPerformers []*PerformerAppearance `json:"removed_performers,omitempty"` AddedTags []*Tag `json:"added_tags,omitempty"` RemovedTags []*Tag `json:"removed_tags,omitempty"` AddedImages []*Image `json:"added_images,omitempty"` RemovedImages []*Image `json:"removed_images,omitempty"` AddedFingerprints []*Fingerprint `json:"added_fingerprints,omitempty"` RemovedFingerprints []*Fingerprint `json:"removed_fingerprints,omitempty"` Duration *int `json:"duration,omitempty"` Director *string `json:"director,omitempty"` Code *string `json:"code,omitempty"` DraftID *string `json:"draft_id,omitempty"` Urls []*URL `json:"urls"` Performers []*PerformerAppearance `json:"performers"` Tags []*Tag `json:"tags"` Images []*Image `json:"images"` Fingerprints []*Fingerprint `json:"fingerprints"` } func (SceneEdit) IsEditDetails() {} type SceneEditDetailsInput struct { Title *string `json:"title,omitempty"` Details *string `json:"details,omitempty"` Urls []*URLInput `json:"urls,omitempty"` Date *string `json:"date,omitempty"` ProductionDate *string `json:"production_date,omitempty"` StudioID *string `json:"studio_id,omitempty"` Performers []*PerformerAppearanceInput `json:"performers,omitempty"` TagIds []string `json:"tag_ids,omitempty"` ImageIds []string `json:"image_ids,omitempty"` Duration *int `json:"duration,omitempty"` Director *string `json:"director,omitempty"` Code *string `json:"code,omitempty"` Fingerprints []*FingerprintInput `json:"fingerprints,omitempty"` DraftID *string `json:"draft_id,omitempty"` } type SceneEditInput struct { Edit *EditInput `json:"edit"` // Not required for destroy type Details *SceneEditDetailsInput `json:"details,omitempty"` } type SceneQueryInput struct { // Filter to search title and details - assumes like query unless quoted Text *string `json:"text,omitempty"` // Filter to search title - assumes like query unless quoted Title *string `json:"title,omitempty"` // Filter to search urls - assumes like query unless quoted URL *string `json:"url,omitempty"` // Filter by date Date *DateCriterionInput `json:"date,omitempty"` // Filter by production date ProductionDate *DateCriterionInput `json:"production_date,omitempty"` // Filter to only include scenes with this studio Studios *MultiIDCriterionInput `json:"studios,omitempty"` // Filter to only include scenes with this studio as primary or parent ParentStudio *string `json:"parentStudio,omitempty"` // Filter to only include scenes with these tags Tags *MultiIDCriterionInput `json:"tags,omitempty"` // Filter to only include scenes with these performers Performers *MultiIDCriterionInput `json:"performers,omitempty"` // Filter to include scenes with performer appearing as alias Alias *StringCriterionInput `json:"alias,omitempty"` // Filter to only include scenes with these fingerprints Fingerprints *MultiStringCriterionInput `json:"fingerprints,omitempty"` // Filter by favorited entity Favorites *FavoriteFilter `json:"favorites,omitempty"` // Filter to scenes with fingerprints submitted by the user HasFingerprintSubmissions *bool `json:"has_fingerprint_submissions,omitempty"` Page int `json:"page"` PerPage int `json:"per_page"` Direction SortDirectionEnum `json:"direction"` Sort SceneSortEnum `json:"sort"` } type SceneUpdateInput struct { ID string `json:"id"` Title *string `json:"title,omitempty"` Details *string `json:"details,omitempty"` Urls []*URLInput `json:"urls,omitempty"` Date *string `json:"date,omitempty"` ProductionDate *string `json:"production_date,omitempty"` StudioID *string `json:"studio_id,omitempty"` Performers []*PerformerAppearanceInput `json:"performers,omitempty"` TagIds []string `json:"tag_ids,omitempty"` ImageIds []string `json:"image_ids,omitempty"` Fingerprints []*FingerprintEditInput `json:"fingerprints,omitempty"` Duration *int `json:"duration,omitempty"` Director *string `json:"director,omitempty"` Code *string `json:"code,omitempty"` } type Site struct { ID string `json:"id"` Name string `json:"name"` Description *string `json:"description,omitempty"` URL *string `json:"url,omitempty"` Regex *string `json:"regex,omitempty"` ValidTypes []ValidSiteTypeEnum `json:"valid_types"` Icon string `json:"icon"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` } type SiteCreateInput struct { Name string `json:"name"` Description *string `json:"description,omitempty"` URL *string `json:"url,omitempty"` Regex *string `json:"regex,omitempty"` ValidTypes []ValidSiteTypeEnum `json:"valid_types"` } type SiteDestroyInput struct { ID string `json:"id"` } type SiteUpdateInput struct { ID string `json:"id"` Name string `json:"name"` Description *string `json:"description,omitempty"` URL *string `json:"url,omitempty"` Regex *string `json:"regex,omitempty"` ValidTypes []ValidSiteTypeEnum `json:"valid_types"` } type StashBoxConfig struct { HostURL string `json:"host_url"` RequireInvite bool `json:"require_invite"` RequireActivation bool `json:"require_activation"` VotePromotionThreshold *int `json:"vote_promotion_threshold,omitempty"` VoteApplicationThreshold int `json:"vote_application_threshold"` VotingPeriod int `json:"voting_period"` MinDestructiveVotingPeriod int `json:"min_destructive_voting_period"` VoteCronInterval string `json:"vote_cron_interval"` GuidelinesURL string `json:"guidelines_url"` RequireSceneDraft bool `json:"require_scene_draft"` EditUpdateLimit int `json:"edit_update_limit"` RequireTagRole bool `json:"require_tag_role"` } type StringCriterionInput struct { Value string `json:"value"` Modifier CriterionModifier `json:"modifier"` } type Studio struct { ID string `json:"id"` Name string `json:"name"` Aliases []string `json:"aliases"` Urls []*URL `json:"urls"` Parent *Studio `json:"parent,omitempty"` ChildStudios []*Studio `json:"child_studios"` Images []*Image `json:"images"` Deleted bool `json:"deleted"` IsFavorite bool `json:"is_favorite"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` Performers *QueryPerformersResultType `json:"performers"` } func (Studio) IsEditTarget() {} func (Studio) IsSceneDraftStudio() {} type StudioCreateInput struct { Name string `json:"name"` Aliases []string `json:"aliases,omitempty"` Urls []*URLInput `json:"urls,omitempty"` ParentID *string `json:"parent_id,omitempty"` ImageIds []string `json:"image_ids,omitempty"` } type StudioDestroyInput struct { ID string `json:"id"` } type StudioEdit struct { Name *string `json:"name,omitempty"` // Added and modified URLs AddedUrls []*URL `json:"added_urls,omitempty"` RemovedUrls []*URL `json:"removed_urls,omitempty"` Parent *Studio `json:"parent,omitempty"` AddedImages []*Image `json:"added_images,omitempty"` RemovedImages []*Image `json:"removed_images,omitempty"` AddedAliases []string `json:"added_aliases,omitempty"` RemovedAliases []string `json:"removed_aliases,omitempty"` Images []*Image `json:"images"` Urls []*URL `json:"urls"` } func (StudioEdit) IsEditDetails() {} type StudioEditDetailsInput struct { Name *string `json:"name,omitempty"` Aliases []string `json:"aliases,omitempty"` Urls []*URLInput `json:"urls,omitempty"` ParentID *string `json:"parent_id,omitempty"` ImageIds []string `json:"image_ids,omitempty"` } type StudioEditInput struct { Edit *EditInput `json:"edit"` // Not required for destroy type Details *StudioEditDetailsInput `json:"details,omitempty"` } type StudioQueryInput struct { // Filter to search name - assumes like query unless quoted Name *string `json:"name,omitempty"` // Filter to search studio name, aliases and parent studio name - assumes like query unless quoted Names *string `json:"names,omitempty"` // Filter to search url - assumes like query unless quoted URL *string `json:"url,omitempty"` Parent *IDCriterionInput `json:"parent,omitempty"` HasParent *bool `json:"has_parent,omitempty"` // Filter by studio favorite status for the current user IsFavorite *bool `json:"is_favorite,omitempty"` Page int `json:"page"` PerPage int `json:"per_page"` Direction SortDirectionEnum `json:"direction"` Sort StudioSortEnum `json:"sort"` } type StudioUpdateInput struct { ID string `json:"id"` Name *string `json:"name,omitempty"` Aliases []string `json:"aliases,omitempty"` Urls []*URLInput `json:"urls,omitempty"` ParentID *string `json:"parent_id,omitempty"` ImageIds []string `json:"image_ids,omitempty"` } type Tag struct { ID string `json:"id"` Name string `json:"name"` Description *string `json:"description,omitempty"` Aliases []string `json:"aliases"` Deleted bool `json:"deleted"` Edits []*Edit `json:"edits"` Category *TagCategory `json:"category,omitempty"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` } func (Tag) IsEditTarget() {} func (Tag) IsSceneDraftTag() {} type TagCategory struct { ID string `json:"id"` Name string `json:"name"` Group TagGroupEnum `json:"group"` Description *string `json:"description,omitempty"` } type TagCategoryCreateInput struct { Name string `json:"name"` Group TagGroupEnum `json:"group"` Description *string `json:"description,omitempty"` } type TagCategoryDestroyInput struct { ID string `json:"id"` } type TagCategoryUpdateInput struct { ID string `json:"id"` Name *string `json:"name,omitempty"` Group *TagGroupEnum `json:"group,omitempty"` Description *string `json:"description,omitempty"` } type TagCreateInput struct { Name string `json:"name"` Description *string `json:"description,omitempty"` Aliases []string `json:"aliases,omitempty"` CategoryID *string `json:"category_id,omitempty"` } type TagDestroyInput struct { ID string `json:"id"` } type TagEdit struct { Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` AddedAliases []string `json:"added_aliases,omitempty"` RemovedAliases []string `json:"removed_aliases,omitempty"` Category *TagCategory `json:"category,omitempty"` Aliases []string `json:"aliases"` } func (TagEdit) IsEditDetails() {} type TagEditDetailsInput struct { Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` Aliases []string `json:"aliases,omitempty"` CategoryID *string `json:"category_id,omitempty"` } type TagEditInput struct { Edit *EditInput `json:"edit"` // Not required for destroy type Details *TagEditDetailsInput `json:"details,omitempty"` } type TagQueryInput struct { // Filter to search name, aliases and description - assumes like query unless quoted Text *string `json:"text,omitempty"` // Searches name and aliases - assumes like query unless quoted Names *string `json:"names,omitempty"` // Filter to search name - assumes like query unless quoted Name *string `json:"name,omitempty"` // Filter to category ID CategoryID *string `json:"category_id,omitempty"` Page int `json:"page"` PerPage int `json:"per_page"` Direction SortDirectionEnum `json:"direction"` Sort TagSortEnum `json:"sort"` } type TagUpdateInput struct { ID string `json:"id"` Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` Aliases []string `json:"aliases,omitempty"` CategoryID *string `json:"category_id,omitempty"` } type URL struct { URL string `json:"url"` Type string `json:"type"` Site *Site `json:"site"` } type URLInput struct { URL string `json:"url"` SiteID string `json:"site_id"` } type UpdatedEdit struct { Edit *Edit `json:"edit"` } func (UpdatedEdit) IsNotificationData() {} type User struct { ID string `json:"id"` Name string `json:"name"` // Should not be visible to other users Roles []RoleEnum `json:"roles,omitempty"` // Should not be visible to other users Email *string `json:"email,omitempty"` // Should not be visible to other users APIKey *string `json:"api_key,omitempty"` NotificationSubscriptions []NotificationEnum `json:"notification_subscriptions"` // Vote counts by type VoteCount *UserVoteCount `json:"vote_count"` // Edit counts by status EditCount *UserEditCount `json:"edit_count"` // Calls to the API from this user over a configurable time period APICalls int `json:"api_calls"` InvitedBy *User `json:"invited_by,omitempty"` InviteTokens *int `json:"invite_tokens,omitempty"` ActiveInviteCodes []string `json:"active_invite_codes,omitempty"` InviteCodes []*InviteKey `json:"invite_codes,omitempty"` } type UserChangeEmailInput struct { ExistingEmailToken *string `json:"existing_email_token,omitempty"` NewEmailToken *string `json:"new_email_token,omitempty"` NewEmail *string `json:"new_email,omitempty"` } type UserChangePasswordInput struct { // Password in plain text ExistingPassword *string `json:"existing_password,omitempty"` NewPassword string `json:"new_password"` ResetKey *string `json:"reset_key,omitempty"` } type UserCreateInput struct { Name string `json:"name"` // Password in plain text Password string `json:"password"` Roles []RoleEnum `json:"roles"` Email string `json:"email"` InvitedByID *string `json:"invited_by_id,omitempty"` } type UserDestroyInput struct { ID string `json:"id"` } type UserEditCount struct { Accepted int `json:"accepted"` Rejected int `json:"rejected"` Pending int `json:"pending"` ImmediateAccepted int `json:"immediate_accepted"` ImmediateRejected int `json:"immediate_rejected"` Failed int `json:"failed"` Canceled int `json:"canceled"` } type UserQueryInput struct { // Filter to search user name - assumes like query unless quoted Name *string `json:"name,omitempty"` // Filter to search email - assumes like query unless quoted Email *string `json:"email,omitempty"` // Filter by roles Roles *RoleCriterionInput `json:"roles,omitempty"` // Filter by api key APIKey *string `json:"apiKey,omitempty"` // Filter by successful edits SuccessfulEdits *IntCriterionInput `json:"successful_edits,omitempty"` // Filter by unsuccessful edits UnsuccessfulEdits *IntCriterionInput `json:"unsuccessful_edits,omitempty"` // Filter by votes on successful edits SuccessfulVotes *IntCriterionInput `json:"successful_votes,omitempty"` // Filter by votes on unsuccessful edits UnsuccessfulVotes *IntCriterionInput `json:"unsuccessful_votes,omitempty"` // Filter by number of API calls APICalls *IntCriterionInput `json:"api_calls,omitempty"` // Filter by user that invited InvitedBy *string `json:"invited_by,omitempty"` Page int `json:"page"` PerPage int `json:"per_page"` } type UserUpdateInput struct { ID string `json:"id"` Name *string `json:"name,omitempty"` // Password in plain text Password *string `json:"password,omitempty"` Roles []RoleEnum `json:"roles,omitempty"` Email *string `json:"email,omitempty"` } type UserVoteCount struct { Abstain int `json:"abstain"` Accept int `json:"accept"` Reject int `json:"reject"` ImmediateAccept int `json:"immediate_accept"` ImmediateReject int `json:"immediate_reject"` } type Version struct { Hash string `json:"hash"` BuildTime string `json:"build_time"` BuildType string `json:"build_type"` Version string `json:"version"` } type BreastTypeEnum string const ( BreastTypeEnumNatural BreastTypeEnum = "NATURAL" BreastTypeEnumFake BreastTypeEnum = "FAKE" BreastTypeEnumNa BreastTypeEnum = "NA" ) var AllBreastTypeEnum = []BreastTypeEnum{ BreastTypeEnumNatural, BreastTypeEnumFake, BreastTypeEnumNa, } func (e BreastTypeEnum) IsValid() bool { switch e { case BreastTypeEnumNatural, BreastTypeEnumFake, BreastTypeEnumNa: return true } return false } func (e BreastTypeEnum) String() string { return string(e) } func (e *BreastTypeEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = BreastTypeEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid BreastTypeEnum", str) } return nil } func (e BreastTypeEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *BreastTypeEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e BreastTypeEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type CriterionModifier string const ( // = CriterionModifierEquals CriterionModifier = "EQUALS" // != CriterionModifierNotEquals CriterionModifier = "NOT_EQUALS" // > CriterionModifierGreaterThan CriterionModifier = "GREATER_THAN" // < CriterionModifierLessThan CriterionModifier = "LESS_THAN" // IS NULL CriterionModifierIsNull CriterionModifier = "IS_NULL" // IS NOT NULL CriterionModifierNotNull CriterionModifier = "NOT_NULL" // INCLUDES ALL CriterionModifierIncludesAll CriterionModifier = "INCLUDES_ALL" CriterionModifierIncludes CriterionModifier = "INCLUDES" CriterionModifierExcludes CriterionModifier = "EXCLUDES" ) var AllCriterionModifier = []CriterionModifier{ CriterionModifierEquals, CriterionModifierNotEquals, CriterionModifierGreaterThan, CriterionModifierLessThan, CriterionModifierIsNull, CriterionModifierNotNull, CriterionModifierIncludesAll, CriterionModifierIncludes, CriterionModifierExcludes, } func (e CriterionModifier) IsValid() bool { switch e { case CriterionModifierEquals, CriterionModifierNotEquals, CriterionModifierGreaterThan, CriterionModifierLessThan, CriterionModifierIsNull, CriterionModifierNotNull, CriterionModifierIncludesAll, CriterionModifierIncludes, CriterionModifierExcludes: return true } return false } func (e CriterionModifier) String() string { return string(e) } func (e *CriterionModifier) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = CriterionModifier(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid CriterionModifier", str) } return nil } func (e CriterionModifier) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *CriterionModifier) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e CriterionModifier) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type DateAccuracyEnum string const ( DateAccuracyEnumYear DateAccuracyEnum = "YEAR" DateAccuracyEnumMonth DateAccuracyEnum = "MONTH" DateAccuracyEnumDay DateAccuracyEnum = "DAY" ) var AllDateAccuracyEnum = []DateAccuracyEnum{ DateAccuracyEnumYear, DateAccuracyEnumMonth, DateAccuracyEnumDay, } func (e DateAccuracyEnum) IsValid() bool { switch e { case DateAccuracyEnumYear, DateAccuracyEnumMonth, DateAccuracyEnumDay: return true } return false } func (e DateAccuracyEnum) String() string { return string(e) } func (e *DateAccuracyEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = DateAccuracyEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid DateAccuracyEnum", str) } return nil } func (e DateAccuracyEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *DateAccuracyEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e DateAccuracyEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type EditSortEnum string const ( EditSortEnumCreatedAt EditSortEnum = "CREATED_AT" EditSortEnumUpdatedAt EditSortEnum = "UPDATED_AT" EditSortEnumClosedAt EditSortEnum = "CLOSED_AT" ) var AllEditSortEnum = []EditSortEnum{ EditSortEnumCreatedAt, EditSortEnumUpdatedAt, EditSortEnumClosedAt, } func (e EditSortEnum) IsValid() bool { switch e { case EditSortEnumCreatedAt, EditSortEnumUpdatedAt, EditSortEnumClosedAt: return true } return false } func (e EditSortEnum) String() string { return string(e) } func (e *EditSortEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = EditSortEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid EditSortEnum", str) } return nil } func (e EditSortEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *EditSortEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e EditSortEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type EthnicityEnum string const ( EthnicityEnumCaucasian EthnicityEnum = "CAUCASIAN" EthnicityEnumBlack EthnicityEnum = "BLACK" EthnicityEnumAsian EthnicityEnum = "ASIAN" EthnicityEnumIndian EthnicityEnum = "INDIAN" EthnicityEnumLatin EthnicityEnum = "LATIN" EthnicityEnumMiddleEastern EthnicityEnum = "MIDDLE_EASTERN" EthnicityEnumMixed EthnicityEnum = "MIXED" EthnicityEnumOther EthnicityEnum = "OTHER" ) var AllEthnicityEnum = []EthnicityEnum{ EthnicityEnumCaucasian, EthnicityEnumBlack, EthnicityEnumAsian, EthnicityEnumIndian, EthnicityEnumLatin, EthnicityEnumMiddleEastern, EthnicityEnumMixed, EthnicityEnumOther, } func (e EthnicityEnum) IsValid() bool { switch e { case EthnicityEnumCaucasian, EthnicityEnumBlack, EthnicityEnumAsian, EthnicityEnumIndian, EthnicityEnumLatin, EthnicityEnumMiddleEastern, EthnicityEnumMixed, EthnicityEnumOther: return true } return false } func (e EthnicityEnum) String() string { return string(e) } func (e *EthnicityEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = EthnicityEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid EthnicityEnum", str) } return nil } func (e EthnicityEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *EthnicityEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e EthnicityEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type EthnicityFilterEnum string const ( EthnicityFilterEnumUnknown EthnicityFilterEnum = "UNKNOWN" EthnicityFilterEnumCaucasian EthnicityFilterEnum = "CAUCASIAN" EthnicityFilterEnumBlack EthnicityFilterEnum = "BLACK" EthnicityFilterEnumAsian EthnicityFilterEnum = "ASIAN" EthnicityFilterEnumIndian EthnicityFilterEnum = "INDIAN" EthnicityFilterEnumLatin EthnicityFilterEnum = "LATIN" EthnicityFilterEnumMiddleEastern EthnicityFilterEnum = "MIDDLE_EASTERN" EthnicityFilterEnumMixed EthnicityFilterEnum = "MIXED" EthnicityFilterEnumOther EthnicityFilterEnum = "OTHER" ) var AllEthnicityFilterEnum = []EthnicityFilterEnum{ EthnicityFilterEnumUnknown, EthnicityFilterEnumCaucasian, EthnicityFilterEnumBlack, EthnicityFilterEnumAsian, EthnicityFilterEnumIndian, EthnicityFilterEnumLatin, EthnicityFilterEnumMiddleEastern, EthnicityFilterEnumMixed, EthnicityFilterEnumOther, } func (e EthnicityFilterEnum) IsValid() bool { switch e { case EthnicityFilterEnumUnknown, EthnicityFilterEnumCaucasian, EthnicityFilterEnumBlack, EthnicityFilterEnumAsian, EthnicityFilterEnumIndian, EthnicityFilterEnumLatin, EthnicityFilterEnumMiddleEastern, EthnicityFilterEnumMixed, EthnicityFilterEnumOther: return true } return false } func (e EthnicityFilterEnum) String() string { return string(e) } func (e *EthnicityFilterEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = EthnicityFilterEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid EthnicityFilterEnum", str) } return nil } func (e EthnicityFilterEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *EthnicityFilterEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e EthnicityFilterEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type EyeColorEnum string const ( EyeColorEnumBlue EyeColorEnum = "BLUE" EyeColorEnumBrown EyeColorEnum = "BROWN" EyeColorEnumGrey EyeColorEnum = "GREY" EyeColorEnumGreen EyeColorEnum = "GREEN" EyeColorEnumHazel EyeColorEnum = "HAZEL" EyeColorEnumRed EyeColorEnum = "RED" ) var AllEyeColorEnum = []EyeColorEnum{ EyeColorEnumBlue, EyeColorEnumBrown, EyeColorEnumGrey, EyeColorEnumGreen, EyeColorEnumHazel, EyeColorEnumRed, } func (e EyeColorEnum) IsValid() bool { switch e { case EyeColorEnumBlue, EyeColorEnumBrown, EyeColorEnumGrey, EyeColorEnumGreen, EyeColorEnumHazel, EyeColorEnumRed: return true } return false } func (e EyeColorEnum) String() string { return string(e) } func (e *EyeColorEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = EyeColorEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid EyeColorEnum", str) } return nil } func (e EyeColorEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *EyeColorEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e EyeColorEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type FavoriteFilter string const ( FavoriteFilterPerformer FavoriteFilter = "PERFORMER" FavoriteFilterStudio FavoriteFilter = "STUDIO" FavoriteFilterAll FavoriteFilter = "ALL" ) var AllFavoriteFilter = []FavoriteFilter{ FavoriteFilterPerformer, FavoriteFilterStudio, FavoriteFilterAll, } func (e FavoriteFilter) IsValid() bool { switch e { case FavoriteFilterPerformer, FavoriteFilterStudio, FavoriteFilterAll: return true } return false } func (e FavoriteFilter) String() string { return string(e) } func (e *FavoriteFilter) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = FavoriteFilter(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid FavoriteFilter", str) } return nil } func (e FavoriteFilter) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *FavoriteFilter) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e FavoriteFilter) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type FingerprintAlgorithm string const ( FingerprintAlgorithmMd5 FingerprintAlgorithm = "MD5" FingerprintAlgorithmOshash FingerprintAlgorithm = "OSHASH" FingerprintAlgorithmPhash FingerprintAlgorithm = "PHASH" ) var AllFingerprintAlgorithm = []FingerprintAlgorithm{ FingerprintAlgorithmMd5, FingerprintAlgorithmOshash, FingerprintAlgorithmPhash, } func (e FingerprintAlgorithm) IsValid() bool { switch e { case FingerprintAlgorithmMd5, FingerprintAlgorithmOshash, FingerprintAlgorithmPhash: return true } return false } func (e FingerprintAlgorithm) String() string { return string(e) } func (e *FingerprintAlgorithm) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = FingerprintAlgorithm(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid FingerprintAlgorithm", str) } return nil } func (e FingerprintAlgorithm) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *FingerprintAlgorithm) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e FingerprintAlgorithm) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type FingerprintSubmissionType string const ( // Positive vote FingerprintSubmissionTypeValid FingerprintSubmissionType = "VALID" // Report as invalid FingerprintSubmissionTypeInvalid FingerprintSubmissionType = "INVALID" // Remove vote FingerprintSubmissionTypeRemove FingerprintSubmissionType = "REMOVE" ) var AllFingerprintSubmissionType = []FingerprintSubmissionType{ FingerprintSubmissionTypeValid, FingerprintSubmissionTypeInvalid, FingerprintSubmissionTypeRemove, } func (e FingerprintSubmissionType) IsValid() bool { switch e { case FingerprintSubmissionTypeValid, FingerprintSubmissionTypeInvalid, FingerprintSubmissionTypeRemove: return true } return false } func (e FingerprintSubmissionType) String() string { return string(e) } func (e *FingerprintSubmissionType) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = FingerprintSubmissionType(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid FingerprintSubmissionType", str) } return nil } func (e FingerprintSubmissionType) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *FingerprintSubmissionType) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e FingerprintSubmissionType) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type GenderEnum string const ( GenderEnumMale GenderEnum = "MALE" GenderEnumFemale GenderEnum = "FEMALE" GenderEnumTransgenderMale GenderEnum = "TRANSGENDER_MALE" GenderEnumTransgenderFemale GenderEnum = "TRANSGENDER_FEMALE" GenderEnumIntersex GenderEnum = "INTERSEX" GenderEnumNonBinary GenderEnum = "NON_BINARY" ) var AllGenderEnum = []GenderEnum{ GenderEnumMale, GenderEnumFemale, GenderEnumTransgenderMale, GenderEnumTransgenderFemale, GenderEnumIntersex, GenderEnumNonBinary, } func (e GenderEnum) IsValid() bool { switch e { case GenderEnumMale, GenderEnumFemale, GenderEnumTransgenderMale, GenderEnumTransgenderFemale, GenderEnumIntersex, GenderEnumNonBinary: return true } return false } func (e GenderEnum) String() string { return string(e) } func (e *GenderEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = GenderEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid GenderEnum", str) } return nil } func (e GenderEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *GenderEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e GenderEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type GenderFilterEnum string const ( GenderFilterEnumUnknown GenderFilterEnum = "UNKNOWN" GenderFilterEnumMale GenderFilterEnum = "MALE" GenderFilterEnumFemale GenderFilterEnum = "FEMALE" GenderFilterEnumTransgenderMale GenderFilterEnum = "TRANSGENDER_MALE" GenderFilterEnumTransgenderFemale GenderFilterEnum = "TRANSGENDER_FEMALE" GenderFilterEnumIntersex GenderFilterEnum = "INTERSEX" GenderFilterEnumNonBinary GenderFilterEnum = "NON_BINARY" ) var AllGenderFilterEnum = []GenderFilterEnum{ GenderFilterEnumUnknown, GenderFilterEnumMale, GenderFilterEnumFemale, GenderFilterEnumTransgenderMale, GenderFilterEnumTransgenderFemale, GenderFilterEnumIntersex, GenderFilterEnumNonBinary, } func (e GenderFilterEnum) IsValid() bool { switch e { case GenderFilterEnumUnknown, GenderFilterEnumMale, GenderFilterEnumFemale, GenderFilterEnumTransgenderMale, GenderFilterEnumTransgenderFemale, GenderFilterEnumIntersex, GenderFilterEnumNonBinary: return true } return false } func (e GenderFilterEnum) String() string { return string(e) } func (e *GenderFilterEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = GenderFilterEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid GenderFilterEnum", str) } return nil } func (e GenderFilterEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *GenderFilterEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e GenderFilterEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type HairColorEnum string const ( HairColorEnumBlonde HairColorEnum = "BLONDE" HairColorEnumBrunette HairColorEnum = "BRUNETTE" HairColorEnumBlack HairColorEnum = "BLACK" HairColorEnumRed HairColorEnum = "RED" HairColorEnumAuburn HairColorEnum = "AUBURN" HairColorEnumGrey HairColorEnum = "GREY" HairColorEnumBald HairColorEnum = "BALD" HairColorEnumVarious HairColorEnum = "VARIOUS" HairColorEnumWhite HairColorEnum = "WHITE" HairColorEnumOther HairColorEnum = "OTHER" ) var AllHairColorEnum = []HairColorEnum{ HairColorEnumBlonde, HairColorEnumBrunette, HairColorEnumBlack, HairColorEnumRed, HairColorEnumAuburn, HairColorEnumGrey, HairColorEnumBald, HairColorEnumVarious, HairColorEnumWhite, HairColorEnumOther, } func (e HairColorEnum) IsValid() bool { switch e { case HairColorEnumBlonde, HairColorEnumBrunette, HairColorEnumBlack, HairColorEnumRed, HairColorEnumAuburn, HairColorEnumGrey, HairColorEnumBald, HairColorEnumVarious, HairColorEnumWhite, HairColorEnumOther: return true } return false } func (e HairColorEnum) String() string { return string(e) } func (e *HairColorEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = HairColorEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid HairColorEnum", str) } return nil } func (e HairColorEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *HairColorEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e HairColorEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type NotificationEnum string const ( NotificationEnumFavoritePerformerScene NotificationEnum = "FAVORITE_PERFORMER_SCENE" NotificationEnumFavoritePerformerEdit NotificationEnum = "FAVORITE_PERFORMER_EDIT" NotificationEnumFavoriteStudioScene NotificationEnum = "FAVORITE_STUDIO_SCENE" NotificationEnumFavoriteStudioEdit NotificationEnum = "FAVORITE_STUDIO_EDIT" NotificationEnumCommentOwnEdit NotificationEnum = "COMMENT_OWN_EDIT" NotificationEnumDownvoteOwnEdit NotificationEnum = "DOWNVOTE_OWN_EDIT" NotificationEnumFailedOwnEdit NotificationEnum = "FAILED_OWN_EDIT" NotificationEnumCommentCommentedEdit NotificationEnum = "COMMENT_COMMENTED_EDIT" NotificationEnumCommentVotedEdit NotificationEnum = "COMMENT_VOTED_EDIT" NotificationEnumUpdatedEdit NotificationEnum = "UPDATED_EDIT" NotificationEnumFingerprintedSceneEdit NotificationEnum = "FINGERPRINTED_SCENE_EDIT" ) var AllNotificationEnum = []NotificationEnum{ NotificationEnumFavoritePerformerScene, NotificationEnumFavoritePerformerEdit, NotificationEnumFavoriteStudioScene, NotificationEnumFavoriteStudioEdit, NotificationEnumCommentOwnEdit, NotificationEnumDownvoteOwnEdit, NotificationEnumFailedOwnEdit, NotificationEnumCommentCommentedEdit, NotificationEnumCommentVotedEdit, NotificationEnumUpdatedEdit, NotificationEnumFingerprintedSceneEdit, } func (e NotificationEnum) IsValid() bool { switch e { case NotificationEnumFavoritePerformerScene, NotificationEnumFavoritePerformerEdit, NotificationEnumFavoriteStudioScene, NotificationEnumFavoriteStudioEdit, NotificationEnumCommentOwnEdit, NotificationEnumDownvoteOwnEdit, NotificationEnumFailedOwnEdit, NotificationEnumCommentCommentedEdit, NotificationEnumCommentVotedEdit, NotificationEnumUpdatedEdit, NotificationEnumFingerprintedSceneEdit: return true } return false } func (e NotificationEnum) String() string { return string(e) } func (e *NotificationEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = NotificationEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid NotificationEnum", str) } return nil } func (e NotificationEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *NotificationEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e NotificationEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type OperationEnum string const ( OperationEnumCreate OperationEnum = "CREATE" OperationEnumModify OperationEnum = "MODIFY" OperationEnumDestroy OperationEnum = "DESTROY" OperationEnumMerge OperationEnum = "MERGE" ) var AllOperationEnum = []OperationEnum{ OperationEnumCreate, OperationEnumModify, OperationEnumDestroy, OperationEnumMerge, } func (e OperationEnum) IsValid() bool { switch e { case OperationEnumCreate, OperationEnumModify, OperationEnumDestroy, OperationEnumMerge: return true } return false } func (e OperationEnum) String() string { return string(e) } func (e *OperationEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = OperationEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid OperationEnum", str) } return nil } func (e OperationEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *OperationEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e OperationEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type PerformerSortEnum string const ( PerformerSortEnumName PerformerSortEnum = "NAME" PerformerSortEnumBirthdate PerformerSortEnum = "BIRTHDATE" PerformerSortEnumDeathdate PerformerSortEnum = "DEATHDATE" PerformerSortEnumSceneCount PerformerSortEnum = "SCENE_COUNT" PerformerSortEnumCareerStartYear PerformerSortEnum = "CAREER_START_YEAR" PerformerSortEnumDebut PerformerSortEnum = "DEBUT" PerformerSortEnumLastScene PerformerSortEnum = "LAST_SCENE" PerformerSortEnumCreatedAt PerformerSortEnum = "CREATED_AT" PerformerSortEnumUpdatedAt PerformerSortEnum = "UPDATED_AT" ) var AllPerformerSortEnum = []PerformerSortEnum{ PerformerSortEnumName, PerformerSortEnumBirthdate, PerformerSortEnumDeathdate, PerformerSortEnumSceneCount, PerformerSortEnumCareerStartYear, PerformerSortEnumDebut, PerformerSortEnumLastScene, PerformerSortEnumCreatedAt, PerformerSortEnumUpdatedAt, } func (e PerformerSortEnum) IsValid() bool { switch e { case PerformerSortEnumName, PerformerSortEnumBirthdate, PerformerSortEnumDeathdate, PerformerSortEnumSceneCount, PerformerSortEnumCareerStartYear, PerformerSortEnumDebut, PerformerSortEnumLastScene, PerformerSortEnumCreatedAt, PerformerSortEnumUpdatedAt: return true } return false } func (e PerformerSortEnum) String() string { return string(e) } func (e *PerformerSortEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = PerformerSortEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid PerformerSortEnum", str) } return nil } func (e PerformerSortEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *PerformerSortEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e PerformerSortEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type RoleEnum string const ( RoleEnumRead RoleEnum = "READ" RoleEnumVote RoleEnum = "VOTE" RoleEnumEdit RoleEnum = "EDIT" RoleEnumModify RoleEnum = "MODIFY" RoleEnumAdmin RoleEnum = "ADMIN" // May generate invites without tokens RoleEnumInvite RoleEnum = "INVITE" // May grant and rescind invite tokens and resind invite keys RoleEnumManageInvites RoleEnum = "MANAGE_INVITES" RoleEnumBot RoleEnum = "BOT" RoleEnumReadOnly RoleEnum = "READ_ONLY" RoleEnumEditTags RoleEnum = "EDIT_TAGS" ) var AllRoleEnum = []RoleEnum{ RoleEnumRead, RoleEnumVote, RoleEnumEdit, RoleEnumModify, RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites, RoleEnumBot, RoleEnumReadOnly, RoleEnumEditTags, } func (e RoleEnum) IsValid() bool { switch e { case RoleEnumRead, RoleEnumVote, RoleEnumEdit, RoleEnumModify, RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites, RoleEnumBot, RoleEnumReadOnly, RoleEnumEditTags: return true } return false } func (e RoleEnum) String() string { return string(e) } func (e *RoleEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = RoleEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid RoleEnum", str) } return nil } func (e RoleEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *RoleEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e RoleEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type SceneSortEnum string const ( SceneSortEnumTitle SceneSortEnum = "TITLE" SceneSortEnumDate SceneSortEnum = "DATE" SceneSortEnumTrending SceneSortEnum = "TRENDING" SceneSortEnumCreatedAt SceneSortEnum = "CREATED_AT" SceneSortEnumUpdatedAt SceneSortEnum = "UPDATED_AT" ) var AllSceneSortEnum = []SceneSortEnum{ SceneSortEnumTitle, SceneSortEnumDate, SceneSortEnumTrending, SceneSortEnumCreatedAt, SceneSortEnumUpdatedAt, } func (e SceneSortEnum) IsValid() bool { switch e { case SceneSortEnumTitle, SceneSortEnumDate, SceneSortEnumTrending, SceneSortEnumCreatedAt, SceneSortEnumUpdatedAt: return true } return false } func (e SceneSortEnum) String() string { return string(e) } func (e *SceneSortEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = SceneSortEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid SceneSortEnum", str) } return nil } func (e SceneSortEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *SceneSortEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e SceneSortEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type SortDirectionEnum string const ( SortDirectionEnumAsc SortDirectionEnum = "ASC" SortDirectionEnumDesc SortDirectionEnum = "DESC" ) var AllSortDirectionEnum = []SortDirectionEnum{ SortDirectionEnumAsc, SortDirectionEnumDesc, } func (e SortDirectionEnum) IsValid() bool { switch e { case SortDirectionEnumAsc, SortDirectionEnumDesc: return true } return false } func (e SortDirectionEnum) String() string { return string(e) } func (e *SortDirectionEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = SortDirectionEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid SortDirectionEnum", str) } return nil } func (e SortDirectionEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *SortDirectionEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e SortDirectionEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type StudioSortEnum string const ( StudioSortEnumName StudioSortEnum = "NAME" StudioSortEnumCreatedAt StudioSortEnum = "CREATED_AT" StudioSortEnumUpdatedAt StudioSortEnum = "UPDATED_AT" ) var AllStudioSortEnum = []StudioSortEnum{ StudioSortEnumName, StudioSortEnumCreatedAt, StudioSortEnumUpdatedAt, } func (e StudioSortEnum) IsValid() bool { switch e { case StudioSortEnumName, StudioSortEnumCreatedAt, StudioSortEnumUpdatedAt: return true } return false } func (e StudioSortEnum) String() string { return string(e) } func (e *StudioSortEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = StudioSortEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid StudioSortEnum", str) } return nil } func (e StudioSortEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *StudioSortEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e StudioSortEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type TagGroupEnum string const ( TagGroupEnumPeople TagGroupEnum = "PEOPLE" TagGroupEnumScene TagGroupEnum = "SCENE" TagGroupEnumAction TagGroupEnum = "ACTION" ) var AllTagGroupEnum = []TagGroupEnum{ TagGroupEnumPeople, TagGroupEnumScene, TagGroupEnumAction, } func (e TagGroupEnum) IsValid() bool { switch e { case TagGroupEnumPeople, TagGroupEnumScene, TagGroupEnumAction: return true } return false } func (e TagGroupEnum) String() string { return string(e) } func (e *TagGroupEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = TagGroupEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid TagGroupEnum", str) } return nil } func (e TagGroupEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *TagGroupEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e TagGroupEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type TagSortEnum string const ( TagSortEnumName TagSortEnum = "NAME" TagSortEnumCreatedAt TagSortEnum = "CREATED_AT" TagSortEnumUpdatedAt TagSortEnum = "UPDATED_AT" ) var AllTagSortEnum = []TagSortEnum{ TagSortEnumName, TagSortEnumCreatedAt, TagSortEnumUpdatedAt, } func (e TagSortEnum) IsValid() bool { switch e { case TagSortEnumName, TagSortEnumCreatedAt, TagSortEnumUpdatedAt: return true } return false } func (e TagSortEnum) String() string { return string(e) } func (e *TagSortEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = TagSortEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid TagSortEnum", str) } return nil } func (e TagSortEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *TagSortEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e TagSortEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type TargetTypeEnum string const ( TargetTypeEnumScene TargetTypeEnum = "SCENE" TargetTypeEnumStudio TargetTypeEnum = "STUDIO" TargetTypeEnumPerformer TargetTypeEnum = "PERFORMER" TargetTypeEnumTag TargetTypeEnum = "TAG" ) var AllTargetTypeEnum = []TargetTypeEnum{ TargetTypeEnumScene, TargetTypeEnumStudio, TargetTypeEnumPerformer, TargetTypeEnumTag, } func (e TargetTypeEnum) IsValid() bool { switch e { case TargetTypeEnumScene, TargetTypeEnumStudio, TargetTypeEnumPerformer, TargetTypeEnumTag: return true } return false } func (e TargetTypeEnum) String() string { return string(e) } func (e *TargetTypeEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = TargetTypeEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid TargetTypeEnum", str) } return nil } func (e TargetTypeEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *TargetTypeEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e TargetTypeEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type UserChangeEmailStatus string const ( UserChangeEmailStatusConfirmOld UserChangeEmailStatus = "CONFIRM_OLD" UserChangeEmailStatusConfirmNew UserChangeEmailStatus = "CONFIRM_NEW" UserChangeEmailStatusExpired UserChangeEmailStatus = "EXPIRED" UserChangeEmailStatusInvalidToken UserChangeEmailStatus = "INVALID_TOKEN" UserChangeEmailStatusSuccess UserChangeEmailStatus = "SUCCESS" UserChangeEmailStatusError UserChangeEmailStatus = "ERROR" ) var AllUserChangeEmailStatus = []UserChangeEmailStatus{ UserChangeEmailStatusConfirmOld, UserChangeEmailStatusConfirmNew, UserChangeEmailStatusExpired, UserChangeEmailStatusInvalidToken, UserChangeEmailStatusSuccess, UserChangeEmailStatusError, } func (e UserChangeEmailStatus) IsValid() bool { switch e { case UserChangeEmailStatusConfirmOld, UserChangeEmailStatusConfirmNew, UserChangeEmailStatusExpired, UserChangeEmailStatusInvalidToken, UserChangeEmailStatusSuccess, UserChangeEmailStatusError: return true } return false } func (e UserChangeEmailStatus) String() string { return string(e) } func (e *UserChangeEmailStatus) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = UserChangeEmailStatus(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid UserChangeEmailStatus", str) } return nil } func (e UserChangeEmailStatus) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *UserChangeEmailStatus) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e UserChangeEmailStatus) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type UserVotedFilterEnum string const ( UserVotedFilterEnumAbstain UserVotedFilterEnum = "ABSTAIN" UserVotedFilterEnumAccept UserVotedFilterEnum = "ACCEPT" UserVotedFilterEnumReject UserVotedFilterEnum = "REJECT" UserVotedFilterEnumNotVoted UserVotedFilterEnum = "NOT_VOTED" ) var AllUserVotedFilterEnum = []UserVotedFilterEnum{ UserVotedFilterEnumAbstain, UserVotedFilterEnumAccept, UserVotedFilterEnumReject, UserVotedFilterEnumNotVoted, } func (e UserVotedFilterEnum) IsValid() bool { switch e { case UserVotedFilterEnumAbstain, UserVotedFilterEnumAccept, UserVotedFilterEnumReject, UserVotedFilterEnumNotVoted: return true } return false } func (e UserVotedFilterEnum) String() string { return string(e) } func (e *UserVotedFilterEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = UserVotedFilterEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid UserVotedFilterEnum", str) } return nil } func (e UserVotedFilterEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *UserVotedFilterEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e UserVotedFilterEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type ValidSiteTypeEnum string const ( ValidSiteTypeEnumPerformer ValidSiteTypeEnum = "PERFORMER" ValidSiteTypeEnumScene ValidSiteTypeEnum = "SCENE" ValidSiteTypeEnumStudio ValidSiteTypeEnum = "STUDIO" ) var AllValidSiteTypeEnum = []ValidSiteTypeEnum{ ValidSiteTypeEnumPerformer, ValidSiteTypeEnumScene, ValidSiteTypeEnumStudio, } func (e ValidSiteTypeEnum) IsValid() bool { switch e { case ValidSiteTypeEnumPerformer, ValidSiteTypeEnumScene, ValidSiteTypeEnumStudio: return true } return false } func (e ValidSiteTypeEnum) String() string { return string(e) } func (e *ValidSiteTypeEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = ValidSiteTypeEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid ValidSiteTypeEnum", str) } return nil } func (e ValidSiteTypeEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *ValidSiteTypeEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e ValidSiteTypeEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type VoteStatusEnum string const ( VoteStatusEnumAccepted VoteStatusEnum = "ACCEPTED" VoteStatusEnumRejected VoteStatusEnum = "REJECTED" VoteStatusEnumPending VoteStatusEnum = "PENDING" VoteStatusEnumImmediateAccepted VoteStatusEnum = "IMMEDIATE_ACCEPTED" VoteStatusEnumImmediateRejected VoteStatusEnum = "IMMEDIATE_REJECTED" VoteStatusEnumFailed VoteStatusEnum = "FAILED" VoteStatusEnumCanceled VoteStatusEnum = "CANCELED" ) var AllVoteStatusEnum = []VoteStatusEnum{ VoteStatusEnumAccepted, VoteStatusEnumRejected, VoteStatusEnumPending, VoteStatusEnumImmediateAccepted, VoteStatusEnumImmediateRejected, VoteStatusEnumFailed, VoteStatusEnumCanceled, } func (e VoteStatusEnum) IsValid() bool { switch e { case VoteStatusEnumAccepted, VoteStatusEnumRejected, VoteStatusEnumPending, VoteStatusEnumImmediateAccepted, VoteStatusEnumImmediateRejected, VoteStatusEnumFailed, VoteStatusEnumCanceled: return true } return false } func (e VoteStatusEnum) String() string { return string(e) } func (e *VoteStatusEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = VoteStatusEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid VoteStatusEnum", str) } return nil } func (e VoteStatusEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *VoteStatusEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e VoteStatusEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } type VoteTypeEnum string const ( VoteTypeEnumAbstain VoteTypeEnum = "ABSTAIN" VoteTypeEnumAccept VoteTypeEnum = "ACCEPT" VoteTypeEnumReject VoteTypeEnum = "REJECT" // Immediately accepts the edit - bypassing the vote VoteTypeEnumImmediateAccept VoteTypeEnum = "IMMEDIATE_ACCEPT" // Immediately rejects the edit - bypassing the vote VoteTypeEnumImmediateReject VoteTypeEnum = "IMMEDIATE_REJECT" ) var AllVoteTypeEnum = []VoteTypeEnum{ VoteTypeEnumAbstain, VoteTypeEnumAccept, VoteTypeEnumReject, VoteTypeEnumImmediateAccept, VoteTypeEnumImmediateReject, } func (e VoteTypeEnum) IsValid() bool { switch e { case VoteTypeEnumAbstain, VoteTypeEnumAccept, VoteTypeEnumReject, VoteTypeEnumImmediateAccept, VoteTypeEnumImmediateReject: return true } return false } func (e VoteTypeEnum) String() string { return string(e) } func (e *VoteTypeEnum) UnmarshalGQL(v any) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } *e = VoteTypeEnum(str) if !e.IsValid() { return fmt.Errorf("%s is not a valid VoteTypeEnum", str) } return nil } func (e VoteTypeEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } func (e *VoteTypeEnum) UnmarshalJSON(b []byte) error { s, err := strconv.Unquote(string(b)) if err != nil { return err } return e.UnmarshalGQL(s) } func (e VoteTypeEnum) MarshalJSON() ([]byte, error) { var buf bytes.Buffer e.MarshalGQL(&buf) return buf.Bytes(), nil } ================================================ FILE: pkg/stashbox/performer.go ================================================ package stashbox import ( "bytes" "context" "fmt" "io" "net/http" "strconv" "strings" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scraper" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/stashbox/graphql" "github.com/stashapp/stash/pkg/utils" "golang.org/x/text/cases" "golang.org/x/text/language" ) // QueryPerformer queries stash-box for performers using a query string. func (c Client) QueryPerformer(ctx context.Context, queryStr string) ([]*models.ScrapedPerformer, error) { performers, err := c.queryPerformer(ctx, queryStr) // set the deprecated image field for _, p := range performers { if len(p.Images) > 0 { p.Image = &p.Images[0] } } return performers, err } func (c Client) queryPerformer(ctx context.Context, queryStr string) ([]*models.ScrapedPerformer, error) { performers, err := c.client.SearchPerformer(ctx, queryStr) if err != nil { return nil, err } performerFragments := performers.SearchPerformer var ret []*models.ScrapedPerformer var ignoredTags []string for _, fragment := range performerFragments { performer := performerFragmentToScrapedPerformer(*fragment) // exclude tags that match the excludeTagRE var thisIgnoredTags []string performer.Tags, thisIgnoredTags = scraper.FilterTags(c.excludeTagRE, performer.Tags) ignoredTags = sliceutil.AppendUniques(ignoredTags, thisIgnoredTags) ret = append(ret, performer) } scraper.LogIgnoredTags(ignoredTags) return ret, nil } // QueryPerformers queries stash-box for performers using a list of names. func (c Client) QueryPerformers(ctx context.Context, names []string) ([][]*models.ScrapedPerformer, error) { ret := make([][]*models.ScrapedPerformer, len(names)) for i, name := range names { if name != "" { continue } var err error ret[i], err = c.queryPerformer(ctx, name) if err != nil { return nil, err } } return ret, nil } func findURL(urls []*graphql.URLFragment, urlType string) *string { for _, u := range urls { if u.Type == urlType { ret := u.URL return &ret } } return nil } func enumToStringPtr(e fmt.Stringer, titleCase bool) *string { if e != nil { ret := strings.ReplaceAll(e.String(), "_", " ") if titleCase { c := cases.Title(language.Und) ret = c.String(strings.ToLower(ret)) } return &ret } return nil } func translateGender(gender *graphql.GenderEnum) *string { var res models.GenderEnum switch *gender { case graphql.GenderEnumMale: res = models.GenderEnumMale case graphql.GenderEnumFemale: res = models.GenderEnumFemale case graphql.GenderEnumIntersex: res = models.GenderEnumIntersex case graphql.GenderEnumTransgenderFemale: res = models.GenderEnumTransgenderFemale case graphql.GenderEnumTransgenderMale: res = models.GenderEnumTransgenderMale case graphql.GenderEnumNonBinary: res = models.GenderEnumNonBinary } if res != "" { strVal := res.String() return &strVal } return nil } func formatMeasurements(m *graphql.MeasurementsFragment) *string { if m != nil && m.BandSize != nil && m.CupSize != nil && m.Hip != nil && m.Waist != nil { ret := fmt.Sprintf("%d%s-%d-%d", *m.BandSize, *m.CupSize, *m.Waist, *m.Hip) return &ret } return nil } func formatCareerLength(start, end *int) *string { if start == nil && end == nil { return nil } var ret string switch { case end == nil: ret = fmt.Sprintf("%d -", *start) case start == nil: ret = fmt.Sprintf("- %d", *end) default: ret = fmt.Sprintf("%d - %d", *start, *end) } return &ret } func formatBodyModifications(m []*graphql.BodyModificationFragment) *string { if len(m) == 0 { return nil } var retSlice []string for _, f := range m { if f.Description == nil { retSlice = append(retSlice, f.Location) } else { retSlice = append(retSlice, fmt.Sprintf("%s, %s", f.Location, *f.Description)) } } ret := strings.Join(retSlice, "; ") return &ret } func fetchImage(ctx context.Context, client *http.Client, url string) (*string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } // determine the image type and set the base64 type contentType := resp.Header.Get("Content-Type") if contentType == "" { contentType = http.DetectContentType(body) } img := "data:" + contentType + ";base64," + utils.GetBase64StringFromData(body) return &img, nil } func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.ScrapedPerformer { images := []string{} for _, image := range p.Images { images = append(images, image.URL) } sp := &models.ScrapedPerformer{ Name: &p.Name, Disambiguation: p.Disambiguation, Country: p.Country, Measurements: formatMeasurements(p.Measurements), CareerLength: formatCareerLength(p.CareerStartYear, p.CareerEndYear), Tattoos: formatBodyModifications(p.Tattoos), Piercings: formatBodyModifications(p.Piercings), Twitter: findURL(p.Urls, "TWITTER"), RemoteSiteID: &p.ID, RemoteDeleted: p.Deleted, RemoteMergedIntoId: p.MergedIntoID, Images: images, // TODO - tags not currently supported // graphql schema change to accommodate this. Leave off for now. } if len(sp.Images) > 0 { sp.Image = &sp.Images[0] } if p.Height != nil && *p.Height > 0 { hs := strconv.Itoa(*p.Height) sp.Height = &hs } if p.CareerStartYear != nil { cs := strconv.Itoa(*p.CareerStartYear) sp.CareerStart = &cs } if p.CareerEndYear != nil { ce := strconv.Itoa(*p.CareerEndYear) sp.CareerEnd = &ce } if p.BirthDate != nil { sp.Birthdate = padFuzzyDate(p.BirthDate) } if p.DeathDate != nil { sp.DeathDate = padFuzzyDate(p.DeathDate) } if p.Gender != nil { sp.Gender = translateGender(p.Gender) } if p.Ethnicity != nil { sp.Ethnicity = enumToStringPtr(p.Ethnicity, true) } if p.EyeColor != nil { sp.EyeColor = enumToStringPtr(p.EyeColor, true) } if p.HairColor != nil { sp.HairColor = enumToStringPtr(p.HairColor, true) } if p.BreastType != nil { sp.FakeTits = enumToStringPtr(p.BreastType, true) } if len(p.Aliases) > 0 { // #4437 - stash-box may return aliases that are equal to the performer name // filter these out p.Aliases = sliceutil.Filter(p.Aliases, func(s string) bool { return !strings.EqualFold(s, p.Name) }) // #4596 - stash-box may return duplicate aliases. Filter these out p.Aliases = stringslice.UniqueFold(p.Aliases) alias := strings.Join(p.Aliases, ", ") sp.Aliases = &alias } for _, u := range p.Urls { sp.URLs = append(sp.URLs, u.URL) } return sp } func padFuzzyDate(date *string) *string { if date == nil { return nil } var paddedDate string switch len(*date) { case 10: paddedDate = *date case 7: paddedDate = fmt.Sprintf("%s-01", *date) case 4: paddedDate = fmt.Sprintf("%s-01-01", *date) } return &paddedDate } // FindPerformerByID queries stash-box for a performer by ID. func (c Client) FindPerformerByID(ctx context.Context, id string) (*models.ScrapedPerformer, error) { performer, err := c.client.FindPerformerByID(ctx, id) if err != nil { return nil, err } if performer.FindPerformer == nil { return nil, nil } ret := performerFragmentToScrapedPerformer(*performer.FindPerformer) return ret, nil } // FindPerformerByName queries stash-box for a performer by name. // Unlike QueryPerformer, this function will only return a performer if the name matches exactly. func (c Client) FindPerformerByName(ctx context.Context, name string) (*models.ScrapedPerformer, error) { performers, err := c.client.SearchPerformer(ctx, name) if err != nil { return nil, err } var ret *models.ScrapedPerformer for _, performer := range performers.SearchPerformer { if strings.EqualFold(performer.Name, name) { ret = performerFragmentToScrapedPerformer(*performer) } } return ret, nil } // SubmitPerformerDraft submits a performer draft to stash-box. // The performer parameter must have aliases, URLs and stash IDs loaded. func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Performer, img []byte) (*string, error) { draft := graphql.PerformerDraftInput{} var image io.Reader endpoint := c.box.Endpoint if len(img) > 0 { image = bytes.NewReader(img) } if performer.Name != "" { draft.Name = performer.Name } if performer.Disambiguation != "" { draft.Disambiguation = &performer.Disambiguation } if performer.Birthdate != nil { d := performer.Birthdate.String() draft.Birthdate = &d } if performer.Country != "" { draft.Country = &performer.Country } if performer.Ethnicity != "" { draft.Ethnicity = &performer.Ethnicity } if performer.EyeColor != "" { draft.EyeColor = &performer.EyeColor } if performer.FakeTits != "" { draft.BreastType = &performer.FakeTits } if performer.Gender != nil && performer.Gender.IsValid() { v := performer.Gender.String() draft.Gender = &v } if performer.HairColor != "" { draft.HairColor = &performer.HairColor } if performer.Height != nil { v := strconv.Itoa(*performer.Height) draft.Height = &v } if performer.Measurements != "" { draft.Measurements = &performer.Measurements } if performer.Piercings != "" { draft.Piercings = &performer.Piercings } if performer.Tattoos != "" { draft.Tattoos = &performer.Tattoos } if len(performer.Aliases.List()) > 0 { aliases := strings.Join(performer.Aliases.List(), ",") draft.Aliases = &aliases } if performer.CareerStart != nil { year := performer.CareerStart.Year() draft.CareerStartYear = &year } if performer.CareerEnd != nil { year := performer.CareerEnd.Year() draft.CareerEndYear = &year } if len(performer.URLs.List()) > 0 { draft.Urls = performer.URLs.List() } var stashID *string for _, v := range performer.StashIDs.List() { c := v if v.Endpoint == endpoint { stashID = &c.StashID break } } draft.ID = stashID var id *string var ret graphql.SubmitPerformerDraft err := c.submitDraft(ctx, graphql.SubmitPerformerDraftDocument, draft, image, &ret) id = ret.SubmitPerformerDraft.ID return id, err // ret, err := c.client.SubmitPerformerDraft(ctx, draft, uploadImage(image)) // if err != nil { // return nil, err // } // id := ret.SubmitPerformerDraft.ID // return id, nil } ================================================ FILE: pkg/stashbox/scene.go ================================================ package stashbox import ( "bytes" "context" "errors" "io" "net/http" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scraper" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/stashbox/graphql" "github.com/stashapp/stash/pkg/utils" ) // QueryScene queries stash-box for scenes using a query string. func (c Client) QueryScene(ctx context.Context, queryStr string) ([]*models.ScrapedScene, error) { scenes, err := c.client.SearchScene(ctx, queryStr) if err != nil { return nil, err } sceneFragments := scenes.SearchScene var ret []*models.ScrapedScene var ignoredTags []string for _, s := range sceneFragments { ss, err := c.sceneFragmentToScrapedScene(ctx, s) if err != nil { return nil, err } var thisIgnoredTags []string ss.Tags, thisIgnoredTags = scraper.FilterTags(c.excludeTagRE, ss.Tags) ignoredTags = sliceutil.AppendUniques(ignoredTags, thisIgnoredTags) ret = append(ret, ss) } scraper.LogIgnoredTags(ignoredTags) return ret, nil } // FindStashBoxScenesByFingerprints queries stash-box for a scene using the // scene's MD5/OSHASH checksum, or PHash. func (c Client) FindSceneByFingerprints(ctx context.Context, fps models.Fingerprints) ([]*models.ScrapedScene, error) { res, err := c.FindScenesByFingerprints(ctx, []models.Fingerprints{fps}) if len(res) > 0 { return res[0], err } return nil, err } // FindScenesByFingerprints queries stash-box for scenes using every // scene's MD5/OSHASH checksum, or PHash, and returns results in the same order // as the input slice. func (c Client) FindScenesByFingerprints(ctx context.Context, fps []models.Fingerprints) ([][]*models.ScrapedScene, error) { var fingerprints [][]*graphql.FingerprintQueryInput for _, fp := range fps { fingerprints = append(fingerprints, convertFingerprints(fp)) } return c.findScenesByFingerprints(ctx, fingerprints) } func convertFingerprints(fps models.Fingerprints) []*graphql.FingerprintQueryInput { var ret []*graphql.FingerprintQueryInput for _, f := range fps { var i = &graphql.FingerprintQueryInput{} switch f.Type { case models.FingerprintTypeMD5: i.Algorithm = graphql.FingerprintAlgorithmMd5 i.Hash = f.String() case models.FingerprintTypeOshash: i.Algorithm = graphql.FingerprintAlgorithmOshash i.Hash = f.String() case models.FingerprintTypePhash: i.Algorithm = graphql.FingerprintAlgorithmPhash i.Hash = utils.PhashToString(f.Int64()) default: continue } if !i.Algorithm.IsValid() { continue } ret = append(ret, i) } return ret } func (c Client) findScenesByFingerprints(ctx context.Context, scenes [][]*graphql.FingerprintQueryInput) ([][]*models.ScrapedScene, error) { var results [][]*models.ScrapedScene // filter out nils var validScenes [][]*graphql.FingerprintQueryInput for _, s := range scenes { if len(s) > 0 { validScenes = append(validScenes, s) } } var ignoredTags []string for i := 0; i < len(validScenes); i += 40 { end := i + 40 if end > len(validScenes) { end = len(validScenes) } scenes, err := c.client.FindScenesBySceneFingerprints(ctx, validScenes[i:end]) if err != nil { return nil, err } for _, sceneFragments := range scenes.FindScenesBySceneFingerprints { var sceneResults []*models.ScrapedScene for _, scene := range sceneFragments { ss, err := c.sceneFragmentToScrapedScene(ctx, scene) if err != nil { return nil, err } var thisIgnoredTags []string ss.Tags, thisIgnoredTags = scraper.FilterTags(c.excludeTagRE, ss.Tags) ignoredTags = sliceutil.AppendUniques(ignoredTags, thisIgnoredTags) sceneResults = append(sceneResults, ss) } results = append(results, sceneResults) } } scraper.LogIgnoredTags(ignoredTags) // repopulate the results to be the same order as the input ret := make([][]*models.ScrapedScene, len(scenes)) upTo := 0 for i, v := range scenes { if len(v) > 0 { ret[i] = results[upTo] upTo++ } } return ret, nil } func (c Client) sceneFragmentToScrapedScene(ctx context.Context, s *graphql.SceneFragment) (*models.ScrapedScene, error) { stashID := s.ID ss := &models.ScrapedScene{ Title: s.Title, Code: s.Code, Date: s.Date, Details: s.Details, Director: s.Director, URL: findURL(s.Urls, "STUDIO"), Duration: s.Duration, RemoteSiteID: &stashID, Fingerprints: getFingerprints(s), // Image // stash_id } for _, u := range s.Urls { ss.URLs = append(ss.URLs, u.URL) } if len(ss.URLs) > 0 { ss.URL = &ss.URLs[0] } if len(s.Images) > 0 { // TODO - #454 code sorts images by aspect ratio according to a wanted // orientation. I'm just grabbing the first for now ss.Image = getFirstImage(ctx, c.httpClient, s.Images) } ss.URLs = make([]string, len(s.Urls)) for i, u := range s.Urls { ss.URLs[i] = u.URL } if s.Studio != nil { var err error ss.Studio, err = c.resolveStudio(ctx, s.Studio) if err != nil { return nil, err } } for _, p := range s.Performers { sp := performerFragmentToScrapedPerformer(*p.Performer) ss.Performers = append(ss.Performers, sp) } for _, t := range s.Tags { st := &models.ScrapedTag{ Name: t.Name, RemoteSiteID: &t.ID, } ss.Tags = append(ss.Tags, st) } return ss, nil } func getFirstImage(ctx context.Context, client *http.Client, images []*graphql.ImageFragment) *string { ret, err := fetchImage(ctx, client, images[0].URL) if err != nil && !errors.Is(err, context.Canceled) { logger.Warnf("Error fetching image %s: %s", images[0].URL, err.Error()) } return ret } func getFingerprints(scene *graphql.SceneFragment) []*models.StashBoxFingerprint { fingerprints := []*models.StashBoxFingerprint{} for _, fp := range scene.Fingerprints { fingerprint := models.StashBoxFingerprint{ Algorithm: fp.Algorithm.String(), Hash: fp.Hash, Duration: fp.Duration, } fingerprints = append(fingerprints, &fingerprint) } return fingerprints } type SceneDraft struct { // Files, URLs, StashIDs must be loaded Scene *models.Scene // StashIDs must be loaded Performers []*models.Performer // StashIDs must be loaded Studio *models.Studio // StashIDs must be loaded Tags []*models.Tag Cover []byte } func (c Client) SubmitSceneDraft(ctx context.Context, d SceneDraft) (*string, error) { draft := newSceneDraftInput(d, c.box.Endpoint) var image io.Reader if len(d.Cover) > 0 { image = bytes.NewReader(d.Cover) } var id *string var ret graphql.SubmitSceneDraft err := c.submitDraft(ctx, graphql.SubmitSceneDraftDocument, draft, image, &ret) id = ret.SubmitSceneDraft.ID return id, err // ret, err := c.client.SubmitSceneDraft(ctx, draft, uploadImage(image)) // if err != nil { // return nil, err // } // id := ret.SubmitSceneDraft.ID // return id, nil } func newSceneDraftInput(d SceneDraft, endpoint string) graphql.SceneDraftInput { scene := d.Scene draft := graphql.SceneDraftInput{} if scene.Title != "" { draft.Title = &scene.Title } if scene.Code != "" { draft.Code = &scene.Code } if scene.Details != "" { draft.Details = &scene.Details } if scene.Director != "" { draft.Director = &scene.Director } draft.Urls = scene.URLs.List() if scene.Date != nil { v := scene.Date.String() draft.Date = &v } if d.Studio != nil { studio := d.Studio studioDraft := graphql.DraftEntityInput{ Name: studio.Name, } stashIDs := studio.StashIDs.List() for _, stashID := range stashIDs { c := stashID if stashID.Endpoint == endpoint { studioDraft.ID = &c.StashID break } } draft.Studio = &studioDraft } fingerprints := []*graphql.FingerprintInput{} for _, f := range scene.Files.List() { duration := f.Duration if duration != 0 { fingerprints = appendFingerprintsUnique(fingerprints, fileFingerprintsToInputGraphQL(f.Fingerprints, int(duration))...) } } draft.Fingerprints = fingerprints scenePerformers := d.Performers inputPerformers := []*graphql.DraftEntityInput{} for _, p := range scenePerformers { performerDraft := graphql.DraftEntityInput{ Name: p.Name, } stashIDs := p.StashIDs.List() for _, stashID := range stashIDs { c := stashID if stashID.Endpoint == endpoint { performerDraft.ID = &c.StashID break } } inputPerformers = append(inputPerformers, &performerDraft) } draft.Performers = inputPerformers var tags []*graphql.DraftEntityInput sceneTags := d.Tags for _, tag := range sceneTags { tagDraft := graphql.DraftEntityInput{Name: tag.Name} stashIDs := tag.StashIDs.List() for _, stashID := range stashIDs { if stashID.Endpoint == endpoint { tagDraft.ID = &stashID.StashID break } } tags = append(tags, &tagDraft) } draft.Tags = tags stashIDs := scene.StashIDs.List() var stashID *string for _, v := range stashIDs { if v.Endpoint == endpoint { vv := v.StashID stashID = &vv break } } draft.ID = stashID return draft } func fileFingerprintsToInputGraphQL(fps models.Fingerprints, duration int) []*graphql.FingerprintInput { var ret []*graphql.FingerprintInput for _, f := range fps { var i = &graphql.FingerprintInput{ Duration: duration, } switch f.Type { case models.FingerprintTypeMD5: i.Algorithm = graphql.FingerprintAlgorithmMd5 i.Hash = f.String() case models.FingerprintTypeOshash: i.Algorithm = graphql.FingerprintAlgorithmOshash i.Hash = f.String() case models.FingerprintTypePhash: i.Algorithm = graphql.FingerprintAlgorithmPhash i.Hash = utils.PhashToString(f.Int64()) default: continue } if !i.Algorithm.IsValid() { continue } ret = appendFingerprintUnique(ret, i) } return ret } func (c Client) SubmitFingerprints(ctx context.Context, scenes []*models.Scene) (bool, error) { endpoint := c.box.Endpoint var fingerprints []graphql.FingerprintSubmission for _, scene := range scenes { stashIDs := scene.StashIDs.List() sceneStashID := "" for _, stashID := range stashIDs { if stashID.Endpoint == endpoint { sceneStashID = stashID.StashID } } if sceneStashID == "" { continue } for _, f := range scene.Files.List() { duration := f.Duration if duration == 0 { continue } fps := fileFingerprintsToInputGraphQL(f.Fingerprints, int(duration)) for _, fp := range fps { fingerprints = append(fingerprints, graphql.FingerprintSubmission{ SceneID: sceneStashID, Fingerprint: fp, }) } } } return c.submitFingerprints(ctx, fingerprints) } func (c Client) submitFingerprints(ctx context.Context, fingerprints []graphql.FingerprintSubmission) (bool, error) { for _, fingerprint := range fingerprints { _, err := c.client.SubmitFingerprint(ctx, fingerprint) if err != nil { return false, err } } return true, nil } func appendFingerprintUnique(v []*graphql.FingerprintInput, toAdd *graphql.FingerprintInput) []*graphql.FingerprintInput { for _, vv := range v { if vv.Algorithm == toAdd.Algorithm && vv.Hash == toAdd.Hash { return v } } return append(v, toAdd) } func appendFingerprintsUnique(v []*graphql.FingerprintInput, toAdd ...*graphql.FingerprintInput) []*graphql.FingerprintInput { for _, a := range toAdd { v = appendFingerprintUnique(v, a) } return v } ================================================ FILE: pkg/stashbox/studio.go ================================================ package stashbox import ( "context" "strings" "github.com/google/uuid" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/stashbox/graphql" ) func (c Client) resolveStudio(ctx context.Context, s *graphql.StudioFragment) (*models.ScrapedStudio, error) { scraped := studioFragmentToScrapedStudio(*s) if s.Parent != nil { parentStudio, err := c.client.FindStudio(ctx, &s.Parent.ID, nil) if err != nil { return nil, err } if parentStudio.FindStudio == nil { return scraped, nil } scraped.Parent, err = c.resolveStudio(ctx, parentStudio.FindStudio) if err != nil { return nil, err } } return scraped, nil } func (c Client) FindStudio(ctx context.Context, query string) (*models.ScrapedStudio, error) { var studio *graphql.FindStudio _, err := uuid.Parse(query) if err == nil { // Confirmed the user passed in a Stash ID studio, err = c.client.FindStudio(ctx, &query, nil) } else { // Otherwise assume they're searching on a name studio, err = c.client.FindStudio(ctx, nil, &query) } if err != nil { return nil, err } var ret *models.ScrapedStudio if studio.FindStudio != nil { ret, err = c.resolveStudio(ctx, studio.FindStudio) if err != nil { return nil, err } } return ret, nil } func studioFragmentToScrapedStudio(s graphql.StudioFragment) *models.ScrapedStudio { images := []string{} for _, image := range s.Images { images = append(images, image.URL) } aliases := strings.Join(s.Aliases, ", ") st := &models.ScrapedStudio{ Name: s.Name, Aliases: &aliases, Images: images, RemoteSiteID: &s.ID, } for _, u := range s.Urls { st.URLs = append(st.URLs, u.URL) } if len(st.Images) > 0 { st.Image = &st.Images[0] } return st } ================================================ FILE: pkg/stashbox/tag.go ================================================ package stashbox import ( "context" "github.com/google/uuid" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/stashbox/graphql" ) // QueryTag searches for tags by name or ID. // If query is a valid UUID, it searches by ID (returns single result). // Otherwise, it searches by name (returns multiple results). func (c Client) QueryTag(ctx context.Context, query string) ([]*models.ScrapedTag, error) { _, err := uuid.Parse(query) if err == nil { // Query is a UUID, use findTag for exact match return c.findTagByID(ctx, query) } // Otherwise search by name return c.queryTagsByName(ctx, query) } func (c Client) findTagByID(ctx context.Context, id string) ([]*models.ScrapedTag, error) { tag, err := c.client.FindTag(ctx, &id, nil) if err != nil { return nil, err } if tag.FindTag == nil { return nil, nil } ret := tagFragmentToScrapedTag(*tag.FindTag) return []*models.ScrapedTag{ret}, nil } func (c Client) queryTagsByName(ctx context.Context, name string) ([]*models.ScrapedTag, error) { input := graphql.TagQueryInput{ Name: &name, Page: 1, PerPage: 25, Direction: graphql.SortDirectionEnumAsc, Sort: graphql.TagSortEnumName, } result, err := c.client.QueryTags(ctx, input) if err != nil { return nil, err } if result.QueryTags.Tags == nil { return nil, nil } var ret []*models.ScrapedTag for _, t := range result.QueryTags.Tags { ret = append(ret, tagFragmentToScrapedTag(*t)) } return ret, nil } func tagFragmentToScrapedTag(t graphql.TagFragment) *models.ScrapedTag { ret := &models.ScrapedTag{ Name: t.Name, Description: t.Description, RemoteSiteID: &t.ID, } if len(t.Aliases) > 0 { ret.AliasList = t.Aliases } if t.Category != nil { ret.Parent = &models.ScrapedTag{ Name: t.Category.Name, Description: t.Category.Description, } } return ret } ================================================ FILE: pkg/studio/doc.go ================================================ // Package studio provides the application logic for studio functionality. package studio ================================================ FILE: pkg/studio/export.go ================================================ package studio import ( "context" "fmt" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/utils" ) type FinderImageStashIDGetter interface { models.StudioGetter models.AliasLoader models.URLLoader models.StashIDLoader GetImage(ctx context.Context, studioID int) ([]byte, error) models.CustomFieldsReader } // ToJSON converts a Studio object into its JSON equivalent. func ToJSON(ctx context.Context, reader FinderImageStashIDGetter, studio *models.Studio) (*jsonschema.Studio, error) { newStudioJSON := jsonschema.Studio{ Name: studio.Name, Details: studio.Details, Favorite: studio.Favorite, IgnoreAutoTag: studio.IgnoreAutoTag, Organized: studio.Organized, CreatedAt: json.JSONTime{Time: studio.CreatedAt}, UpdatedAt: json.JSONTime{Time: studio.UpdatedAt}, } if studio.ParentID != nil { parent, err := reader.Find(ctx, *studio.ParentID) if err != nil { return nil, fmt.Errorf("error getting parent studio: %v", err) } if parent != nil { newStudioJSON.ParentStudio = parent.Name } } if studio.Rating != nil { newStudioJSON.Rating = *studio.Rating } if err := studio.LoadAliases(ctx, reader); err != nil { return nil, fmt.Errorf("loading studio aliases: %w", err) } newStudioJSON.Aliases = studio.Aliases.List() if err := studio.LoadURLs(ctx, reader); err != nil { return nil, fmt.Errorf("loading studio URLs: %w", err) } newStudioJSON.URLs = studio.URLs.List() if err := studio.LoadStashIDs(ctx, reader); err != nil { return nil, fmt.Errorf("loading studio stash ids: %w", err) } newStudioJSON.StashIDs = studio.StashIDs.List() var err error newStudioJSON.CustomFields, err = reader.GetCustomFields(ctx, studio.ID) if err != nil { return nil, fmt.Errorf("getting studio custom fields: %v", err) } image, err := reader.GetImage(ctx, studio.ID) if err != nil { logger.Errorf("Error getting studio image: %v", err) } if len(image) > 0 { newStudioJSON.Image = utils.GetBase64StringFromData(image) } return &newStudioJSON, nil } ================================================ FILE: pkg/studio/export_test.go ================================================ package studio import ( "errors" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "testing" "time" ) const ( noImageID = 2 errImageID = 3 missingParentStudioID = 4 errStudioID = 5 customFieldsID = 6 parentStudioID = 10 missingStudioID = 11 errParentStudioID = 12 errCustomFieldsID = 13 ) var ( studioName = "testStudio" url = "url" details = "details" parentStudioName = "parentStudio" autoTagIgnored = true studioOrganized = true emptyCustomFields = make(map[string]interface{}) customFields = map[string]interface{}{ "customField1": "customValue1", } ) var studioID = 1 var rating = 5 var parentStudio models.Studio = models.Studio{ Name: parentStudioName, } var imageBytes = []byte("imageBytes") var aliases = []string{"alias"} var stashID = models.StashID{ StashID: "StashID", Endpoint: "Endpoint", } var stashIDs = []models.StashID{ stashID, } const image = "aW1hZ2VCeXRlcw==" var ( createTime = time.Date(2001, 01, 01, 0, 0, 0, 0, time.Local) updateTime = time.Date(2002, 01, 01, 0, 0, 0, 0, time.Local) ) func createFullStudio(id int, parentID int) models.Studio { ret := models.Studio{ ID: id, Name: studioName, URLs: models.NewRelatedStrings([]string{url}), Details: details, Favorite: true, CreatedAt: createTime, UpdatedAt: updateTime, Rating: &rating, IgnoreAutoTag: autoTagIgnored, Organized: studioOrganized, Aliases: models.NewRelatedStrings(aliases), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs(stashIDs), } if parentID != 0 { ret.ParentID = &parentID } return ret } func createEmptyStudio(id int) models.Studio { return models.Studio{ ID: id, CreatedAt: createTime, UpdatedAt: updateTime, URLs: models.NewRelatedStrings([]string{}), Aliases: models.NewRelatedStrings([]string{}), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), } } func createFullJSONStudio(parentStudio, image string, aliases []string, customFields map[string]interface{}) *jsonschema.Studio { return &jsonschema.Studio{ Name: studioName, URLs: []string{url}, Details: details, Favorite: true, CreatedAt: json.JSONTime{ Time: createTime, }, UpdatedAt: json.JSONTime{ Time: updateTime, }, ParentStudio: parentStudio, Image: image, Rating: rating, Aliases: aliases, StashIDs: stashIDs, IgnoreAutoTag: autoTagIgnored, Organized: studioOrganized, CustomFields: customFields, } } func createEmptyJSONStudio() *jsonschema.Studio { return &jsonschema.Studio{ CreatedAt: json.JSONTime{ Time: createTime, }, UpdatedAt: json.JSONTime{ Time: updateTime, }, Aliases: []string{}, URLs: []string{}, StashIDs: []models.StashID{}, CustomFields: emptyCustomFields, } } type testScenario struct { input models.Studio customFields map[string]interface{} expected *jsonschema.Studio err bool } var scenarios []testScenario func initTestTable() { scenarios = []testScenario{ { createFullStudio(studioID, parentStudioID), emptyCustomFields, createFullJSONStudio(parentStudioName, image, []string{"alias"}, emptyCustomFields), false, }, { createFullStudio(customFieldsID, parentStudioID), customFields, createFullJSONStudio(parentStudioName, image, []string{"alias"}, customFields), false, }, { createEmptyStudio(noImageID), emptyCustomFields, createEmptyJSONStudio(), false, }, { createFullStudio(errImageID, parentStudioID), emptyCustomFields, createFullJSONStudio(parentStudioName, "", []string{"alias"}, emptyCustomFields), // failure to get image is not an error false, }, { createFullStudio(missingParentStudioID, missingStudioID), emptyCustomFields, createFullJSONStudio("", image, []string{"alias"}, emptyCustomFields), false, }, { createFullStudio(errStudioID, errParentStudioID), emptyCustomFields, nil, true, }, { createFullStudio(errCustomFieldsID, parentStudioID), customFields, nil, // failure to get custom fields should cause an error true, }, } } func TestToJSON(t *testing.T) { initTestTable() db := mocks.NewDatabase() imageErr := errors.New("error getting image") db.Studio.On("GetImage", testCtx, studioID).Return(imageBytes, nil).Once() db.Studio.On("GetImage", testCtx, noImageID).Return(nil, nil).Once() db.Studio.On("GetImage", testCtx, errImageID).Return(nil, imageErr).Once() db.Studio.On("GetImage", testCtx, missingParentStudioID).Return(imageBytes, nil).Maybe() db.Studio.On("GetImage", testCtx, errStudioID).Return(imageBytes, nil).Maybe() db.Studio.On("GetImage", testCtx, customFieldsID).Return(imageBytes, nil).Once() parentStudioErr := errors.New("error getting parent studio") db.Studio.On("Find", testCtx, parentStudioID).Return(&parentStudio, nil) db.Studio.On("Find", testCtx, missingStudioID).Return(nil, nil) db.Studio.On("Find", testCtx, errParentStudioID).Return(nil, parentStudioErr) customFieldsErr := errors.New("error getting custom fields") db.Studio.On("GetCustomFields", testCtx, studioID).Return(emptyCustomFields, nil).Once() db.Studio.On("GetCustomFields", testCtx, customFieldsID).Return(customFields, nil).Once() db.Studio.On("GetCustomFields", testCtx, missingParentStudioID).Return(emptyCustomFields, nil).Once() db.Studio.On("GetCustomFields", testCtx, noImageID).Return(emptyCustomFields, nil).Once() db.Studio.On("GetCustomFields", testCtx, errImageID).Return(emptyCustomFields, nil).Once() db.Studio.On("GetCustomFields", testCtx, errCustomFieldsID).Return(nil, customFieldsErr).Once() for i, s := range scenarios { studio := s.input json, err := ToJSON(testCtx, db.Studio, &studio) switch { case !s.err && err != nil: t.Errorf("[%d] unexpected error: %s", i, err.Error()) case s.err && err == nil: t.Errorf("[%d] expected error not returned", i) default: assert.Equal(t, s.expected, json, "[%d]", i) } } db.AssertExpectations(t) } ================================================ FILE: pkg/studio/import.go ================================================ package studio import ( "context" "errors" "fmt" "slices" "strings" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/utils" ) type ImporterReaderWriter interface { models.StudioCreatorUpdater FindByName(ctx context.Context, name string, nocase bool) (*models.Studio, error) } var ErrParentStudioNotExist = errors.New("parent studio does not exist") type Importer struct { ReaderWriter ImporterReaderWriter TagWriter models.TagFinderCreator Input jsonschema.Studio MissingRefBehaviour models.ImportMissingRefEnum ID int studio models.Studio customFields models.CustomFieldMap imageData []byte } func (i *Importer) PreImport(ctx context.Context) error { i.studio = studioJSONtoStudio(i.Input) i.customFields = i.Input.CustomFields if err := i.populateParentStudio(ctx); err != nil { return err } if err := i.populateTags(ctx); err != nil { return err } var err error if len(i.Input.Image) > 0 { i.imageData, err = utils.ProcessBase64Image(i.Input.Image) if err != nil { return fmt.Errorf("invalid image: %v", err) } } return nil } func (i *Importer) populateTags(ctx context.Context) error { if len(i.Input.Tags) > 0 { tags, err := importTags(ctx, i.TagWriter, i.Input.Tags, i.MissingRefBehaviour) if err != nil { return err } for _, p := range tags { i.studio.TagIDs.Add(p.ID) } } return nil } func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string, missingRefBehaviour models.ImportMissingRefEnum) ([]*models.Tag, error) { tags, err := tagWriter.FindByNames(ctx, names, false) if err != nil { return nil, err } var pluckedNames []string for _, tag := range tags { pluckedNames = append(pluckedNames, tag.Name) } missingTags := sliceutil.Filter(names, func(name string) bool { return !slices.Contains(pluckedNames, name) }) if len(missingTags) > 0 { if missingRefBehaviour == models.ImportMissingRefEnumFail { return nil, fmt.Errorf("tags [%s] not found", strings.Join(missingTags, ", ")) } if missingRefBehaviour == models.ImportMissingRefEnumCreate { createdTags, err := createTags(ctx, tagWriter, missingTags) if err != nil { return nil, fmt.Errorf("error creating tags: %v", err) } tags = append(tags, createdTags...) } // ignore if MissingRefBehaviour set to Ignore } return tags, nil } func createTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string) ([]*models.Tag, error) { var ret []*models.Tag for _, name := range names { newTag := models.NewTag() newTag.Name = name err := tagWriter.Create(ctx, &models.CreateTagInput{ Tag: &newTag, }) if err != nil { return nil, err } ret = append(ret, &newTag) } return ret, nil } func (i *Importer) populateParentStudio(ctx context.Context) error { if i.Input.ParentStudio != "" { studio, err := i.ReaderWriter.FindByName(ctx, i.Input.ParentStudio, false) if err != nil { return fmt.Errorf("error finding studio by name: %v", err) } if studio == nil { if i.MissingRefBehaviour == models.ImportMissingRefEnumFail { return ErrParentStudioNotExist } if i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore { return nil } if i.MissingRefBehaviour == models.ImportMissingRefEnumCreate { parentID, err := i.createParentStudio(ctx, i.Input.ParentStudio) if err != nil { return err } i.studio.ParentID = &parentID } } else { i.studio.ParentID = &studio.ID } } return nil } func (i *Importer) createParentStudio(ctx context.Context, name string) (int, error) { newStudio := models.NewCreateStudioInput() newStudio.Name = name err := i.ReaderWriter.Create(ctx, &newStudio) if err != nil { return 0, err } return newStudio.ID, nil } func (i *Importer) PostImport(ctx context.Context, id int) error { if len(i.imageData) > 0 { if err := i.ReaderWriter.UpdateImage(ctx, id, i.imageData); err != nil { return fmt.Errorf("error setting studio image: %v", err) } } return nil } func (i *Importer) Name() string { return i.Input.Name } func (i *Importer) FindExistingID(ctx context.Context) (*int, error) { const nocase = false existing, err := i.ReaderWriter.FindByName(ctx, i.Name(), nocase) if err != nil { return nil, err } if existing != nil { id := existing.ID return &id, nil } return nil, nil } func (i *Importer) Create(ctx context.Context) (*int, error) { err := i.ReaderWriter.Create(ctx, &models.CreateStudioInput{ Studio: &i.studio, CustomFields: i.customFields, }) if err != nil { return nil, fmt.Errorf("error creating studio: %v", err) } id := i.studio.ID return &id, nil } func (i *Importer) Update(ctx context.Context, id int) error { studio := i.studio studio.ID = id err := i.ReaderWriter.Update(ctx, &models.UpdateStudioInput{ Studio: &studio, CustomFields: models.CustomFieldsInput{ Full: i.customFields, }, }) if err != nil { return fmt.Errorf("error updating existing studio: %v", err) } return nil } func studioJSONtoStudio(studioJSON jsonschema.Studio) models.Studio { newStudio := models.Studio{ Name: studioJSON.Name, Aliases: models.NewRelatedStrings(studioJSON.Aliases), Details: studioJSON.Details, Favorite: studioJSON.Favorite, IgnoreAutoTag: studioJSON.IgnoreAutoTag, Organized: studioJSON.Organized, CreatedAt: studioJSON.CreatedAt.GetTime(), UpdatedAt: studioJSON.UpdatedAt.GetTime(), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs(studioJSON.StashIDs), } if len(studioJSON.URLs) > 0 { newStudio.URLs = models.NewRelatedStrings(studioJSON.URLs) } else { urls := []string{} if studioJSON.URL != "" { urls = append(urls, studioJSON.URL) } if len(urls) > 0 { newStudio.URLs = models.NewRelatedStrings(urls) } } if studioJSON.Rating != 0 { newStudio.Rating = &studioJSON.Rating } return newStudio } ================================================ FILE: pkg/studio/import_test.go ================================================ package studio import ( "context" "errors" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) const invalidImage = "aW1hZ2VCeXRlcw&&" const ( studioNameErr = "studioNameErr" existingStudioName = "existingStudioName" existingStudioID = 100 existingTagID = 105 errTagsID = 106 existingParentStudioName = "existingParentStudioName" existingParentStudioErr = "existingParentStudioErr" missingParentStudioName = "existingParentStudioName" existingTagName = "existingTagName" existingTagErr = "existingTagErr" missingTagName = "missingTagName" ) var testCtx = context.Background() func TestImporterName(t *testing.T) { i := Importer{ Input: jsonschema.Studio{ Name: studioName, }, } assert.Equal(t, studioName, i.Name()) } func TestImporterPreImport(t *testing.T) { i := Importer{ Input: jsonschema.Studio{ Name: studioName, Image: invalidImage, IgnoreAutoTag: autoTagIgnored, Organized: studioOrganized, }, } err := i.PreImport(testCtx) assert.NotNil(t, err) i.Input.Image = image err = i.PreImport(testCtx) assert.Nil(t, err) i.Input = *createFullJSONStudio(studioName, image, []string{"alias"}, customFields) i.Input.ParentStudio = "" err = i.PreImport(testCtx) assert.Nil(t, err) expectedStudio := createFullStudio(0, 0) expectedStudio.ParentID = nil assert.Equal(t, expectedStudio, i.studio) assert.Equal(t, models.CustomFieldMap(customFields), i.customFields) } func TestImporterPreImportWithTag(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Studio, TagWriter: db.Tag, MissingRefBehaviour: models.ImportMissingRefEnumFail, Input: jsonschema.Studio{ Tags: []string{ existingTagName, }, }, } db.Tag.On("FindByNames", testCtx, []string{existingTagName}, false).Return([]*models.Tag{ { ID: existingTagID, Name: existingTagName, }, }, nil).Once() db.Tag.On("FindByNames", testCtx, []string{existingTagErr}, false).Return(nil, errors.New("FindByNames error")).Once() err := i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, existingTagID, i.studio.TagIDs.List()[0]) i.Input.Tags = []string{existingTagErr} err = i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithMissingTag(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Studio, TagWriter: db.Tag, Input: jsonschema.Studio{ Tags: []string{ missingTagName, }, }, MissingRefBehaviour: models.ImportMissingRefEnumFail, } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3) db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Run(func(args mock.Arguments) { t := args.Get(1).(*models.CreateTagInput) t.Tag.ID = existingTagID }).Return(nil) err := i.PreImport(testCtx) assert.NotNil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore err = i.PreImport(testCtx) assert.Nil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumCreate err = i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, existingTagID, i.studio.TagIDs.List()[0]) db.AssertExpectations(t) } func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Studio, TagWriter: db.Tag, Input: jsonschema.Studio{ Tags: []string{ missingTagName, }, }, MissingRefBehaviour: models.ImportMissingRefEnumCreate, } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once() db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithParent(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Studio, Input: jsonschema.Studio{ Name: studioName, Image: image, ParentStudio: existingParentStudioName, }, } db.Studio.On("FindByName", testCtx, existingParentStudioName, false).Return(&models.Studio{ ID: existingStudioID, }, nil).Once() db.Studio.On("FindByName", testCtx, existingParentStudioErr, false).Return(nil, errors.New("FindByName error")).Once() err := i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, existingStudioID, *i.studio.ParentID) i.Input.ParentStudio = existingParentStudioErr err = i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPreImportWithMissingParent(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Studio, Input: jsonschema.Studio{ Name: studioName, Image: image, ParentStudio: missingParentStudioName, }, MissingRefBehaviour: models.ImportMissingRefEnumFail, } db.Studio.On("FindByName", testCtx, missingParentStudioName, false).Return(nil, nil).Times(3) db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Run(func(args mock.Arguments) { s := args.Get(1).(*models.CreateStudioInput) s.Studio.ID = existingStudioID }).Return(nil) err := i.PreImport(testCtx) assert.NotNil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore err = i.PreImport(testCtx) assert.Nil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumCreate err = i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, existingStudioID, *i.studio.ParentID) db.AssertExpectations(t) } func TestImporterPreImportWithMissingParentCreateErr(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Studio, Input: jsonschema.Studio{ Name: studioName, Image: image, ParentStudio: missingParentStudioName, }, MissingRefBehaviour: models.ImportMissingRefEnumCreate, } db.Studio.On("FindByName", testCtx, missingParentStudioName, false).Return(nil, nil).Once() db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPostImport(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Studio, TagWriter: db.Tag, Input: jsonschema.Studio{ Aliases: []string{"alias"}, }, imageData: imageBytes, } updateStudioImageErr := errors.New("UpdateImage error") db.Studio.On("UpdateImage", testCtx, studioID, imageBytes).Return(nil).Once() db.Studio.On("UpdateImage", testCtx, errImageID, imageBytes).Return(updateStudioImageErr).Once() err := i.PostImport(testCtx, studioID) assert.Nil(t, err) err = i.PostImport(testCtx, errImageID) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterFindExistingID(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Studio, TagWriter: db.Tag, Input: jsonschema.Studio{ Name: studioName, }, } errFindByName := errors.New("FindByName error") db.Studio.On("FindByName", testCtx, studioName, false).Return(nil, nil).Once() db.Studio.On("FindByName", testCtx, existingStudioName, false).Return(&models.Studio{ ID: existingStudioID, }, nil).Once() db.Studio.On("FindByName", testCtx, studioNameErr, false).Return(nil, errFindByName).Once() id, err := i.FindExistingID(testCtx) assert.Nil(t, id) assert.Nil(t, err) i.Input.Name = existingStudioName id, err = i.FindExistingID(testCtx) assert.Equal(t, existingStudioID, *id) assert.Nil(t, err) i.Input.Name = studioNameErr id, err = i.FindExistingID(testCtx) assert.Nil(t, id) assert.NotNil(t, err) db.AssertExpectations(t) } func TestCreate(t *testing.T) { db := mocks.NewDatabase() studio := models.Studio{ Name: studioName, } studioErr := models.Studio{ Name: studioNameErr, } i := Importer{ ReaderWriter: db.Studio, TagWriter: db.Tag, studio: studio, } errCreate := errors.New("Create error") db.Studio.On("Create", testCtx, &models.CreateStudioInput{Studio: &studio}).Run(func(args mock.Arguments) { s := args.Get(1).(*models.CreateStudioInput) s.ID = studioID }).Return(nil).Once() db.Studio.On("Create", testCtx, &models.CreateStudioInput{Studio: &studioErr}).Return(errCreate).Once() id, err := i.Create(testCtx) assert.Equal(t, studioID, *id) assert.Nil(t, err) i.studio = studioErr id, err = i.Create(testCtx) assert.Nil(t, id) assert.NotNil(t, err) db.AssertExpectations(t) } func TestUpdate(t *testing.T) { db := mocks.NewDatabase() studio := models.Studio{ Name: studioName, } studioErr := models.Studio{ Name: studioNameErr, } i := Importer{ ReaderWriter: db.Studio, TagWriter: db.Tag, studio: studio, } errUpdate := errors.New("Update error") // id needs to be set for the mock input studio.ID = studioID db.Studio.On("Update", testCtx, &models.UpdateStudioInput{Studio: &studio}).Return(nil).Once() err := i.Update(testCtx, studioID) assert.Nil(t, err) i.studio = studioErr // need to set id separately studioErr.ID = errImageID db.Studio.On("Update", testCtx, &models.UpdateStudioInput{Studio: &studioErr}).Return(errUpdate).Once() err = i.Update(testCtx, errImageID) assert.NotNil(t, err) db.AssertExpectations(t) } ================================================ FILE: pkg/studio/query.go ================================================ package studio import ( "context" "strconv" "github.com/stashapp/stash/pkg/models" ) func ByName(ctx context.Context, qb models.StudioQueryer, name string) (*models.Studio, error) { f := &models.StudioFilterType{ Name: &models.StringCriterionInput{ Value: name, Modifier: models.CriterionModifierEquals, }, } pp := 1 ret, count, err := qb.Query(ctx, f, &models.FindFilterType{ PerPage: &pp, }) if err != nil { return nil, err } if count > 0 { return ret[0], nil } return nil, nil } func ByAlias(ctx context.Context, qb models.StudioQueryer, alias string) (*models.Studio, error) { f := &models.StudioFilterType{ Aliases: &models.StringCriterionInput{ Value: alias, Modifier: models.CriterionModifierEquals, }, } pp := 1 ret, count, err := qb.Query(ctx, f, &models.FindFilterType{ PerPage: &pp, }) if err != nil { return nil, err } if count > 0 { return ret[0], nil } return nil, nil } func CountByTagID(ctx context.Context, qb models.StudioQueryer, id int, depth *int) (int, error) { filter := &models.StudioFilterType{ Tags: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, Depth: depth, }, } return qb.QueryCount(ctx, filter, nil) } ================================================ FILE: pkg/studio/validate.go ================================================ package studio import ( "context" "errors" "fmt" "github.com/stashapp/stash/pkg/models" ) var ( ErrNameMissing = errors.New("studio name must not be blank") ErrEmptyAlias = errors.New("studio alias must not be an empty string") ErrStudioOwnAncestor = errors.New("studio cannot be an ancestor of itself") ) type NameExistsError struct { Name string } func (e *NameExistsError) Error() string { return fmt.Sprintf("studio with name '%s' already exists", e.Name) } type NameUsedByAliasError struct { Name string OtherStudio string } func (e *NameUsedByAliasError) Error() string { return fmt.Sprintf("name '%s' is used as alias for '%s'", e.Name, e.OtherStudio) } // EnsureStudioNameUnique returns an error if the studio name provided // is used as a name or alias of another existing tag. func EnsureStudioNameUnique(ctx context.Context, id int, name string, qb models.StudioQueryer) error { // ensure name is unique sameNameStudio, err := ByName(ctx, qb, name) if err != nil { return err } if sameNameStudio != nil && id != sameNameStudio.ID { return &NameExistsError{ Name: name, } } // query by alias sameNameStudio, err = ByAlias(ctx, qb, name) if err != nil { return err } if sameNameStudio != nil && id != sameNameStudio.ID { return &NameUsedByAliasError{ Name: name, OtherStudio: sameNameStudio.Name, } } return nil } func ValidateAliases(ctx context.Context, id int, aliases []string, qb models.StudioQueryer) error { for _, a := range aliases { if err := validateName(ctx, id, a, qb); err != nil { if errors.Is(err, ErrNameMissing) { return ErrEmptyAlias } return err } } return nil } func ValidateCreate(ctx context.Context, studio models.CreateStudioInput, qb models.StudioQueryer) error { if err := validateName(ctx, 0, studio.Name, qb); err != nil { return err } if studio.Aliases.Loaded() && len(studio.Aliases.List()) > 0 { if err := ValidateAliases(ctx, 0, studio.Aliases.List(), qb); err != nil { return err } } return nil } func validateName(ctx context.Context, studioID int, name string, qb models.StudioQueryer) error { if name == "" { return ErrNameMissing } if err := EnsureStudioNameUnique(ctx, studioID, name, qb); err != nil { return err } return nil } type ValidateModifyReader interface { models.StudioGetter models.StudioQueryer models.AliasLoader } // Checks to make sure that: // 1. The studio exists locally // 2. The studio is not its own ancestor // 3. The studio's aliases are unique // 4. The name is unique func ValidateModify(ctx context.Context, s models.StudioPartial, qb ValidateModifyReader) error { existing, err := qb.Find(ctx, s.ID) if err != nil { return err } if existing == nil { return fmt.Errorf("studio with id %d not found", s.ID) } newParentID := s.ParentID.Ptr() if newParentID != nil { if err := validateParent(ctx, s.ID, *newParentID, qb); err != nil { return err } } if s.Aliases != nil { if err := existing.LoadAliases(ctx, qb); err != nil { return err } effectiveAliases := s.Aliases.Apply(existing.Aliases.List()) if err := ValidateAliases(ctx, s.ID, effectiveAliases, qb); err != nil { return err } } if s.Name.Set && s.Name.Value != existing.Name { if err := validateName(ctx, s.ID, s.Name.Value, qb); err != nil { return err } } return nil } func validateParent(ctx context.Context, studioID int, newParentID int, qb models.StudioGetter) error { if newParentID == studioID { return ErrStudioOwnAncestor } // ensure there is no cyclic dependency parentStudio, err := qb.Find(ctx, newParentID) if err != nil { return fmt.Errorf("error finding parent studio: %v", err) } if parentStudio == nil { return fmt.Errorf("studio with id %d not found", newParentID) } if parentStudio.ParentID != nil { return validateParent(ctx, studioID, *parentStudio.ParentID, qb) } return nil } ================================================ FILE: pkg/studio/validate_test.go ================================================ package studio import ( "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) func nameFilter(n string) *models.StudioFilterType { return &models.StudioFilterType{ Name: &models.StringCriterionInput{ Value: n, Modifier: models.CriterionModifierEquals, }, } } func TestValidateName(t *testing.T) { db := mocks.NewDatabase() const ( name1 = "name 1" newName = "new name" ) existing1 := models.Studio{ ID: 1, Name: name1, } pp := 1 findFilter := &models.FindFilterType{ PerPage: &pp, } db.Studio.On("Query", testCtx, nameFilter(name1), findFilter).Return([]*models.Studio{&existing1}, 1, nil) db.Studio.On("Query", testCtx, mock.Anything, findFilter).Return(nil, 0, nil) tests := []struct { tName string name string want error }{ {"missing name", "", ErrNameMissing}, {"new name", newName, nil}, {"existing name", name1, &NameExistsError{name1}}, } for _, tt := range tests { t.Run(tt.tName, func(t *testing.T) { got := validateName(testCtx, 0, tt.name, db.Studio) assert.Equal(t, tt.want, got) }) } } func TestValidateUpdateName(t *testing.T) { db := mocks.NewDatabase() const ( name1 = "name 1" name2 = "name 2" newName = "new name" ) existing1 := models.Studio{ ID: 1, Name: name1, } existing2 := models.Studio{ ID: 2, Name: name2, } pp := 1 findFilter := &models.FindFilterType{ PerPage: &pp, } db.Studio.On("Query", testCtx, nameFilter(name1), findFilter).Return([]*models.Studio{&existing1}, 1, nil) db.Studio.On("Query", testCtx, nameFilter(name2), findFilter).Return([]*models.Studio{&existing2}, 2, nil) db.Studio.On("Query", testCtx, mock.Anything, findFilter).Return(nil, 0, nil) tests := []struct { tName string studio models.Studio name string want error }{ {"missing name", existing1, "", ErrNameMissing}, {"same name", existing2, name2, nil}, {"new name", existing1, newName, nil}, } for _, tt := range tests { t.Run(tt.tName, func(t *testing.T) { got := validateName(testCtx, tt.studio.ID, tt.name, db.Studio) assert.Equal(t, tt.want, got) }) } } func TestValidateUpdateAliases(t *testing.T) { db := mocks.NewDatabase() const ( name1 = "name 1" name2 = "name 2" alias1 = "alias 1" newAlias = "new alias" ) existing1 := models.Studio{ ID: 1, Name: name1, } existing2 := models.Studio{ ID: 2, Name: name2, } pp := 1 findFilter := &models.FindFilterType{ PerPage: &pp, } aliasFilter := func(n string) *models.StudioFilterType { return &models.StudioFilterType{ Aliases: &models.StringCriterionInput{ Value: n, Modifier: models.CriterionModifierEquals, }, } } // name1 matches existing1 name - ok db.Studio.On("Query", testCtx, nameFilter(alias1), findFilter).Return(nil, 0, nil) db.Studio.On("Query", testCtx, aliasFilter(alias1), findFilter).Return(nil, 0, nil) // name2 matches existing2 name - error db.Studio.On("Query", testCtx, nameFilter(name2), findFilter).Return([]*models.Studio{&existing2}, 1, nil) // alias matches existing alias - error db.Studio.On("Query", testCtx, nameFilter(newAlias), findFilter).Return(nil, 0, nil) db.Studio.On("Query", testCtx, aliasFilter(newAlias), findFilter).Return([]*models.Studio{&existing2}, 1, nil) // valid alias db.Studio.On("Query", testCtx, nameFilter("valid"), findFilter).Return(nil, 0, nil) db.Studio.On("Query", testCtx, aliasFilter("valid"), findFilter).Return(nil, 0, nil) tests := []struct { tName string studio models.Studio aliases []string want error }{ {"valid alias", existing1, []string{alias1}, nil}, {"alias duplicates other name", existing1, []string{name2}, &NameExistsError{name2}}, {"alias duplicates other alias", existing1, []string{newAlias}, &NameUsedByAliasError{newAlias, existing2.Name}}, {"valid new alias", existing1, []string{"valid"}, nil}, {"empty alias", existing1, []string{""}, ErrEmptyAlias}, } for _, tt := range tests { t.Run(tt.tName, func(t *testing.T) { got := ValidateAliases(testCtx, tt.studio.ID, tt.aliases, db.Studio) assert.Equal(t, tt.want, got) }) } } ================================================ FILE: pkg/tag/doc.go ================================================ // Package tag provides application logic for tag objects. package tag ================================================ FILE: pkg/tag/export.go ================================================ package tag import ( "context" "fmt" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/utils" ) type FinderAliasImageGetter interface { GetAliases(ctx context.Context, studioID int) ([]string, error) GetImage(ctx context.Context, tagID int) ([]byte, error) FindByChildTagID(ctx context.Context, childID int) ([]*models.Tag, error) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) models.StashIDLoader } // ToJSON converts a Tag object into its JSON equivalent. func ToJSON(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag) (*jsonschema.Tag, error) { newTagJSON := jsonschema.Tag{ Name: tag.Name, SortName: tag.SortName, Description: tag.Description, Favorite: tag.Favorite, IgnoreAutoTag: tag.IgnoreAutoTag, CreatedAt: json.JSONTime{Time: tag.CreatedAt}, UpdatedAt: json.JSONTime{Time: tag.UpdatedAt}, } aliases, err := reader.GetAliases(ctx, tag.ID) if err != nil { return nil, fmt.Errorf("error getting tag aliases: %v", err) } newTagJSON.Aliases = aliases if err := tag.LoadStashIDs(ctx, reader); err != nil { return nil, fmt.Errorf("loading tag stash ids: %w", err) } stashIDs := tag.StashIDs.List() if len(stashIDs) > 0 { newTagJSON.StashIDs = stashIDs } image, err := reader.GetImage(ctx, tag.ID) if err != nil { logger.Errorf("Error getting tag image: %v", err) } if len(image) > 0 { newTagJSON.Image = utils.GetBase64StringFromData(image) } parents, err := reader.FindByChildTagID(ctx, tag.ID) if err != nil { return nil, fmt.Errorf("error getting parents: %v", err) } newTagJSON.Parents = GetNames(parents) newTagJSON.CustomFields, err = reader.GetCustomFields(ctx, tag.ID) if err != nil { return nil, fmt.Errorf("getting tag custom fields: %v", err) } return &newTagJSON, nil } // GetDependentTagIDs returns a slice of unique tag IDs that this tag references. func GetDependentTagIDs(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag) ([]int, error) { var ret []int parents, err := reader.FindByChildTagID(ctx, tag.ID) if err != nil { return nil, fmt.Errorf("error getting parents: %v", err) } for _, tt := range parents { toAdd, err := GetDependentTagIDs(ctx, reader, tt) if err != nil { return nil, fmt.Errorf("error getting dependent tag IDs: %v", err) } ret = sliceutil.AppendUniques(ret, toAdd) ret = sliceutil.AppendUnique(ret, tt.ID) } return ret, nil } func GetIDs(tags []*models.Tag) []int { var results []int for _, tag := range tags { results = append(results, tag.ID) } return results } func GetNames(tags []*models.Tag) []string { var results []string for _, tag := range tags { results = append(results, tag.Name) } return results } ================================================ FILE: pkg/tag/export_test.go ================================================ package tag import ( "errors" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "testing" "time" ) const ( tagID = iota + 1 customFieldsID noImageID errImageID errAliasID withParentsID errParentsID errCustomFieldsID ) const ( tagName = "testTag" sortName = "sortName" description = "description" ) var ( autoTagIgnored = true createTime = time.Date(2001, 01, 01, 0, 0, 0, 0, time.UTC) updateTime = time.Date(2002, 01, 01, 0, 0, 0, 0, time.UTC) emptyCustomFields = make(map[string]interface{}) customFields = map[string]interface{}{ "customField1": "customValue1", } ) func createTag(id int) models.Tag { return models.Tag{ ID: id, Name: tagName, SortName: sortName, Favorite: true, Description: description, IgnoreAutoTag: autoTagIgnored, CreatedAt: createTime, UpdatedAt: updateTime, } } func createJSONTag(aliases []string, image string, parents []string, withCustomFields bool) *jsonschema.Tag { ret := &jsonschema.Tag{ Name: tagName, SortName: sortName, Favorite: true, Description: description, Aliases: aliases, IgnoreAutoTag: autoTagIgnored, CreatedAt: json.JSONTime{ Time: createTime, }, UpdatedAt: json.JSONTime{ Time: updateTime, }, Image: image, Parents: parents, CustomFields: emptyCustomFields, } if withCustomFields { ret.CustomFields = customFields } return ret } type testScenario struct { tag models.Tag customFields map[string]interface{} expected *jsonschema.Tag err bool } var scenarios []testScenario func initTestTable() { scenarios = []testScenario{ { createTag(tagID), emptyCustomFields, createJSONTag([]string{"alias"}, image, nil, false), false, }, { createTag(customFieldsID), customFields, createJSONTag([]string{"alias"}, image, nil, true), false, }, { createTag(noImageID), emptyCustomFields, createJSONTag(nil, "", nil, false), false, }, { createTag(errImageID), emptyCustomFields, createJSONTag(nil, "", nil, false), // getting the image should not cause an error false, }, { createTag(errAliasID), emptyCustomFields, nil, true, }, { createTag(withParentsID), emptyCustomFields, createJSONTag(nil, image, []string{"parent"}, false), false, }, { createTag(errParentsID), emptyCustomFields, nil, true, }, { createTag(errCustomFieldsID), customFields, nil, true, }, } } func TestToJSON(t *testing.T) { initTestTable() db := mocks.NewDatabase() imageErr := errors.New("error getting image") aliasErr := errors.New("error getting aliases") parentsErr := errors.New("error getting parents") customFieldsErr := errors.New("error getting custom fields") db.Tag.On("GetAliases", testCtx, tagID).Return([]string{"alias"}, nil).Once() db.Tag.On("GetAliases", testCtx, customFieldsID).Return([]string{"alias"}, nil).Once() db.Tag.On("GetAliases", testCtx, noImageID).Return(nil, nil).Once() db.Tag.On("GetAliases", testCtx, errImageID).Return(nil, nil).Once() db.Tag.On("GetAliases", testCtx, errAliasID).Return(nil, aliasErr).Once() db.Tag.On("GetAliases", testCtx, withParentsID).Return(nil, nil).Once() db.Tag.On("GetAliases", testCtx, errParentsID).Return(nil, nil).Once() db.Tag.On("GetAliases", testCtx, errCustomFieldsID).Return(nil, nil).Once() db.Tag.On("GetStashIDs", testCtx, tagID).Return(nil, nil).Once() db.Tag.On("GetStashIDs", testCtx, customFieldsID).Return(nil, nil).Once() db.Tag.On("GetStashIDs", testCtx, noImageID).Return(nil, nil).Once() db.Tag.On("GetStashIDs", testCtx, errImageID).Return(nil, nil).Once() // errAliasID test fails before GetStashIDs is called, so no mock needed db.Tag.On("GetStashIDs", testCtx, withParentsID).Return(nil, nil).Once() db.Tag.On("GetStashIDs", testCtx, errParentsID).Return(nil, nil).Once() db.Tag.On("GetStashIDs", testCtx, errCustomFieldsID).Return(nil, nil).Once() db.Tag.On("GetImage", testCtx, tagID).Return(imageBytes, nil).Once() db.Tag.On("GetImage", testCtx, customFieldsID).Return(imageBytes, nil).Once() db.Tag.On("GetImage", testCtx, noImageID).Return(nil, nil).Once() db.Tag.On("GetImage", testCtx, errImageID).Return(nil, imageErr).Once() db.Tag.On("GetImage", testCtx, withParentsID).Return(imageBytes, nil).Once() db.Tag.On("GetImage", testCtx, errParentsID).Return(nil, nil).Once() db.Tag.On("GetImage", testCtx, errCustomFieldsID).Return(nil, nil).Once() db.Tag.On("FindByChildTagID", testCtx, tagID).Return(nil, nil).Once() db.Tag.On("FindByChildTagID", testCtx, customFieldsID).Return(nil, nil).Once() db.Tag.On("FindByChildTagID", testCtx, noImageID).Return(nil, nil).Once() db.Tag.On("FindByChildTagID", testCtx, withParentsID).Return([]*models.Tag{{Name: "parent"}}, nil).Once() db.Tag.On("FindByChildTagID", testCtx, errParentsID).Return(nil, parentsErr).Once() db.Tag.On("FindByChildTagID", testCtx, errImageID).Return(nil, nil).Once() db.Tag.On("FindByChildTagID", testCtx, errCustomFieldsID).Return(nil, nil).Once() db.Tag.On("GetCustomFields", testCtx, tagID).Return(emptyCustomFields, nil).Once() db.Tag.On("GetCustomFields", testCtx, customFieldsID).Return(customFields, nil).Once() db.Tag.On("GetCustomFields", testCtx, noImageID).Return(emptyCustomFields, nil).Once() db.Tag.On("GetCustomFields", testCtx, errImageID).Return(emptyCustomFields, nil).Once() db.Tag.On("GetCustomFields", testCtx, withParentsID).Return(emptyCustomFields, nil).Once() db.Tag.On("GetCustomFields", testCtx, errCustomFieldsID).Return(nil, customFieldsErr).Once() for i, s := range scenarios { tag := s.tag json, err := ToJSON(testCtx, db.Tag, &tag) switch { case !s.err && err != nil: t.Errorf("[%d] unexpected error: %s", i, err.Error()) case s.err && err == nil: t.Errorf("[%d] expected error not returned", i) default: assert.Equal(t, s.expected, json, "[%d]", i) } } db.AssertExpectations(t) } ================================================ FILE: pkg/tag/import.go ================================================ package tag import ( "context" "fmt" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/utils" ) type ImporterReaderWriter interface { models.TagCreatorUpdater FindByName(ctx context.Context, name string, nocase bool) (*models.Tag, error) } type ParentTagNotExistError struct { missingParent string } func (e ParentTagNotExistError) Error() string { return fmt.Sprintf("parent tag <%s> does not exist", e.missingParent) } func (e ParentTagNotExistError) MissingParent() string { return e.missingParent } type Importer struct { ReaderWriter ImporterReaderWriter Input jsonschema.Tag MissingRefBehaviour models.ImportMissingRefEnum tag models.Tag imageData []byte customFields map[string]interface{} } func (i *Importer) PreImport(ctx context.Context) error { i.tag = models.Tag{ Name: i.Input.Name, SortName: i.Input.SortName, Description: i.Input.Description, Favorite: i.Input.Favorite, IgnoreAutoTag: i.Input.IgnoreAutoTag, StashIDs: models.NewRelatedStashIDs(i.Input.StashIDs), CreatedAt: i.Input.CreatedAt.GetTime(), UpdatedAt: i.Input.UpdatedAt.GetTime(), } var err error if len(i.Input.Image) > 0 { i.imageData, err = utils.ProcessBase64Image(i.Input.Image) if err != nil { return fmt.Errorf("invalid image: %v", err) } } i.customFields = i.Input.CustomFields return nil } func (i *Importer) PostImport(ctx context.Context, id int) error { if len(i.imageData) > 0 { if err := i.ReaderWriter.UpdateImage(ctx, id, i.imageData); err != nil { return fmt.Errorf("error setting tag image: %v", err) } } if err := i.ReaderWriter.UpdateAliases(ctx, id, i.Input.Aliases); err != nil { return fmt.Errorf("error setting tag aliases: %v", err) } parents, err := i.getParents(ctx) if err != nil { return err } if err := i.ReaderWriter.UpdateParentTags(ctx, id, parents); err != nil { return fmt.Errorf("error setting parents: %v", err) } if len(i.customFields) > 0 { if err := i.ReaderWriter.SetCustomFields(ctx, id, models.CustomFieldsInput{ Full: i.customFields, }); err != nil { return fmt.Errorf("error setting tag custom fields: %v", err) } } return nil } func (i *Importer) Name() string { return i.Input.Name } func (i *Importer) FindExistingID(ctx context.Context) (*int, error) { const nocase = false existing, err := i.ReaderWriter.FindByName(ctx, i.Name(), nocase) if err != nil { return nil, err } if existing != nil { id := existing.ID return &id, nil } return nil, nil } func (i *Importer) Create(ctx context.Context) (*int, error) { err := i.ReaderWriter.Create(ctx, &models.CreateTagInput{ Tag: &i.tag, CustomFields: i.customFields, }) if err != nil { return nil, fmt.Errorf("error creating tag: %v", err) } id := i.tag.ID return &id, nil } func (i *Importer) Update(ctx context.Context, id int) error { tag := i.tag tag.ID = id err := i.ReaderWriter.Update(ctx, &models.UpdateTagInput{ Tag: &tag, CustomFields: models.CustomFieldsInput{ Full: i.customFields, }, }) if err != nil { return fmt.Errorf("error updating existing tag: %v", err) } return nil } func (i *Importer) getParents(ctx context.Context) ([]int, error) { var parents []int for _, parent := range i.Input.Parents { tag, err := i.ReaderWriter.FindByName(ctx, parent, false) if err != nil { return nil, fmt.Errorf("error finding parent by name: %v", err) } if tag == nil { if i.MissingRefBehaviour == models.ImportMissingRefEnumFail { return nil, ParentTagNotExistError{missingParent: parent} } if i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore { continue } if i.MissingRefBehaviour == models.ImportMissingRefEnumCreate { parentID, err := i.createParent(ctx, parent) if err != nil { return nil, err } parents = append(parents, parentID) } } else { parents = append(parents, tag.ID) } } return parents, nil } func (i *Importer) createParent(ctx context.Context, name string) (int, error) { newTag := models.NewTag() newTag.Name = name err := i.ReaderWriter.Create(ctx, &models.CreateTagInput{ Tag: &newTag, }) if err != nil { return 0, err } return newTag.ID, nil } ================================================ FILE: pkg/tag/import_test.go ================================================ package tag import ( "context" "errors" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) const image = "aW1hZ2VCeXRlcw==" const invalidImage = "aW1hZ2VCeXRlcw&&" var imageBytes = []byte("imageBytes") const ( tagNameErr = "tagNameErr" existingTagName = "existingTagName" existingTagID = 100 ) var testCtx = context.Background() func TestImporterName(t *testing.T) { i := Importer{ Input: jsonschema.Tag{ Name: tagName, }, } assert.Equal(t, tagName, i.Name()) } func TestImporterPreImport(t *testing.T) { i := Importer{ Input: jsonschema.Tag{ Name: tagName, SortName: sortName, Description: description, Image: invalidImage, IgnoreAutoTag: autoTagIgnored, }, } err := i.PreImport(testCtx) assert.NotNil(t, err) i.Input.Image = image err = i.PreImport(testCtx) assert.Nil(t, err) } func TestImporterPostImport(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Tag, Input: jsonschema.Tag{ Aliases: []string{"alias"}, }, imageData: imageBytes, } updateTagImageErr := errors.New("UpdateImage error") updateTagAliasErr := errors.New("UpdateAlias error") updateTagParentsErr := errors.New("UpdateParentTags error") db.Tag.On("UpdateAliases", testCtx, tagID, i.Input.Aliases).Return(nil).Once() db.Tag.On("UpdateAliases", testCtx, errAliasID, i.Input.Aliases).Return(updateTagAliasErr).Once() db.Tag.On("UpdateAliases", testCtx, withParentsID, i.Input.Aliases).Return(nil).Once() db.Tag.On("UpdateAliases", testCtx, errParentsID, i.Input.Aliases).Return(nil).Once() db.Tag.On("UpdateImage", testCtx, tagID, imageBytes).Return(nil).Once() db.Tag.On("UpdateImage", testCtx, errAliasID, imageBytes).Return(nil).Once() db.Tag.On("UpdateImage", testCtx, errImageID, imageBytes).Return(updateTagImageErr).Once() db.Tag.On("UpdateImage", testCtx, withParentsID, imageBytes).Return(nil).Once() db.Tag.On("UpdateImage", testCtx, errParentsID, imageBytes).Return(nil).Once() var parentTags []int db.Tag.On("UpdateParentTags", testCtx, tagID, parentTags).Return(nil).Once() db.Tag.On("UpdateParentTags", testCtx, withParentsID, []int{100}).Return(nil).Once() db.Tag.On("UpdateParentTags", testCtx, errParentsID, []int{100}).Return(updateTagParentsErr).Once() db.Tag.On("FindByName", testCtx, "Parent", false).Return(&models.Tag{ID: 100}, nil) err := i.PostImport(testCtx, tagID) assert.Nil(t, err) err = i.PostImport(testCtx, errImageID) assert.NotNil(t, err) err = i.PostImport(testCtx, errAliasID) assert.NotNil(t, err) i.Input.Parents = []string{"Parent"} err = i.PostImport(testCtx, withParentsID) assert.Nil(t, err) err = i.PostImport(testCtx, errParentsID) assert.NotNil(t, err) db.AssertExpectations(t) } func TestImporterPostImportParentMissing(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Tag, Input: jsonschema.Tag{}, imageData: imageBytes, } createID := 1 createErrorID := 2 createFindErrorID := 3 createFoundID := 4 failID := 5 failFindErrorID := 6 failFoundID := 7 ignoreID := 8 ignoreFindErrorID := 9 ignoreFoundID := 10 findError := errors.New("failed finding parent") var emptyParents []int db.Tag.On("UpdateImage", testCtx, mock.Anything, mock.Anything).Return(nil) db.Tag.On("UpdateAliases", testCtx, mock.Anything, mock.Anything).Return(nil) db.Tag.On("FindByName", testCtx, "Create", false).Return(nil, nil).Once() db.Tag.On("FindByName", testCtx, "CreateError", false).Return(nil, nil).Once() db.Tag.On("FindByName", testCtx, "CreateFindError", false).Return(nil, findError).Once() db.Tag.On("FindByName", testCtx, "CreateFound", false).Return(&models.Tag{ID: 101}, nil).Once() db.Tag.On("FindByName", testCtx, "Fail", false).Return(nil, nil).Once() db.Tag.On("FindByName", testCtx, "FailFindError", false).Return(nil, findError) db.Tag.On("FindByName", testCtx, "FailFound", false).Return(&models.Tag{ID: 102}, nil).Once() db.Tag.On("FindByName", testCtx, "Ignore", false).Return(nil, nil).Once() db.Tag.On("FindByName", testCtx, "IgnoreFindError", false).Return(nil, findError) db.Tag.On("FindByName", testCtx, "IgnoreFound", false).Return(&models.Tag{ID: 103}, nil).Once() db.Tag.On("UpdateParentTags", testCtx, createID, []int{100}).Return(nil).Once() db.Tag.On("UpdateParentTags", testCtx, createFoundID, []int{101}).Return(nil).Once() db.Tag.On("UpdateParentTags", testCtx, failFoundID, []int{102}).Return(nil).Once() db.Tag.On("UpdateParentTags", testCtx, ignoreID, emptyParents).Return(nil).Once() db.Tag.On("UpdateParentTags", testCtx, ignoreFoundID, []int{103}).Return(nil).Once() db.Tag.On("Create", testCtx, mock.MatchedBy(func(input *models.CreateTagInput) bool { return input.Tag.Name == "Create" })).Run(func(args mock.Arguments) { input := args.Get(1).(*models.CreateTagInput) input.Tag.ID = 100 }).Return(nil).Once() db.Tag.On("Create", testCtx, mock.MatchedBy(func(input *models.CreateTagInput) bool { return input.Tag.Name == "CreateError" })).Return(errors.New("failed creating parent")).Once() i.MissingRefBehaviour = models.ImportMissingRefEnumCreate i.Input.Parents = []string{"Create"} err := i.PostImport(testCtx, createID) assert.Nil(t, err) i.Input.Parents = []string{"CreateError"} err = i.PostImport(testCtx, createErrorID) assert.NotNil(t, err) i.Input.Parents = []string{"CreateFindError"} err = i.PostImport(testCtx, createFindErrorID) assert.NotNil(t, err) i.Input.Parents = []string{"CreateFound"} err = i.PostImport(testCtx, createFoundID) assert.Nil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumFail i.Input.Parents = []string{"Fail"} err = i.PostImport(testCtx, failID) assert.NotNil(t, err) i.Input.Parents = []string{"FailFindError"} err = i.PostImport(testCtx, failFindErrorID) assert.NotNil(t, err) i.Input.Parents = []string{"FailFound"} err = i.PostImport(testCtx, failFoundID) assert.Nil(t, err) i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore i.Input.Parents = []string{"Ignore"} err = i.PostImport(testCtx, ignoreID) assert.Nil(t, err) i.Input.Parents = []string{"IgnoreFindError"} err = i.PostImport(testCtx, ignoreFindErrorID) assert.NotNil(t, err) i.Input.Parents = []string{"IgnoreFound"} err = i.PostImport(testCtx, ignoreFoundID) assert.Nil(t, err) db.AssertExpectations(t) } func TestImporterFindExistingID(t *testing.T) { db := mocks.NewDatabase() i := Importer{ ReaderWriter: db.Tag, Input: jsonschema.Tag{ Name: tagName, }, } errFindByName := errors.New("FindByName error") db.Tag.On("FindByName", testCtx, tagName, false).Return(nil, nil).Once() db.Tag.On("FindByName", testCtx, existingTagName, false).Return(&models.Tag{ ID: existingTagID, }, nil).Once() db.Tag.On("FindByName", testCtx, tagNameErr, false).Return(nil, errFindByName).Once() id, err := i.FindExistingID(testCtx) assert.Nil(t, id) assert.Nil(t, err) i.Input.Name = existingTagName id, err = i.FindExistingID(testCtx) assert.Equal(t, existingTagID, *id) assert.Nil(t, err) i.Input.Name = tagNameErr id, err = i.FindExistingID(testCtx) assert.Nil(t, id) assert.NotNil(t, err) db.AssertExpectations(t) } func TestCreate(t *testing.T) { db := mocks.NewDatabase() tag := models.Tag{ Name: tagName, } tagErr := models.Tag{ Name: tagNameErr, } i := Importer{ ReaderWriter: db.Tag, tag: tag, } errCreate := errors.New("Create error") db.Tag.On("Create", testCtx, mock.MatchedBy(func(input *models.CreateTagInput) bool { return input.Tag.Name == tag.Name })).Run(func(args mock.Arguments) { input := args.Get(1).(*models.CreateTagInput) input.Tag.ID = tagID }).Return(nil).Once() db.Tag.On("Create", testCtx, mock.MatchedBy(func(input *models.CreateTagInput) bool { return input.Tag.Name == tagErr.Name })).Return(errCreate).Once() id, err := i.Create(testCtx) assert.Equal(t, tagID, *id) assert.Nil(t, err) i.tag = tagErr id, err = i.Create(testCtx) assert.Nil(t, id) assert.NotNil(t, err) db.AssertExpectations(t) } func TestUpdate(t *testing.T) { db := mocks.NewDatabase() tag := models.Tag{ Name: tagName, } tagErr := models.Tag{ Name: tagNameErr, } i := Importer{ ReaderWriter: db.Tag, tag: tag, } errUpdate := errors.New("Update error") // id needs to be set for the mock input tag.ID = tagID tagInput := models.UpdateTagInput{ Tag: &tag, } db.Tag.On("Update", testCtx, &tagInput).Return(nil).Once() err := i.Update(testCtx, tagID) assert.Nil(t, err) i.tag = tagErr // need to set id separately tagErr.ID = errImageID errInput := models.UpdateTagInput{ Tag: &tagErr, } db.Tag.On("Update", testCtx, &errInput).Return(errUpdate).Once() err = i.Update(testCtx, errImageID) assert.NotNil(t, err) db.AssertExpectations(t) } ================================================ FILE: pkg/tag/query.go ================================================ package tag import ( "context" "github.com/stashapp/stash/pkg/models" ) func ByName(ctx context.Context, qb models.TagQueryer, name string) (*models.Tag, error) { f := &models.TagFilterType{ Name: &models.StringCriterionInput{ Value: name, Modifier: models.CriterionModifierEquals, }, } pp := 1 ret, count, err := qb.Query(ctx, f, &models.FindFilterType{ PerPage: &pp, }) if err != nil { return nil, err } if count > 0 { return ret[0], nil } return nil, nil } func ByAlias(ctx context.Context, qb models.TagQueryer, alias string) (*models.Tag, error) { f := &models.TagFilterType{ Aliases: &models.StringCriterionInput{ Value: alias, Modifier: models.CriterionModifierEquals, }, } pp := 1 ret, count, err := qb.Query(ctx, f, &models.FindFilterType{ PerPage: &pp, }) if err != nil { return nil, err } if count > 0 { return ret[0], nil } return nil, nil } ================================================ FILE: pkg/tag/update.go ================================================ package tag import ( "context" "fmt" "github.com/stashapp/stash/pkg/models" ) type NameExistsError struct { Name string } func (e *NameExistsError) Error() string { return fmt.Sprintf("tag with name '%s' already exists", e.Name) } type NameUsedByAliasError struct { Name string OtherTag string } func (e *NameUsedByAliasError) Error() string { return fmt.Sprintf("name '%s' is used as alias for '%s'", e.Name, e.OtherTag) } type InvalidTagHierarchyError struct { Direction string CurrentRelation string InvalidTag string ApplyingTag string TagPath string } func (e *InvalidTagHierarchyError) Error() string { if e.ApplyingTag == "" { return fmt.Sprintf("cannot apply tag \"%s\" as a %s of tag as it is already %s", e.InvalidTag, e.Direction, e.CurrentRelation) } return fmt.Sprintf("cannot apply tag \"%s\" as a %s of \"%s\" as it is already %s (%s)", e.InvalidTag, e.Direction, e.ApplyingTag, e.CurrentRelation, e.TagPath) } // EnsureTagNameUnique returns an error if the tag name provided // is used as a name or alias of another existing tag. func EnsureTagNameUnique(ctx context.Context, id int, name string, qb models.TagQueryer) error { // ensure name is unique sameNameTag, err := ByName(ctx, qb, name) if err != nil { return err } if sameNameTag != nil && id != sameNameTag.ID { return &NameExistsError{ Name: name, } } // query by alias sameNameTag, err = ByAlias(ctx, qb, name) if err != nil { return err } if sameNameTag != nil && id != sameNameTag.ID { return &NameUsedByAliasError{ Name: name, OtherTag: sameNameTag.Name, } } return nil } func EnsureAliasesUnique(ctx context.Context, id int, aliases []string, qb models.TagQueryer) error { for _, a := range aliases { if err := EnsureTagNameUnique(ctx, id, a, qb); err != nil { return err } } return nil } type RelationshipFinder interface { FindAllAncestors(ctx context.Context, tagID int, excludeIDs []int) ([]*models.TagPath, error) FindAllDescendants(ctx context.Context, tagID int, excludeIDs []int) ([]*models.TagPath, error) models.TagRelationLoader } func ValidateHierarchyNew(ctx context.Context, parentIDs, childIDs []int, qb RelationshipFinder) error { allAncestors := make(map[int]*models.TagPath) allDescendants := make(map[int]*models.TagPath) for _, parentID := range parentIDs { parentsAncestors, err := qb.FindAllAncestors(ctx, parentID, nil) if err != nil { return err } for _, ancestorTag := range parentsAncestors { allAncestors[ancestorTag.ID] = ancestorTag } } for _, childID := range childIDs { childsDescendants, err := qb.FindAllDescendants(ctx, childID, nil) if err != nil { return err } for _, descendentTag := range childsDescendants { allDescendants[descendentTag.ID] = descendentTag } } // Validate that the tag is not a parent of any of its ancestors validateParent := func(testID int) error { if parentTag, exists := allDescendants[testID]; exists { return &InvalidTagHierarchyError{ Direction: "parent", CurrentRelation: "a descendant", InvalidTag: parentTag.Name, TagPath: parentTag.Path, } } return nil } // Validate that the tag is not a child of any of its ancestors validateChild := func(testID int) error { if childTag, exists := allAncestors[testID]; exists { return &InvalidTagHierarchyError{ Direction: "child", CurrentRelation: "an ancestor", InvalidTag: childTag.Name, TagPath: childTag.Path, } } return nil } for _, parentID := range parentIDs { if err := validateParent(parentID); err != nil { return err } } for _, childID := range childIDs { if err := validateChild(childID); err != nil { return err } } return nil } func ValidateHierarchyExisting(ctx context.Context, tag *models.Tag, parentIDs, childIDs []int, qb RelationshipFinder) error { allAncestors := make(map[int]*models.TagPath) allDescendants := make(map[int]*models.TagPath) parentsAncestors, err := qb.FindAllAncestors(ctx, tag.ID, nil) if err != nil { return err } for _, ancestorTag := range parentsAncestors { allAncestors[ancestorTag.ID] = ancestorTag } childsDescendants, err := qb.FindAllDescendants(ctx, tag.ID, nil) if err != nil { return err } for _, descendentTag := range childsDescendants { allDescendants[descendentTag.ID] = descendentTag } validateParent := func(testID int) error { if parentTag, exists := allDescendants[testID]; exists { return &InvalidTagHierarchyError{ Direction: "parent", CurrentRelation: "a descendant", InvalidTag: parentTag.Name, ApplyingTag: tag.Name, TagPath: parentTag.Path, } } return nil } validateChild := func(testID int) error { if childTag, exists := allAncestors[testID]; exists { return &InvalidTagHierarchyError{ Direction: "child", CurrentRelation: "an ancestor", InvalidTag: childTag.Name, ApplyingTag: tag.Name, TagPath: childTag.Path, } } return nil } for _, parentID := range parentIDs { if err := validateParent(parentID); err != nil { return err } } for _, childID := range childIDs { if err := validateChild(childID); err != nil { return err } } return nil } ================================================ FILE: pkg/tag/update_test.go ================================================ package tag import ( "context" "fmt" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) var testUniqueHierarchyTags = map[int]*models.Tag{ 1: { ID: 1, Name: "one", }, 2: { ID: 2, Name: "two", }, 3: { ID: 3, Name: "three", }, 4: { ID: 4, Name: "four", }, } var testUniqueHierarchyTagPaths = map[int]*models.TagPath{ 1: { Tag: *testUniqueHierarchyTags[1], }, 2: { Tag: *testUniqueHierarchyTags[2], }, 3: { Tag: *testUniqueHierarchyTags[3], }, 4: { Tag: *testUniqueHierarchyTags[4], }, } type testUniqueHierarchyCase struct { id int parents []*models.Tag children []*models.Tag onFindAllAncestors []*models.TagPath onFindAllDescendants []*models.TagPath expectedError string } var testUniqueHierarchyCases = []testUniqueHierarchyCase{ { id: 1, parents: []*models.Tag{}, children: []*models.Tag{}, onFindAllAncestors: []*models.TagPath{}, onFindAllDescendants: []*models.TagPath{}, expectedError: "", }, { id: 1, parents: []*models.Tag{testUniqueHierarchyTags[2]}, children: []*models.Tag{testUniqueHierarchyTags[3]}, onFindAllAncestors: []*models.TagPath{ testUniqueHierarchyTagPaths[2], }, onFindAllDescendants: []*models.TagPath{ testUniqueHierarchyTagPaths[3], }, expectedError: "", }, { id: 2, parents: []*models.Tag{testUniqueHierarchyTags[3]}, children: make([]*models.Tag, 0), onFindAllAncestors: []*models.TagPath{ testUniqueHierarchyTagPaths[3], }, onFindAllDescendants: []*models.TagPath{ testUniqueHierarchyTagPaths[2], }, expectedError: "", }, { id: 2, parents: []*models.Tag{ testUniqueHierarchyTags[3], testUniqueHierarchyTags[4], }, children: []*models.Tag{}, onFindAllAncestors: []*models.TagPath{ testUniqueHierarchyTagPaths[3], testUniqueHierarchyTagPaths[4], }, onFindAllDescendants: []*models.TagPath{ testUniqueHierarchyTagPaths[2], }, expectedError: "", }, { id: 2, parents: []*models.Tag{}, children: []*models.Tag{testUniqueHierarchyTags[3]}, onFindAllAncestors: []*models.TagPath{ testUniqueHierarchyTagPaths[2], }, onFindAllDescendants: []*models.TagPath{ testUniqueHierarchyTagPaths[3], }, expectedError: "", }, { id: 2, parents: []*models.Tag{}, children: []*models.Tag{ testUniqueHierarchyTags[3], testUniqueHierarchyTags[4], }, onFindAllAncestors: []*models.TagPath{ testUniqueHierarchyTagPaths[2], }, onFindAllDescendants: []*models.TagPath{ testUniqueHierarchyTagPaths[3], testUniqueHierarchyTagPaths[4], }, expectedError: "", }, { id: 1, parents: []*models.Tag{testUniqueHierarchyTags[2]}, children: []*models.Tag{testUniqueHierarchyTags[3]}, onFindAllAncestors: []*models.TagPath{ testUniqueHierarchyTagPaths[2], testUniqueHierarchyTagPaths[3], }, onFindAllDescendants: []*models.TagPath{ testUniqueHierarchyTagPaths[3], }, expectedError: "cannot apply tag \"three\" as a child of \"one\" as it is already an ancestor ()", }, { id: 1, parents: []*models.Tag{testUniqueHierarchyTags[2]}, children: []*models.Tag{testUniqueHierarchyTags[3]}, onFindAllAncestors: []*models.TagPath{ testUniqueHierarchyTagPaths[2], }, onFindAllDescendants: []*models.TagPath{ testUniqueHierarchyTagPaths[3], testUniqueHierarchyTagPaths[2], }, expectedError: "cannot apply tag \"two\" as a parent of \"one\" as it is already a descendant ()", }, { id: 1, parents: []*models.Tag{testUniqueHierarchyTags[3]}, children: []*models.Tag{testUniqueHierarchyTags[3]}, onFindAllAncestors: []*models.TagPath{ testUniqueHierarchyTagPaths[3], }, onFindAllDescendants: []*models.TagPath{ testUniqueHierarchyTagPaths[3], }, expectedError: "cannot apply tag \"three\" as a parent of \"one\" as it is already a descendant ()", }, { id: 1, parents: []*models.Tag{ testUniqueHierarchyTags[2], }, children: []*models.Tag{ testUniqueHierarchyTags[3], }, onFindAllAncestors: []*models.TagPath{ testUniqueHierarchyTagPaths[2], }, onFindAllDescendants: []*models.TagPath{ testUniqueHierarchyTagPaths[3], testUniqueHierarchyTagPaths[2], }, expectedError: "cannot apply tag \"two\" as a parent of \"one\" as it is already a descendant ()", }, { id: 1, parents: []*models.Tag{testUniqueHierarchyTags[2]}, children: []*models.Tag{testUniqueHierarchyTags[2]}, onFindAllAncestors: []*models.TagPath{ testUniqueHierarchyTagPaths[2], }, onFindAllDescendants: []*models.TagPath{ testUniqueHierarchyTagPaths[2], }, expectedError: "cannot apply tag \"two\" as a parent of \"one\" as it is already a descendant ()", }, { id: 2, parents: []*models.Tag{testUniqueHierarchyTags[1]}, children: []*models.Tag{testUniqueHierarchyTags[3]}, onFindAllAncestors: []*models.TagPath{ testUniqueHierarchyTagPaths[1], }, onFindAllDescendants: []*models.TagPath{ testUniqueHierarchyTagPaths[3], testUniqueHierarchyTagPaths[1], }, expectedError: "cannot apply tag \"one\" as a parent of \"two\" as it is already a descendant ()", }, } func TestEnsureHierarchy(t *testing.T) { for _, tc := range testUniqueHierarchyCases { testEnsureHierarchy(t, tc) } } func testEnsureHierarchy(t *testing.T, tc testUniqueHierarchyCase) { db := mocks.NewDatabase() var parentIDs, childIDs []int find := make(map[int]*models.Tag) find[tc.id] = testUniqueHierarchyTags[tc.id] if tc.parents != nil { parentIDs = make([]int, 0) for _, parent := range tc.parents { if parent.ID != tc.id { find[parent.ID] = parent parentIDs = append(parentIDs, parent.ID) } } } if tc.children != nil { childIDs = make([]int, 0) for _, child := range tc.children { if child.ID != tc.id { find[child.ID] = child childIDs = append(childIDs, child.ID) } } } db.Tag.On("FindAllAncestors", testCtx, mock.AnythingOfType("int"), []int(nil)).Return(func(ctx context.Context, tagID int, excludeIDs []int) []*models.TagPath { return tc.onFindAllAncestors }, func(ctx context.Context, tagID int, excludeIDs []int) error { if tc.onFindAllAncestors != nil { return nil } return fmt.Errorf("undefined ancestors for: %d", tagID) }).Maybe() db.Tag.On("FindAllDescendants", testCtx, mock.AnythingOfType("int"), []int(nil)).Return(func(ctx context.Context, tagID int, excludeIDs []int) []*models.TagPath { return tc.onFindAllDescendants }, func(ctx context.Context, tagID int, excludeIDs []int) error { if tc.onFindAllDescendants != nil { return nil } return fmt.Errorf("undefined descendants for: %d", tagID) }).Maybe() res := ValidateHierarchyExisting(testCtx, testUniqueHierarchyTags[tc.id], parentIDs, childIDs, db.Tag) assert := assert.New(t) if tc.expectedError != "" { if assert.NotNil(res) { assert.Equal(tc.expectedError, res.Error()) } } else { assert.Nil(res) } db.AssertExpectations(t) } ================================================ FILE: pkg/tag/validate.go ================================================ package tag import ( "context" "errors" "fmt" "github.com/stashapp/stash/pkg/models" ) var ( ErrNameMissing = errors.New("tag name must not be blank") ) type NotFoundError struct { id int } func (e *NotFoundError) Error() string { return fmt.Sprintf("tag with id %d not found", e.id) } func ValidateCreate(ctx context.Context, tag models.Tag, qb models.TagReader) error { if tag.Name == "" { return ErrNameMissing } if err := EnsureTagNameUnique(ctx, 0, tag.Name, qb); err != nil { return err } if tag.Aliases.Loaded() { if err := EnsureAliasesUnique(ctx, tag.ID, tag.Aliases.List(), qb); err != nil { return err } } if len(tag.ParentIDs.List()) > 0 || len(tag.ChildIDs.List()) > 0 { if err := ValidateHierarchyNew(ctx, tag.ParentIDs.List(), tag.ChildIDs.List(), qb); err != nil { return err } } return nil } func ValidateUpdate(ctx context.Context, id int, partial models.TagPartial, qb models.TagReader) error { existing, err := qb.Find(ctx, id) if err != nil { return err } if existing == nil { return &NotFoundError{id} } if partial.Name.Set { if partial.Name.Value == "" { return ErrNameMissing } if err := EnsureTagNameUnique(ctx, id, partial.Name.Value, qb); err != nil { return err } } if partial.Aliases != nil { if err := existing.LoadAliases(ctx, qb); err != nil { return err } newAliases := partial.Aliases.Apply(existing.Aliases.List()) if err := EnsureAliasesUnique(ctx, id, newAliases, qb); err != nil { return err } } if partial.ParentIDs != nil || partial.ChildIDs != nil { if err := existing.LoadParentIDs(ctx, qb); err != nil { return err } if err := existing.LoadChildIDs(ctx, qb); err != nil { return err } parentIDs := partial.ParentIDs if parentIDs == nil { parentIDs = &models.UpdateIDs{IDs: existing.ParentIDs.List(), Mode: models.RelationshipUpdateModeSet} } childIDs := partial.ChildIDs if childIDs == nil { childIDs = &models.UpdateIDs{IDs: existing.ChildIDs.List(), Mode: models.RelationshipUpdateModeSet} } if err := ValidateHierarchyExisting(ctx, existing, parentIDs.Apply(existing.ParentIDs.List()), childIDs.Apply(existing.ChildIDs.List()), qb); err != nil { return err } } return nil } ================================================ FILE: pkg/tag/validate_test.go ================================================ package tag import ( "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" ) func nameFilter(n string) *models.TagFilterType { return &models.TagFilterType{ Name: &models.StringCriterionInput{ Value: n, Modifier: models.CriterionModifierEquals, }, } } func aliasFilter(n string) *models.TagFilterType { return &models.TagFilterType{ Aliases: &models.StringCriterionInput{ Value: n, Modifier: models.CriterionModifierEquals, }, } } func TestEnsureAliasesUnique(t *testing.T) { db := mocks.NewDatabase() const ( name1 = "name 1" name2 = "name 2" alias1 = "alias 1" newAlias = "new alias" ) existing2 := models.Tag{ ID: 2, Name: name2, } pp := 1 findFilter := &models.FindFilterType{ PerPage: &pp, } // name1 matches existing1 name - ok // EnsureAliasesUnique calls EnsureTagNameUnique. // EnsureTagNameUnique calls ByName then ByAlias. // Case 1: valid alias // ByName "alias 1" -> nil // ByAlias "alias 1" -> nil db.Tag.On("Query", testCtx, nameFilter(alias1), findFilter).Return(nil, 0, nil) db.Tag.On("Query", testCtx, aliasFilter(alias1), findFilter).Return(nil, 0, nil) // Case 2: alias duplicates existing2 name // ByName "name 2" -> existing2 db.Tag.On("Query", testCtx, nameFilter(name2), findFilter).Return([]*models.Tag{&existing2}, 1, nil) // Case 3: alias duplicates existing2 alias // ByName "new alias" -> nil // ByAlias "new alias" -> existing2 db.Tag.On("Query", testCtx, nameFilter(newAlias), findFilter).Return(nil, 0, nil) db.Tag.On("Query", testCtx, aliasFilter(newAlias), findFilter).Return([]*models.Tag{&existing2}, 1, nil) tests := []struct { tName string id int aliases []string want error }{ {"valid alias", 1, []string{alias1}, nil}, {"alias duplicates other name", 1, []string{name2}, &NameExistsError{name2}}, {"alias duplicates other alias", 1, []string{newAlias}, &NameUsedByAliasError{newAlias, existing2.Name}}, } for _, tt := range tests { t.Run(tt.tName, func(t *testing.T) { got := EnsureAliasesUnique(testCtx, tt.id, tt.aliases, db.Tag) assert.Equal(t, tt.want, got) }) } } ================================================ FILE: pkg/txn/hooks.go ================================================ package txn import ( "context" ) type key int const ( hookManagerKey key = iota + 1 ) type hookManager struct { preCommitHooks []TxnFunc postCommitHooks []MustFunc postRollbackHooks []MustFunc postCompleteHooks []MustFunc } func (m *hookManager) register(ctx context.Context) context.Context { return context.WithValue(ctx, hookManagerKey, m) } func hookManagerCtx(ctx context.Context) *hookManager { m, ok := ctx.Value(hookManagerKey).(*hookManager) if !ok { return nil } return m } func executeHooks(ctx context.Context, hooks []TxnFunc) error { // we need to return the first error for _, h := range hooks { if err := h(ctx); err != nil { return err } } return nil } func executeMustHooks(ctx context.Context, hooks []MustFunc) { for _, h := range hooks { h(ctx) } } func (m *hookManager) executePostCommitHooks(ctx context.Context) { executeMustHooks(ctx, m.postCommitHooks) } func (m *hookManager) executePostRollbackHooks(ctx context.Context) { executeMustHooks(ctx, m.postRollbackHooks) } func (m *hookManager) executePreCommitHooks(ctx context.Context) error { return executeHooks(ctx, m.preCommitHooks) } func (m *hookManager) executePostCompleteHooks(ctx context.Context) { executeMustHooks(ctx, m.postCompleteHooks) } func AddPreCommitHook(ctx context.Context, hook TxnFunc) { m := hookManagerCtx(ctx) m.preCommitHooks = append(m.preCommitHooks, hook) } func AddPostCommitHook(ctx context.Context, hook MustFunc) { m := hookManagerCtx(ctx) m.postCommitHooks = append(m.postCommitHooks, hook) } func AddPostRollbackHook(ctx context.Context, hook MustFunc) { m := hookManagerCtx(ctx) m.postRollbackHooks = append(m.postRollbackHooks, hook) } func AddPostCompleteHook(ctx context.Context, hook MustFunc) { m := hookManagerCtx(ctx) m.postCompleteHooks = append(m.postCompleteHooks, hook) } ================================================ FILE: pkg/txn/transaction.go ================================================ // Package txn provides functions for running transactions. package txn import ( "context" "fmt" ) type Manager interface { Begin(ctx context.Context, writable bool) (context.Context, error) Commit(ctx context.Context) error Rollback(ctx context.Context) error IsLocked(err error) bool } type DatabaseProvider interface { WithDatabase(ctx context.Context) (context.Context, error) } // TxnFunc is a function that is used in transaction hooks. // It should return an error if something went wrong. type TxnFunc func(ctx context.Context) error // MustFunc is a function that is used in transaction hooks. // It does not return an error. type MustFunc func(ctx context.Context) // WithTxn executes fn in a transaction. If fn returns an error then // the transaction is rolled back. Otherwise it is committed. // This function will call m.Begin with writable = true. // This function should be used for making changes to the database. func WithTxn(ctx context.Context, m Manager, fn TxnFunc) error { const ( execComplete = true writable = true ) return withTxn(ctx, m, fn, writable, execComplete) } // WithReadTxn executes fn in a transaction. If fn returns an error then // the transaction is rolled back. Otherwise it is committed. // This function will call m.Begin with writable = false. func WithReadTxn(ctx context.Context, m Manager, fn TxnFunc) error { const ( execComplete = true writable = false ) return withTxn(ctx, m, fn, writable, execComplete) } func withTxn(ctx context.Context, m Manager, fn TxnFunc, writable bool, execCompleteOnLocked bool) error { // post-hooks should be executed with the outside context txnCtx, err := begin(ctx, m, writable) if err != nil { return err } hookMgr := hookManagerCtx(txnCtx) defer func() { if p := recover(); p != nil { // a panic occurred, rollback and repanic rollback(txnCtx, m) panic(p) } if err != nil { // something went wrong, rollback rollback(txnCtx, m) // execute post-hooks with outside context hookMgr.executePostRollbackHooks(ctx) if execCompleteOnLocked || !m.IsLocked(err) { hookMgr.executePostCompleteHooks(ctx) } } else { // all good, commit err = commit(txnCtx, m) // execute post-hooks with outside context hookMgr.executePostCommitHooks(ctx) hookMgr.executePostCompleteHooks(ctx) } }() err = fn(txnCtx) return err } func begin(ctx context.Context, m Manager, writable bool) (context.Context, error) { var err error ctx, err = m.Begin(ctx, writable) if err != nil { return nil, err } hm := hookManager{} ctx = hm.register(ctx) return ctx, nil } func commit(ctx context.Context, m Manager) error { hookMgr := hookManagerCtx(ctx) if err := hookMgr.executePreCommitHooks(ctx); err != nil { return err } if err := m.Commit(ctx); err != nil { return err } return nil } func rollback(ctx context.Context, m Manager) { if err := m.Rollback(ctx); err != nil { return } } // WithDatabase executes fn with the context provided by p.WithDatabase. // It does not run inside a transaction, so all database operations will be // executed in their own transaction. func WithDatabase(ctx context.Context, p DatabaseProvider, fn TxnFunc) error { var err error ctx, err = p.WithDatabase(ctx) if err != nil { return err } return fn(ctx) } // Retryer is a provides WithTxn function that retries the transaction // if it fails with a locked database error. // Transactions are run in exclusive mode. type Retryer struct { Manager Manager // use value < 0 to retry forever Retries int OnFail func(ctx context.Context, err error, attempt int) error } func (r Retryer) WithTxn(ctx context.Context, fn TxnFunc) error { var attempt int var err error for attempt = 1; attempt <= r.Retries || r.Retries < 0; attempt++ { const ( execComplete = false exclusive = true ) err = withTxn(ctx, r.Manager, fn, exclusive, execComplete) if err == nil { return nil } if !r.Manager.IsLocked(err) { return err } if r.OnFail != nil { if err := r.OnFail(ctx, err, attempt); err != nil { return err } } } return fmt.Errorf("failed after %d attempts: %w", attempt, err) } ================================================ FILE: pkg/utils/boolean.go ================================================ package utils // IsTrue returns true if the bool pointer is not nil and true. func IsTrue(b *bool) bool { return b != nil && *b } ================================================ FILE: pkg/utils/date.go ================================================ package utils import ( "fmt" "time" ) func ParseDateStringAsTime(dateString string) (time.Time, error) { // https://stackoverflow.com/a/20234207 WTF? t, e := time.Parse(time.RFC3339, dateString) if e == nil { return t, nil } t, e = time.Parse("2006-01-02", dateString) if e == nil { return t, nil } t, e = time.Parse("2006-01-02 15:04:05", dateString) if e == nil { return t, nil } return time.Time{}, fmt.Errorf("ParseDateStringAsTime failed: dateString <%s>", dateString) } ================================================ FILE: pkg/utils/date_test.go ================================================ package utils import ( "testing" ) func TestParseDateStringAsTime(t *testing.T) { tests := []struct { name string input string expectError bool }{ // Full date formats (existing support) {"RFC3339", "2014-01-02T15:04:05Z", false}, {"Date only", "2014-01-02", false}, {"Date with time", "2014-01-02 15:04:05", false}, // Invalid formats {"Invalid format", "not-a-date", true}, {"Empty string", "", true}, {"Year-Month", "2006-08", true}, {"Year only", "2014", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := ParseDateStringAsTime(tt.input) if tt.expectError { if err == nil { t.Errorf("Expected error for input %q, but got none", tt.input) } } else { if err != nil { t.Errorf("Unexpected error for input %q: %v", tt.input, err) } if result.IsZero() { t.Errorf("Expected non-zero time for input %q", tt.input) } } }) } } ================================================ FILE: pkg/utils/doc.go ================================================ // Package utils provides various utility functions for the application. package utils ================================================ FILE: pkg/utils/func.go ================================================ package utils // Do executes each function in the slice in order. If any function returns an error, it is returned immediately. func Do(fn []func() error) error { for _, f := range fn { if err := f(); err != nil { return err } } return nil } ================================================ FILE: pkg/utils/http.go ================================================ package utils import ( "bytes" "errors" "io/fs" "net/http" "path/filepath" "time" "github.com/stashapp/stash/pkg/hash/md5" ) // Returns an MD5 hash of data, formatted for use as an HTTP ETag header. // Intended for use with `http.ServeContent`, to respond to conditional requests. func GenerateETag(data []byte) string { hash := md5.FromBytes(data) return `"` + hash + `"` } func setStaticContentCacheControl(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Has("t") { w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "no-cache") } } // Serves static content, adding Cache-Control: no-cache and a generated ETag header. // Responds to conditional requests using the ETag. func ServeStaticContent(w http.ResponseWriter, r *http.Request, data []byte) { setStaticContentCacheControl(w, r) w.Header().Set("ETag", GenerateETag(data)) http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(data)) } // Serves static content at filepath, adding Cache-Control: no-cache. // Responds to conditional requests using the file modtime. func ServeStaticFile(w http.ResponseWriter, r *http.Request, filepath string) { setStaticContentCacheControl(w, r) http.ServeFile(w, r, filepath) } func toHTTPError(err error) (msg string, httpStatus int) { if errors.Is(err, fs.ErrNotExist) { return "404 page not found", http.StatusNotFound } if errors.Is(err, fs.ErrPermission) { return "403 Forbidden", http.StatusForbidden } return "500 Internal Server Error", http.StatusInternalServerError } // ServeStaticFileModTime serves a static file at the given path using the given modTime instead of the file modTime. func ServeStaticFileModTime(w http.ResponseWriter, r *http.Request, path string, modTime time.Time) { setStaticContentCacheControl(w, r) dir, file := filepath.Split(path) fs := http.Dir(dir) f, err := fs.Open(file) if err != nil { msg, code := toHTTPError(err) http.Error(w, msg, code) return } defer f.Close() d, err := f.Stat() if err != nil { msg, code := toHTTPError(err) http.Error(w, msg, code) return } http.ServeContent(w, r, d.Name(), modTime, f) } ================================================ FILE: pkg/utils/image.go ================================================ package utils import ( "context" "crypto/tls" "encoding/base64" "fmt" "io" "net/http" "regexp" "time" ) // Timeout to get the image. Includes transfer time. May want to make this // configurable at some point. const imageGetTimeout = time.Second * 60 const base64RE = `^data:.+\/(.+);base64,(.*)$` // ProcessImageInput transforms an image string either from a base64 encoded // string, or from a URL, and returns the image as a byte slice func ProcessImageInput(ctx context.Context, imageInput string) ([]byte, error) { if imageInput == "" { return []byte{}, nil } regex := regexp.MustCompile(base64RE) if regex.MatchString(imageInput) { d, err := ProcessBase64Image(imageInput) return d, err } // assume input is a URL. Read it. return ReadImageFromURL(ctx, imageInput) } // ReadImageFromURL returns image data from a URL func ReadImageFromURL(ctx context.Context, url string) ([]byte, error) { client := &http.Client{ Transport: &http.Transport{ // ignore insecure certificates TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, Proxy: http.ProxyFromEnvironment, }, Timeout: imageGetTimeout, } req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } // assume is a URL for now // set the host of the URL as the referer if req.URL.Scheme != "" { req.Header.Set("Referer", req.URL.Scheme+"://"+req.Host+"/") } req.Header.Set("User-Agent", getUserAgent()) resp, err := client.Do(req) if err != nil { return nil, err } if resp.StatusCode >= 400 { return nil, fmt.Errorf("http error %d", resp.StatusCode) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } return body, nil } // ProcessBase64Image transforms a base64 encoded string from a form post and // returns the image itself as a byte slice. func ProcessBase64Image(imageString string) ([]byte, error) { if imageString == "" { return nil, fmt.Errorf("empty image string") } regex := regexp.MustCompile(base64RE) matches := regex.FindStringSubmatch(imageString) var encodedString string if len(matches) > 2 { encodedString = regex.FindStringSubmatch(imageString)[2] } else { encodedString = imageString } imageData, err := GetDataFromBase64String(encodedString) if err != nil { return nil, err } return imageData, nil } // GetDataFromBase64String returns the given base64 encoded string as a byte slice func GetDataFromBase64String(encodedString string) ([]byte, error) { return base64.StdEncoding.DecodeString(encodedString) } // GetBase64StringFromData returns the given byte slice as a base64 encoded string func GetBase64StringFromData(data []byte) string { return base64.StdEncoding.EncodeToString(data) } func ServeImage(w http.ResponseWriter, r *http.Request, image []byte) { contentType := http.DetectContentType(image) if contentType == "text/xml; charset=utf-8" || contentType == "text/plain; charset=utf-8" { contentType = "image/svg+xml" } w.Header().Set("Content-Type", contentType) ServeStaticContent(w, r, image) } ================================================ FILE: pkg/utils/map.go ================================================ package utils import ( "strings" ) // NestedMap is a map that supports nested keys. // It is expected that the nested maps are of type map[string]interface{} type NestedMap map[string]interface{} func (m NestedMap) Get(key string) (interface{}, bool) { fields := strings.Split(key, ".") current := m for _, f := range fields[:len(fields)-1] { v, found := current[f] if !found { return nil, false } current, _ = v.(map[string]interface{}) if current == nil { return nil, false } } ret, found := current[fields[len(fields)-1]] return ret, found } func (m NestedMap) Set(key string, value interface{}) { fields := strings.Split(key, ".") current := m for _, f := range fields[:len(fields)-1] { v, ok := current[f].(map[string]interface{}) if !ok { v = make(map[string]interface{}) current[f] = v } current = v } current[fields[len(fields)-1]] = value } func (m NestedMap) Delete(key string) { fields := strings.Split(key, ".") current := m for _, f := range fields[:len(fields)-1] { v, ok := current[f].(map[string]interface{}) if !ok { return } current = v } delete(current, fields[len(fields)-1]) } // MergeMaps merges src into dest. If a key exists in both maps, the value from src is used. func MergeMaps(dest map[string]interface{}, src map[string]interface{}) { for k, v := range src { if _, ok := dest[k]; ok { if srcMap, ok := v.(map[string]interface{}); ok { if destMap, ok := dest[k].(map[string]interface{}); ok { MergeMaps(destMap, srcMap) continue } } } dest[k] = v } } ================================================ FILE: pkg/utils/map_test.go ================================================ package utils import ( "reflect" "testing" ) func TestNestedMapGet(t *testing.T) { m := NestedMap{ "foo": map[string]interface{}{ "bar": map[string]interface{}{ "baz": "qux", }, }, } tests := []struct { name string key string want interface{} found bool }{ { name: "Get a value from a nested map", key: "foo.bar.baz", want: "qux", found: true, }, { name: "Get a value from a nested map with a missing key", key: "foo.bar.quux", want: nil, found: false, }, { name: "Get a value from a nested map with a missing key", key: "foo.quux.baz", want: nil, found: false, }, { name: "Get a value from a nested map with a missing key", key: "quux.bar.baz", want: nil, found: false, }, { name: "Get a value from a nested map with a missing key", key: "foo.bar", want: map[string]interface{}{"baz": "qux"}, found: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, found := m.Get(tt.key) if !reflect.DeepEqual(got, tt.want) { t.Errorf("NestedMap.Get() got = %v, want %v", got, tt.want) } if found != tt.found { t.Errorf("NestedMap.Get() found = %v, want %v", found, tt.found) } }) } } func TestNestedMapSet(t *testing.T) { tests := []struct { name string key string existing NestedMap want NestedMap }{ { name: "Set a value in a nested map", key: "foo.bar.baz", existing: NestedMap{}, want: NestedMap{ "foo": map[string]interface{}{ "bar": map[string]interface{}{ "baz": "qux", }, }, }, }, { name: "Overwrite existing value", key: "foo.bar", existing: NestedMap{ "foo": map[string]interface{}{ "bar": "old", }, }, want: NestedMap{ "foo": map[string]interface{}{ "bar": "qux", }, }, }, { name: "Set a value overwriting a primitive with a nested map", key: "foo.bar", existing: NestedMap{ "foo": "bar", }, want: NestedMap{ "foo": map[string]interface{}{ "bar": "qux", }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tt.existing.Set(tt.key, "qux") if !reflect.DeepEqual(tt.existing, tt.want) { t.Errorf("NestedMap.Set() got = %v, want %v", tt.existing, tt.want) } }) } } func TestNestedMapDelete(t *testing.T) { tests := []struct { name string key string existing NestedMap want NestedMap }{ { name: "Delete non existing value", key: "foo.bar.baa", existing: NestedMap{ "foo": map[string]interface{}{ "bar": map[string]interface{}{ "baz": "qux", }, }, }, want: NestedMap{ "foo": map[string]interface{}{ "bar": map[string]interface{}{ "baz": "qux", }, }, }, }, { name: "Delete existing value", key: "foo.bar", existing: NestedMap{ "foo": map[string]interface{}{ "bar": "old", }, }, want: NestedMap{ "foo": map[string]interface{}{}, }, }, { name: "Delete existing map", key: "foo.bar", existing: NestedMap{ "foo": map[string]interface{}{ "bar": map[string]interface{}{ "baz": "qux", }, }, }, want: NestedMap{ "foo": map[string]interface{}{}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tt.existing.Delete(tt.key) if !reflect.DeepEqual(tt.existing, tt.want) { t.Errorf("NestedMap.Set() got = %v, want %v", tt.existing, tt.want) } }) } } func TestMergeMaps(t *testing.T) { tests := []struct { name string dest map[string]interface{} src map[string]interface{} result map[string]interface{} }{ { name: "Merge two maps", dest: map[string]interface{}{ "foo": "bar", }, src: map[string]interface{}{ "baz": "qux", }, result: map[string]interface{}{ "foo": "bar", "baz": "qux", }, }, { name: "Merge two maps with overlapping keys", dest: map[string]interface{}{ "foo": "bar", "baz": "qux", }, src: map[string]interface{}{ "baz": "quux", }, result: map[string]interface{}{ "foo": "bar", "baz": "quux", }, }, { name: "Merge two maps with overlapping keys and nested maps", dest: map[string]interface{}{ "foo": map[string]interface{}{ "bar": "baz", }, }, src: map[string]interface{}{ "foo": map[string]interface{}{ "qux": "quux", }, }, result: map[string]interface{}{ "foo": map[string]interface{}{ "bar": "baz", "qux": "quux", }, }, }, { name: "Merge two maps with overlapping keys and nested maps", dest: map[string]interface{}{ "foo": map[string]interface{}{ "bar": "baz", }, }, src: map[string]interface{}{ "foo": "qux", }, result: map[string]interface{}{ "foo": "qux", }, }, { name: "Merge two maps with overlapping keys and nested maps", dest: map[string]interface{}{ "foo": "qux", }, src: map[string]interface{}{ "foo": map[string]interface{}{ "bar": "baz", }, }, result: map[string]interface{}{ "foo": map[string]interface{}{ "bar": "baz", }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { MergeMaps(tt.dest, tt.src) if !reflect.DeepEqual(tt.dest, tt.result) { t.Errorf("NestedMap.Set() got = %v, want %v", tt.dest, tt.result) } }) } } ================================================ FILE: pkg/utils/mutex.go ================================================ package utils import "sync" // MutexManager manages access to mutexes using a mutex type and key. type MutexManager struct { mapChan chan map[string]<-chan struct{} } // NewMutexManager returns a new instance of MutexManager. func NewMutexManager() *MutexManager { ret := &MutexManager{ mapChan: make(chan map[string]<-chan struct{}, 1), } initial := make(map[string]<-chan struct{}) ret.mapChan <- initial return ret } // Claim blocks until the mutex for the mutexType and key pair is available. // The mutex is then claimed by the calling code until the provided done // channel is closed. func (csm *MutexManager) Claim(mutexType string, key string, done <-chan struct{}) { mapKey := mutexType + "_" + key success := false var existing <-chan struct{} for !success { // grab the map m := <-csm.mapChan // get the entry for the given key newEntry := m[mapKey] // if its the existing entry or nil, then it's available, add our channel if newEntry == nil || newEntry == existing { m[mapKey] = done success = true } // return the map csm.mapChan <- m // if there is an existing entry, now we can wait for it to // finish, then repeat the process if newEntry != nil { existing = newEntry <-newEntry } } // add to goroutine to remove from the map only go func() { <-done m := <-csm.mapChan if m[mapKey] == done { delete(m, mapKey) } csm.mapChan <- m }() } type MutexField[T any] struct { mutex sync.RWMutex value T } func (mf *MutexField[T]) Get() T { mf.mutex.RLock() defer mf.mutex.RUnlock() return mf.value } func (mf *MutexField[T]) Set(value T) { mf.mutex.Lock() defer mf.mutex.Unlock() mf.value = value } func (mf *MutexField[T]) SetFunc(f func(T) T) { mf.mutex.Lock() defer mf.mutex.Unlock() mf.value = f(mf.value) } ================================================ FILE: pkg/utils/mutex_test.go ================================================ package utils import ( "sync" "testing" ) // should be run with -race func TestMutexManager(t *testing.T) { m := NewMutexManager() map1 := make(map[string]bool) map2 := make(map[string]bool) map3 := make(map[string]bool) maps := []map[string]bool{ map1, map2, map3, } types := []string{ "foo", "foo", "bar", } const key = "baz" const workers = 8 const loops = 300 var wg sync.WaitGroup for k := 0; k < workers; k++ { wg.Add(1) go func(wk int) { defer wg.Done() for l := 0; l < loops; l++ { func(l int) { c := make(chan struct{}) defer close(c) m.Claim(types[l%3], key, c) maps[l%3][key] = true }(l) } }(k) } wg.Wait() } ================================================ FILE: pkg/utils/phash.go ================================================ package utils import ( "math" "strconv" "github.com/corona10/goimagehash" "github.com/stashapp/stash/pkg/sliceutil" ) type Phash struct { SceneID int `db:"id"` Hash int64 `db:"phash"` Duration float64 `db:"duration"` Neighbors []int Bucket int } func FindDuplicates(hashes []*Phash, distance int, durationDiff float64) [][]int { for i, scene := range hashes { sceneHash := goimagehash.NewImageHash(uint64(scene.Hash), goimagehash.PHash) for j, neighbor := range hashes { if i != j && scene.SceneID != neighbor.SceneID { neighbourDurationDistance := 0. if scene.Duration > 0 && neighbor.Duration > 0 { neighbourDurationDistance = math.Abs(scene.Duration - neighbor.Duration) } if (neighbourDurationDistance <= durationDiff) || (durationDiff < 0) { neighborHash := goimagehash.NewImageHash(uint64(neighbor.Hash), goimagehash.PHash) neighborDistance, _ := sceneHash.Distance(neighborHash) if neighborDistance <= distance { scene.Neighbors = append(scene.Neighbors, j) } } } } } var buckets [][]int for _, scene := range hashes { if len(scene.Neighbors) > 0 && scene.Bucket == -1 { bucket := len(buckets) scenes := []int{scene.SceneID} scene.Bucket = bucket findNeighbors(bucket, scene.Neighbors, hashes, &scenes) if len(scenes) > 1 { buckets = append(buckets, scenes) } } } return buckets } func findNeighbors(bucket int, neighbors []int, hashes []*Phash, scenes *[]int) { for _, id := range neighbors { hash := hashes[id] if hash.Bucket == -1 { hash.Bucket = bucket *scenes = sliceutil.AppendUnique(*scenes, hash.SceneID) findNeighbors(bucket, hash.Neighbors, hashes, scenes) } } } func PhashToString(phash int64) string { return strconv.FormatUint(uint64(phash), 16) } func StringToPhash(s string) (int64, error) { ret, err := strconv.ParseUint(s, 16, 64) if err != nil { return 0, err } return int64(ret), nil } ================================================ FILE: pkg/utils/reflect.go ================================================ package utils import "reflect" // NotNilFields returns the matching tag values of fields from an object that are not nil. // Panics if the provided object is not a struct. func NotNilFields(subject interface{}, tag string) []string { value := reflect.ValueOf(subject) structType := value.Type() if structType.Kind() != reflect.Struct { panic("subject must be struct") } var ret []string for i := 0; i < value.NumField(); i++ { field := value.Field(i) kind := field.Type().Kind() if (kind == reflect.Ptr || kind == reflect.Slice) && !field.IsNil() { tagValue := structType.Field(i).Tag.Get(tag) if tagValue != "" { ret = append(ret, tagValue) } } } return ret } ================================================ FILE: pkg/utils/reflect_test.go ================================================ package utils import ( "reflect" "testing" ) func TestNotNilFields(t *testing.T) { v := "value" var zeroStr string type testObject struct { ptrField *string `tag:"ptrField"` noTagField *string otherTagField *string `otherTag:"otherTagField"` sliceField []string `tag:"sliceField"` } type args struct { subject interface{} tag string } tests := []struct { name string args args want []string }{ { "basic", args{ testObject{ ptrField: &v, noTagField: &v, otherTagField: &v, sliceField: []string{v}, }, "tag", }, []string{"ptrField", "sliceField"}, }, { "empty", args{ testObject{}, "tag", }, nil, }, { "zero values", args{ testObject{ ptrField: &zeroStr, noTagField: &zeroStr, otherTagField: &zeroStr, sliceField: []string{}, }, "tag", }, []string{"ptrField", "sliceField"}, }, { "other tag", args{ testObject{ ptrField: &v, noTagField: &v, otherTagField: &v, sliceField: []string{v}, }, "otherTag", }, []string{"otherTagField"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := NotNilFields(tt.args.subject, tt.args.tag); !reflect.DeepEqual(got, tt.want) { t.Errorf("NotNilFields() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: pkg/utils/resources.go ================================================ package utils var PendingGenerateResource, _ = GetDataFromBase64String("iVBORw0KGgoAAAANSUhEUgAAAfQAAADwBAMAAAAEHosbAAAAG1BMVEUAAADMzMyZmZkzMzNmZmZ/f38ZGRmysrJMTEwh+DPkAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAEOklEQVR4nO3YzW/bNhjHcfpN9jGP8iIfI2xrdrQLrLs67Zpdo21pelTWdbnGSTPvaBdosT+7z8OXxAFkIDu0Vrfv59AmpEjoJ1IkFecAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMC/JCqfNNV0Dja1GR0Vv36+O/piLLrsNdVo9EyaKnpzbfLkMZ1vfnptIGdnZ6XUDTUbo0+lOJKiqUlDFy1mqbP5aUPNpujZfK92b6WpSUMXLeYHfHXVULPpvvtiS0P1mFRfQfSLnYaaTffd8SvDsHF9eGQX7eCj2y2+n79z9hCeFq/0/7fzJ2HCp4LsPJ+EJWHln1P/1vmlfqZV0itvrSp0IX9WS9c/yk9cV2Q3lbaQjzPdcQNdtQ81+lhsBevpbxcheihwlyIHIXp1/5ZXujFa9JXIXy51Ib/L0s2tlY8eS1vIx6mu3HHxU7mv0eWbni56Q/mtLEP0UODK/GUeopez1LYv757rcpfpweBHbRu7kEqWA3n9QU570/GzVNpCFucHmWUycyP9+aLwM3q6q7+F6KEg05FchejzZWp7oe/7yq660qtd6kLy2g3H+iSuwiYRSlvIH2mk7uvEdeXSp9GZXto4h+ihYKD13ZDAFvjS2rjjQy0c+3TZfRf6IKyFvUUaPZW2kE8+dl1bsI9n7mLXL3rzSRzPVKAJ0+DdR7ep39/TqyY2e1IXOj9cVvu5Yg1jaQvJYrH4TmeofwSnfrj0hi3kNEQPBcNdW85qa2ET/nyx0F/sQCv7oVzq1IXf9t2bD+Kjp9IWiq9hx9/hod/hOwf+FBdXeF/gOg+jh4a+TR7OfFKnLvz58Fx/9NFTaQul6IWO/uJB9M56dBv1eG01s3/tOdiMWVzfRY9d2FWXkr8O73oqbaEYvRsPZzGplT4YdXvX46iHU+8ovuvOpeipC7uquvFdpXe9nWL0gW29b+qU1Ja56Xr0gSbohWtt4QvLvR1usmcpeurCrrKXIox6Km2hGL2v27eNYkxqk/p4PbrVD8K1A/+9apu87tw2qjF66sIvevrojndSw7VTUJvE6OHkMUlJp3ouna9Ht/ppuDaTGzvI2fnHjjS7KXrqwo/6TOfIji2OqbSF0kGrysNBNr7a+eSprEfX+pcSr13J7S8yLmudBt+Gg2zoJ3bh3/X9fyr9rB/mH1NpC6Xo3fj5EgdZt+wHE97q9+K1I9uullXtTzb53TKXuggrvBSXMhvo50u3rXvbXXT3vvje3UXXj9a/H2xuzv1cfEzX/jEvTtxKfxmV18v76LGLsK8Xs95imZU3qfTr1vyXuv+83gsd42Lbd7EVI/tyy7d9F1uRyateOd72XWyHfaY2/d32f8C2q1YeTD6/7Pn1ybbvAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAL+ITrOa6fKadMcEAAAAASUVORK5CYII=") ================================================ FILE: pkg/utils/strings.go ================================================ package utils import ( "fmt" "strings" ) type StrFormatMap map[string]interface{} // StrFormat formats the provided format string, replacing placeholders // in the form of "{fieldName}" with the values in the provided // StrFormatMap. // // For example, // // StrFormat("{foo} bar {baz}", StrFormatMap{ // "foo": "bar", // "baz": "abc", // }) // // would return: "bar bar abc" func StrFormat(format string, m StrFormatMap) string { args := make([]string, len(m)*2) i := 0 for k, v := range m { args[i] = fmt.Sprintf("{%s}", k) args[i+1] = fmt.Sprint(v) i += 2 } return strings.NewReplacer(args...).Replace(format) } // StringerSliceToStringSlice converts a slice of fmt.Stringers to a slice of strings. func StringerSliceToStringSlice[V fmt.Stringer](v []V) []string { ret := make([]string, len(v)) for i, vv := range v { ret[i] = vv.String() } return ret } ================================================ FILE: pkg/utils/strings_test.go ================================================ package utils import "fmt" func ExampleStrFormat() { fmt.Println(StrFormat("{foo} bar {baz}", StrFormatMap{ "foo": "bar", "baz": "abc", })) // Output: // bar bar abc } ================================================ FILE: pkg/utils/time.go ================================================ package utils import "time" // Timeout executes the provided todo function, and waits for it to return. If // the function does not return before the waitTime duration is elapsed, then // onTimeout is executed, passing a channel that will be closed when the // function returns. func Timeout(todo func(), waitTime time.Duration, onTimeout func(done chan struct{})) { done := make(chan struct{}) go func() { todo() close(done) }() select { case <-done: // on time, just exit case <-time.After(waitTime): onTimeout(done) } } ================================================ FILE: pkg/utils/url.go ================================================ package utils import "regexp" // URLFromHandle adds the site URL to the input if the input is not already a URL // siteURL must not end with a slash func URLFromHandle(input string, siteURL string) string { // if the input is already a URL, return it re := regexp.MustCompile(`^https?://`) if re.MatchString(input) { return input } return siteURL + "/" + input } ================================================ FILE: pkg/utils/url_test.go ================================================ package utils import "testing" func TestURLFromHandle(t *testing.T) { type args struct { input string siteURL string } tests := []struct { name string args args want string }{ { name: "input is already a URL https", args: args{ input: "https://foo.com", siteURL: "https://bar.com", }, want: "https://foo.com", }, { name: "input is already a URL http", args: args{ input: "http://foo.com", siteURL: "https://bar.com", }, want: "http://foo.com", }, { name: "input is not a URL", args: args{ input: "foo", siteURL: "https://foo.com", }, want: "https://foo.com/foo", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := URLFromHandle(tt.args.input, tt.args.siteURL); got != tt.want { t.Errorf("URLFromHandle() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: pkg/utils/urlmap.go ================================================ package utils import "strings" // URLMap is a map of URL prefixes to filesystem locations type URLMap map[string]string // GetFilesystemLocation returns the adjusted URL and the filesystem location func (m URLMap) GetFilesystemLocation(url string) (newURL string, fsPath string) { newURL = url if m == nil { return } root := m["/"] for k, v := range m { if k != "/" && strings.HasPrefix(url, k) { newURL = strings.TrimPrefix(url, k) fsPath = v return } } if root != "" { fsPath = root return } return } ================================================ FILE: pkg/utils/urlmap_test.go ================================================ package utils import ( "testing" ) func TestURLMap_GetFilesystemLocation(t *testing.T) { // create the URLMap urlMap := make(URLMap) urlMap["/"] = "root" urlMap["/foo"] = "bar" empty := make(URLMap) var nilMap URLMap tests := []struct { name string urlMap URLMap url string wantNewURL string wantFsPath string }{ { name: "simple", urlMap: urlMap, url: "/foo/bar", wantNewURL: "/bar", wantFsPath: "bar", }, { name: "root", urlMap: urlMap, url: "/baz", wantNewURL: "/baz", wantFsPath: "root", }, { name: "root", urlMap: urlMap, url: "/baz", wantNewURL: "/baz", wantFsPath: "root", }, { name: "empty", urlMap: empty, url: "/xyz", wantNewURL: "/xyz", wantFsPath: "", }, { name: "nil", urlMap: nilMap, url: "/xyz", wantNewURL: "/xyz", wantFsPath: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotNewURL, gotFsPath := tt.urlMap.GetFilesystemLocation(tt.url) if gotNewURL != tt.wantNewURL { t.Errorf("URLMap.GetFilesystemLocation() gotNewURL = %v, want %v", gotNewURL, tt.wantNewURL) } if gotFsPath != tt.wantFsPath { t.Errorf("URLMap.GetFilesystemLocation() gotFsPath = %v, want %v", gotFsPath, tt.wantFsPath) } }) } } ================================================ FILE: pkg/utils/user_agent.go ================================================ package utils import "runtime" // valid UA from https://user-agents.net const Safari = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Safari/605.1.15/iY0wnXbs-59" const FirefoxWindows = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0" const FirefoxLinux = "Mozilla/5.0 (X11; Linux x86_64; rv:88.0) Gecko/20100101 Firefox/88.0" const FirefoxLinuxArm = "Mozilla/5.0 (X11; Linux armv7l; rv:86.0) Gecko/20100101 Firefox/86.0" const FirefoxLinuxArm64 = "Mozilla/5.0 (X11; Linux aarch64; rv:86.0) Gecko/20100101 Firefox/86.0" // getUserAgent returns a valid User Agent string that matches the running os/arch func getUserAgent() string { arch := runtime.GOARCH os := runtime.GOOS switch os { case "darwin": return Safari case "windows": return FirefoxWindows case "linux": switch arch { case "arm": return FirefoxLinuxArm case "arm64": return FirefoxLinuxArm64 case "amd64": return FirefoxLinux default: return FirefoxLinux } default: return FirefoxLinux } } ================================================ FILE: pkg/utils/vtt.go ================================================ package utils import ( "fmt" "math" ) // from stdlib's time.go func norm(hi, lo, base int) (nhi, nlo int) { if lo < 0 { n := (-lo-1)/base + 1 hi -= n lo += n * base } if lo >= base { n := lo / base hi += n lo -= n * base } return hi, lo } // GetVTTTime returns a timestamp appropriate for VTT files (hh:mm:ss.mmm) func GetVTTTime(fracSeconds float64) string { if fracSeconds < 0 || math.IsNaN(fracSeconds) || math.IsInf(fracSeconds, 0) { return "00:00:00.000" } var msec, sec, mnt, hour int msec = int(fracSeconds * 1000) sec, msec = norm(sec, msec, 1000) mnt, sec = norm(mnt, sec, 60) hour, mnt = norm(hour, mnt, 60) return fmt.Sprintf("%02d:%02d:%02d.%03d", hour, mnt, sec, msec) } ================================================ FILE: pkg/utils/vtt_test.go ================================================ package utils import ( "math" "testing" ) func TestZeroTimestamp(t *testing.T) { if want, got := "00:00:00.000", GetVTTTime(0); want != got { t.Errorf("TestZeroTimestamp: GetVTTTime(0) = %v; want %v", got, want) } } func TestValidTimestamp(t *testing.T) { s := 0.1 if want, got := "00:00:00.100", GetVTTTime(s); want != got { t.Errorf("TestValidTimestamp: GetVTTTime(%v) = %v; want %v", s, got, want) } s = ((24+1)*60+1)*60 + 1 + 0.1 if want, got := "25:01:01.100", GetVTTTime(s); want != got { t.Errorf("TestValidTimestamp: GetVTTTime(%v) = %v; want %v", s, got, want) } } // Negative timestamps are not defined by WebVTT. func TestNegativeTimestamp(t *testing.T) { if want, got := "00:00:00.000", GetVTTTime(-1); want != got { t.Errorf("TestNegativeTimestamp: GetVTTTime(-1) = %v; want %v", got, want) } } func TestInvalidTimestamp(t *testing.T) { if want, got := "00:00:00.000", GetVTTTime(math.NaN()); want != got { t.Errorf("TestInvalidTimestamp: GetVTTTime(NaN) = %v; want %v", got, want) } if want, got := "00:00:00.000", GetVTTTime(math.Inf(1)); want != got { t.Errorf("TestInvalidTimestamp: GetVTTTime(Inf) = %v; want %v", got, want) } if want, got := "00:00:00.000", GetVTTTime(math.Inf(-1)); want != got { t.Errorf("TestInvalidTimestamp: GetVTTTime(-Inf) = %v; want %v", got, want) } } ================================================ FILE: scripts/generateLoginLocales.go ================================================ //go:build ignore // +build ignore package main import ( "encoding/json" "fmt" "io" "io/fs" "os" "path/filepath" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/utils" ) func main() { verbose := len(os.Args) > 1 && os.Args[1] == "-v" fmt.Printf("Generating login locales\n") // read all json files in the locales directory // and extract only the login part // assume running from ui directory dirFS := os.DirFS(filepath.Join("v2.5", "src", "locales")) // ensure the login/locales directory exists if err := fsutil.EnsureDir(filepath.Join("login", "locales")); err != nil { panic(err) } fs.WalkDir(dirFS, ".", func(path string, d fs.DirEntry, err error) error { if err != nil { panic(err) } if d.IsDir() { return nil } if filepath.Ext(path) != ".json" { return nil } // extract the login part // from the json file src, err := dirFS.Open(path) if err != nil { panic(err) } defer src.Close() data, err := io.ReadAll(src) if err != nil { panic(err) } m := make(utils.NestedMap) if err := json.Unmarshal(data, &m); err != nil { panic(err) } l, found := m.Get("login") if !found { // nothing to do return nil } // create new json file // with only the login part if verbose { fmt.Printf("Writing %s\n", d.Name()) } f, err := os.Create(filepath.Join("login", "locales", d.Name())) if err != nil { panic(err) } defer f.Close() e := json.NewEncoder(f) if err := e.Encode(l); err != nil { panic(err) } return nil }) } ================================================ FILE: scripts/generate_icons.sh ================================================ #!/bin/bash # Update the Stash icon throughout the project from a master stash-logo.png # Imagemagick, and go packages icns and rsrc are required. # Copy a high-resolution stash-logo.png to this stash/scripts folder # and run this script from said folder, commit the result. if [ ! -f "stash-logo.png" ]; then echo "stash-logo.png not found." exit fi if [ -z "$GOPATH" ]; then echo "GOPATH environment variable not set" exit fi if [ ! -e "$GOPATH/bin/rsrc" ]; then echo "Missing Dependency:" echo "Please run the following /outside/ of the stash folder:" echo "go install github.com/akavel/rsrc@latest" exit fi if [ ! -e "$GOPATH/bin/icnsify" ]; then echo "Missing Dependency:" echo "Please run the following /outside/ of the stash folder:" echo "go install github.com/jackmordaunt/icns/v2/cmd/icnsify@latest" exit fi # Favicon, used for web favicon, windows systray icon, windows executable icon convert stash-logo.png -define icon:auto-resize=256,64,48,32,16 favicon.ico cp favicon.ico ../ui/v2.5/public/ # Build .syso for Windows icon, consumed by linker while building stash-win.exe "$GOPATH"/bin/rsrc -ico favicon.ico -o icon_windows.syso mv icon_windows.syso ../pkg/desktop/ # *nixes systray icon convert stash-logo.png -resize x256 favicon.png cp favicon.png ../ui/v2.5/public/ # MacOS, used for bundle icon # https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html "$GOPATH"/bin/icnsify -i stash-logo.png -o icon.icns mv icon.icns macos-bundle/Contents/Resources/icon.icns # cleanup rm favicon.png favicon.ico ================================================ FILE: scripts/getDate.go ================================================ // +build ignore package main import "fmt" import "time" func main() { now := time.Now().Format("2006-01-02 15:04:05") fmt.Printf("%s", now) } ================================================ FILE: scripts/macos-bundle/Contents/Info.plist ================================================ CFBundleExecutable stash CFBundleIconFile icon.icns CFBundleTypeIconFile icon.icns CFBundleIdentifier cc.stashapp.stash NSHighResolutionCapable True LSUIElement 1 ================================================ FILE: scripts/test_db_generator/README.md ================================================ This is a quick and dirty go script for generating a contrived database for testing purposes. Edit the `config.yml` file to your liking. The numbers indicate the number of objects to generate, the `naming` section indicates the files from which to generate names. May cause unexpected behaviour if run against an existing database file. To run - from the `test_db_generator`: `go run .` The database file will be generated in the current directory. ================================================ FILE: scripts/test_db_generator/config.yml ================================================ database: generated.sqlite scenes: 30000 images: 4000000 galleries: 1500 chapters: 3000 markers: 3000 performers: 10000 studios: 1500 tags: 1500 naming: scenes: scene.txt performers: male: male.txt female: female.txt surname: surname.txt galleries: scene.txt studios: studio.txt tags: scene.txt images: scene.txt ================================================ FILE: scripts/test_db_generator/female.txt ================================================ A.J. Aaiyah Aali aaliyah Aalyiah Aaralyn Aarielle Aarin Aarolyn Aayla Abagail Abagelle Abany Abbey Abbi Abbie Abbraxa Abby Abegail Abelia Abelinda Abella Abhilasha Abi Abia Abigail Abigaile Abril Abrill Absinthe Acropolis Ada Adahlia Adajja Adalisa Adalyn Adanna Adara Addam Addee Addi Addie Addison Addy Addyson Adel Adela Adelaida Adele Adelia Adelina Adeline Adelisa Adell Adella Adelle Adelyn Aden Adessa Adhasa Adia Adiamo Adicktion Adin Adina Adira Adison Adl Adley Adney Adora Adoration Adreanna Adreena Adrenalyn Adrenalynn Adri Adria Adrian Adriana Adrianna Adrianne Adrien Adrienn Adrienne Adrienz Adriyana Adry Adryanna Advril Ady Adyen Aelita Aeon Aerial Aeris Afelia Afina Afox Afra Africa Afrodita Afrodite Afrodithe Afroditte Afrodity Aften Afton Agata Agatha Agathe Agatta Ageha Aggie Aghata Aghatha Agita Aglaya Aglona Agnejka Agnes Agnesa Agnese Agness Agnessa Agneta Agnetta Agnise Agnyese Agota Ahayla Ahisa Ahn Ahna Ahryan Ahud Aicha Aico Aida Aidan Aiden Aidra Aika Aiko Aila Aileen Ailek Aimee Aimeelynn Aimer Aina Ainara Ainsley Ainy Airita Airlady Airodite Aisha Aisis Aislin Aitor Aiwe Aiya Aiyana Aiza Aj Aja Ajenda Ajpa Ak Akari Akarra Akasha Akemi Akemy AKGingersnaps Aki Akilina Akira Akita Akkara Akropolis Aktavia Akyra Alaiah Alaina Alamea Alana Alanah Alanaleigh Alani Alania Alanis Alanna Alannah Alannis Alanova Alasa Alaska Alaura Alay Alaya Alayah Alayla Alayna Alba Albert Alberta Albertina Albina Alby Aldana Aldena Aldi Ale Alea Alecextra Alecia Alecto Aleena Aleera Aleesha Aleftina Alegra Aleigh Aleijsha Alejandra Aleks Aleksa Aleksaise Aleksandra Alektra Alena Alena* Alenia Alesha Alesia Aleska Alessa Alessandra Alessandria Alessia Alesya Aletta Alex alexa Alexandera Alexandra Alexandrea Alexandria Alexcia Alexes Alexextra Alexi Alexia Alexiasky alexis Alexiss Alexiz Alexsis Alexus Alexx Alexxa Alexxx Alexxxis Alexy Alexya Ali Alia Aliah Aliana Alianna Alica Alice Alice* Alice, Alice85JJ AliceafterDark Alicia Alicia* Alicija Alicyn Alida Alie Aliela Alien Aliesha Aliha Alin Alina Alinda Aline Aliny Aliona Alis Alisa Alisandra Alise Alisha Alishia Alisia Alison Alissa Alissia Alisson Alissya Alita Alitzia Alive Alix Alixus Aliya Aliyah Aliysa Aliz Aliza Alize Aljena Alla Allan Allana Allanah Allatra Allaura Allayah Allayana Allee Allegra Allen Allesandra Allex Allexis Alli Allie Alliehaze Allisan Allison Allister Alliyah Alloa Allona Allora Allsa Allura Allure Ally Allyana Allyann Allysa Allysah Allysia Allysin Allyson Allyssa Alma Almond Alocica Aloha Alona Alondra Alonna Alonya Alora Aloura Alsana Alsu Alta Altea Alura Alux Alvero Aly Alya Alyce Alycia Alyia Alyiah Alykat Alyn Alyona Alysa Alysee Alysha Alysia Alyson Alyssa Alyssia Alysson Alyx Alyz'e Am Ama Amabella Amabielle Amadea Amahi Amai Amalia Amalie Amanda Amandae Amandah Amandine Amara Amaranta Amaretta Amari Amaris amarna Amateur Amathyst Amatuer Amaya Amazon Amba Ambar Amber Ambergrand Amberi Amberlee Amberr Ambery Ambika Ambra Ambrosia Ambyr Amee Ameena Amel Ameli Amelia Amelie Amelinda Ameliya Amely America Amerie Amethyst Ametista Ami Amia Amica Amie Amiee Amile Amilia Amilian Amina Amira Amirah Ammey Ammon Ammy Amo Amor Amora Amwlie Amy Amy, Amybrooke Amyiaa Amyna Amyra Amysativa Amyza ana Anaa Anabel Anabela Anabell Anabella Anabelle Anabeth Anael Anaidha Anaile Anainda Anais Analee Analia Analine Analise Analyssa Anamarie Ananova Ananta Anas Anastacia Anastasha Anastasia Anastasija Anastasiya Anastassia Anastasya Anastaysa Anastaza Anastazia Anastazie Anata Anavelle Anaya Ancsi Anda Andchana Andee Ander Andi Andia Andie Andre Andrea Andreea Andreena Andreina Andri Andria Andrianna Andriena Andy Andys Anea Anechka Aneli Anelie Anell Anella Anelly Anelys Anemona Anesha Anet Aneta Anete Anett Anetta Anette Anezka Anfisa Ange Angee Angeka Angel Angela Angela** Angela**** Angele Angeleena Angelena Angeles Angeli Angelia Angelic Angelica Angelik Angelika Angelin Angelina Angelinaash Angeline Angelique AngeliXXX Angell Angella Angelli Angellina Angelline Angellyna Angellyne Angelyca Angelyna Angelynne Anges Angie Angiela Angienoir Angry Angy Angyelkana Ani Ania Anie Anife Anija Anik Anika Anike Anikka Aniko Anila Anina Aninha Anisa Anise Anisha Anisiya Anissa Anita Anitra Anitta aniya Anjanette Anjela Anjelica Anjelina Anjelyze Anjie Anjii Ann Anna Anna** Anna, Anna_Rose Annabel Annabella Annabelle Annabellle Annalee Annalisa Anna-Marie Annara Annastevens Anne Anne-angel Anneje Anneke Anneli Anneliese Annellise Annemari Annemarie Anne-Marie Annet Anneta Annett Annette Anni Annie Annik Annika Annika, Annina Annis Annita Annlore Ann-lyz Annmarie Ann-marie AnnMarie Ann-Marie Anny Anoli Anorei Anoushka Anouska Anri Ansie Ansley Antea Anthia Antiana Antica Antinia Antoanett Antoinette Anton Antonella Antonia Antoniette Antonina Antonya Antonyia Antuanetta Antynia Anunka Anushka Anuta Any Anya Anyjah Anyl Anyssa Anyutka Anzelika Aoki Aon Aphrodesia Aphrodite Apolonia Apple april Aprilia Apriloneil Apryl Aqua Ara Arabella Arabelle Arachnia Aracoeli Aragne Aralyn Aranka Aranxa Araya Arcadia Archida Ardelia Areana Areena Arejay Arelis Areta Argentina Aria Ariadna Ariah Arial Arian Ariana Ariandra Ariane Arianna Arianny Ariany Arie Ariel Ariel(AKA Ariela ariella Arielle Arienn Aries Arietta Arijna Arika Arin Arina Aris Arissa Aristeia Arkida Arlene Arlenne Arlet Arlisa Armani Armany Aroana Arpita Arria Arriana Arrow Arroyo Arryn Ars Artemida Artemis Artemisia Arteya Aruba Aruna Arvil Arwen Arya Aryah Aryan Aryana Aryell Asa Asdis Ash Asha Ashanti Ashari Ashawni Ashawrya Ashden Ashdon Ashe Asheerah Ashely Ashiana Ashle Ashleah Ashlee Ashleigh Ashlen Ashley Ashleyjane Ashli Ashlie Ashly Ashlyn Ashlynn Ashlynrae Ashson Ashton Ashura Asia Asian Asiana Asiniya Asley Asmaret Asmareth Asol Asole Asolia Aspen Asphyxia Aspid Assh Assoli Asta Astra Astrid Astrud Asuna Asya Atena Atenas Athena Athina Atina Atlanta Atlantis Aubree Aubrey Aubrie Aubrielle Aubry Auburn Auddi Audee Audie Audra Audrey Audri Audriana Audrianna Audrie Audrina Audrinna Audry august Augustina Aundrea Aurelia Aurelie Aurelly Auriana Aurielee Aurita Aurmi Aurora Aurore Auroura Aussie Austin Austine Austral Austyn Autilia Autum Autumm Autumn Auxanna ava Avalon Avanti Aveena Avena Averi Averie Avery Avi Avia Aviana Avidat Avina Avinna Aviva Avona Avonna Avory Avri Avril Avrill Avrora Avrova Avy Avya Avylee Avylynn Awanda Axelle Axen Axis Aya Ayana Ayane Ayanna Ayano Ayda Ayden Aydie Ayla Aylin Ayline Aymee Ayn Aysha Aysla Ayumi Ayumu Ayza Aza Azaelia Azalea Azaria Azella Aziza Azlea Azrael Azul Azura Azure Azyza Azzi Babaloun Babalu Babba Babe Babette Babsy Babuci Babuska Baby Babydoll Babylou Babymoni Babyshar Bac Bacardi Bad Badiya BADKITTYYY BadLady Badlittlegrrl Bado Baffy Bagheera Bagira Bagyraa Baiba Bailee Bailey Bailie Bala Balina Bam Bamba Bambi Bambie Bambina Bamboo Bamby Banesca Bara Barb Barba Barbamiska Barbara Barbarah Barbarela Barbarella Barbary Barber Barbi Barbie Barbora Barbra Barby Barett Barra Barracuda Barran Barrett Bartscha Barunka Bashti Bay Baylee Baylie Bayliss Bb Bea Beata Beatha Beatrice Beatris Beatrix Beau Beaue Beauty Bebe Bebel Beca Becca Becki Beckie Becky Bedeli Bee Bees Beeue Behind Beiley Beili Beka Bekah Bela Belacortes Belicia Belina Belinda Belinha Belka Bell Bella Bella, Belladonna Bellah Bella-Marie Bella-Nikole Bellavitana Belle Bellena Belleniko Bellina Bellize Belonika Bena Bendy Benextra Benji Benta Bente Bentley Benzey Berenice Beretta Berinice Berlin Berlina Berlyn Bernadett Bernadetta Bernadette Bernice Bernie Beronica Berta Bertha Berthe Bess Bessi Bessie Bessy Beta Betania Betcee Beth Bethany Bethina Beti Beto Betsey Betsy Betta Bettey Betti Bettie Bettina Betty Bety Beverly Bexa Bexxxy Bhiankha Bia Bianca Biancka Bianka Biatriss, Bibette Bibi Bicky Big Bigwil Bijou Billi Billie Billy Binky Bionca Birdy Bisexual BJ Bjorg Black Blackberry Blacke Blaiden Blair Blaire Blake Blakely Blakey Blanca Blanche Blandine Blane Blanka Blaten Blaze Blazer Bliss Blond Blonde Blondie Blondy Bloom Blossom Blu Blue Blueberry Bobbi Bobbie Bobby Bodana Bodylicious Bogdana Boglarka Bojinka Bolivia Bolly Bombshell Boni Bonie Bonita Bonne Bonni Bonnie Bonny Bony Boo Boom Boomerang Borbella Boroka Borya Boxxy Brady Branda Brandalyn Brandee Brandi Brandie Brandii Brandon Brandy Brandyextra Brazil Brazilla Brazzers Bre Brea Breana Breanna Breanne Breat Breathe Bree Breeanna Breena Brekell Brenda Brendy Brenna brett Bretta Breyelle Breyta Bri Bria briana Brianna Briar Brice Bridges Bridget Bridgete Bridgett Bridgette Brie Briella Brielle Briget Brigett Brigi Brigit Brigita Brigite Brigitt Brigitta Brigitte Brik Brill Brin Brind Brit Britanny Britany Brithany Britne Britney Britny Britt Britta Brittaney Brittaneyextra Brittani Brittania Brittanie Brittany Brittney Brittneyextra Brittny Brixley Briz Briza Brizit Brodi Bronte Bronze Brook Brooke Brookie Brooklyn Brooklyne Brooklynn Brookyln Brown Bruan Brucie Brun Bruna Brunna Bryana Bryanna Bryce Bryci Brylee Bryn Brynn Bubbles Bubi Buddah Buffy Bugatti Bulgari Bulija Bulma Bulsinesa Bumikia Bunni Bunnie Bunny Busty Butter Butterfly Buxom Byanca Bysya C C.G. C.J. Cabana Cabiria Caddy Cadence Cadey Caesaria Caila Cailey Caise Cait Caitin Caitlin Cala Calenita cali Calibri Calico Calina Calisi Calista Calisyn Calli Callie Calliedee Calliste Calypsa Calypso Cam Cambrey Camelia Camelita Cameo Cameran Cameron Cameronxoxo Cameryn Cami Camie Camil Camila Camile Camilla Camille Cammie Cammille Camrie Camryn Canara Candace Candalyn Candance Cande Candee Candel Candela Candi Candice Candide Candie Candis Candise Candy Candybelle Canela Canella Canyon Caomei Capri Caprice Capris Caprise Capry Cara Caralyn Caramel Caramellito Careena Caren Caress Caressa Caresse Carey Cari Carie Carin Carina Carine Carissa Cariza Carla Carla** Carla4Garda Carleigh Carley Carli Carlie Carlin Carly Carlyn Carlynn Carman Carmel Carmela Carmeline Carmelita Carmella Carmen Carmene Carmilla Carmin Carmina Carol Carola Carole Carolin Carolina Caroline Carolizi Caroll Carolline Carolyn Carolyne Caron Carre Carrie Carrina Carrine Carrmen Carrol Carrole Carroll Carry Carson Carter Casada Casana Casandra Casca Casey Cashamere Cashmere Casi Casia Casie Casper Cassady Cassandra Cassara Cassey Cassi Cassia Cassidey Cassidy Cassie Cassy Cassye Cat Catalia Catalin Catalina Catalya Catania Catarina Cate Caterine Cath Cathaleen Catharina Catherina Catherine Cathiaextra Cathleen Cathrin Cathrine Cathy Cathy_B Cati Catia Catie Catina Catlin Catlyn Catrina Catryn Cats Catt Catti Catty Catwoman Caty Cayden Cayenne Cayla Caylee Caylian Cayton Cb CC Ce Cece Cecelia Cecil Cecilia Cecille Cecily Cedella Cee Ceira Celena Celest Celeste Celestia Celestine Celezte Celia Celina Celinange Celine Celiny Ceran Cerecita Cerise Cessa Chadd Chade Chaise Chalice Champagne Chance Chandler Chandra chanel Chanell Chanelle Channel Channone Chanonne Chanta Chantal Chanta-Rose Chantay Chante Chantel Chantell Chantelle Chanty Chapel Char Charina Charisma Charisse Charity Charlee Charlei Charlen Charlena Charlene Charley Charli Charlie Charlielynn Charlise Charlize Charlly Charlot Charlott Charlotta Charlotte Charly Charlys Charlyse Charmaine Charmane Charmed Charmel Charo Charol Charolette Charry Charumati Chas Chase Chaseextra Chasey Chasi Chasidy Chasity Chastity chavon Chayanna Chayanne Chayd Chayen Chayse Chazz Chechi Checky Cheer Cheetah Chela Chelci Chelsea Chelsey Chelsie Chelsy Chenin Chennin Cher Cherelle Cheri Cherie Cherise Cherish Cherokee Cherri Cherrie Cherries Cherry Chery Cheryl Chesire Chesley Chessie Chevon Chevy Cheyanne Cheyenne Chi Chiara Chiarra Chica Chicago Chichi Chicky Chiki Chikie Chikita Chilli Chimille China Chinara Chinita Chin-nai Chintia Chintya Chips Chipy Chiqui Chiquita Chloe Chloé Chloee Chloejames ChloeLynn Chloey Chlooe Chocky Chocolate Chole Choley Chontelle Chris Chriss Chrissie Chrissy Christa Christal Christel Christelle Christen Christgen Christi Christia Christian Christiana Christianne Christiano christie Christien Christin Christina Christine Christixana Christoff Christoph Christy Chrys Chrysantem Chrystal Chrystina Chrystine Chudamani Chudina Chula Chumundra Chyanne Chyna ChynaWhite Cia Ciara Ciarra Cibely Cica Cicciolina Cici Ciera Cierra Cigogniatella Cikita Cila Cilla Cindee Cinderella Cindi Cindy Cindyrella Cinna Cinnamon Cinta Cinthia Cinthya Cinti Cintia Cintija Cintya Cipriana Cira Circe Cirenia Ciri Citah Cj Clair Claire Clanddi Clar Clara Clare Clarice Clarise Clarissa Clarisse Clarixa Clark Clary Classy Claudi Claudia Claudia** Claudie Claudine Clayra Clea Cleare Clementine Cleo Cleopatra Cler Clio Clockwork Cloe Cloee Cloey Clouey Clover Coby Cocco Coco Cocoa Coddie Codi Codie Cody Coffee Coffey Colby Cole Colette Colleen Collette Collie Conchita Conie Connie Conny Constance Constantine Consuela Consuelo Contessa Cony Cookie Coolmona Cora Coral Coralee Coralie Coralina Coralyn Corazon Corbin Cordelia Coreena Cori Corie Corin Corina Corinna Corinne Cornelia Corrine Corry Cortknee Cory Cosette Cosima Cosmia Costanza Country Courtney Courtnie Cowgirl Coxy Crave Cream Crecy Creme Cricket Crimson Cris Crismary Criss Crissey Crissie Crissy Crissysnow Crista Cristal Cristaliana Cristhina Cristi Cristian Cristiane Cristien Cristin Cristina Cristine Cristy Crosby Crystal Crystalynn Crystina Crystl Csila Csilla Csuka Cumshot Curious Curly Customer Cute Cutie Cyara Cybelle Cydel Cydella Cyhanne Cyle Cynara Cyndi Cyndy Cynthia Cynthya Cyntia Cypher Cyprus Cyrstal Cyrus Cytherea D.C. Dacada Dachuki Dacota Daeja Daffney Dafna Dafne Dag Daggy Dagmar Dagmara Dahlia Dai Daiana Daicy Daiga Daikiri Dailany Daina Daineris Daiquiri Daisy Daizy Dajen Dakini Dakoda Dakota Daksani Dalan Dalatika Dalene Dalia Daliah Dalila Dalilah Dalilla Daliy Dallas Dalle Dalny Damanie Damaris Dame Damien Damiyenextra Damyanti Dana Danae Danalea Danaya Dandara Daneila Dangerdoll Dani Dania Danica Daniela Daniele Daniella Danielle Danielo Daniely Danika Danira Danissa Danlee Danley Danlia Danna Danni Danny Dany Danyel Daphne Daphnee Daphnie Daquiri Dara Darby Darce Darci Darcia Darcie Darcy Darenzia Daria Darian Dariana Dariel Darien Darin Darina Dariya Darla Darlene Darling Darma Darryl Darshani Darya Daryl Daryn Daryna Dasani Dascha Dasha Dashia Dasi Dasia Dasie Dasy Datse Dava Davani Davia Davie Davina Davon Davy Dawkins Dawn Dawna Dawnee Dawson Daya Dayana Dayanne Daylene Daylynn Dayna Dayse Daysie Dayton Daytona Dayvid Day-Z-Jay Dayzjha Dazz DD Dea Deadra Deaf Deana Deanna Deauxma Debbie Debby Debella De'Bella Debi Debie Debora Deborah Debra Deby Decksana Dede Dee Deedee Deejay Deena Deeni Deepika Defrancesca Deidra Deina Deirdre Deitra deja Dejia Del Delaney Delfin Delfynn Delia Deliah Delicious Delightful-Debbie Delila Delilah Delirious Delizi Della Delores Delotta Delphina Delphine Delta Delyla Delzangel Demetris Demi Demia Demida Demmi Demmy Demona Demonia Demonika Demy Dena Denali Deneice Deni Denice Deniese Denis Denisa Denise Deniseextra Deniska Denissa Denisse Denni Dennise Deny Denys Denyse Derek Derik Derya Desani Deserae Desert Desi Desika Desirae Desire Desiree Desray Dessa Dessarrey Dessert Desteny Destinee Destinty Destiny Destinyextra Detox Detroit Detty Dev Devaki Devaun Deven Devi Devildog Devin Devina Devinn Devlyn Devon Devonte Devora Devyn Dewey Dexlynn Deyny Deysy Dez Dezeray Dezire Dgil Dgill Dharma Di Dia Diamond Diana Diana, Diane Dianna Dianne Dicksani DiDevi Didi Dido Die Diem Dijana Dike Dila Dilaila Dilion Dillan Dillion Dillon Dima Dimitra Dimond Dimonty Dina Dinah Dinara Dinna Dionisia Dionne Dior Diora Diore Diorr Diosa Dipti Dirty Distania Dita Diti Ditta Ditty Diva Diverse Divina Divine Divinity Dixie Dixon Diya Diyana Dizel Djein Djiana Djulianaa Dksana Dlava Dnay Dobrila DoDa Dolce Doll Dollce Dollie Dolly Dolores Dolorian Domenica Domenika Domina Dominic Dominica Dominick Dominicka Dominika Dominikka Dominique Dominka Dominno Domino Domonique Dona Donatela Donatella Donita Donna Donya Dora Doreen Dorety, Dori Doria Dorian Dorida Dorina Dorine Doris Dorka Doro Dorota Dorothe Dorothea Dorothee Dorothy Doroty Dorro Dors Dorthoy Dory Dot Dragaya Dragonlily Draven Dray Drea Dresden Drew Drika Drimla Dru Drunna Druuna Duda Dulce Dulcia Dulcinea Duli Dull23 Dulsineya Duna Dunia Dunn Dunya Duran Durga Dushenka Dusk Dusya Dyana Dyanna Dyanne Dylan Dylann Dyllan Dynamite Dynasty Eadie Eana Easah Easy Ebba Ebbi Ebbonie Ebonita Ebony Echo Ecstasy Eddi Eddison Eden Edenadams Edible Edie Edina Edit Edita Edith Edna Edo Eduarda Edwarda Edwige Edy Edyn Edyphia Effie Effy Egypt Eidyia Eileen Eilin Eilona Eimi Einve Ekaterina Ekenedilichukwu El ela Elaina Elaine Elana Elaura Elayna Eleanor Electra Electre Elektra Elen Elena Elenora Eleonora Elexis Elfie Elga Elha Eli Eliana Elianie Elicia Elida Elin Elina Elindi Elinor Elis Elisa Elisabet Elisabeth Elisabeth, Elisaveta Elise Elisha Elishka Elisia Eliska Elison Eliss Elissa Elisse Elita Eliz Eliza Elizabet Elizabeth Elizabethanne Elizaveta Elizibeth Elke Ella Elle Ellen Ellena Elley Elli Ellie Ellin Ellina Ellington Ellis Ellison Elly Ellyella Elma Elnara Eloa Elody Elona Elouisa Elsa Else Elvira Ely Elycia Elysa Elysee Elyssa Elza Ema Emanuel Emanuela Emanuele Emanuell Emanuella Emanuelle Emanuely Ember Embry Emeche Emelia Emelie Emerald Emerode Emery Emese Emi Emilee Emili Emilia Emiliana Emilianna Emilie Emillia Emilly Emily Eminot Emjay Emma Emma_Brown Emmanuelle Emmeline Emmi Emmy Emori Empera Emuna Emy Emylia Ena Endless Endlessa Endza Engel Engi Eni Enigma Eniko Ennessi Ennie Enny Enny* Enolla Enricque Enrika Entice Envi Envy Enza Eodit Eos Epiphany Episode Era Erato Erian Eric Erica Erick Ericka Erika Erikah Erike Eriko Erin Erina Eris Ernesta Ernestine Erotica Errin Erykuh Erzsebet Esegna Eselda Esenia Esis Esme Esmeralda Esmerelda Esmi Esperanse Esperanza Esperenza Essence Essy Estee Estefana Estefany Estela Estella Estelle Ester Esther Estrelah Estrella Estreya Eszmeralda Eszter Etalia Ethel Etheleen Ethelle Etna Etta Eufrat Eugenia Eugenya Eujenya Eunice Eunique Euphoria Eva Evah Evalynn Evan Evangelina Evangeline Evanni Evdokia Eve Evelin Evelina Eveline Evelyn Evelyne Evelynn Even Everlin Everly Evette Evey Evgenia Evgeniya Evi Evia Evie Evika Evila Evilyn Evita Evolet Evonna Ewa Ewe Extreme Eyla Ezster F. Fabian Fabiana Fabiane Fabiola Fae Fah Faina Fairy Faith Fake Falana Falicha Fallon Famous Fan Fania Fannie Fanny Fantasy Fantina Farah Farrah Fashion Fast Fathima Fatima Fatime Fatzilla Fawn Fawna Fawnna Fawny Faye Fayex Fayina Fayth Fe Feathers Febby Febe Federica Federico Fedorova Fedra Feeona Fefy Felecia Felicia Felicity Felina Felisha Felisia Felix Felony Fendi Fenna Fenny Feodora Ferdanda Ferggy Fernanda FernandaSW Fernandinha Fernannda Fernenda Feroky Ferrara Ferrera Ferriana Fery Fetish Fey Fiera Fiero Fifi Filippa Finch Finesse Fiona Fione Fira Fire Firnanda Firstclassxxx Fit Fitness FitXXX Fiva Flaca Flame Flarice Flavia Flavinha Flelucia Fleur Flick Flicka Flor Flora Florane Floranse Florence Florencia Florina Florinda Flower Floya Fluah Fonda Fovea Fox Foxi Foxie Foxies Foxii Foxxi Foxxxies Foxxy Foxy Frances Francesca Francesco Franceska Franchesca Francheska Franchezca Francie Francine Franciska Francoise Francys Franki Frankie Franky Franny Franscina Fransheliz Franziska Fraulein Frayda Frea Freaky Freddie Frederica Free Freedom Freja French Frenchie Frenchy Frenky Freshblonde Fresia Freya Freyja Frida Friday Frideric Frieda Friend Frisky Frost Froya Frujina Fruzsi Fujiko Funky Furia Furiya G.i. Gabana Gabbi Gabbie Gabby Gabi Gabina Gabriel Gabriela Gabriele Gabriella Gabrielle Gabrim Gaby Gaga Gage Gaia Gail Gala Galaxy Galechka Galia Galidiva Galilea Galina Galiyah Gandhali Gandhari Ganna Garcia Gasha Gaston Gattina Gauge Gaviota Gaya Geah Geana Geena Geiser Geisha Geizer Gela Gelina Gelya Gelyn Gem Gema Gemini Gemma Gen Gena Generosa Genesis Geneva Genevieve Genevievre Geni Genia Genice Genie Genna Genny Gensen Gentilly Gentle Geny Genya Geona Georgette Georgia Georgianna Georgie Georgina Gera Geraldine Gerda Germiona Gerra Gerri Gerta Gertie Gessica Getties Gettin Getty Geyshila GI Gia Giada Giana Giancarlo Giani Gianna Giaoni Gibson Gidget Gieselle Giggy Gigi GiiGi Gilda GILF Gili Gillian Gily Gin Gina Ginebra Ginette Ginger Gingerlee Gingers Gingie Ginie Ginjer Ginn Ginna Ginnie Ginny Gino Ginta Ginyer Gioia Giorgia Giorgiana Giovana Giovanna Giovanni Girls Gisela Gisele Gisell Giselle Gisellex Gisha Gisile Gislene Gisselle Gita Gitta Gitti Giulia Giuliamma Giuliana Giuno Gizella Gizelle Gizelly Gizzelle Gladys Glasha Glenda Glitter Gloria Glorie Glory Glory, Glorya Glubayana Gobi Goca Goddess Goddness Godiva Gogo Gold Goldee Golden Goldi Goldie Goldy Goldye Gorgy Goth Gotti Goulnara Grace Grace* Gracelynn Graci Gracie Graciela Grae Grase, Grayson Graziella Grazy Green Gresy Greta Greta, Gretchen Grete Gretta Grety Grisha Grunya Guerlain Gueysha Guiliana Guillermo Gujarati Gulissa Gulliana Guna Gunnar Gunta Gustavo Guy Guyanna Guyta Gwen Gwena Gwendoline Gwyneth Gya Gyana Gyanti Gyna Gynger Gyongy Gyöngy Gyongyi Gyorgy Gyorgyi Gypsy Gyselle Habibi Haddie Hadjara Hadley Haide Haighlee Hailee Haileey Hailey Hailie Haily Hairy Hajni Halena Haley Haleysweet Halia Halie Halina Halle Hallee Halli Hally Halmia Halona Haly Hamyna Han Hana Hanah Handee Hanela Hanka Hanna Hannah Hara Hari Harlee Harley Harlow Harlowe Harmoni Harmonie Harmony Harold Harper Harriett Harris Harumi Haty Havana Havanna Haven Havoc Haydee Hayden Haylee Hayley Hayli Haylie Haylo Hazel Heather Heaven Heda Heddie Hedvika Heena Hei Heidi Heidy Hela Helen Helena Helene Helenna Helga Hellen Hellena Hellene Hellga Helli Hellion Hellizabeth Helly Heloisa Hemlata Hene Henessy Heni Henley Henna Hennesie Henriett Henrietta Henriette Herda Hermione Hermionie Hermyna Hershey Hetera Hettie Hetty Hexxus Hiady Hilaire Hilaria Hilary Hilina Hillary Hilliary Hilo Hime Himera Hippy Hitomi Holey Holland Holli Hollie Hollow Holly Hollyfox Hollyvanhough Hollywhood Hollywood Hombre Homemade Hona Honey Honney Honour Hope Horny Hot Hotkinkyjo Hottie Houston Hrisanta Hulda Hümerya Hunni Hunter Hurricane Hydie Hydii Hypnotica Hypnotiq Ianisha Iara Icarus Ice icelafox Ida Idel Idelsy Ielza Ieva Ievina Iggy Ihra Ilana Ildi Ildico Ildiko Ildy Ileen Ilessya Ilina Ilka Illana Illy Ilna Ilona Ilsa Ilze Iman Imani Imanie Imma Imogene Ina Inalka Inari India Indiana Indianna Indica Indigo Indio Indira Indra Indy Ines Inessa Inez Infanta Infinity Inga Inga, Ingrid Inia Initha Inna Innes Inness Innocencia Inta Inus Ioana Iona Ionella Ira Iraina Iren Irena Irene Irenka Irida Irie Irin Irina Iris Irish Irisha Irishka Irma Iruki Isa Isabel Isabela Isabele Isabeli Isabell Isabella Isabelle Isacc Isada Isadora Isaphan Isaxx Isebella Isha Ishani Isida Isis Isizzu Iskra Isla Island Islavoika Isobel Ispahan Issa Issabella Issis Italia Itna Iul Iva Ivana Ivanka Ivanna Iveta Ivett Ivetta Ivette Ivetty Ivey Ivi Ivija Ivka Ivon Ivona Ivonne Ivory Ivy Iwia Iya Iyana Iyesha Iyeva Iyulta Iza Izabella Izabelly Izadora Izamar Izces Izi Izida Izobella Izy Izy-bella Izzi Izzie Izzy J J. J.J. J.T. Jacinda Jack Jackeline Jacki Jackie Jacklin Jacklyn Jacky Jaclene Jacline Jaclyn Jaco Jacqay Jacquelin jacqueline Jacquelinet Jacquelinne Jacquelyn Jacqui Jacy Jacyline Jacynda Jada Jadan jade Jaded Jadee Jadelyn Jaden Jadena Jadis Jady Jadyn Jae Jaeden Jaeline Jaelyn Jagdelfe Jagger Jahn Jahna Jai Jaiden Jaime Jaimi Jaimie Jaiyona Jakeline Jakie Jalace Jamacia Jamaica Jamey Jami Jamie Jamina Jana Janae Janah Janaina Janavi Janay Janaya Jandi Jandra Jane Janea Janeextra Janelle Janessa Janet Janetextra Janeth Janett Janette Janey Jani Janice Janie Janika Janine Janis Janka Janna Janne Jannelle Jannet Jannete Jannette Janny Jannys January Jany Japan Jaque Jaquelin Jaqueline Jaquelline Jaquelyn Jaqui Jared Jarmila Jarushka Jasha Jasika Jaslene Jaslin Jasline Jasmeen Jasmen Jasmin Jasmina Jasmine Jasmine* Jasmyn Jasse Jassi Jassica Jassie Jassy Jaszmina Jaxxa Jay Jaya Jayashree Jaycee Jaycie Jayda Jaydah Jaydan Jayda's Jayde Jayded Jayden Jaydence Jaye Jayj Jayla Jaylee Jayleine Jaylene Jaylie Jaylin Jaylnn Jaylyn Jaylynn Jayma Jayme Jaymee Jayna Jayne Jayogen Jazabella Jazel Jazella Jazlin Jazlyn Jazmin Jazmine Jazmyn Jazmyneextra Jazy Jazz Jazzi Jazzmin Jazzmine Jazzy Jc Jean Jeanette Jeanie Jeanine Jeanna Jeanne Jeannie Jeanny Jecica Jecika Jecky Jeff Jeleana Jelena Jelice Jellibean Jellie Jelly Jely Jema Jemeni Jemini Jemma Jen Jena Jenae Jenaveve Jene Jenelle Jenessa Jenet Jenette Jenevieve Jeni Jenia Jenica Jenifer Jeniffer Jenis Jenla Jenn Jenna Jennai Jennavie Jennaxxx Jennay Jennet Jenneva Jenni Jenniah Jennie Jennifer Jennifer, Jennla Jenny Jenny*** Jennyfer Jensen Jentina Jeny Jenya Jera Jereni Jericha Jerilyn Jerri Jerrika Jerry Jersee Jersey Jerzi Jes Jesebella Jesica Jesicca Jesie Jesika Jeska Jess jessa Jessae Jessamine Jesse Jessee Jessey Jessi Jessica Jessica, Jessica_Malony Jessicaextra Jessicca Jessie Jessie-Renee Jessika Jessmindra Jessy Jessy* Jessyca Jessye Jessyka Jesyka Jet Jett Jetta Jeva Jewel Jewelextra Jeweliette Jewell Jewels Jewelz Jewley Jey Jeycy Jeymey Jezabel Jezabelle Jezaree Jeze Jezebel Jezebelle Jezebeth Jezelle Jezhabel Jezla Jezzicat Jharin Jhazira Jhenya Jia Jilana Jill Jillean Jillian Jilly Jilova Jim Jimena Jina Jinger Jinglzz Jini Jinjer Jinny Jiselle Jitka Jiz Jizelle Jizzelle Jj Jkwon J-lo Jme Jo Joan Joana Joanie Joann joanna Joanne Jocalynn Jocelyn Jocelyne Jocelynn Joceyln Joclyn Joclynn Jodi Jodie Jody Joei Joel Joelean Joelle Joelyn Joey Joeye Johane Johanna Johanne Johannes John Johnni Johnnie Johnny Jojo Jola Jolanna Jolee Jolene Joleyn Joleyna Joli Jolie Jolieen Jolina Jolisa Jolly Joly Jolyne Jonathan Jonni Jonsone Jorani Jordan Jordana Jordanbliss Jordann Jordanna Jordanne Jorden Jordi Jordie Jordin Jordinextra Jordy Jordyn Jordynn Jori Josefina Josefine Joselina Joseline Joselyn Josephine Josette Josey Josi Josie Josline Joslyn Joss Josta Josy Joulie Jourdan Journey Jovana Jovanna Joy Joyce Jozephine JR Ju Juana Juanita Juany Jubilee Judi Judie Judit Judita Judith Juditta Judy Judyt Juelz Juicee Juicy Juja Jujana Juju Jul Jule Jules Juli Julia Julian Juliana Juliane Julianna Julianne Julie Juliea Julieann Julien Julienne Juliet Julieta Juliete Juliett Juliette Julissa Julius Juliya July Julya Julyana Julyanova Julytis Julz Julze Juman June Junko Juno Jureka Just Justene Justice Justin Justina Justine Justyn Justyne Jynn Jynx K. K.C. Ka Kacee Kacey Kaci Kacie Kacy Kaden Kadence Kadia Kadri Kadru Kady Kaedyn Kaegan Kaeina Kaela Kaely Kaelyn Kaerin Kagney Kahi Kahlen Kahlisa Kahlista Kaho Kai Kaia Kaiia Kail Kaila Kaila-Mai Kailani Kailey Kaily Kainoa Kaira Kaire Kaisa Kaiserin Kaisey Kaisha Kait Kaitlin Kaitlyn Kaitlynn Kaity Kaiya Kaiyla Kajira Kakey Kala Kalani Kalea Kaleah Kalee Kaleena Kaleesy Kalei Kaley Kali Kalie Kalila Kalilane Kalina Kalisy Kaliy Kalla Kallie Kallisto Kally Kaly Kalyssa Kama Kamala Kamani Kamay Kameya Kami Kamila Kamila. Kamilla Kamille Kamlyn Kammy Kamryn Kan Kandace Kandall Kandee Kandi Kandice Kandie Kandy Kani Kanya Kapri Kapriznaya Kara Karan Karanovak Karaoku Karatai Karen Karena Karensisima Karessa Kari Karib Karie Karima Karin Karina Karine Kariney Karinne Karisma Karissa Karla Karlee Karlie Karlin Karly Karlye Karma Karmen Karmin Karo Karol Karola Karolin Karolina Karoline Karoll Karolline Karolly Karolyne Karrey Karri Karrina Karrlie Karrol Karry Karson Karter Karyn Karyna Kasandra Kase Kasey Kash Kasha Kashmir Kasia Kasmine Kasorn Kassandra Kassey Kassie Kassius Kassondra Kassyana Kastumi Kat Kata Katala Kataleena Katalin Katalina Kataliza Kataljna Katallina Katalyn Katalynix Katalynka Katana Katanya Katarina Katarinka kate Katej Katelyn Katelyne Katerin Katerina Katerine Katey Kath Kathalina Katharina Katharine Katherin Katherine Katherinne Kathi Kathia Kathleen Kathlen Kathryn Kathy Kati Katia Katie Katiee Katiejordin Katiek Katija Katika Katilly Katin Katina Katinka Katiy Katja Katka Katkam Katlein Katlyn Katra Katreena Katrell Katrena Katrin katrina Katrine KatrinTequila Katsi Katsumi Katsuni Katt Katti Kattie Katty Katy Katya Kauana Kaula Kavane Kawanna Kay Kaya Kaycee Kayden Kaydence Kayia Kayla Kaylah Kaylan kaylani Kaylann Kaylee Kayleen Kayleigh Kayley Kayli Kaylia Kaylie Kaylin Kayly Kaylyn Kaylynn Kayme Kayne Kaytee Kaz Kc Kea Keana Keanna Keanni Kecey Kecy Keeani Keegan Keeley Keely Keena Keensahra Keeth Kefren Kehlani Keiko Keila Keilani Keira Keisha Keit Keita Keith Keithy Keity Keiyra Kelen Keli Kelle Kelley Kelli Kellie Kelly KellyA Kellyextra Kelm Kelsey Kelsi Kelsie Kelsy Kely Kendal Kendall Kendra Kendyll Kenet Kenia Kenley Kenna Kenndra Kennedy Kenneth Kenni Kensey Kenya Kenza Kenze Kenzi Kenzie Keoki Kerani Keri Kerie Kerija Kerra Kerri Kerry Kerry-Louise Kersti Kerti Kertu Kery Kesare Kesha Kesia Kesidy Kessi Kessie Kessy Ketrin Ketthy Ketti Ketty Kety Kevinn Keylie Keymore Keymy Keyona Keyty Khadisha Khaleesi Khalista Kharlie Kharyi Khaya Khia Khira Khloe Khris Khyanna Kia Kiana Kianna Kiara Kiaraxx Kiarra Kiera Kiere Kierra Kierstin Kierstyn Kik Kika Kiki Kikko Kiko Kiley Kili Killa Kim Kimber Kimberlee Kimberley kimberly Kimbra Kimeleane Kimi Kimiko Kimmie Kimmy Kimora KimXXX Kimy Kina Kinga Kinky Kinley Kinnley Kinsley Kinuski Kinzie Kinzy Kira Kiralanai Kirani Kirati Kirby Kiri Kirin Kiriztina Kirke Kirra Kirschley Kirsten Kirstin Kirsty Kirylam Kisa Kisha Kiska Kiss Kissa Kissy Kit Kita Kitana Kiti Kitkat Kitri Kitten Kitti Kittie Kittina Kittina* Kitty Kiwi Kiyanna Kizzy Klara Klarisa Klarissa Klaudia Klea Kleio Klementine Klenot Kleo Kleopatra Kler Kloe Kloey Kloffina Klotild Kno Ko Kobe Kobi Kodi Kody Koell Kohl Koika Kokie Kokkine Koko Kokohontas Koks Koni Kora Korene Kori Korina Kornelia Kortney Kortni Kortny Kosa Kosame Kosane Koty Kouroko Kourtney Koy Koyuki Kravanna Kream Kreatris Kris Krisei Kriss Krissie Krissy Krista Kristal Kristall Kristana Kristarah Kristel Kristell Kristen Kristi Kristian Kristie Kristin Kristina Kristinarose Kristine Kristty Kristy Kristyna Kristynka Krisy Krisztin Krisztina Kriztina Krsi Krysta Krystal Krystena Krysti Krystina Krysty Krystyna Ksandra Ksantia Ksara Ksena Ksenia Ksenija Kseniy Kseniya Ksenya Ksu Ksucha KsuColt Ksurina Ksusha Ktee Kumani Kumara Kurious Kuznec Kveta Kvetoslava Ky Kya Kyaa Kyah Kyan Kyana Kyanna Kyara Kykola Kyla Kylani Kylea Kylee Kyleigh Kyler Kylie Kym Kymber Kymberlee Kymberlie Kymberly Kymora Kynthia Kypa Kyra Kyrashina Kyrin Kysara Kytiana L. La Labelly Lacee Lacey Lachasse Lachelle LaChere Lachia Laci Lacie Lacy Lada Laddie Ladie Ladonna Lady Laeh Laela Laeticia Laetitia Lafee Lagoon Lagoona Lahia Laiken Laila Lailanie Lailonni Lailouni Laima Lain Laina Lainey Laiza Lajla Laka Lake Lakota Lala Lalassa Lalita Lallasa Lalovv Laly Lamia Lana Lanaviolet Lance Landon Lane Lanewood Laney Lani Lanie Lanka Lanna Lanne Lannie Lanny Laora Laoura Lapoehica Lappi Lapreece Lara Laraan Lareina Larem Larin La'Rin Larina Larisa Larissa Larkin Larra Laryne Laryssa Lasirena69 Laska Latalli Lataya Latex Latia Laticia Latika Latin Latina Latmi Latoya Laura Laura* Lauracrystal Laurah Lauralai Lauralyn Laure Laurea Laurean Laureen Laurel Lauren Laurence Laurianne Laurie Laurine Laurita Lauro Lauryl Lauryn Lavana Lavanda Lavatta Lavender Lavin Lavina Lavish Lawanda Laya Laycee Layden Layla Laylah Layla-Jade Laylani Layloni Laylynn Layma Layna Layne Laysa Layton Laz LC Lcdc Lea Leah Leana Leandra Leanella Leann Leanna Leanne Leannella Leanni Leaya Lecette Lecher Leda Ledona Lee Leea Leeanne Leeda Leela Leena Leenda Leenuh Leesa Leeza Lei Leia Leida Leigh Leighlani Leighton Leihla Leila Leilani Leili Leilla Lek Leka Leksi Leksy Lela Lelani Lellie Lellou Lelloy Lelu Lelya Lena Lenai Lenda Lendsay Leni Lenia Lenina Lenka Lenna Lennox Lennoz Lenny Lentia Leny Lenya Leo Leoa LeoLulu Leona Leonella Leonelle Leoni Leonie Leonora Leonsia Leony Lepidoptera Lera Lerika Lerissa Lerou Lesa Lesley Leslie Lesperansa Lessie Lesslane Lessy Lesya Lethal Leticia Leticiya Lettie Letty Levina Lex Lexa Lexas lexi Lexian Lexiana Lexiangel Lexid Lexidiamond Lexie Lexii Lexis Lexxi Lexxis Lexxxi Lexxxus Lexxy Lexy Leya Leyla Leylani Leylou Leyre Leza Lezley Li Lia Liaa Liah Liams Lian Liana Liandra Liania Lianna Lianne Libby Liberta Liberty Libit Libuse Lichelle Licie Licije Licious Lickable Licky Lida Lidia Lidiya Lidy Lielani Lien Liena Liga Light Lightfairy Lija Lika Lil Lila Lilah Lildre Lili Lilia Lilian Liliana Liliane Lilianna Lilianne Lilie Lilien Lilit Lilith Lilla Lillandra Lilli Lillia Lillian Lillianne Lillie Lillike Lillis Lillith Lilly Lilo Liloo Lilou Lilouch Lilu lily Lily_Cat Lilya Lilyan Lilyana Lilyanna Lilyna Lin Lina Linda Linda_Sweet Lindababy Linde Lindie Lindsay Lindsey Lindsia Lindsy Lindy Lindzey Linet Linette Linna Linnea Linnette Linny Linsay Linsey Linx Linzee Linzi Liolya Liona Lionees Lioness Lipa Lis Lisa Lisaextra Lisamor Lisbeth Lisel Lisen Lisette Lisey Lisi Liss Lissa Lita Litia Little Liuba Liuko liv Livia Livie Liya Liyera Liyla Liz Liza Lizabeta Lizaveta Lizeth Lizette Lizi Lizie Lizka Lizz Lizzette Lizzie Lizzy Ljuba Llana Lluna Lo Loana Locke Logan Lohra Loida Lois Loisa lola Lola** Lola***** Lolah Lolana Lolashut Loli Lolita Lo-Lita Lolitka Lolla Lolli Lollie Lollipop Lolly Lollypop Lolo Lona Londa London Londyn Long Loni Lonnie Lonny Loona Lopes Loquis Lora Loraine Loredana Loree Loreen Lorelai Lorelei Lorelie Loren Lorena Lorene Lorenia Loretta Lorette Lorey Lori Lorie Lorina Lorinda Lorine Loris Lorna Lorraine Lorrelai Lory Loser Lote Lotta Lotti Lottie Lotty Lotus Lou Louisa Louise Loula LoulaLou Loulou Loura Lourdes Loureen Lovanna Love Lovely Lovenia Loventa Lovette Lovisa Lovita Loylita Lua Luana Luanah Luanna Luba Lubice Lubochka Luca Luccia Lucette Luchya Luci Lucia Luciana Lucianna Lucie Lucie**** Lucie, Lucile Lucilla Lucina Luck Luckey Lucky Lucle Lucretia Lucy Lucyka Lucylux Lucynova Luda Ludiya Ludmila Ludmilla Ludwiga Ludy Luigina Luisa Luissa Luiza Lukava Lukki Lula Lullu Lulu Luly Luma Luna Lunae Lupe Lupei Lupita Lus Luschious Luscious Lusi Lusie Lusil Lusila Lusilla Lusita Lussi Lussy Lusy Lusya Luventa Luvy Lux Luxe Luxury Luysan Luz Luzbel Luzzy Lya Lyanna Lyava Lydia Lydie Lyen Lyla Lylia Lylie Lylith Lylitty Lyliya Lylla Lylyta Lyn Lyna Lynda Lyndsey Lyndsy Lyne Lynn Lynna Lynne Lynnlove Lynx Lyra Lysa Lystra Lyudmilla Lyudmyla M. M.J. Ma Maara Maarit Mabel Mable Macarena Maccy Macey Macha Machella Maci Macie Mackenzee Mackenzie Macroc Macy Mad Madalena Madam Maddey Maddi Maddie Maddison Maddy MadeinCanarias Madeleine Madelen Madeline Madelyn Madelyne Madge Madi Madien Madisen Madisin Madison Madisson Madlen Madlena Madleyn Madlin Madonna Maduri Mae Maeketa Maelynn Maeva Mafalda Mag Magalie Magda Magdalen Magdalena Magdalene Magdi Magdolna Magela Magella Maggie Magic Magy Mahamari Maheda Mahina Mahlia Mahogany Mahumari Mai Maia Maible Maija Maikana Maila Mailan Mailani Mailly Maira Maisie Maitland Maitresse Maiya Maja Majida Majo Mak Makali Makana makayla Makbota Makenna Makenzie Malacka Malaysia Maleah Maleena Malena Malezia Mali Malia Maliana Malibu Malica Malicia Malika Malin Malina Malinda Malisha Malitia Maliyah Mallory Malloy Malone Maloo Maloree Malorie Malory Malou Malu Malusha Malvina Malvine Malwina Malya Malyska Mam Mame Mami Mamie Mancy Manda Mandalay Mandee Mandi Mandie Mandii Mandy Manga Manindra Mannella Manoela Manon Mansa Manu Manuela Manuella Manya Manyika Manzell Mar Mara Maralyn Marcela Marcele Marcelina Marceline Marcella Marcella_C Marcellinha Marcelly Marcena Marci Marcia Marcie Marcona Marcy Mareen Maren Marena Marfa Margaret Margareta Margarete Margareth Margarethe Margarette Margarita Margaux Margery Margitta Margo Margot Margrett Marhyan Mari Maria Mariah Mariam Marian Mariana Mariann Marianna Marianne Mariar Maribel Marica Maricella Marie Marie-Anne Mariel Marie-laure Marielena Mariella Marielou Marien Marienne Mariesa Marietta Marija Marijana Marije Marijo Marika Marila Marilin Marille Marilyn Marilynn Marimar Marina Marine Marinella Marinka Marins Marion Marisa Marisaextra Marisela Marisha Mariska Marisol Marisole Marissa Marit Maritrini Maritza Mariya Marizza Mark Markelly Marketa Marki Markie Markiza Marky Markyza Marla Marleana Marlee Marleigh Marlena Marlene Marlette Marley Marli Marlice Marlie Marlyn Marnie Marquetta Marquize Marri Marria Marrietta Marry Marsa Marsela Marselina Marsha Marsila Marta Martha Martina Martinaz Martine Martini Martins Marusha Marushka Marusia Marusya Mary Marya Maryah Maryana Maryann Mary-Ann Maryanne Mary-Dee Maryel Maryja Maryjane Mary-Jane Maryjean Mary-Kate Marylin Maryline Marylon Maryna Marysol Masa Maserati Masha Masie Masked Mason Master Masuimi Matao Mathea Mathilda Mathilde Matilda Matilde Mattie Mature Matylda Maude Maura Maureen Maurina Maurissa Mauro Maven Max Maxi Maxim Maxime Maxine Maxx May Maya Maya, Mayaextra Mayara Mayarah Mayerly Mayhem Mayim Mayine Mayla Maylee Maylin Mayline Mayna Mayo Mayola Maza Mazsa Mazy Mazzaratie Mazzy Mc McCoy Mckayla Mckenzee Mckenzi Mckenzie McQueen Me Mea Meadow Meagan Meagen Meara Mecca Mecha Meddie Medea Medlin Medora Medusa Meeham Meesha Meg Megan Megane Meggan Meggi Meggie Meggy Meghan Meghann Megia Megie Megifa Mei Meiko Meilani Meira Meka Mekeilah Mekellah Meko Mel Mela Melaine Melana Melane Melanei Melania Melanie Melany Melea Melena Melenna Meli Meliah Melika Melina Melinda Melisa Melisande Melisia Melissa Melissza Melita Meliza Melizza Mell Mella Mellanie Mellie Mellisa Mellisandra Mellissa Melly Melodee Melodie Melodii Melody MelodyWilde Melon Meloney Melonie Melony Melory Melrose Memphis Mena Menage Mendi Meng Meow Mercedes Mercedesz Mercedez Merci Mercia Mercury Mercy Merda Meredith Merelyn Meren Meretrix Meri Meriah Meridian Meriesa Merilee Merilin Merilyn Meriosa Merissa Meriva Merlina Merllin Merri Merrie Merrion Merry Mersi Merszedes Mery Meryl Merylin Messua Messy Mette Mexi Mey Mi Mia Mia* Mia** Miahilton Miako Mianna Miayah Mica Micah Micara Micca Micha Michael Michaela MichaelaIsizzu Michaella Michel Michele Micheli Michell Michelle Michelleextra Michellemoist Michellemyers Micka Mickaella Micke Mickey Micki Mickie Mickinzie Micky Mida Midge Midnite Midori Miel Miela Miesha MihaNika Miho Miia Mija Mika Mikaela Mikaella Mikaelle Mikah Mikana Mikayla Mike Mikela Miki Mikita Mikka Mikki Mikky Miko Miky Mila Mila, Milada Milair Milan Milana Milaya Milcah Milea Miledy Mileena Milena Milene Miley Mileyann Milf Mili Milia Milian Miliani Milisa Milissa Milka Milla Millena Milli Millia Millian Millie Milly Milu Miluska Mily Mima Mimi Mimosa Min Mina Mindee Mindi Mindori Mindy Minel Minerva mini Minka Minna Minnie Minny Minori Miosotis Mira Mirabel Mirabella Miracle Mirage Mirai Mirami Miranda Mirayn Mireira Mirela Mirella Mirelle Miren Miri Miriam Mirinda Mirjam Mirka Miroslava Mirra Mirta Miryam Mis Misa Misa-f Miscani Mischa Mischel Mischell Mischelle Mischievous Misel Mish Misha Mishel Mishelle Mishka Mishkany Mishulinka Mishy Miss Missa Missi Missie Misslove Missty missy Misti Mistress Misty Mitsuki Mitzi Mitzy Miu Miuk Miulee Mivina Miya Miyabi Miyamme Miyuki Miza Mizz Mj MJFresh Mlle Mo Moana Mocca Mocha Models Moet Mohini Moira Moka Molleuex Mollie Molly Mollymadison Moloko Mom Momoko Momy Mona Monalee Monaliza Monchi Mone Monet Monetextra Moni Monic Monica Monicca Monicka Monicue Monik Monika Moniq monique Monlave Monna Monroe Montana Montanna Monti Montse Mony Moon Moonlight Mora Moray Morena Moretta Morey Morgalny Morgan Morgana Morgane Morgann Morghan Moriah Morning Morocha Morrigan Mortica Morticia Morven Mother-Daughter Mouna Mounia Mount Moxxie Moxxxies Moxxy Moyanne Mr.Charlie Mr.Long Mrs Mrs. Ms Ms. Ms.ForbiddenLoyalty Ms.Yummy Mspanther Mulani Multiple Munequita Muriel Murina Murka Musky Muza My Mya Myah Myeshia Myiuki Myka Mykaela Mykka Myla Mylen Mylena Mylene Myli Mylie Mylka Mynxx Myra Myriam Myrille Myrka Myrna Myshell Mystery Mysti Mystica Mystika Mystique MySweetApple Mz Mz. Mz.Twilight Naara Nada Nadea Nadegda Nadejda Nadezda Nadezhda Nadi Nadia Nadija Nadin Nadina Nadine Nadira Nadiya Nadya Nadyenka Nagini Naia Naidyne Naimee Naiomi Naira Naja Najra Nakia Nakiah Nakita Nala Nami Nan Nana Nanay Nancie Nancy Nanda Nandi Nanette Naney Nani Nannccy Nanney Nannie Nanny Nanoe Nansy Naoimi Naomi Naomie Naomy Nara Narcissa Nari Nariah Narkiss Narlie Nasita Nassy Nasta Nastasy Nastaya Nastia Nastie Nastika Nastiya Nastja Nasty Nastya Nastyhka Nata Nata?lie Natal Natalee Natali Natalia Nataliah Natalie Nataliex Nataliia Natalija Natalin Natalissa Nataliy Nataliya Natalli Natallia Natallie Natally Nataly Natalya Natana Natany Nataria Natascha Natasha Natashaextra Natashia Natcha Nathalie Nathaly Nathasha Natie Natile Natisha Natosha Natti Natty Natusya Naty Naudi Naudia Naudie Naudya Naughtia Naughty Naurin Nautica Navaeh Naveah Naveen Naya Nayma Nayomi Nayra Nazar Nea Necro Nedda Nedra Neecie Neela Neesa Nefertiti Neilla Neisa Nekane Nela Nell Nella Nelli Nellie Nelly Nelya Nelys Nena Nenetl Neona Nerea Neriah Nerine Nesa Ness Nessa Nessi Nessie Nessy Nessye Nestee Nesti Nesty Netra Netta Netty Netu Neva Nevaeh Neve Neveah Nevena Nezvera Nia Niana Nica Nicca Niccole Nichol Nichole Nicholee Nici Nicka Nickel Nickey Nicki Nickie Nickol Nickolay Nicky Nico Nicol Nicola nicole Nicoleextra Nicoleray Nicoletta Nicolette Nicolety Nicolina Nicoline Nicoll Nicolle Nicollette Nicotine Nicova Nieves Nigell Night Nika Nikala Nikara Nike Niki Nikia Nikic Nikida Nikita Nikitta Nikka Nikki Nikkie Nikkita Nikkivee Nikko Nikky Nikol Nikola Nikole Nikoleta Nikolett Nikoletta Nikolla Niksha Niky Nikysweet Nila Nilah Nilaya Nilla Nimfa Nina Ninel Ninelly Ninety Ninita Ninna Ninola Ninoska Ninouska Nipsey Nipsy Nira Nisha Nissa Nita Nitca Nitty Niurka Niva Nivea Nixie Niya Niza No Noah Nocera Nody Noe Noel Noela Noelani Noelio Noelle Noemi Noemie Noemilk Noemy NoFaceGirl Noira Nola Noleta Nolita Nollie Noma Nomi Nomy Nona Noname Noni Nonna Noon Nora Nora, Norah Nordica Noreen Nori Norina Norma Norman North Nouvelle Nova Novalie Novi Novia Nozomi Nubia Nubiles Nuch Nung Nury Nya Nyah Nychole Nyeema Nyikita Nyla Nym Nyna Nyomi Nyrobi Nysha Nyusha Nyx Oana Oasis Obsession Ocean Oceane Octavia Oda Odara Odell Odessa Odette Odile Ofelia Offilia OG Ogzija Ohana Oi Okami Oklahoma Oksana Oksy Oktavia Ola Olarita Olay Oldrich Ole Oleanna Oleg Oleja Olena Olesia Olesya Olga Olha Oli Olia Oliana Olien Olimpia Olina Oliva Olive Olivia Oliviana Olivie Oliviya Olivya Olja Olla Olli Ollie Ollivia Olwen Olya Olympia Olympia_C Ondinne Onia Onix Onna Onyx Opal Ophelia Ophelie Oprah Ora Orchidea Oretha Orhidea Oriana Oriel Orika Orina Orlane Orlenda Ornelia Ornella Orsay Orsi Orsolya Orssi Orsy Orvelia Osa Oscar Othelia Ouan Ovidie Oxana Oxanna Oxaunna Oxiana Oxijana Oxy P.J. Pabloextra Pada Page Pageextra Paglia Pahola Paige Paisley Palloma Palma Paloma Palomino Palova Palva Pam Pamela Pammy Pandara Pandora Pantera Panther Panthera Paola Paolina Paolla Papaya Paradise Paris Parisa Parish Parke Parker Particia Party Parvin Pason Passion Pat Patira Patricia Patricie Patrick Patricya Patrikia Patris Patrisha Patritcy Patriza Patsy Pattie Patty Paty Paul Paula Paulina Pauline Paulinha Pavla Pavlina Paxton Payge Payton P-Chan PD Peace Peach Peaches Peachess Peachy Peanut Pearl Pearlin Pebbles Pecosa Peggy Pelageya Pellenia Penelopa penelope Peneloppe Penni penny Pepa Pepper Percy Perfect Peris Perla Perri Perry Persia Persian Persona Persuajon Persuasion Pet Peta Pete Peter Petia Petite Petka Petra Petra_C Petral Petraska Petronela Petrova Petty Peyton Phelanie Pheobe Pheona Pheonix Phil Philipina Philippe Philmore Phoebe Phoenix Phylicia Phylisha Phyllisha Pia Pierre Pietra Piggy Pike Pilar Pim Pina pink Pinkule Pinky Piper Pippa Piros Piroshka Piroska Pixi Pixie Pixiee Pixxxi Plagebabe Platainito Playful Playfull Pleasure Plenty Poca Pocahontas Pochontas Pocket Poesha Pokahontas Pola Poliana Polin Polina Polli Polly Poly Ponny Poopea Pop Popira Poppy Porcelain Porche Porscha Porsche Porschea Porsha Portia Poteera Prada Pradah Praskovia Praveena Praya Precious Preeda Pregnant Prescilia Presley Pressley Pricilia Pricilla Prima Princess Princessa Princyany Prinzzess Priscila Priscilia Priscilla Pristine Priva Priya Prodigy Promesita Promise Proxy Prycliss Pryscila Public Puma Pure Purl Purple Pusia Pussika Pussy Pussycat Pussykat Pusya Putri Pyper Pyrah Queen Queeni Queenie Queenlin Quenna Questa Quezia Quin Quincy Quinn Qutie Rachael Rachel Rachel_C Rachele Rachelextra Rachell Rachelle Rachida Rachyda Racquel Rada Radina Radislava Radka Radona Raduschka Rady Rae Raeah Raeleen Raelynn Raena Rafaela Rafaella Rafaila Raffaella Rahda Rahyndee Raica Raikova Railee Rain Raina Rainbow Raine Raini Rainia Rainy Raisa Raissa Raj Rakely Ralina Ramba Rambakhsh Rami Ramona Ramu Rana Randee Randi Randolf Randy Rane Rangeni Rani Ranie Raphaela Raphaella Raquel Raquelextra Raquelle Rashae Rashmika Ratna Raul Rava raven Ravon Ravyn Ray Raya Rayana Rayann Rayanne Raychel Raychelle Raye Raylan Raylene Raylin Raylyn Raylynn Rayna Rayne Raysa Rayssa Rayveness Rea Reagan Reba Rebbeca Rebeca Rebecca Rebeccablue Rebeka Rebekah Rebel Rebell Rebequita Red RedKiteKat Redly Ree Reecy Reeka reena Reese Regan Reggie Regi Regina Regine Rehtaeh Rei Reighlei Reilly Reina Reisha Reislin Reka Remi Remira Remmy Remy Rena Renae Renata Renate Renatta Rene renee Reni Renna Rennata Reny ReVAY Reyka Reyla Reyna Rezika Rezza Rharri Rhaya Rheanna Rheina Rhiana Rhianna Rhiannan Rhiannon Rhonda Rhyanna Rhylee Rhyse Ria Riana Rianna Riba Ricarda Ricardo richelle Rici Rick Rickextra Ricki Rickie Rick-O-Shea Ridick Riesa Rihana Rihanna Rihannon Rija Rikki Rikky Rilee Riley Rilynn Rima Rimma Rin Rina Rinialta Rio Riomarxxx Risi Risika Rissa Rita Ritta Riva River Rivera Riya Riyanna Riza Rizzo Robbin Robby Robbye Roberta Robin Robyn Rochelle Rocío Rock Rockell Rocki Rockie Rocky Rococo Rod Rodolph Roelly Roggie rogue Roka Rolando Roma Romana Romance Romanetta Romi Romie Romina Romona Romy Ron Ronda Ronita Ronni Ronta Ropebaby Roquel Rorie Rosa Rosalia Rosalie Rosalina Rosalinda Rosaline Rosalyn Rosanna Rosanne Rosario Roscoe Rose Rosea Rose'ana Roseanna RoseAnne Rosee Roseline Rosella Roselyn Rosemary Roses Roshell Rosie Rosina Rosita Ross Rossa Rossana Rossanaextra Rossella Rosses Rossinka Rossis Rosy Rowena Rox Roxana Roxane Roxanna Roxanne Roxee Roxetta Roxette Roxi Roxie Roxii Roxsy Roxxanne Roxxi Roxxxie Roxxxy Roxxy Roxy Royalty Roza Rozalia Rozalina Rozalind Rozarka Rozen Rozita Rozsa Rubber RubberDoll Rubby Rubee Rubi Rubin Ruby Rucca Rudy Rumika Rusal Rusalka Rusanna Rusita Ruslana Russia Rusty Ruta Ruth Ruthwas Ryaan Ryan Ryana Ryann Ryanna ryder Rye Rylee Ryley Rylie Rylynn Ryon Ryta Ryzele Ryzell S. Saana Sabana Saber Sabian Sabien Sabina Sabine Sable Sabree Sabrena Sabrin Sabrina Sabrina-Jade Sabrine Sabrinka Sabrisse Sabryna Sabyne Sacha Sade sadie Sadiebanks Sadine Safi Safina Safira Safire Safo Sage Sahara Sahenka Sahily Saidat Saige Sailor Saki Sakura Salem Salena Salina Salinas Salley Sally Salma Salome Salomi Salomja Sam Samali Samanta Samante samantha Sambuca Sami Samia Samie Samilla Samira Samm Sammi Sammie Sammy Sammy-Jayne Samone Samora Samuela Samy Samyra Sana Sanaei Sandee Sandi Sandie Sandora Sandra Sandri Sandrine Sandro Sandy Sandysummers Sanie Sanita Sanity Sanja Sanjeit Sanna Sanny Santa Santina Sany Sanya Saphir Saphire Sapphira Sapphire Sara sarah Sarahjo Sarahsweets Sarai Sarala Saranah Saray Sarena Sari Saria Sariah Sarika Sarka Sarolta Sarra Sarrah Sascha Sasha Sashaa Sashenka Sashia Sashina Saskia Sassy Sath Sati Satin Satine Satiny Sativa Saundra Sausha Sava Savana Savanah Savanna Savannah Savina Savvy Savy Sawyer Saya Sayeh Sayra Sayuri Sayurii Scar Scaret Scarlet Scarlett Scarlette Scarlettfay Scarlit Scarlotte Schilla Schlucki Scott Scotti Scyley Sea Sean Sear Sebass Sebastiane Secille Secret Seda Sedona Seejulie Seka Selah Selby Selena Selene Selenna Seleste Selexia Selina Selita Selma Selva Selvaggia Selvagia Semija Semmi Semmie Sendi Sendy Sensai Sensi Sensious Sensual Seny Senyualo Sephora September Sequoia Sera Serafima Seranade Seraphima Seraphine Seren Serena Serendipity Serene Serenity Sereyna Serilla Serina Serpente Serrena Seven Severin Sex Sexi Sexis Sexual Sexy Sha Shae Shaena Shafry Shai Shaina Shairy Shakila Shakra Shalina Shally Shame Shana Shandra Shane Shani Shania Shanice Shanie Shanis Shanna Shannon Shannya Shanon Shantal Shantel Shanti Shanty Shany Shar Shara Shardae Sharee Shari Sharika Sharin Sharka Sharla Sharon Sharone Sharron Shasha Shasta Shataya Shauna Shavelle Shawna Shawnee Shawnie Shay Shaye Shayenne Shayina Shayla Shaylen Shaylene Shayna Shayne Shazia Shea Sheala Sheeba Sheehan Sheela Sheena Shefali Sheila Shelbe Shelbee Shelby Sheli Shelia Shellen Shelley Shellie Shelly Shena Sheq Sherazade Sherazadee Sheree Shereese Sherezade Sheri Sherice Sheridan Sheril Sherill Sherina Sherly Sherom Sheron Sherri Sherry Shery Sheryl Shevelle Sheyla Sheylley Shi Shia Shiela Shila Shilo Shiloh Shione Shira Shirley Shiva Shivay Sholeh Shona Shortie Shorty shrima shy Shyann Shyla Shyler Shylina Shyne Shyra Sia Sian Siarilis Siarrs Sibilla Sibyl Sicilia Sicily Sid Sidney Sidny Sidonia Sidonie Sidra Sielle Siena Sienna Siera Sierra Sigal Sigourney Sigy Sigyta Siiri Sila Silena Silk Silky Silla Silva Silvana Silveo Silver Silvia Silvie Silvija Silviya Silvy Sima Simella Simi Simira Simmer Simon Simona Simone Simonia Simony Simran Sina Sincere Sincerre Sindee Sinderella Sindi Sindy Sinead Sinful Sinia Sinn Sinnamon Sinovia Sinstar Sintia Sinty Sinya Siouxsie Siraell Sirale Siren Sirena Sirenita Siri Sirmione Sirvi Sisa Sisi Sissy Sister Sisy Sita Siya Sizi Skarlett Skarlit Skarlitt Skie Skigh Skiley Skin Sklya Sky Skye Skyeler Skyla Skylar Skyler Skylor Skylynn Skyy Skyye Slatsjana Slava Slave Slavina Slavka Slay Slevie Slight Slim Sloan Sloane Slone Slut Sly Smiley Smilla Smith Smokey Smokie Sneila Snistcx Snoopy Snow Sochee Soffia Soffie Sofi Sofia Sofía Sofie Sofija Sofy Sofya Sohan Sohley Soileda Sol Sola Solah Solana Solange Solaya SolaZola Sole Solei Soleil Solhey Solsa Solstice Solveig Som Soma Somiet Sommer Sona Sonam Sondra Sondrine Sonechka Song Sonia Sonita Soniy Sonja Sonny Sony Sonya Soolin Soon Sophea Sophei Sophia Sophiana Sophie Sophya Sopia Sorana Soraya Sorayan Sotra Sovereign Sowan Sowanna Soyivania Sparkes Sparky Sparta Special Spencer Spice Spicy Sprenda Sprinda Spring Sprmda Spunky Sreta Ssindy Stacee Stacey Staci Stacie Stacy Stalfra Star Stardom Stario Starla Starlett Starly Starr Starri Stasey Stasha Stasia Stassi Stasy Stasya Staxxx Steadman Stef Stefana Stefani Stefania Stefanie Stefanija Stefany Steffanie Steffany Steffi Steffie Stefy Stela Steliana Stella Stella_C Stellah Steorra Stepanka Stepanska Steph Stephani Stephanie Stephanies Stephanna Stephannie Stephany Stephie Stephy Stepphanie Sterling Stesha Steve Steven Steveo Stevie Sthefany Stiffany Stock Stonell Stoney Stormy Storri Storry Stoya Stracy Strawberry Strokahontas Stunning Su Subil Suckable Sue Suelen Suellen Sugar Sugian Suhaila Sukanja Sukanya Suki Sukra Sully Sultana Sumi Summer Summeran Summersilver Sun Sundy Sunisa Sunni Sunnie Sunny Sunrise Sunset Sunshine Sunshyne Suny Sury Surya Susa Susan Susan, Susana Susane Susann Susanna Susannah Susanne Susi Susian Susie Susy Suwanne Suzan Suzana Suzanna Suzanne Suze Suzette Suzi Suzie Suzumi Suzy Suzy* Sveera Sveta Svetik Svetlana Svitlana Swaberry Swabery Swan Swany Sweet Sweetie Sweety Swiss Sybelle Sybil Sybille Sycamore Syd Sydeney Sydnee Sydney Sydonia Syesha Syl Sylva Sylvana Sylvi Sylvia Sylvie Symbia Symone Syndee Syndi Syndy Synthia Syren Syvally Syvette Szabina Szabo Szabrina Szabyna Szandi Szasza Szelina Szidonia Szilvia Szindy Szofy Szofya Szonja Szuzanne Szuzie Szuzy T. T.J. T.J.Hart Tabatha Tabby Tabetha Tabita Tabitha Tacori Taft Tafy Tahlia Tahlita Tahnee Tai Taija Tailor Tainah Taira Tais Taisa Taisiya Taissia Taj Taja Tajza Takota Takya Tala Talana Tali Talia Taliah Talin Talina Talisa Talita Tall Tallie Tallulah Talula Talya Tam Tama Tamar Tamara Tamaya Tamber Tami Tamila Tamiry Tammi Tammie Tammy Tamra Tana Tanata Tandy Tanga Tangi Tani Tania Tanichka Taniella Tanielle Tanika Tanita Tanja Tank Tanna Tanner Tannermays Tanvi Tanya Taopus Tapanga Tapenga Taquila Tara Taraextra Tarah Taralynn Tareva Tarja Tarra Tarsila Taryn Tasha Tasia Tassie Tasty Tata Tatalila Tatana Tati Tatiana Tatiane Tatianna Tatiyana Tatiyna Tatjana Tatty Tatum Tatumn Taty Tatyana Taurus Tavalia Tavia Tawnee Tawney Tawni Tawny Tawny-Brie Tay Taya Taybre Tayla Taylan Taylee Tayler Taylir Tayllor Taylor Taylorann Taylorextra Taysha Taytum Tayza Taz Tchanka TD Tea TeacherOfMagic Teagan Teaganism Teagen Teal Teamuku Teana Teanna Tease Tecey Ted Teddi Teddy Teegan Teekah Teela Teen Teena Teenah Teera Teeta Teffany Tegan Tellula Tempe Temptation Temptress Tennesse Tennila Teoni Tequila Tera Teran Tere Terenka Terenza Teresa Teresina Teressa Teresse Tereza Terezka Terezska Teri Terika Terka Terra Terrance Terri Terry Terryn Tery Tesia Tesla Tesoro Tess Tessa Tessalia Tetti Tetyana Texas Thai Thaina Thais Thaise Thalia Thallia Thatty Thatyana Thaylor Thayna Thayne The Thea Thecla Theia Themis Thena Theo Theodora Theona Thepair Theresa Therese Thereza Theza Thia Thunder Thundy Thurzday Tia Tiacox Tialer Tiana Tianna Tiara Tiaz Tibby Tibor Tidus Tieler Tiere Tierra Tif Tifany Tifereth Tiff Tiffan Tiffanee Tiffani Tiffanie Tiffanny Tiffany Tifffany Tifini Tiger Tigerr Tiggle Tigra Tigress Tihana Tihanna Tii Tila Tilana Tilda Tiler Tilly Timber Timea Timi Timycat Tina Tindra Tini Tinka Tinker Tinkerbell Tinkerbelle Tinna Tinslee Tiny Tipsy Tira Tiry Tisa Tish Tisha Tison Tissy Tita Titiella Tj Tobee Tobi Todd Token Tolly Tom Toma Tomi Tomiko Tommi Tommie Tommy Tomnat Tomo Tomy Tona Toni Tonia Tonisha Tony Tonya Tootsie Topanga Topaz Topmodel Torekeny Tori Torontina Torrey Torri Torrid Torrie Torry Tory Tosh Tosha Tosya Totally Totaly Totti Toxic Tracey Traci Tracy Trana Trasy Travers Treasure Trenton Tresseme Tressy Treza Tria Triada Tricia Tricsy Trillium Trillum Trimly Trina Trinety Trinidad Triniti Trinitie Trinity Trish Trisha Trishna Trista Tristal Tristan Tristana Tristano Tristen Tristian Tristina Tristyn Trixi Trixie Trixx Trixy Tru Tryme Trystan Tsunami Tubbea Tucker Tuesday Tuhina Tulia Tunde Tundella Tweety Twiggy Twilight Twix Ty Tya Tyana Tyann Tye Tyera Tyextra Tyffany Tyger Tyla Tylar Tylene Tyler Tylla Tylo Tylor Tyna Tyra Tyran Tysen Tyung Ualilou Ula Ulia Uliana Uliane Uliya Ulpiana Ulrika Ultima Ulya Ulyana Uma Una Unique Unknown Upadhriti Ursula Usca Usha Utah V V. Vada Vadmacs Vai Vainilla Val Valarie Valda Valeli Valencia valentina Valentina_C Valentine Valeri Valeria Valerie Valeriya Valery Valerye Valeska Valice Valika Vallarie Vallerie Valletta Vally Valoria Valory Valtina Valya Vanalika Vanda Vaneesa Vanesa Vaness vanessa Vanessa** Vanett Vanezza Vania Vanila Vanilla Vanina Vanita Vanity Vanna Vannah Vanny Vany Vanya Various Varla Varya Vashti Vasilina Vasilisa Vasilissa Vavilia Vayana Vedrana Vee Veiki Veila Velia Velicity Vella Velma Velonka Velvet Venday Vendetta Vendi Vendula Vendy Veneisse Venera Venessa Venice Venoly Ventura Venus Venuse Vera Veranica Veranika Verasha Verbena Verena Veri Vero Verona veronica Veronik Veronika Veronika, Veronique Verronica Verta Veruca Verunka Verushka Veryca Veryica Vesna Vetra Via Viana Vianey Vic Vica Vicca Vick Vicki Vickie Vicktoria Vicky Vico Victori Victoria Victoriasweet Victorie Victorija Victoriya Victory Vida Vienna Viera Vika Vikalita Viki Viki, Vikki Viktoria Viktorie Viktorija Viktorina Viktoriya Viktory Viktorya Viky Vila Vilia Vilma Vimala Vina Vinette Vinjera Vinna Vinnie Vio Viol Viola Violet Violeta Violett Violetta Violetta, Violette Viollette Vionah Viorica Viper Vipera Virag Virgin Virgina Virginee Virginia Virginie Virginy Virgo Visconti Vishna Vishra Vita Vittoria Viv Viva Vive Vivi Vivian Viviana Viviane Vivianee Viviania Viviann Vivianna Vivianne Vivica Vivie Vivien Vivienn Vivienne Vivika Vixen Vixxen Vixxxen Vlada Vladimira Vladlena Vlaska Vlasta Vlena Volkanik Vos Vova Vulpix VV Vyona Vyvan Vyxen Waitney Walda Waleria Waleska Walleria Walt Walteenie Wanda Wandy Wanessa Wednesday Weed Weendy Welesa Welli Welly Wendee Wendi Wendie Wendy Wener Wenessy Wenona Wesly West Wetty Whinny Whiskey Whit White Whitney Wiana Wibeke Wickey Wild Wildassholeslut Wildcat Wilde Wildy Wilhelmina Willa Willie Willow Wilma Wilmar Winni Winnie Winny Winona Winter Wintersky Wisha Wiska Withney Witta Wivien Wren Wynona X Xana Xandra Xandy Xania Xara Xasia Xaya Xeena Xena Xenia Xenija Xenni Xenta Xianna Xica Xiemena Xiomara X-Lady Xo Xotica Xseila Xtin Xvai XXX Xxxena Xyla Yahaira Yahira Yahra Yaiselys Yaisha Yakima Yalena Yamile Yana Yanel Yanet Yaneta Yani Yanie Yanina Yanire Yanka Yanna Yannie Yara Yarenis Yari Yarina Yarisa Yarissa Yasemin Yasmeen Yasmeena Yasmin Yasmina Yasmine Yasmyn Yasmyne Yassica Yaya Yazmin Yazmina Yda Yekaterina Yelena Yeley Yello Yemmi Yena Yeni Yenka Yenna Yenny Yesenia Yesica Yesina Yessica Yeva Yevanne Yevonne Yhivi Yieva Yiki Yillie Yisela Ylane Ylena Ylia Yoanna Yoha Yohana Yohane Yoia Yola Yolanda Yoli Yolka Yollanda Yollanta You Ysana Yudita Yuffie Yui Yuki Yukikon Yukki Yulia Yulia, Yulianna Yulie Yulissa Yuliya Yuly Yulya Yumi Yumy Yuno Yuri Yurizan Yvett Yvetta Yvette Yvone Yvonne Yvy Z. Zaawaadi Zabrina Zaccara Zadie Zadyn Zafira Zafiro Zagri Zaisha Zana Zandra Zaneta Zania Zanita Zanna Zara Zarena Zarina Zarrah Zarreena Zasha Zaya Zayda Zaylen Zaza Zazi Zazie Zdenka Zee Zeek Zeina Zelda Zelina Zelma Zemira Zena Zenda Zenia Zenya Zeo Zerah Zerella Zerra Zeta Zeyna Zhang Zhanna Zhenya Zia Ziba Ziggy Zigri Zilla Zina Zinai Zintra Zita Zizi Zladka Zlata Zo Zoe Zoé Zoey Zofia Zofka Zoi Zoie Zoie. Zoila Zola Zolvita Zolvyta Zoodie Zooey Zora Zorah Zoraya Zoryana Zoy Zoya Zoya, Zsabina Zsanett Zsazsa Zsizsi Zsofia Zsu Zsuja Zsuza Zsuzsa Zsuzsana Zuana Zufia Zuleika Zuleima Zuri Zusie Zuzana Zuzanna Zuzu Zylona Zyna Мonik ================================================ FILE: scripts/test_db_generator/makeTestDB.go ================================================ //go:build tools // +build tools package main import ( "context" "database/sql" "fmt" "log" "math" "math/rand" "os" "path" "strconv" "time" "gopkg.in/yaml.v2" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/hash/md5" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sqlite" "github.com/stashapp/stash/pkg/txn" ) const batchSize = 50000 // create an example database by generating a number of scenes, markers, // performers, studios, galleries, chapters and tags, and associating between them all type config struct { Database string `yaml:"database"` Scenes int `yaml:"scenes"` Markers int `yaml:"markers"` Images int `yaml:"images"` Galleries int `yaml:"galleries"` Chapters int `yaml:"chapters"` Performers int `yaml:"performers"` Studios int `yaml:"studios"` Tags int `yaml:"tags"` Naming namingConfig `yaml:"naming"` } var ( repo models.Repository c *config db *sqlite.Database folderID file.FolderID ) func main() { rand.Seed(time.Now().UnixNano()) var err error c, err = loadConfig() if err != nil { log.Fatalf("couldn't load configuration: %v", err) } initNaming(*c) db = sqlite.NewDatabase() repo = db.TxnRepository() logf("Initializing database...") if err = db.Open(c.Database); err != nil { log.Fatalf("couldn't initialize database: %v", err) } logf("Populating database...") populateDB() } func loadConfig() (*config, error) { ret := &config{} file, err := os.Open("config.yml") if err != nil { return nil, err } defer file.Close() parser := yaml.NewDecoder(file) parser.SetStrict(true) err = parser.Decode(&ret) if err != nil { return nil, err } return ret, nil } func populateDB() { makeTags(c.Tags) makeStudios(c.Studios) makePerformers(c.Performers) makeScenes(c.Scenes) makeImages(c.Images) makeGalleries(c.Galleries) makeChapters(c.Chapters) makeMarkers(c.Markers) } func withTxn(f func(ctx context.Context) error) error { return txn.WithTxn(context.Background(), db, f) } func retry(attempts int, fn func() error) error { var err error for tries := 0; tries < attempts; tries++ { err = fn() if err == nil { return nil } } return err } func getOrCreateFolder(ctx context.Context, p string) (*file.Folder, error) { ret, err := repo.Folder.FindByPath(ctx, p) if err != nil { return nil, err } if ret != nil { return ret, nil } var parentID *file.FolderID if p != "." { parent := path.Dir(p) parentFolder, err := getOrCreateFolder(ctx, parent) if err != nil { return nil, err } parentID = &parentFolder.ID } f := file.Folder{ Path: p, ParentFolderID: parentID, } if err := repo.Folder.Create(ctx, &f); err != nil { return nil, err } ret = &f return ret, nil } func makeTags(n int) { logf("creating %d tags...", n) for i := 0; i < n; i++ { if err := retry(100, func() error { return withTxn(func(ctx context.Context) error { name := names[c.Naming.Tags].generateName(1) tag := models.Tag{ Name: name, } created, err := repo.Tag.Create(ctx, tag) if err != nil { return err } if rand.Intn(100) > 5 { t, _, err := repo.Tag.Query(ctx, nil, getRandomFilter(1)) if err != nil { return err } if len(t) > 0 && t[0].ID != created.ID { if err := repo.Tag.UpdateParentTags(ctx, created.ID, []int{t[0].ID}); err != nil { return err } } } return nil }) }); err != nil { panic(err) } } } func makeStudios(n int) { logf("creating %d studios...", n) for i := 0; i < n; i++ { if err := retry(100, func() error { return withTxn(func(ctx context.Context) error { name := names[c.Naming.Tags].generateName(rand.Intn(5) + 1) studio := models.Studio{ Name: sql.NullString{String: name, Valid: true}, Checksum: md5.FromString(name), } if rand.Intn(100) > 5 { ss, _, err := repo.Studio.Query(ctx, nil, getRandomFilter(1)) if err != nil { return err } if len(ss) > 0 { studio.ParentID = sql.NullInt64{ Int64: int64(ss[0].ID), Valid: true, } } } _, err := repo.Studio.Create(ctx, studio) return err }) }); err != nil { panic(err) } } } func makePerformers(n int) { logf("creating %d performers...", n) for i := 0; i < n; i++ { if err := retry(100, func() error { return withTxn(func(ctx context.Context) error { name := generatePerformerName() performer := &models.Performer{ Name: name, Checksum: md5.FromString(name), } // TODO - set tags err := repo.Performer.Create(ctx, performer) if err != nil { err = fmt.Errorf("error creating performer with name: %s: %s", performer.Name, err.Error()) } return err }) }); err != nil { panic(err) } } } func generateBaseFile(parentFolderID file.FolderID, path string) *file.BaseFile { return &file.BaseFile{ Basename: path, ParentFolderID: parentFolderID, Fingerprints: []file.Fingerprint{ file.Fingerprint{ Type: "md5", Fingerprint: md5.FromString(path), }, file.Fingerprint{ Type: "oshash", Fingerprint: md5.FromString(path), }, }, CreatedAt: time.Now(), UpdatedAt: time.Now(), } } func generateVideoFile(parentFolderID file.FolderID, path string) file.File { w, h := getResolution() return &file.VideoFile{ BaseFile: generateBaseFile(parentFolderID, path), Duration: rand.Float64() * 14400, Height: h, Width: w, } } func makeVideoFile(ctx context.Context, path string) (file.File, error) { folderPath := fsutil.GetIntraDir(path, 2, 2) parentFolder, err := getOrCreateFolder(ctx, folderPath) if err != nil { return nil, err } f := generateVideoFile(parentFolder.ID, path) if err := repo.File.Create(ctx, f); err != nil { return nil, err } return f, nil } func logf(f string, args ...interface{}) { log.Printf(f+"\n", args...) } func makeScenes(n int) { logf("creating %d scenes...", n) for i := 0; i < n; { // do in batches of 1000 batch := i + batchSize if err := withTxn(func(ctx context.Context) error { for ; i < batch && i < n; i++ { scene := generateScene(i) scene.StudioID = getRandomStudioID(ctx) makeSceneRelationships(ctx, &scene) path := md5.FromString("scene/" + strconv.Itoa(i)) f, err := makeVideoFile(ctx, path) if err != nil { return err } if err := repo.Scene.Create(ctx, &scene, []file.ID{f.Base().ID}); err != nil { return err } } return nil }); err != nil { panic(err) } logf("... created %d scenes", i) } } func getResolution() (int, int) { res := models.AllResolutionEnum[rand.Intn(len(models.AllResolutionEnum))] h := res.GetMaxResolution() var w int if h == 240 || h == 480 || rand.Intn(10) == 9 { w = h * 4 / 3 } else { w = h * 16 / 9 } if rand.Intn(10) == 9 { return h, w } return w, h } func getBool() { return rand.Intn(2) == 0 } func getDate() time.Time { s := rand.Int63n(time.Now().Unix()) return time.Unix(s, 0) } func generateScene(i int) models.Scene { return models.Scene{ Title: names[c.Naming.Scenes].generateName(rand.Intn(7) + 1), Date: &models.Date{ Time: getDate(), }, CreatedAt: time.Now(), UpdatedAt: time.Now(), } } func generateImageFile(parentFolderID file.FolderID, path string) file.File { w, h := getResolution() return &file.ImageFile{ BaseFile: generateBaseFile(parentFolderID, path), Height: h, Width: w, Clip: getBool(), } } func makeImageFile(ctx context.Context, path string) (file.File, error) { folderPath := fsutil.GetIntraDir(path, 2, 2) parentFolder, err := getOrCreateFolder(ctx, folderPath) if err != nil { return nil, err } f := generateImageFile(parentFolder.ID, path) if err := repo.File.Create(ctx, f); err != nil { return nil, err } return f, nil } func makeImages(n int) { logf("creating %d images...", n) for i := 0; i < n; { // do in batches of 1000 batch := i + batchSize if err := withTxn(func(ctx context.Context) error { for ; i < batch && i < n; i++ { image := generateImage(i) image.StudioID = getRandomStudioID(ctx) makeImageRelationships(ctx, &image) path := md5.FromString("image/" + strconv.Itoa(i)) f, err := makeImageFile(ctx, path) if err != nil { return err } if err := repo.Image.Create(ctx, &models.ImageCreateInput{ Image: &image, FileIDs: []file.ID{f.Base().ID}, }); err != nil { return err } } logf("... created %d images", i) return nil }); err != nil { panic(err) } } } func generateImage(i int) models.Image { return models.Image{ Title: names[c.Naming.Images].generateName(rand.Intn(7) + 1), CreatedAt: time.Now(), UpdatedAt: time.Now(), } } func makeGalleries(n int) { logf("creating %d galleries...", n) for i := 0; i < n; { // do in batches of 1000 batch := i + batchSize if err := withTxn(func(ctx context.Context) error { for ; i < batch && i < n; i++ { gallery := generateGallery(i) gallery.StudioID = getRandomStudioID(ctx) gallery.TagIDs = models.NewRelatedIDs(getRandomTags(ctx, 0, 15)) gallery.PerformerIDs = models.NewRelatedIDs(getRandomPerformers(ctx)) path := md5.FromString("gallery/" + strconv.Itoa(i)) f, err := makeZipFile(ctx, path) if err != nil { return err } if err := repo.Gallery.Create(ctx, &gallery, []file.ID{f.Base().ID}); err != nil { return err } makeGalleryRelationships(ctx, &gallery) } return nil }); err != nil { panic(err) } logf("... created %d galleries", i) } } func generateZipFile(parentFolderID file.FolderID, path string) file.File { return generateBaseFile(parentFolderID, path) } func makeZipFile(ctx context.Context, path string) (file.File, error) { folderPath := fsutil.GetIntraDir(path, 2, 2) parentFolder, err := getOrCreateFolder(ctx, folderPath) if err != nil { return nil, err } f := generateZipFile(parentFolder.ID, path) if err := repo.File.Create(ctx, f); err != nil { return nil, err } return f, nil } func generateGallery(i int) models.Gallery { return models.Gallery{ Title: names[c.Naming.Galleries].generateName(rand.Intn(7) + 1), Date: &models.Date{ Time: getDate(), }, CreatedAt: time.Now(), UpdatedAt: time.Now(), } } func makeChapters(n int) { logf("creating %d chapters...", n) for i := 0; i < n; { // do in batches of 1000 batch := i + batchSize if err := withTxn(func(ctx context.Context) error { for ; i < batch && i < n; i++ { chapter := generateChapter(i) chapter.GalleryID = models.NullInt64(int64(getRandomGallery())) created, err := repo.GalleryChapter.Create(ctx, chapter) if err != nil { return err } } logf("... created %d chapters", i) return nil }); err != nil { panic(err) } } } func generateChapter(i int) models.GalleryChapter { return models.GalleryChapter{ Title: names[c.Naming.Galleries].generateName(rand.Intn(7) + 1), ImageIndex: rand.Intn(200), } } func makeMarkers(n int) { logf("creating %d markers...", n) for i := 0; i < n; { // do in batches of 1000 batch := i + batchSize if err := withTxn(func(ctx context.Context) error { for ; i < batch && i < n; i++ { marker := generateMarker(i) marker.SceneID = models.NullInt64(int64(getRandomScene())) marker.PrimaryTagID = getRandomTags(ctx, 1, 1)[0] created, err := repo.SceneMarker.Create(ctx, marker) if err != nil { return err } tags := getRandomTags(ctx, 0, 5) // remove primary tag tags = sliceutil.Exclude(tags, []int{marker.PrimaryTagID}) if err := repo.SceneMarker.UpdateTags(ctx, created.ID, tags); err != nil { return err } } logf("... created %d markers", i) return nil }); err != nil { panic(err) } } } func generateMarker(i int) models.SceneMarker { return models.SceneMarker{ Title: names[c.Naming.Scenes].generateName(rand.Intn(7) + 1), } } func getRandomFilter(n int) *models.FindFilterType { seed := math.Floor(rand.Float64() * math.Pow10(8)) sortBy := fmt.Sprintf("random_%.f", seed) return &models.FindFilterType{ Sort: &sortBy, PerPage: &n, } } func getRandomStudioID(ctx context.Context) *int { if rand.Intn(10) == 0 { return nil } // s, _, err := r.Studio().Query(nil, getRandomFilter(1)) // if err != nil { // panic(err) // } v := rand.Intn(c.Studios) + 1 return &v } func makeSceneRelationships(ctx context.Context, s *models.Scene) { // add tags s.TagIDs = models.NewRelatedIDs(getRandomTags(ctx, 0, 15)) // add performers s.PerformerIDs = models.NewRelatedIDs(getRandomPerformers(ctx)) } func makeImageRelationships(ctx context.Context, i *models.Image) { // there are typically many more images. For performance reasons // only a small proportion should have tags/performers // add tags if rand.Intn(100) == 0 { i.TagIDs = models.NewRelatedIDs(getRandomTags(ctx, 1, 15)) } // add performers if rand.Intn(100) <= 1 { i.PerformerIDs = models.NewRelatedIDs(getRandomPerformers(ctx)) } } func makeGalleryRelationships(ctx context.Context, g *models.Gallery) { // add images imageIDs := getRandomImages(ctx) if len(imageIDs) > 0 { if err := repo.Gallery.UpdateImages(ctx, g.ID, imageIDs); err != nil { panic(err) } } } func getRandomPerformers(ctx context.Context) []int { n := rand.Intn(5) var ret []int // if n > 0 { // p, _, err := r.Performer().Query(nil, getRandomFilter(n)) // if err != nil { // panic(err) // } // for _, pp := range p { // ret = sliceutil.AppendUnique(ret, pp.ID) // } // } for i := 0; i < n; i++ { ret = sliceutil.AppendUnique(ret, rand.Intn(c.Performers)+1) } return ret } func getRandomScene() int { return rand.Intn(c.Scenes) + 1 } func getRandomGallery() int { return rand.Intn(c.Galleries) + 1 } func getRandomTags(ctx context.Context, min, max int) []int { var n int if min == max { n = min } else { n = rand.Intn(max-min) + min } var ret []int // if n > 0 { // t, _, err := r.Tag().Query(nil, getRandomFilter(n)) // if err != nil { // panic(err) // } // for _, tt := range t { // ret = sliceutil.AppendUnique(ret, tt.ID) // } // } for i := 0; i < n; i++ { ret = sliceutil.AppendUnique(ret, rand.Intn(c.Tags)+1) } return ret } func getRandomImages(ctx context.Context) []int { n := rand.Intn(500) var ret []int // if n > 0 { // t, _, err := r.Image().Query(nil, getRandomFilter(n)) // if err != nil { // panic(err) // } // for _, tt := range t { // ret = sliceutil.AppendUnique(ret, tt.ID) // } // } for i := 0; i < n; i++ { ret = sliceutil.AppendUnique(ret, rand.Intn(c.Images)+1) } return ret } ================================================ FILE: scripts/test_db_generator/male.txt ================================================ A.J. Aarin Aaron Abdul Abe Abel Abraham Abu Ace Achil Adam Addison Aden Adiel Adonis Adria Adrian Adriana Adrianno Adriano Adrielly Ads Advik Agatha Agattha Agent Ago Ags Ahmed Aidan Aiden Airin Aiumy Aj Ajay AK Akos Al Alain Alam Alan Alaska Alb Al-b Albert Alberto Aldo Alec Aleck Alecmiller Alejandro Alek Aleks Alen Alesandra Alessandro Alessia Alessio Alex Alex**** Alexander Alexandre Alexei Alexia Alexis Alexsander Alexx Alexxx Alexy Alfie Alfred Alfredo Ali Aline Allan Allen Alonzo Alpacabowel Alrik Alson Amanda Amandha Amani Amartinsa Amaya Amelio Amir Amos Ams Anaconda Anders Anderson Andre Andrea Andreas Andreia Andrej Andres Andressa Andrew Andrey Andreya Andrezza Andro Andron Andy Angel Angelly Angelo Anita Anonymous Anthonie Anthony Antoine Anton Antoni Antonio Anubis Anzus Apollo Aquiles Arad Aras Arcanjo Archer Archie Aretha Argo Argos Ari Aries Armando Armond Arnie Arnold Arny Aron Arpad Art Artem Arthur Artur Arturo Asa Asante Asher Ashland Ashton Aslan Assassin Aston Astral Atlanta Atreyu Atticus August Austin Avangard Avatar Avenger Avery Axe Axel Axil Axl Ayden Ayer Aymeric Backey Bad Badger Bady Baily Balls Balu Bama Bambino Bar Barbara Barret Barrett Barron Barry Bart Bash Basil Bastien Bazuca Bear Beatriiz Beau Beaux Bebel Begasus Bellamy Ben Benie Benito Benjamin Benn Bennett Benny Benson Bentley Benton Berke Bia Bianca Biannca Biff Big Bigdick Bigg Biggz Bignj Bill Billie Billy Bily Binx Bishop Bj Black Blaine Blak Blake Blanca Blaze Blonder Bls Blu Blue Bnasty Bo Bob Bobbie Bobby Bobi Boby Bogdan Bone-O Boner Bony Boomer Boris Boston Bostonbrawler Bottom Boxer Boyce Boyd Brad Braden Bradford Bradley Bradly Bradon Brady Brain Bran Brando Brandon Brannon Branson Brant Brat Bravo Braxton Brayden Brayen Breezy Brendan Brenden Brendon Brenn Brennan Brenner Brennon Brent Brett Breyden Brian Brice Brick Brickzilla Brisa Britan Broc Brock Broden Broderick Brodie Brody Brogan Brooks Brother Bruce Bruna Brunna Bruno Bryan Bryant Bryce Brycen Brysen Bryson Bt Buck Bud Buddy Bum Burke Burton Buster Butch Byonda Byron C. C.J. Cabrera Cade Caden Caesar Cage Cail Cailean Cain Caio Cal Caleb Calhoun Calixto Callum Calvin Cam Camden Cameron Camila Camilly Campbell Can Cano Capoeira Captain Carl Carla Carlita Carlo Carlos Carlton Carmello Carole Carrol Carson Carter Cary Casey Cash Casper Cassian Cassidy Castro Cavin Cayden Cazden Cc Cedric Cesar Chad Chance Chandler Chapels Charlamagne Charles Charley Charlie Chase Chawane Chayenne Chaz Chelsia Chester Chet Cheyne Chico Chikito Chiky Chilli Chip Choky Chose Chris Chriss Christian Christoph Christopher Christos Chuck Chucky Chulo Ciccle Cintia Cj CK Clark Clarke Claude Claudea Claudio Claudya Clay Clayton Cliff Clint Clinton Clover Clyde Coach Coby Codey Codi Cody Coen Colby Colden Cole Coleman Colin Collin Collins Colm Colt Colton Commando Coner Conner Connor Conny Conor Conrad Cooper Corbin Corey Cort Corvin Cory Coty Coudy Cousin Craig Craven Cris Criss Cristian Cristiane Cruize Cruz Csaba Csoky Ct Cuntre Curt Curtis Cutler Cyara Cybelle Cyrus D D. D.Arclyte D.O. D2 Dacotah Dada Daddy Dade Dak Dakota Dalas Dale Dallas Dalton Damaso Damian Damien Damion Damon Dan Danarama Dane Danelle D'Angelo Dani Daniel Daniela Danii Danko Dann Danny Dante Dant'e Danton Dany Danyela Daphynne Darcy Darell Daren Darian Darin Dario Darius Darko Darnell Darrel Darrell Darren Darron Darryl Darwin Daryl Dato Dave Davey David Davida Davide Davie Davin Davis Dawny Dax Daxx Dayane Dayanna Daymin Dayn Dayton Deacon DeAiro Dean DeAngelo Declan Deep Deezki Dellon Demetri Demetrixxx Demond Den Deniro Denis Denis* Dennis Denny Denson Denton Deny Dereck Derek Derick Dermott Deron Derrek Derrell Derrick Deshaun Deshawn Desmond Deston Deviant Devils Devin Devlin Devon Dex Dexter Dexx Dhones Diablo Dick Diego Diesel Dieter Diether Dietrich Diezel Dillan Dillion Dillon Dimitri Dimitry Dingo Dinio Dino Dion Dirk Dirty Dixon Dizzy Dj Dolan Dolce Dolche Dolf Dom Domenic Domenico Domineko Dominic Dominik Dominique Domonic Domonique Don Donald Donato Donnie Donnovan Donny Donovan Donte Dorian Doug Dougie Douglas Drago Dragon Drak Drake Draven Dredd Drehyden Drew Driely Drnasty Dru Dsnoop Duane Duda Duke Duncan Dustin Dusty Dustyn Dutch Dvda Dviano Dwayne Dwight Dylan E.J. Eamon Earl Ed Edan Eddie Eddiie Eddy Edin Edison Edmond Edu Eduard Eduardo Edward Edwin Ej Ekzavir El Eli Eliesky Elijah Elio Elisha Elliot Elliott Ellis Elmilio Elton Ely Elye Emanuel Emerg Emerge E-MERGE Emerson Emi Emilio Emilly Emir Emmanuel Emmett Emoke Emory Enio Enrico Enrique Enzo Er Eric Erick Erik Erika Erin Ernie Erob Eros Erycka Esteban Ethan Etienne Eugene Evan Evec Everett Eviie Evo Ewan Ezra Fabiana Fabio Fabiola Fabricia Fabrizio Fabyana Falco Falcon Fame Farell Fatimah Faube Faun Fausto Favio Fehu Felipe Felishia Felix Fena Fenomen Fer Ferdinando Ferenc Fernanda Fernando Figi Filippo Filthy Fingaz Finn Flash Flex Flexx Flipper Floyd Fly Flynt Ford Fornaro Forrest Foster Fox Fran Franc Francesco Francine Francis Francisco Franck Franco Francois Frank Frankie Franklin Franko Franky Fred Freddie Freddy Frederic Frederick Fredrick Frei Frenk Frenky Frew Fuller Gabe Gaberial Gabor Gabriel Gabriela Gabriella Gabriely Gabryela Gael Gage Galen Gang Gareth Garett Garin Garren Garrett Garry Garth Gary Gator Gaucho Gavi Gavin Gaz GC Gemini Gene Generallee Geo Geoff Geoffrey George Georgie Georgio Geri Gerry Gerson Gery GG Giacomo Gianni Giany Gideon Gilberto Gino Gio Giorgio Giovanni Girth Gisele Gleica Gleice Glen Glenn Godiva Gold Gonzo Good Goran Gordon Graham Graicy Grandpa Grant Grayson Grazieli Greg Gregg Gregor Gregory Greyson Griffin Gruff Guadalupe Guatemal Guerimca Guillaume Gunner Gunter Gus Gustav Guy Gyovanna H3ll4Sl00tz Hacan Haghata Hagi Haigen Hakan Hal Hammer Hank Hans Hanz Harisson Harley Harmon Harris Harrison Harry Hart Harvey Hatman Hayden Hayes Heath Hector Hefty Helen Heloiza Hendrick Henier Henry Herald Herschel Hez Hijo Hilda Hobie Holden Holy Homer Hooks Horse Horus Hotkarl Howard Howie Hoyt Hoytt Hudson Hugh Hugo Humberto Hung Hunter Hygor Ian Ibe Ice Igor Ike Il Ilan Illz Immanuel Indiana Intrigue Ipauta Irv Isaac Isabele Isabella Isabelle Isabelly Isabely Isaiah Isiah Israel Issac Ivan Ivo Izaak Izadora J J. J.J J.J. J.P J.R. J.T. Jac Jace Jacen Jack Jack23 Jackeline Jackie Jackk Jacklyne Jackson Jacob Jacques Jade Jaden Jadyn Jae Jael Jag Jaime Jair Jaison Jake Jakob Jalif Jalil Jamal James James* Jameson Jamie Jamison Jan Janily Janira Janos Jarec Jared Jarek Jarod Jarret Jarrod Jason Jasper Jasten Javier Javy Jax Jaxon Jaxton Jaxx Jaxxx Jay Jayce Jayden Jaymus Jayson Jazz JB Jbrown Jc JD Jean Jean-Claude Jean-Luc Jean-Pierre Jean-Yves Jeb Jed Jeff Jeffrey Jenaveve Jener Jenner Jennifer Jeovanni Jeph Jeremey Jeremiah Jeremie Jeremy Jericob Jermaine Jermal Jerome Jerry Jesse Jessie Jessy Jesus Jet Jett Jeyden Jez Jhenifer Jhon Jhonny Jhony Jigz Jim Jimmie Jimmy Jiri Jj Jlee jmac J-Mac Joachim Joaquin Jodi Jodie Joe Joel Joemac Joey Johan Johann John Johnathan Johnathn Johnathon Johnny Johny Johnyy Jon Jonah Jonan Jonas Jonathan Jones Jonna Jonni Jonny Jordan Jordano Jorden Jordi Jorge Jose Joseph Josh Joshua Joshva Josiah Joss Jovan Joy Joyce Jp Jr Jrock Jt Juan Juani Juanito Judas Judass Judd Jude Jules Julian Juliana Julianne Juliano Julio Julius Jun June's Junior Jurek Just Justen Justice Justin Justy Juuh K K. K.D. Kace Kade Kaden Kadu Kaelon Kaffy Kai Kaike Kaikie Kaleb Kalena Kam Kamil Kamily Kane Kanil Karen Karim Karl Karlo Karol Karter Kash Kastiel Kawanni Kawany Kayden Kb Kc Kdawg Keane Keanuu Keefe Keenan Keiran Keith Keizy Kellin Kelly Kelso Kelvin Kemer Ken Kenard Kendall Kendo Kendro Keni Kennan Kenny Kent Kenta Kenton Ketenlly Kev Kevin Keving Khriztian Kich Kid Kidd Kiefer Kieran Kike Killiam Killian King Kinky Kinsey Kip Kipp Kipper Kirby Kirk Kiro Kirtane Kit KJ Klein Klint Knight Knox Koby Koda Kody Koji Kolby Koose Kory Kostya Kotly Kr Kris Kriss Krist Kristian Kristof Kristofer Krys Krzysztof Kurt Kurtis Kwang Kye Kyle Kyler L.T. Labely Lachlan Lady Ladys Lamar Lancaster Lance Lancelot Landon Lane Largo Larry Lars Laszlo Lauro Lavinia Laviny Lawrence Lawson Layza Lazlo Le Leah Leander Leche Lee Leei Lefty Legrand Leif Lein Lenita Lenny Leny Leo Leon Leona Leonardo Leonel Leslie Leticia Leticya Letterio Letticia Lev Levi Levy Lew Lewis Lex Lexington Leyluken Liam Libor Liev Lil Link Lionel Liza Lj LK Llee Lloyd Loan Lobo Loco Logan Lohara London Long Lonnie Lopez Lorenita Lorenzo Lorin Lou Louie Louis Loupan Lourranny Lt Luana Luane Luc Luca Lucas Lucca Luciana Lucianna Luciano Lucimara Lucio Lucius Lucke Lucky Lucus Ludo Lugh Luis Luka Lukas Luke Lush Luther Lutro Lyl Lyle Lynda Mac Macana Mack Mad Maddox Madmax Maestro Magnum Maikel Maikl Major Makaveli Malachi Malcolm Malek Mam Mandingo Manny Manu Manuel Mara Marc Marcel Marcela Marcelinha Marcella Marcello Marcelo Marcia Marcila Marcinha Marco Marcos Marcus Marek Mareo Mariana Marinho Mario Marjorie Mark Marko Markov Markus Marquee Mars Marshall Marten Marti Martin Martty Marty Marvin Mason Massimo Master Mat Mateo Matheu Matheus Mathew Matie Matt Matteo Matthew Matty Maui Maurice Mauricio Maverick Max Maxim Maximilian Maximillion Maximo Maximus Maxmilian Maxwell Maxx Mayla Mayorye Mazee Mazus MC Mchico McKensie Megur Meiling Mell Melyssa Merlin Mesa Meu Micah Michael Michal Michel Michelle Michelli Michelly Michi Michy Mick Mickael Mickey Micky Miguel Mihail Mik Mike Mikel Mikey Mikhail Mikkal Miky Mil Milan Miles Milky Miller Milo Milton Mindo Miquel Miran Mirek Miriany Mirko Miro Mischu Misha Mitch Mitchell Mitt MJ Mkax Mo Mocha Moe Mohamed Mohawk Mojo Momo Money Montana Montgomery Monty Mookie Mope Moreno Morgan Moses Moss Mr Mr. Mred Mugur Munick Murray Mykul Myles Myllena Myth Nacho Nade Nando Nartan Nash Nastro Nasty Nat Natalia Natalie Natasha Nate Nathan Nathaniel Neal Ned Neeo Neil Nelson Neo Ners Nestor Nic Nich Nicholas Nick Nicko Nickolas Nicky Nico Nicolai Nicolas Nicoli Nicolly Nicoly Nicoo Nigella Nik Nikke Nikki Nikko Nikky Niko Nikolas Nina Nino Nitro Nixon Noa Noah Noel Nolan Nomad Norby Novis Ocho Odin Oiliver Oleksandr Oliver Omar Orian Orlando Orson Oscar Osiris Oteo Otis Otto Owen P P.J. Pablo Paco Paddy Pal Palmer Pamel Pamela Pamella Panama Pandemonium Papa Papi Paris Park Parker Pascal Pat Patric Patricia Patrick Patrik Patty Patvik Paul Paulah Paulinha Paulo Paulos Pauly Pavel Pavlos Payton Pedro Pehy Pepa Pepe Pepino pepito Perola Perr Perry Pet Pete Peter Peterson Petr Peyton Pg Phat Phenix Pheonix Phil Philip Philippe Phillip Philly Phoenix Pierce Piercing Pierre Pietro Pike Pimp Piotr Pit Platon Poax Poke Pollyana Ponch Porno Porsero Porto Potro Powell Pracik Premek Prescott Preslee Pressure Preston Pretty Primo Prince Priscila Prodigy Pup Pupcheer Pyetro Quake Quentin Quinton Quron Rabeche Race Rad Radar Radim Rafa Rafael Rafaele Rafaella Rafaely Raian Raiin Raissa Ralph Ram Rambo Rami Ramiro Ramon Ramsey Ramy Ran Rand Randall Randy Raoul Raphael Raphaela Raphaelly Raquelle Rassy Rastapica Raul Ray Rayane Raymond Raymone Rb Ready Reckless Red Redd Reed Reese Reg Regan Reggie Reid Reinhardt Remigio Remo Remy Renato Reno Reo Rex Rey Rhett Ricardo Ricci Rich Richard Richi Richie Richy Rick Rickie Ricky Rico Ridge Rik Rikk Riley Rimo Rinata Riny-Rey Rion Ritchie River Rj Rls Ro Rob Robbie Robby Robert Roberta Roberto Robin Robson Roby Rocco Rochielle Rock Rocke Rocky Roco Rod Rodney Rodolfo Rodri Rodrigo Rogan Roge Roger Rogue Rok Rokki Roland Rollie Roly Roman Rome Romeo Ron Ronald Ronnie Ronny Roosevelt Rory Rossa Rowan Rox Roy Royce Rt Ruben Ruckus Rudolpho Rudy Ruka Rumenito Russ Russell Rusty Ryan Ryann Rybot Rycky Rylan Ryu Sabara Sabby Sabreena Sabrina Sajkov Sam Samara Sami Samking Sammy Sampson Samuel San Sandro Sandy Santi Santiago Santino Sarah Sascha Sasha Saul Savage Savannah Savkov Sawyer Saxon Schweger Scooter Scorpio Scott Scottie Scotty Scout Seamus Sean Seb Sebas Sebastian Sebastien Serge Sergeant Sergei Sergey Sergi Sergio Seth Sevyan Sexy Seymore Seymour Sgt Sgt. Shad Shades Shaft Shaggy Shaira Shakira Shane Shannara Sharok Shaun Shaw Shawn Shay Sheldon Sherman Shey Shigeki Shine Sho Short Shreddz Silas Silver Silvester Silvio Siman Simon Simone Sin Sinclair Sir Skilar Skip Skitz Skorpio Skott Sky Skylar Skyler Skyy Slade Slave Sledge Slim SlimPoke Slone Slovac Slut Sly Small Smassh Smugglerblood Soldier Sonny Sophy Spence Spencer Spider Spits Stallion Stan Stanley Stas Ste Steave Steavn Stefan Stefany Stephan Stephen Sterling Steve Steven Stevenrush Stevo Stew Stewart Stig Still Stirling Stiv Strong Stu Stuart Styx Suares Suarez Suitcase Sullivan Summer Sundowner Sunny Suren Surge Surya Sutton Suzanna Suzuki Suzy Sven Swen Swiss Sybill Sylvan Szilard T T. T.J. Tad Tai Taiana Tailor Taina Takuo Tallen Talon Talyta Tamara Tangerine Tank Tanner Tannor Tarzan Tate Tawele Taye Tayla Taylor Tayte Tayveon Teacher Ted Teddy Tee Tegan Tener Teo Terell Terence Terrell Terry Tex Texas Thad Thaiis Thallyne Thays Thaysla The Theo Theodore Thierry Thirteen Thomas Thor Thore Thrax Thyle Tigger Tiko Tim Timarrie Timmi Timmy Timo Timothy Timoti Tino Titof Titus Tj Toastboy Tob Tober Tobey Tobias Toby Todd Toffy Tokyo Tom Tomas Tomi Tomm Tommie Tommy Tone Toni Toniyo Tonny Tony Top Topher Torque Torrey Tory totaleurosex Totto Toudy Trace Travis Trelino Trent Trenton Trentton Trever Trevor Trey Tripp Tristan Tristin Troopers Troy Tru Truman T-Stone TT Tuca Tucker Ty Tyce Tyler Tyr Tyra Tyrone Tyson Udi Ugly Ulan Urbic Urijah Vader Vadim Val Valentin Valentina Valentino Van Vanbam Vance Vandayme Vander Vane Vanessa Vasya Vaughn Veaceslav Vega Ven Venom Vernanda Veronica Vianca Vic Victor Victoria Videl Vikto Viktor Villem Vin Vince Vincent Vinn Vinnie Vinny Vitali Vitally Vitaly Vito Vitoria Vittorio Vitya Viv Viviane Vixtor Vlad Volt Voodoo Wade Wagner Walker Wallace Wanessa Wanny War Warner Warren Wayne Wedding Wein Wes Wesley West Wild Wilde Will William Willian Willie Willis Willy Wilson Windom Winston Wolf Wolfie Woody Woop Wrex Wrexxx Wyatt Xander Xavi Xavier Xenar XL Xman Yago Yan Yanessa Yanick Yanka Yanos Yasmim Yates Yen Yoachim Yolo Youlian Yris Yuliana Yunior Yura Yuri Yves Zac Zach Zachary Zack Zaddy Zak Zakk Zander Zane Zaq Zario Zayne Zdeno Zeak Zeb Zed Zeek Zeke Zenza Zeth Zeus Zhane Zidane Ziggy Zion Zoliboy Zoltan Zor Zsolt Zsur Zyzzje ================================================ FILE: scripts/test_db_generator/naming.go ================================================ //go:build tools // +build tools package main import ( "bufio" "math/rand" "os" "strings" ) var names map[string]*naming type performerNamingConfig struct { Male string `yaml:"male"` Female string `yaml:"female"` Surname string `yaml:"surname"` } type namingConfig struct { Scenes string `yaml:"scenes"` Performers performerNamingConfig `yaml:"performers"` Galleries string `yaml:"galleries"` Studios string `yaml:"studios"` Images string `yaml:"images"` Tags string `yaml:"tags"` } type naming struct { names []string } func (n naming) generateName(words int) string { var ret []string for i := 0; i < words; i++ { w := rand.Intn(len(n.names)) ret = append(ret, n.names[w]) } return strings.Join(ret, " ") } func createNaming(fn string) (*naming, error) { file, err := os.Open(fn) if err != nil { return nil, err } defer file.Close() ret := &naming{} s := bufio.NewScanner(file) for s.Scan() { ret.names = append(ret.names, s.Text()) } if err := s.Err(); err != nil { return nil, err } return ret, nil } func initNaming(c config) { names = make(map[string]*naming) load := func(v string) { if names[v] == nil { var err error names[v], err = createNaming(v) if err != nil { panic(err) } } } n := c.Naming load(n.Galleries) load(n.Images) load(n.Scenes) load(n.Studios) load(n.Tags) load(n.Performers.Female) load(n.Performers.Male) load(n.Performers.Surname) } func generatePerformerName() string { female := rand.Intn(4) > 0 wordRand := rand.Intn(100) givenNames := 1 surnames := 1 if wordRand < 3 { givenNames = 2 } else if wordRand < 26 { surnames = 0 } fn := c.Naming.Performers.Female if !female { fn = c.Naming.Performers.Male } name := names[fn].generateName(givenNames) if surnames > 0 { name += " " + names[c.Naming.Performers.Surname].generateName(1) } return name } ================================================ FILE: scripts/test_db_generator/scene.txt ================================================ 0% 10% 100 100% 1000 1000000 1000facials 1000th 1001 100lb 100lbs 100pct 100pt 100pts 100th 101 1011096 101Big 10-1JJ 102 103 104 105 106 107 108 109 109lb 10-Member 10K 10-man 10on1 10's 10th 1-0The 10v10 11 110 110% 11-Inch 11th 12 1-2 1-2-3-4 12inch 12-Inch 12-man 12th 12-Year 13 13-Inch 13-Inches 13th 14 14in 14inch 14inches 14th 14thBrutal 15 150lbs 155lb 15-man 15on1 15pts 15th 16 16th 17 1-800-BIG 1-800-Flowers--A-Make love 1-800-Sucking 1-800-THIC-DIC 18th 18-year 18-year-old 18-year-olds 18years 18yo 18yr 18yrs 19 1-900-MAKE LOVE-MEE 1950's 1955 1957 1965 1969 19th 19ths 19-year 19-year-old 19-Year-Old's 19yo 19YOs 19YO's 19yr 1Hr 1K 1of2 1of3 1on1 1on3 1on4 1pm 1st-Time 2 20 20% 200 200$ 2000 20000 2003 2004 2005 2007 2008 2009 2009-01-12 2009-01-13 2009-01-15 2009-01-16 2009-01-20 200th 2010 2010-12-06 2011 2012 2013 2014 2015 2016 2017 2018 2018's 2019 2020 2021 20s 20something 20-somethings 20th 20-Year-Old 20YO 20yr 21 21sextury 21sextury-shooting 21st 21-year-old 21YO 21YO's 21yr 22 22year 22-year-old 22yo 22yr 23 23-minute 23-year-old 23years 23yo 23yr's 24 2-4-6-Squirt 247 24-7 24-year-old 25 25-year-old 26 26-year-old 27 27th 27-year-old 28 28-year-old 29 2am 2BBC 2-Member 2Cute 2draws 2-Endurance 2Enjoyment 2Extreme 2for1 2-For-1 2-Girl 2Girls 2-guys 2Heavy 2hot 2-lip 2nd 2of2 2of3 2on1 2on2 2on3 2Riley'n'You 2RUFF4HER 2toyfun 2x 3 3% 30 300th 31 32 32DD 32DDs 32DD's 32F 32FF 32G 32G's 32H 33 33F 34 34d 34DD 34DD’s 34DDD 34DDDs 34E 34f 34F’s 34FF 34F's 34G 34GG 34k 34M-cups 35 35yr 36 36D 36DD 36DD's 36E 36F 36FF 36JJ 36M 36N 37 38 38DDD 38DDD-cupper 38G 38G-cup 38G-Cups 38GGG-Cup 38G's 38H 38J-Cup 38J-cups 38K 38N-cup 39 3am 3BBC 3-Member 3d 3-D 3DD 3D's 3-Girl 3-Hole 3-Load 3of3 3on1 3-On-1 3on1anal 3on1slut 3on2 3on3 3ple 3pm 3pts 3rd 3s 3some 3-some 3Some 3-Some 3somes 3Some's 3Somes-Me 3Sum 3way 3-way 4 40 400-dollar 400th 40H 40inch 40-Inch 40-Love; 40oz 40's 40Something 40th 41 41G 42 420 420th 42Es 42G 42G-cups 43 44 45 45inch 46 46F 47 48 49 49-year-old 4A 4BBC 4BC 4Beejs 4Both 4Buxom 4BWC 4-Member 4Early 4Endurance 4Ever 4Facing 4Intense 4k 4nicating 4on1 4-On-1 4on1+DAP+8ia+Piss 4on2 4on3 4-On-3 4Orgasm 4-play 4pm 4Sadistic 4Sexual 4-Sexual 4some 4-Some 4Submitting 4swallow 4th 4Today 4U 4v4 4vbathtubteen 4-way 4-wheeler 5 5$ 50 5'0 5-0 500th 5-0NEWS 50Plus 50PlusMILF 50PlusMILFscom 50s 50th 50-year-old 51 5'10 52 5-4-3-2-1 57-year-old 58 58YO 59 5A 5BBC 5-Member 5FACIALS 5Full 5k 5Live 5Modified 5on1 5-On-1 5on1with 5on2 5on4 5on5 5's 5Sexual 5-Sexual 5some 5swallow 5SWALLOWS 5th 5vs2 5way 6 60 6'0 6000rpm 600th 60P 60Plus 60's 60something 60th 60-year-old 60-year-old's 61 61-year-old 62 62-Inch 63 63-year-old 64 64-year-old 65 66 66-year-old 67 67-year-old 68 68YO 69 69' 69’ing 69’s 69er 69ers 69ing 69s 69's 69YO 6-Member 6-Creampie 6on1 6-On-1 6on2 6th 7 70 70s 70's 70-year-old 71 71-year-old 72 73 74 75 75k 76 77 78 79 7-Bang 7-Member 7dapPose 7-foot 7on1 7on2 7-On-2 7on3 7's 7th 8 80 80% 80s 80's 81 82 83 84 85 85lb 86 86'd 87 88 89 8am 8-Ball 8-Member 8it 8on1 8-On-1 8on2 8pm 8th 9 90 90% 900th 90210 90lbs 90s 91 92 93 94 95 9-5 95D 96 97 97% 98 99 9-Fun-Fun 9on1 9on3 9th 9thBig a à A+ a2a a2m A2P A6usive AA AAA Aaaaa Aaah AAAhh-OOOOOGAH Aali Aaliayh Aaliyah Aaliyah Love Aaliyah's Aaliyan Aalyiah Aanda Aanl Aaralyn Aariella Aarielle Aarin Aarolyn Aaron Aaron's Aasphyxia Aayla ab Abagelle abandon abandoned Abba Abbbootyato Abbey Abbey's Abbi Abbie Abbie's Abbi's Abbondanza AbbondanzaBig Abbott Abby Abby’s Abbys Abby's Abc ABC's Abdalla Abducted abduction Abe Abel Abelha Abelia Abelinda Abella Abella’s Abellas Abella's Abelun Abetting Abi Abia Abide Abiding Abiertas Abigail Abigaile Abigaile's Abigail's Abigial Abilities ability Abject ablaze able Ablego Abnormal aboard Abominal abou A-bouncing about About? abov above Abra Abracadabra Abraham Abrasador Abrascrewdabra Abrasive Aturkey Abrielle Abril Abrill Abrillantar Abrina abroad A-Broad abs Absences Absences? Absent Absinthe absolute absolutely Ab-Solutely Absorb Absorbing Abstinence abstract ABubble Abundance abundant abuse abused abusedAll abusedNipple Abuser abuses AbuseSomething Abusing abusive Abysm Abyss AC Acadamy Academia Academic Academics Academy Accede Accel accent Accents accept Acceptance accepted acceptedThis Accepting accepts Acces access Accessible Accessories Accessory accident accidental accidentally Acclaim acclaimed Accommodate Accommodation Accommodations accomodate Accomplished Accomplishment Accomplishments according Account Accountable accountant Accountants Accountant's Accounting Accoutrement Accuracy Accurate Accused accustomed ace Acecaria's Aceitoso aces ache Acheivers aches achieve Achieved Achievement Achiever Achievers achieves Achieving Achin aching Achora Achtung Achy Acid Acikin Acing Acionna Acita Ackerman Aclamat A-Clbooty ACME Amemberalypse A-Coming Acon Acosta Acostón Acqua acquaintance Acquaintances? Acquainted acquire acquired Acquisition Acre Acrobat acrobatic acrobatics Acrobats Acropolis across Acro-Yoga act acting actio action ACTION Action2 ActionDevastating actionKicks ACTIONLast actionOnly actions Activate Active Activist activities activity Actor actors Actor's actress actresse acts actual Actuality actually Acuatic Acuerdo Acucat Acusado Acworth ad Ada Adagio Adair Adalisa's Adam Adamo Adams Adamson Adams's Adan Adara Adarah Adara's Aday add Addams added Addee Addi Adjohnsons Adjohnsonted Adjohnsontion addict addicted addiction Addictions addictive addicts addictsamazingly Addie Addies Adding Addio Addis Addison addisonerosebgvid Addison's Addition additional Address adds Addyson Adel Adela Adelaida Adele Adelia Adelina Adelina's Adeline Adell Adelle Adelle's Adelle-ta Adel's Adelyn Adepts Adessa Adia Adicción Ajohnsontion Adidas Adin Adina Adin's Adios Adira Adira’s Adira's Adjani adjectives Adjourned Adjust adjustment adjusts Adley Admin administer administered administers Administrative Administrator Admirable Admiration Admire admired admirer admires admiring admission Admissions Admit admits Admitting Ado Adolescence Adolescente Adonis Adopt adopted Adopts Adora Adorabe adorable Adorably adoration adore Adored adores Adoring adorn Adornment Adoro Adreena Adreena's Adrenaline Adrenalyn Adrenalynn Adri Adria Adrian Adriana Adriana’s Adrianaconda Adrianas Adriana's adrianna Adriannas Adrianna's Adrianne Adriano Adriano's Adria's Adrien Adrienn Adrienne Adrienz Adriyana Adry Adscensio adult Adulteration Adulteress Adulterio Adulterous Adultery adv adva advance Advanced Advances advantage advantages Advenger Advent adventure Adventure' Adventurers adventures adventuress adventurous Advertence Advertia Advertised Advertising advice Advices Advised Advisor Advocate Aee Aegris Aem Aeon Aerial Aerials Aeris aerobic aerobics Aero-MELON-ic Aerolineas Aeterna AF Afar Afer affair Affaire affairs Affect Affection Affectionate Affections Affina Affinity Affirmation Affix Affliction Affluent afford Affordable Affraid Affricate Affront Afghan Aficianado aficionadas Aficionados Afina Afomyn Afor Afortunado afraid A-Freud Afri Africa african Afrika Afro AfroAsian Afrocentric Afrodisiac Afrodita Afroditas Afrodite Afrodithe Afrodity Afrodiziac Afro-Latina's Afrozilla afte Aften after Afterburner Aftercare After-Game afterglow Afterhours After-Hours Afterlife Aftermath Afternnoon afternoo afternoon Afternoons afterparty after-party Afterparty After-party Afterpoon afterschool After-school Aftershower After-shower Aftertaste Afterthoughts afterward Afterwork Afterworkout Afton again Again? again?PLEASE?? Again's against agan Agans agape Agbootyi Agata Agatha Agathas Agave age aged Ageha Agel Ageless agency agenda agent agent' Agent agents agent's Agents Agent's ages Aggie Aggression aggressive Aggressively aggro-make loveed Aghora Aghora's agile agility Agio Agita Agitace Agitated Aglais Aglaya Aglaya's Agnes Agnesa Agnese Agneska Agness Agnessa Agneta Agnetta Agnise Agnyese ago Agogi Agonizing Agony agony? Agos Agota Agradecido A-grades Agree agreed agreement agrees Agressive A-Groovin' Aground Agua Aguas Aguchi Aguilar Aguilara Aguilera Ah Ahab ahead a-Head Ahead AheadTrustfund Ahegao ahelpless Ahh Ahhh Ahhhh Ahhok Ahna Ahnn Ahnyjah Ahoj a-hole Aholics Ahontas Ahora? Ahoy Ahrya Ahryan Ahud Ai aid Aida Aidan Aiden Aiden's Aiding Aidra Aidra's Aids Aika's Aiko's Aila Aileen Ailing Ailment aim Aimee Aimes Aiming Aimoto Aims ain’t Aina Ainara Ainava Ainsley aint ain't Aint Ain't Aintzira Ainy air Airbags AirBnB Aire Airi Airing Airita Airline airlines airplane airport Airs airtight air-tight Airtight Air-Tight AIRTIGHT Airtightsuper Airways Aisha Aisha's Aisle Aislin Aiwe Aiya Aiyana Aiza Aj AJ' AJ’s Aja Ajauro Ajay Ajenda Aj's aka Akari Akarra Akasha Akashova Akashova's Aketa Aki Akira Akizuki Akkara Aktavia Akulova Akward al ala Alabama alabaster Alaina Alaine Alamea Alan Alana Alanah Alanah's Alana's Alani Alanis Alanna Alanna's Alannis alarm Alarming Alaska Alaura Alaura's ala-veegee Alayah Alayaha Alayna Alba Albarez Albergo Albert Albertina Alberto Albert's Albina Albina's Albright Albright's Albrite Albrite's album albumJessy Albuquerque Alby Alby's Alcala Alcantara Alcantara's Alcedo Alcohol alcoves Aldamen's Aldo Alea Alec Alecia Aleck Alectia Aleera Aleesha Aleftina alegbra Alegria alegría Aleigh Alejandra Alek Alekeias Aleks Aleksa Aleksaise Aleksandra Aleksa's alektra Alektrafied Alektrafying Alektra's Alektric Alen Alena Alena's Alenia Alenushka Alergía alert ALERTStrappado Alesbian Alesha Alesia Aleska Aleska's Alessa Alessandra Alessandra's Alessia Alessio Alesya Aletta Aletta Ocean Aletta's Alex Alex’s Alexa Alexander Alexandra Alexandra's Alexandria Alexa's Alexi Alexia Alexia's Alexis Alexi's Alexis's AlexisTexas Alex's Alexsa Alexsis Alexus Alexxa Alexxa's Alexxx Alexy Alexya Alfano Alfred Alfredo algebra Alge-bra Algo Ali Alia Aliana Alia's Alibi Alice Alice’s Alice85JJ Alicensual Alices Alice's alicia Alicia's Alicija Alicious alien aliens Aliesha Alighatti Align Aligning Alii Alik alike Alikins Alimony Alin Alina Alina’s Alina's Aline Aliona Alis Ali's Alisa Alisandra Alisa's Alise Alisha Alishia Alisia Alison Alison's Alissa Alissa's Alissia Alisson Alissya Alisyn Alita Alitta Alitzia Alive Alix Alix's Aliya Aliyah Aliyas Aliya's Aliyeva Aliysa Aliz Aliza Alize Aliz's all A-L-L all? all_over_marleigh Alla All-Access All-American Allanah All-Anal all-around Alla's Allatra Allaura Allayah All-black Allblue1 Allblue2 Allbrite Allbum Allee Allegiance Allegra Allegro Allen Allenias Allens Allergic Allesandra Allex alley Alley's alleyway Allflowers All-Girl All-Holes Alli Alli’s Alliance allie Allies Allie's alliesin All-in all-inclusive Allis Allisa Allison Allison's Allister Allister's all'italiana Alliyah All-lesbian Alllison All-natural Allnetting All-night Alloa Allogaj Allood Allora Allout allover Allow Allowance allowed allowed?? allows Allpink Allpink2 Allpinktoy Allred All's All-Sexclusive Allstar All-Star Allstars All-Stars All-Swallowing Alltogether Allura allure Allureas Allured Allurement alluring Allwet All-white allwith Allwood Allwood's ally Allyana Allyce Allyellow all-you-can-eat Allys Ally's Allysa Allysa's Allysin Allyson Allyssa Allyssa's Alma Alma's Almeida Almighty almond almost Alnite aloen Aloha Alona alone Alone? along Along? Alonna Alonzo alot Alota alotta Aloud Aloura Alpes alpha alpine already Already? Alrededor Alri alright Alrik ALS Alsana also Alson Alsu alt altar alt-chick Altea Alter Alterations altercation alternative Alternatives Altero Alters Alt-Girl Although Altid Alto Altogether Altruism Alura Alura's Alusis Alvares Alvares's Alves Alvin Alvoco alwa always Aly Alya Alyce Alycia Alycia's Alyia Alyiah Alyn Alyona Alysa Alysa's Alyse Alysee Alysha Alyshine Alysia Alyson Alyssa Alyssas Alyssa's Alyssia Alyssia's Alyx am Am? ama Amabella Amabella's Amadea Amadom Amai Amaizing Amalia Amalie Amana Amanda Amandae Amandas Amanda's Amande Amandi Amante Amantes Amara Amara’s Amaranta Amara's Amare Amari Amaris Amari's Amarna Amarna's Amartia Amasian amateur amateurs Amateur's Amatista Amato Amatores Amatuer A-Mature A-matures Amaxa Amaya amaze amazed amazement amazes amazing amazingAA059 amazingCalifornia amazingly Amazingness amazingSarah amazon Amazona amazonas Amazonia Amazonian Amazonian's Amazonion amazons amazon's Amazons Amazon's Amber Amberlee Ambers Amber's AmberVision Ambidextrous Ambiency Ambient Ambika ambition ambitions Ambitious Ambra Ambrose Ambrosia ambulance ambush Ambushed Ambushes Ambyr Amder Amee Amel Amelia Amelia's Amelie Amelie's Amendo Amends Amenities Ameno America American AMERICANA Americano Americans American's America's Amerika Ames Ameso Ames's Amethyst Ametisto Amey Ami Amia Amia’s Amia's Amicable amidst Amie Amiee Amiee's amiga AMIGO Amigos Amile Amilia Amilian Amillion Amina Amina's Amira Amirah Amirah's Amira's Amish Amistoso Ammettere Ammey Ammi Ammy Amnesia Amnesiac Amo Amomile among amongst Amor Amora Amore Amorele Amorina Amorios Amorous Amor's Amos amount amour Amoure Amour's Amp amp; Amphora ample amsterdam Amuse Amuse-Bouche amusement Amuses Amusing Amuzo Amy Amy? Amyiaa Amyna Amy's Amyza an Ana Anabel Anabela Anabell Anabella Anabelle anaconda anaconda's Anacondas Anaidha Anais anal Anal' ANAL Anal? ANAL_FIST anal+DP+airtight Anal-addict Anal-Curious ANALDP Anale Analed Analentines Anales ANALesthesia AnalEyez analfish AnalFist AnalFisting Anal-Gape Anal-Gaping Anal-happy Analia Analia's Analiese Analingus Analists Analize Analized Analized' analizers Analizing Analland Analled Anallove anal-loving anally Anal-ly Anally-driven Anally-flavored Analmalistic Analmals Anal-mistress anal-obsessed Analplay Analplug Anal's Analsexology analtaker AnalTeenAngelscom Analtoy Analtub analversary Anal-versary ANALversery analy ANALyse anal-ysis Analysis Anal-ysis Analyssa Analyst Anal-ytics Analyze analyzed Anal-yzed Analyzing Anal-yzing Ananda Ananta Anarchy Ana's Anastacia Anastasia Anastasia’s Anastasia's Anastasija Anastasiya Anastaysa Anastaysha Anastaysha's Anatomically anatomy Anatropous A-naughty Anaya Anbel Anchieta Ancho anchored Anchorman Anchors Anchorwoman Ancient Ancwhores and Anda Andchana Andee's Ander Anderevi Anders Anderson Anderson's Anderssen Anderssen's Andersson andher Andi Andie Andies Andi's Andrade Andraste Andre Andrea Andreas Andrea's Andrei Andrei’s Andreia Andreina Andreina's Andres Andressa Andrew Andrews Andrey Andria Andrianna Andrina Androgynous Androgyny android Androids Andru Andryely Andy Andya Andylyn Andylynn Andy's Aneli Anelise Anell Anella Anella's Anelly Anesthetic Anet Aneta Anett Anetta Anette Anezka Anfisa Ange angel Ángel Angel? Angel’s Angela Angela's Angelena Angelene Angeles Angelface Angeli angelic Angelica Angelica~Hotter Angelicas Angelica's Angelico Angelik Angelika Angelikas Angelin Angelina Angelina's Angeline Angelique Angelis AngeliXXX Angell Angella Angelle Angellina Angellyna Angellyne Angelo angels Angel's ANGELS angels_in_lace Angelyna Anger Angie Angiela angie's Angill angle angles Anglin Angling angry Angsty Anguish Angy Angyelkana Anh Ani Ania Anie Anija Anik Anika Anikas Anike Anikka Anikkas Anikka's Aniko Anila anilingus animal animalistic animals Animated Anime Animilastic Anina Anina's Ani's Anisha Anisiya Anissa Anissa’s Anissa's Aniston Aniston’s Aniston's Anita A-nita Anita? Anita's Anitta Aniya Anja Anjali Anjanette Anjel Anjela Anjelica anjelina Anjie Anjii ankle ankles Anklet Ann Ann’s Anna Annabel Annabell Annabella Annabelle Annabelle's Annalisa Annalise Annals Anna-Marie Annara Annas Anna's Anne Anne-angel Anneje Anneke Annellise Anne-Marie Anne's Annet Annett Annetta Annette Anni annie annielicious Annies Annie's annihilated annihilates Annihilating Annihilation Annihilator Annihilator0-0 Annihilator1-0vsAnnie Annika Annikas Annika's Annina Anniverasry anniversary Annlore Ann-lyz Annmarie Ann-marie AnnMarie Annoga Announcement Announcements announcer Annoushka Annoy Annoyed Annoying Annoyingly AnnPrincess Anns Ann's AnnShe annual Annuity Anny Anny's Anoga Anomaly Anon anonymous Anorei Anos Años anot anoth Anotha another another's Anoushka Anri Ansey Ansito Anspermio Anstice Anstro answer answered Answering Answers Ant Antala Ante Antes Antex anthing Anthology Anthonie Anthony Anthony's anthropologist Anti Antica Anticipado Anticipated Anticipating anticipation Anticipations antics Antidepressant ANTIFA Antilles antique Antiques anti's Antiso Anti-stress Anti-Vicio Antivirus Anton Antonella Antonella's Antonia Antonia's Antoniette Antonina Antonio Antonya Antonya's Ants Antynia Antynia's Antything Anubis Anunka anus Anvil anxiety anxious any Anya Anya' Anya's anybody anymore anyone anyone? Anyone?? Anyplace Anyssa anything anything? anytime Anyutka Anyutka’s anyway Anyway? anywhere Anywhere? Anza Anzai Aoi A-Okay Aommy Aona Aor Aorta ap Apake apart apartment apartment? Apasionado Apathy Ape A-peeling? apertif Aperture apecan Aphla Aphrodesiacs Aphrodisia aphrodisiac Aphrodite A-Play A-Plus Apocalipse Apocalpse apocalypse Apocalyptic Apocolypse Apofasi Apologies Apologizes apology Apolonia Apolonias Apont A-Poppin' Apotheosis app Appach Apparently Apparire appartment Appbootyionata appeal appealing Appear appearance Appearances appearanceYou appears Appease Appeased Appetency appetiser Appecan appecane Appecanes Appecanion Appecanties appetizer appetizing Applaud applauds Applause apple Apple? Applebottom Apple-Bottom Applefingers applegate Applegates Applegate's Applelolli apples Apples? Appliances Applicant Applicants application applications applies Apply Applying Appna Appoinment appointment Appointments Appraisal appraiser Appreciated Appreciates Appreciating appreciation Appreciative Apprehensive Apprent-BOOTY Apprentice apprenticeship Approach Approached Approaching appropriate approval Approve approved Apres Après Apretado Aprey Aprietos April April's Apryl apt Apcanude ACat Aqa aqua Aquafina Aquafun Aquamarin Aquaphobia Aquarium Aqua-Set Aquatic Aquesta Aquinas ar Arab Arabella Arabelle Arabelle's Arabian Arabic Arachnia ARACNAPHOBIA? Arad Aradia Arad's Arainyday Aralyn Arargund Araujo Araya Araya's Arc arcade Arcadia Arcane arch arched archedBoth Archer Archery arches Archida Archie arching archingMade Architect archive Archives Arch's Arclyte Arclyte's Ardel Ardell Arden Ardent Ardi Ardolino Ardor are area Areana Areil aren aren' Arena arent aren't Areolae Areolas arepink? Argaki Argan Argant Argentina Argentinian Argento Argiles Argos Arguably Arguing argument Arguments Ari Aria Ariadna Ariadny Arial Arian Ariana Ariana’s Ariana's Ariane Arianna Arianny Arias Aria's Aridity Arie ariel Ariela ArielAKA Ariel-make loveing-X Ariella Ariellas Ariella's Arielle Arielle's Ariel's ArielX Aries Arietta Arietta’s Arietta's Arijna Arika Arilyn Arina Arina's Arise Arising Aristocrat Aristocrats Arizona ark Arkansas Arlecchino Arlenne Arlet's Arlisa arm Armada Armageddon Armani Armanis Armbar armchair arm-chair Armchair Arm-chair Armed Armenian Armoire Armond armor armory Armory; Armour Armpit armpits arms Armstrong Armwrestling army Armybabe Army's Arnal Arnie Arnold a-rocking Aroma Aromas around AroundOn Around-the-Member arousal arouse aroused ArouseMe arouses Arousin' arousing Arowyn Arpoone Arquez arrange arranged arrangement Arrangements arranges Arrangment Arrazoi arrest Arrested Arresting Arrgh Arriana Arriba Arrival Arrivals arrive arrived arrives arriving Arrogant Arrow Arroyo Ars arse Arsed arsehole arsenal Arsh art Artem Artemis Artemisia Arteya Artful Arthur Articulation Artifamily Artificia Artificial Artimake loveed Artillery Art-inspired artist artista Artiste Artistess artistic Artistically artistlet's Artistry artists artist's Artists Artist's ArtRock arts Art's Artsy Artur Arty Aruba's Aruna Aruna's Arwen Arwen's Arya Arya’s Aryana Aryana's Aryanna Arya's Aryell as A's AS Asa Asagi Asami Asanas Asanty asap Asa's Ascendancy Ascending Ascension Aserta Asfen ash Asha AShag ashamed Ashden Ashe Ashely Ashen Asher Asher's ashes Ashe's Ashey Ashi Ashiana Ashland ashlee Ashlee's Ashleigh Ashlen ashley Ashley’s Ashleys Ashley's Ashli Ashlie Ashli's Ashly Ashly’s Ashlyn Ashlynn Ashlynne Ashlynn's Ashlyns Ashly's Ashore Ashton Ashton's Ashtray Así asia asian Asiana asians Asian's Asian-style Aside Asiniya Asis ask Askani asked Asker asking asks asleep Asley ASMR ASMRK Asole aspects Aspen Aspen's Asphyxia Asphyxia-Live Asphyxia's asphyxiationwe Aspid Aspiration Aspirations aspirin aspiring booty booty? booty_and_heels booty_celebration Bootyablanca Bootyacre Bootyage bootyarific Bootybootyin Bootybootyin1-2 Bootybootyin2-1 Bootybootyin2-2 Bootybootyination Bootybootyins Bootybootyin's bootyault Bootyaults bootybanged Booty-Banged booty-banging Booty-Blessed Booty-bombing Bootycatraz Booty-centric Bootycheeks 'Bootycoholism' Booty-Crammed booty-crammer bootycrobatics Booty'd Booty-Day Booty-drilled booty-eating bootyed Booty-ed Bootyembled Bootyembly Bootyential Bootyercise Bootyersize Bootyertion bootyes Bootyes' BOOTYes Bootyesina Bootyesinpubliccom Bootyessing Bootyessment Bootyessor bootyet Bootyet? bootyets Booty-ets BOOTYets BOOTY-ets booty-examined bootyfarting Bootyfest Bootyfiller Bootyfingered Booty-fist Booty-Fisted Booty-fisting booty-flashing bootymake love booty-make love Bootymake love Booty-make love booty-make love? bootymake loveed booty-make loveed Bootymake loveed Booty-make loveed BootyMake loveed Booty-Make loveed bootymake loveing booty-make loveing Bootymake loveing Booty-Make loveing BOOTYMAKE LOVEING bootymake loves booty-make loves bootyful BOOTY-full booty-gaped bootygasm bootygressive Bootyh bootyhole Booty-Hole bootyhole? bootyholebootyhole Bootyholefevercom Bootyholefever's bootyholes Bootyhole's Bootyia bootyiatant Bootyiduous bootyignment BOOTY-ignment bootyignments Bootyimilation Bootyist bootyista bootyistance Booty-istance BOOTYistance bootyistant Booty-istant Bootyistants Bootyistant's Bootyisted Bootyisting Booty-isting BOOTYisting Bootyists Booty-ists Booty-Jack Booty-jizz-jazz Bootyk Bootyking Booty-king Bootykissers Bootylane Bootyless Bootylicious Booty-lick booty-licker booty-licking Bootylicking Booty-licking Booty-loving Bootyman's Booty-mbootyage Bootymazing BOOTY-mazing Bootymeat BootyMen Bootymends Bootymissible BOOTYMR bootyociate Booty-O-Rama Bootyortment Bootyparade Bootypirations Booty-piring BOOTYpiring Bootyplay Booty-plays Bootyport Booty-Port booty-pounding Bootycat Bootyquake bootyrageousness Booty-Reaming BootyRider booty-riding booty-rose Booty-Seuse Booty-signment Booty-slapping Booty-Slave Booty-Spanking BOOTYSSStonishing Booty-Stretcher booty-stretching Booty-surance Booty-tasters bootytastic Booty-tastic BOOTYtastic BOOTY-tastic BOOTYTASTIC Bootytate Bootytatics BOOTY-Team Bootythletics Bootyti Booty-To-MILF booty-to-mouth Bootytonishing Booty-tonishing BOOTYtonishment BOOTY-tor Bootytounded bootytounding Booty-tounding BOOTYtounding booty-toy Bootytoy Bootytronaut BOOTYtronomical Bootytronomy Bootyualt Booty-ualties Bootyume bootyumes Booty-Up Bootyurance Bootyurdity Bootyure Bootyured Bootyvengers booty-VR bootyWe Booty-Wrecker Bootyy Bootyylum Booty-ylum Bootyzilla Asta Asti Aston astonishing Astoria Astounding Astoundingly Astray Astrid Astrid's Astrological Astronomical Astudent Asturias Astuta Astyn Astyn's Asucking asylum Asymmetric at at? Atarel atcha ate Atelier ATGOM Athena Athenas Athena's Athens Athian Athina Athina's athlete athleteBound athletes Athlete's athletic Athletics Atido Atkins ATL Atlanta atleast atm A-T-M atmosphere ATM's atogm atom Atomic atoms Atone Atonement atop Atopos ATP Atrapados Atrás Atrium Atrocious attached attack Attacked attacks Attactive attemp attempt attempted attemptedComplete attempting Attempts Attend attendant Attendant's attends attention Attention? attentions Attic Atticus Attire Attison atcanude Attiva attorney Attracks attract Attracted Attracting attraction Attractions Attractive Attracts ATV Atwell Atypical au Aubree Aubree's aubrey Aubrey's Aubrie Aubry Auburn auction Auctioned auctionKaty Audacious Audacity Auddi Audee Audie audience audience? audienceBrutal audienceIsis Audiency Audiiton Audio Audiophile Audiophilia Audit audition 'Audition AUDITION auditioning auditions AuditionsDay Auditions-Day AuditionsSexual AUDITON Auditons Auditorium Audra Audrey Audrey’s Audrey's Audri Audrianna Audrie Audrina Audrina's Audrinna Audry Audtion Aug August Augustina Augustine Augusts August's Aunque aunt auntie Aunt's Aunty Au-Pair aura Aural Aurel Aurelia Aurelly AURMISSION Aurora Aurora's Auspice Aussie Aussie's Austen Austin Austin's Australia Australia’s Australian Australia's Austria Austrian Austyn Aut Auteur Authentic Authentically Author Authority Authorization Autilia auto Autoeroticism Automatic Automobile autoshop Autre Autum Autumn Autumns Autumn's Av ava Ava Addams Ava’s Availability available Avalon Avalon's Avana Avano Avano's Avant Avantgarde Avanti Avarice Avaricious Avary Avas Ava's Avatari avec Aveline Avena avenger Avengers Aventures Avenue average Averi Averie Aversion avery Avery's Avi Aviana Aviator Avilas Avilette Avina Avi's Aviva Avluv Avluv's AVN Avocation avoid Avoiding avoids Avory Avouch Avril Avril’s Avrill Avril's Avrora Avrova Avy Avy's aw await awaited awaiting A-waiting awaits Awake Awake? Awaken Awakened awakening awakens Awaking award awarded Awards Award-winning Awareness away Awe Awe-Inspiring Awesamey awesome Awesomely Awesomeness Awesome-ness Awestruck Awful Awfully awhile Awiss Awkening Awkward Awoken awsome Aww Awww Axe Axel Axelle Axis Axx Ay Aya Ayamori Ayana Ayanna Ayano Ayden Aydie aye Aye-aye Ayers Ayla Aylar Aylin Ayline Aylla Aymee Ayn Ayn's Aysha Ayumi Ayumu Ayumu's Ayu's Aza Azalea Azar Aza's Azea Azella Aziza Azukal Azul Azula Azul's Azumi Azura Azure Azyza Azz b B’s B4 ba baaaad baaack Baam Baba Babaloun Babalu Babba Babbling babe Babe Gives babe? Babe’s babel babelicious babe'll Babe-Next-Door babes babe's Babes Babes' Babe's BABES Babes’ Babette Babewatch BabeZZ Babies Babs Babsier Babsy Babuska baby Baby? Babyblue babydoll Baby-Doll Babydolls Babyface Babyfaced Baby-faced babyfeet Babylon Babylou Babymoni Babyoil Babyoil2 Babyoilrub Babys Baby's Babysat Babyscammer Babyshower Babysit babysitter Baby-sitter Babysitter? babysitters Babysitter's babysitting bac BacchANAL Bacchanalia Bacche Bacchus Bach Bachalorette bachelor bachelorette Bachelorettes Bachelor's Bacholette Bach's Baciami back Back? backache Back-Alley Backbeat backbends backbreaking backBrutal Backcountry backdoor backdoor; backdoor-make loveed Back-Dooring backdraft Backed Backfire Backflipping Backflow backgammon Background Backhand Backhanded backhole Backin Backlash Backlight Backlighting Backlit Backpack backpacker Backpacking Backpage Backroad backroom Back-room backs Back's backseat Back-Seat backside Backspin Backstabber Backstabbing backstage Backster Backscanch Backstore Backstreet Back-Stroke back-talker Backtalker backup backwards back-way Backwoods backyard Bacon Bacstage bad Bada badbooty Bad-booty BadChickes bad-boy Badboy Badder baddest baddie Baddies Bad-Dragon badge Badger Badger2-2 Badger2-3 Badger2-4 Badine Badiya Badkitchen BADKITTYYY BadLady Bad-Lands Badlittlegrrl badly Badmilfs Badminton Badmoms Badness Badonkadonk Badseed bae Bae’s Baeb Bae's Baewatch Baffy bag bag? Bagboy Bagde Bagel Baggage Bagged Bagger Baggin bagging Bagheera Bagnarsi Bagnato Bagning Bagno Bagpipe bags Bagyraa Bahama Bahamas Bahls Baiba Bail Baila Bailando Bailarina Bailed Bailee Bailee's bailey Bailey’s Bailey's Bailing Bailouts Baily Bain Bainug Baise-Moi Baiser bait Bait? Baited Baiter Baitong Baja Bajar Bajo Baka BakAsuna bake Baked baker Baker's Bakery bakes Bakhtiari Baking Bakis Bal Bala Balade balance Balanced Balancing Balazs Balcano Balcon balcony Balconybabe bald Baldachin Balfour Bali Balian Baliee Balina baling ball BALL_DEEP Ball_Deep_Action Ballad Ballat Ballbreaker Ballbuster Ball-busting BallDeep BallDeepAnalDP BALL-DEEP-BOOTYMAKE LOVEING BallDeepDAP BalldeepDapDP BallDeepDP balled Balled-Room Baller ballerina Ballerinas Ballerina's Ballerinas2 Ballerini Ballers ballet Ballgag ballin Ballin' Balling Ball-istic Ballo Ballon balloon balloons Balloons1 Balloons2 Balloons3 Ballot Ballpark Ballroom balls balls? Ballsachs Ballsack ballsdeep Balls-Deep BallsDeepAnal Ballsucker Ball-sucking Ballz Balm baloney baloogas Balouga Balsero Baltic Baltimore Bam Bama Bama-Mama Bamba Bambi Bambie Bambina Bambino Bambi's bamboo Bammelonabe Bamboozle Bamby BAMF Banaki banana Banana1 Bananababe Bananacream Bananalove Banana-mama Bananana1 Bananana2 Banana-rama bananas Bananateen Bananatoy Bananatwo Bananavib Ba-nan-ers Bananinha Bananna bananza band BANDERA Banderas Bandicooch Bandini Bandini's bandit bandits Bandochy bandsThey bane bang bangable bangagong bangarang Bangathon Bang-Bang bangbros BangBros18 bangbus BangbusFirst Bangmember bangcom banged banger bangers Bangfast Bangg Bangg-ed bangin bangin' Bangin Bangin' Bangin’ banging Bangkok Bangles Banglin' Bangmaid Bango Bangover bangs Bang's BANGS Bangs-KOKSS Bang-Style Banguage BangWatch Bangz Banho Banister bank Banked banker Bankers Bankin banking Bankroll bankrupcy Bankrupt Banks Banksand Banks's Banned Banner Banner's Bannister Bano banquet Banshee Banx Banxx Banxxx Banxxx's Banzai Baphomet bap-tastic Baptising Baptism Baptized bar Bara Baranda Baranquilla Barb Barba Barbados Barbamiska Barbara Barbara’s Barbara's Barbarella barbarian Barbarians Barbary barbecue Barbell Barbeque barber Barberella Barber's barbershop Barbershop's Barbi Barbie Barbie’s Barbies Barbie's Barbie-style Barbora Barbra Barbra's Barby Barcelona Bard Bardet Bardot Bardot's Bardoux bare Bare? Bareback Barebacked Barebacking Barebacks Bared Barefeet barefoot Barefooted Bare-Legged barely Barely-legal bares Baretta barFirst bargain bargained Bargaining Bargains Barge Barges Bargo Barhopping Baring Barista Barjeau Barjo Bark Barkeep Barker Barking Barks Barlow barmaid barman barn Barnes Barnett barnyard Baron Baroness Baroque Barr Barra Barrack Barracks Barracuda Barra's Barre barred barrel Barrett Barrier Barriers Barrington Barrio Barron Barry bars Barstool's Bart bartender Bartenders Bartender's Bartending Barter Bartering Barters Barthelemy Bartroom Barts Bartscha Barunka's Barvija Barz Bas Bascule base baseball baseball? baseball?' Baseballs Baseballz Based basement basementSoon basementWelcome Bases Bash Bashed Bashful Bashing basic basics Basil Basined Basinger Basis Basked basket basket? basketball BasketMelons basking baskstage Bbooty Bbootyeya Bbootyt Bast Bastard bastards Baster Bastiani Bastinado bat Batch Batchelorette BatCop bate Bateador Bateau Bateman Batemans Bateman's Bates bath Bath1 Bath2 Bathbabe Bathbeauty Bathbooty Bathbubbles Bathbanana Bathdong Bathe Bathed Bather Bathers bathes Bathfinger Bathfun Bathglbootytoy bathhouse Bathhroom bathing Bathingsuit Bathory Bathcat Bathrabbit bathroom Bathroombabe Bathroombikini Bathroombonus Bathroombrush Bathroombanana Bathroomdong Bathroomfinger Bathroomfun Bathroompink Bathroomcat Bathroomrub Bathroomrubdown Bathrooms Bathroomteen Bathroomtoy Bathroomvibe Bathrub Baths Bathsuck Bathtease Bathteen bathtime Bath-Time Bathtimerub Bathtoy Bathtoys bathtub Bathtub2 Bathtubbeauty Bathtubfingers Bathtubfun Bathtublotion Bathtubplay Bathtubcat Bathtubrabbit Bathtubs Bathtubteen Bathtubtouches Bathtubtoy Bathtubkitty Bathtubvibe Bathvibe bathwater Bating Bation Batista Batman baton Bats Batter Battered Batteries battering Batter's Battery batting battle battlemake love battles battleship battleSmaller Batty Batwing Bauer Baum Bautizo Bavarian Bawdy Baxia Bay Baylee Baylie Bayliss Bayou Bays Bay's Bayside Baywatch Baywolf Baz Bazooka bazookas Bazooms Bb B-Ball bbc BBC' BBC’s BBC=Double BBCs BBC's BBC-throating BBCtwo BBF BBFF BBG B-B-G B-Boy bbq BBS BBTS BBW BBWs BBW's BC B-Control B-Cups bday b'day Bday B'day B-day BDay B-Day BDAY BDSM bdy be be? Be8230; bea Beab beach Beach? beachball Beachballs Beached beaches Beachfront beachnut Beachcat Beachside Beachtime bead Beaded Beaded2 beading beads Beadtoy Beady Beal beam bean Beanbag Beanbagtoy beanie beans beanstalk bear beard bearded Beards Bearing bears Bearskin Beart beas Beasly beast BEAST1st Beastly Beasts Beasty beat Beata Beata's Beatdown beaten beating beatings Beat-it Beatrice Beatricy Beatris Beatrix beats Beaty beau Beaudy Beaue Beaulieu Beau's Beaut Beautful beauties beautif Beautification beautiful B-E-A-UTIFUL beautifull beautifully beauty beauty? Beauty’s beauty's Beautyshop Beaux Beav Beave beaver beavered Beaverpalooza beavers Beaver's beaverville Bebe Bebees Bebel became because Becca Beck Becki Beckie Beckoning Beckons Becks Becky Becky's become becomes becoming bed bed? Bed1 Bed2 Bed3 BedAnd bedazzles Bedbabe Bedball Bedbead Bedbeauty Bedbird Bedbeej Bedbooty Bedbounce Bedmember Bedcream Bedbanana Bedding Beddong Bedeli Bedfellows Bedfinger Bedfinger1 Bedfinger2 Bedfingers Bedfingers2 BedfingersBTS Bedfun Bedfur Bedhole Bed Bedknobs Bedlam Bedland Bedlingerie Bedlotion Bedlove Bedmasturbation Bedmates Bedmirror Bedosia Bedpink Bedplay Bedplaying1 Bedplaying2 Bedpleasures Bedpost Bedcat Bedrabbit Bedrock bedroom Bedroom? Bedroom1 Bedroom2 Bedroombabe Bedroomchat Bedroomclit Bedroomdancer Bedroomdelight Bedroombanana Bedroomdiva Bedroom-Eyed Bedroomfingers Bedroomfingers1 Bedroomfingers2 Bedroomfun Bedroomlove Bedroomlust Bedroomorgasm Bedroompink Bedroomplay Bedroompleasure Bedroomcat Bedroomrabbit Bedroomrub Bedroomrubdown Bedroomtease Bedroomteen Bedroomtoy Bedroomvibe Beds Bed's Bedsex bed-sheets Bedsheets bedside Bedspread Bedspread1 Bedspread2 Bedstockings Bedstrip Bedtalk Bedtease Bedteen Bedtights bedtime Bedtimefun Bedtimepleasure Bedtop1 Bedtop2 Bedtouch Bedtouch1 Bedtoy Bedtoy2 Bedtoys Bedkitty Bedvibe bee beef beefcake Beefcakes Beefin beefsicle Beefsteak beefy Beehind BEE-hind Beeline been Beep beer Bees Beet bef befor before before? beforeAn beforeCountdown Before-party beg Began BeganThe Beggar begged beggi beggin begging begin begining beginner beginners Beginner's beginning beginnings begins Begli Begonias begs Beguiled Beguiles Behave Behaved behaves Behavin behaving behavior behavior-ism Behaviors Behaviour Behemoth behind Behind? behind-the-scenes Behold Beholder Beibedi Beige Bein being Bejeweled Bejizzed Bekah Bekkin Bel Bela Belarus Belarusian Belas Belated Beleco Belehradska Belgian Belgium Belicia belie Belief believe believer Believers believes Belika Belinda Belinda's Belinha Belittled Belize Belizean bell Bell? bella Belladonna Belladonna's Bellah Bella-Nikole Bellas Bella's BellaVendetta Bellboy Bellboys belle Belle’s Belle1stDap Belle--Damsel Bellend Belles Belle's Bellezas Bellgirl Bellhop Belli Bellies Bellina Bellini Bellini's Bellis bellisima bellissima Belliximia Bellman Bello bells Bell's Bellucci Bellucci's Belluci Bellura belly Belly-Dancing bellywasher Bellz Bellz's Belong belongings belongs beloved below Belt Beltran belts Beltway Belucci Ben Bena bench Benchfingers Benchmark Benchpressing Benchcats Benchwarmers bend Bendable Bended Bender Bend'h'er bending Bendover bends bendy beneath Benedict beneficial benefit benefits Beneifts BeNice Benjamin Benjamins Benjamin's Benji Bennet Bennett Bennett's Benny Benson Benson's bent Benta Bentho Bentley Benton Bent-Over-Time Benty Bentz Benue Benvenuti Benz Benzey Benz's Berated Berber Berdu Beret Beretta Beretta's Berg Berger Berinice Berke Berlin Berlin's Berlosta Berlyn Bermuda Bernadett Bernadette Bernard Bernice Bernice's Bernie Beroa Berobed Beronica Beroomorgasm Berretta Berries Berrimore berry Berrylicious Berserk Berta Berta's Bertha Berthe Berton Berton's Berty Besame Beside besides Beso Besos Bespectacled Bess Bessi Bessie best best…to Bestest Bestfriend's Bestial Bestie Bestie’s besties Bestie's Bestsellers bet Bet? Beta Betcha Beth Bethany Bethanys Bethany's Betheny Bethere Bethie Beth's Bethsabe Beti Betka BeTogether Betray Betrayal Betrayals Betrayed Betraying Betrix Betrothed Bets Betsey Betsy Betsy's Betta better better? Bettey Bettie Bettina Bettina's Betting betty Betty? Bettyfun Betty's betwe between Beutiful beverage beverage? beverages Beverly Beverly's Bevy Beware Bewitched Bewitcher Bewitching Bewizmi Bexley Beyach Beyn beyond bf BFD Bff BFF’s BFFs BFF's BFFS BFFs-In-Law bf's BFs BF's BG BGA' BGB BGG b-girl BG's Bhiankha Bhiankha's Bhoom bi Bi? Bi_Curious Bi_Sexual Bia bianca Bianca's Bianchi Bianco Bianka Biatch Biatch? Bi-Babe Bibi Bibifox Bibis Bibi's Bible Bibliotcaria bibliotheque bicep Biceps Bick Bicurious Bi-curious bicycle Bicyclist Bid Bidaia Bidder Biddie Bidding Biddy Bide Bidet Bieber Bieber's Bieder Biella Bienvenue Biergarten Bietch big Big Cans big? Big_Gapes big-booty BigBooty Big-Booty big-bootyed Bigball big-bapped Bigbeads Bigblue Big-melon big-meloned Bigmelonies BigmelonsPaola Big-Booty Big-Turkeyed Big-busted Big-bum big-member bigmembered Big-Membered big-johnson Bigjohnson Big-Johnson big-johnsoned Bigjohnsonens Bigbanana Bigdong Bigg BigGape bigger biggest Biggie 'Biggie' Biggin Biggins Bigglbooty1 Biggs Bigguns Biggz Bighouse Big-lips Bigly Bigmouthful Bigmouths Bigone Bigone2 Bigpink Bigpinkone1 Bigpinkone2 Bigred Bigred2 Bigrodus BigSausagePizzacom Bigtime Big-can big-cans Bigcans bigcanted big-canted Bigcanted Big-canted Big-Cantied big-canty Bigtoy Bigtoyfun BIGTOYS Bigvibe Bigyellow Bijou Bijou’s Bijou's Bijoux bike Bike? biker Biker’s bikers biker's Bikers Biker's bikershop Bikes Biking bikini Bikini? bikini_babes Bikini2 Bikinibabe Bikinibed Bikinibrush Bikini-Busting Bikini-clad bikinis Bikinitoy Bikini-Wearing Bikinni Bilas Bilas's Bilberry Bilingual bill Billable Billatis Billberry Billi billiard billiards Billiards1 Billiards2 Billie Billie's billion Billionaire Billionaires Billionaire's bills Bill's billy Billy's bimbo Bimbofication Bimbos bin Bina bind Binding binds Bines Bing Binge Bingo Bing's Bingster Binilan Binky Binoculars bio Biography Biohazard biological biology Biondi Bionic BIP Bipartisan Bipolar BIP's Biracial bird birdbox Birdie birds Birds-Eye Birdwatching birdy biritish Birlfriend Birrerie birth birthday Birthdays Bi's biscuit Biscuits bisex bisexual Bi-sexual BiSexual Bi-Sexual bisexuality Bi-Sexually Bishop Bishop's Bisous bistro bit chick Chick' CHICK ChickCHICK Chickboy Chick-boy Chick-Boy's Chickcraft Chickdom chicked chickes Chickes? Chickez Chickin Chickmas Chick'n chick's Chicktoy chicky bite Biter Biters bites Bithius biting Bitoni Bitoni's BitoniSex Bits Bitsy Bittencourt bitter Bittersweet Bitties Bitting Bitty Bi-way Bixinisia biz bizarre Bizkits Bizz Bizzare Bizzarre Bizzy bj Bj2 bj's BJs BJ's Bl Blaar Blabber blac Blacc black Black' BLACK Black stepsisters are Black2 Blackbedtoy Blackbelt Blackberry Blackbirdy Blackboard Blackbooty Blackbootyshorts Blackbra1 Blackbra2 Blackbull Blackbuster Blackbusters Blackmember Black-Johnsoned Blackbanana Blackdress blacked BlackEnded Blackened Blackeneded Blackening Blacker Blackfishnet Blackfox black-haired Blacking Blackjack Blackjacket Blacklight blacklight_beauty Blacklisted blackmail blackmailed Blackmailer Blackmailing blackmails Blackness Blacknighty Blackonblack Blackone Black-On-White Blackout blacks Black's Blacksheer Blacksilk Blackskirt Blackstockings Blackstone Blacktoy Blackundies Black-Up Blackvibe Blackwell Blackwidow blade Bladerunners Blading Blague Blahs Blaiden Blaine Blair Blaire Blaire's Blair's blak Blake Blakely Blake'n Blakeney Blake's Blakhart Blakovich Blam Blame Blanc Blanca Blanche Blanche's Blanchette Blanco Blandine Blank Blanka blanket blankets Blasian Blasians Blasphemy blast blasted Blaster Blasters blasting blasts Blaten Blayne Blazaki Blaze Blaze’s Blazer Blazes Blaze's Blazin Blazing Bleach Bleach-Blonde Bleached Bleacher bleachers Bleaching bleeding Bleins blend Blended Blender Blendova Bless blessed blessing Blessings Bleu blew blind Blinded Blinders blindfold blindfolded Blind-Folded Blindfolding Blindfolds Blinding Blindingly Blinds Blindsided bling Blink Blinkers Blinks bliss blissful Blissfully Blistering Blitz Blizzard Blk blo block Blocked Blocking Blocks blog blogger blogger's Bloh Blokes blon blond Blonda Blondage blondAnally blonde blonde’ Blonde’s Blondecutie Blonded Blonde-haired BlondeIt's Blonde-on-brunette Blonder blondes blonde's Blondes Blonde's blondes? Blondi blondie blondies blondie's Blondies Blondie's Blondike blondine blondines Blondinka Blondissima blondPushed blonds blond's Blonds blondy Blone blood blooded Bloodline Bloodlust Bloods Bloodthirsty bloody bloom bloomer Blooming Blooms Bloom's Blooper Bloopers BLOOPERS-Anal BLOOPERS-Hiccups BLOOPERS-Lollipop BLOOPERS-Seducing BLOOPERS-XXX Bloss blossom Blossoming Blossoms Blossom's blouse blow Blowage Blowback Blowdryer Blowdryer2 Blowed Blower Blowfish Blowie Blowin Blowin’ blowing Blowingbubbles Blow-Jay beej Beej Beej? Beej2 Beejber Beejer beejs blown Blow'n'Member Blowob Blow-Off Blowout Blow-Out Blowpbooty Blowpop blows Blowup Blow-Up Blowvocaine blu Blubber blue blue? Blue’s? Blueballs bluebell Bluebra Bluecollar Bluecouch Bluebanana Bluedots Bluedress blue-eyed blue-haired Blueheels Blueinsert1 Blueinsert2 Blueness Bluenet Bluepanties Bluepigtails Blueprints Blueroom blues Blue's Blueshoestoy Blueskirt Blueskirt2 bluest Bluetop Bluetounge Bluetoy Bluetoy1 Bluetoy2 Blueundies Bluevibe Bluevibrator bluey bluff Blum Blumpkin Blunder Blunders Blunt bluntness Blu's blush Blush? Blushing Bluzo BMF BnB B'n'B BnD BnPornstar bo Boa boadacious board Boarded Boardgame Boarding Boardroom Boardwalk Boasting boasts boat boat1 Boatdock Boatfun Boatin' Boating Boats bob bobbed Bobbi Bobbie bobbin bobbing Bobbi's Bobble bobby bobs Bob's bobuccino Bocce bod Boda bodacious Bodalicious Bodas Bodeva bodied bodies Bods body body? Bodyart bodyBrutal bodybuilder Body-Builder Bodybuilders Bodybuilder's bodybuilding Bodycaress body-checked bodyguard bodyis Bodylotion bodyoil BodyRub Body's Bodyslam bodySo bodystocking bodysuit Bodyweight Boff boffed Boffing boffs Bogart Bogdana Bogeyman Boglarka Bogus Bohdan Bohemiam Bohemian Boho Boi Boil boiled Boiler boiling boils Boin boing Boingo Boink boinked Boinker boinking Boioioing boisterous Bold Bolder Bolivar Bolivia Bollente Bollito Bollo Bollocks Bolly Bollywood Bolster Bolstering bolt Bolted Bolton Bolton's Bolts bomb Bomba bombarded Bombardment Bombasic Bomb-Booty bombastic Bombay Bomber Bombi Bombing Bombom Bombon Bombproof Bombs bombshell Bombshell’s bombshells Bombshell's Bon Bona Bonage Bonan bonanaza bonanza Bonbon Bon-Bon Bond Bond’s Bondag bondage Bondage= bondage1st BondageBrutal bondageCategory BondageHuge bondageMade Bond-Con bonde Bonded bonding bonds Bond's Bonds-age bone Bone? Bone-a-fied Boneanza boned Bonefide Bonemember boner Bonerific boners Bonerville bones boneshaker Bones-Maisie Bonet Bong Bongo Bongos Bonified bonin Bonin' boning Boni's bonita Bonjour Bonked bonkers Bonne bonnet Bonni Bonnie Bonnies Bonnie's Bonny bono Bons bonus Bonus?? Bony Boo melon Melonage MelonageA melonageBrutal Melonalicious Melonastic Melon-Blasting Melon-Bomb Melon-day meloned Meloner Meloners Melonmake loveer Melonfun melonie 'Melonielicious' melonies Melonies? Meloniesbunny melonilicious Melonjack melonjob Melonlik melonlivious Melon-maniac melon-mbootyage Melonmusic Melonnanza Melonoo MELON-O-WEEN Melonowski Melonplay1 Melonr melons Melons? Melons-A-Poppin MelonsBig melonsmake loveing MelonsHard Melonwatch Melony melonylicious Melonzilla Boodypest Booga Boogeyman Boogie Boogy book bookand booked Booker Bookhearts Bookie booking Bookmark books Books? Booksfingers Bookshelf bookstore bookworm Bookworm's Bookworrm Bookwurm boom Boombalottie boombastic booming Boondocks Boop boost Booster Boosting boot Bootay Bootblacking Bootcamp Booted booth bootie Bootied Bootiepest booties Bootiful Bootilicious Bootious Bootknocking Bootlicking boots Boot's Bootshoot booty booty? BootyCamp Bootycise bootyful Booty-ful Bootyfull Bootyhole Bootyism bootylicious Booty-licious Bootyliscious Bootylution Bootyoligist Booty-O's Booty-riffic Bootyrub bootys Booty's Bootytopia Bootz Boozing Boozy Bop Bopper Boppers Bopping Borav Borbella Bord Bordas Bordeaux Bordello Border Border-crossing Borderless Borders Bordo Bore bored Bored? boredom bores Borghese Borgia Boricua Boricuas boring boriqua Borja born born? Boroka Boroka's borrow borrowed Borrowing Borya bos bosom bosoms bosomy boss boss? Boss’ Boss’s Boss-Chick bosses Bossing Bosslady's Bossman Bossn Bossom boss's bossy Boston Bot Botandi Botanic Botanical Botanicula Botched Botches Boteille Botella both both' Both Both? bother bothered Bothering Botox Bots bott Botta bottle Bottled Bottlelove Bottleneck BOTTLE-NECK bottles bottom Bottom? Bottomed bottoming bottomless Bottoms Bottom's BOTY Boucing boudoir bought Bouillotte Boujee Boulder Bouldering boulders Boulevard bounce Bounce' bounce-a-thon Bounce-Bounce Bouncemember bounced Bouncer bounces bouncin Bounciness bouncing bouncing_beauties bouncy bouncy-booty bound Boundage boundand boundaries Bounded boundHer bounding Boundless boundMade boundPulled Bounds boundThe BOUNS-Tight Bountiful Bountifully bounty Bouquet Bourgeois Bourgeon bout boutique bow Bowel Bowels Bower bowl bowling Bowling+Anal= Bowman Bows Bowsette Bow-tie box Box? boxed boxer Boxers? Boxes boxing Boxxx Boxxy boy boy? boy’s Boya Boyce Boy-Crazy boyd boy'd Boyd Boyfirend Boyfreind's boyfriend boyfriend; Boyfriend? boyfriend’s boyfriends boyfriend's Boyfriends Boyfriend's BoyFriends boy-girl Boyish Boyrfriend boys boy's Boys Boy's BOYS boytoy boy-toy Boytoy Boy-Toy BoyToys Boyz BPM br bra bra-buster Bra-busters Bra-bustin Bra-busting brace Bracea Braced Braceface Brace-Face Bracefaced Brace-Faced Bracelet braces Braces? bracesFace Bracket Brad Bradburry Bradburry's Bradbury Braddock Braden Bradley Bradon Brad's Bradshaw Brady Bra-free Brag Brags Braided braids brain Brainbuster Brainer brains brainy Brair Brake Brakes Brake's braking Braless Bra-less Bran Branch Branching brand Brandalyn Branded Brandee Brandees Branden Brandi Brandi’s Brandie Brandii Brandii's Brandi's brand-new Brandnew Brand-new brandon Brandon's Brand-Spunking-New Brandt brandy Brandy's Branson Brant Bras Brasa Brash Brashly Brasil Brbooty Brbootyiere Brbootyieres Bra-stuffer brat Brat? Brats Brat's Bratty Bratwurst Braun Brauns Braun's Brave Bravissima Bravo Bra-vo Brawd Brawen Brawler Brawler0-0 Brawler0-5The Brawn Braxton Bray Brayden Brazeau's Brazen Brazil Brazil? brazilian Brazilian' Brazilians Braziliera Brazillian Brazil's Brazzer Brazzers Brazzertarians Brazzerville Brazzibots Brazzle Brdigette Brea Breach Breaching bread Breadth Breadwinner break break? Break_up Break-and-Enter Breakdown breaker breakers Breakey breakfast Breakfastspread Break-Her Breakin Break-in breaking Breakout breaks Breaks? Breakthrough Breakthru breakup Break-Up Breakups Breaky Breanna Breanna's Breanne Breanne's turkey Turkey7 turkeyacular Turkeyball Turkey-cam turkeyed Turkeyercising Turkeyercize Turkeyfast Turkeyfeeding Turkeyfest Turkey-Fest Turkeyfully turkeygate Turkeyicle turkeyicles Breascanude turkeyman Turkeyman’s Turkey-man's Turkeymas Turkeymeat Turkey-men Turkeymore Turkeyopolis Turkeyroom turkeys turkeysis turkeysThis Turkeystroke turkeyy breath Breath-Control breathe breathe; Breatheing BreatheJust breathing Breathless breathtaking Breath-taking Breathtakingly Bred Bree Breeanna Breed Breeder Breeders breeding Breedom breeds Breelsen Bree's breeze Breeze’s Breezy Brekell Bremen Brend Brenda Brendan Brenda's Brendon Brendys Brenn Brenna Brennan Brennon Brent Brett Bretta Brett's Brew Brewed Brewing Brews Brewster Brexit Breyelle Breyta Bri Bria Brian briana Briana's Briancon Brianna brianna's Brian's Briar Bribe Bribed Bribery Bribes Bribing Bric-a-brac Brice Briches brick brickhouse Bricklayers Brickmasterbation Bricks Brick's Bridal bride Bridemaid brides bride's Brides Bride's Bridesmaid bridesmaids Bridesmaid's Bride-To-Be Bridezilla bridge Bridges Bridget BRIDGET Bridget Bridget's Bridgett bridgette Bridgette? bridgette's Briding Brie Brief Briefing Briefs Briella Brielle Brielle's Brigada Brigade Brigette Briggs bright brighten Brighter Brightest bright-eyed brightly Brightness Bright's Brigit brigitta Brigitte Brigitte's Brigitting Briho Brik Brill Brillante brilliant Brill-iant Brillo Brilloso Brillster brim Brimbalant Brin bring Bringin bringing brings Bring's Brink Brin's Brinx Brinx's Briquettes Brisexual brit Brit A Good Brit’s Britain Britannia Britanny Britany Britches Brite Brithday british British-French britney Britneys Britney's Brit-on-Brit Brits Brit's Brits? Britt Brittani Brittania Brittanie Brittany Brittany's Brittle brittney Brittny Brittny's Britton Britts Britt's Brix Brixley Brixton Briza Brizit Brno bro Bro’s Broach broad Broadcast Broadcasting broadcasts Broads Broadwalk Broadway Brock Brocks Broden Broderick Brodi Brodie Brody Brohstel broke Brokelyn broken broken? Broker Bromance Bromley Bromo Bronco Bronson Bronte Bronx Bronze Bronzed Bronzing Brook brooke Brookelynn Brookes Brooke's Brookie Brooklyn Brooklyn’s Brooklynn Brooklyn's Brooks Brook's Brookshire Brooks's Brookyln Broom Broomsticks Broox bros bro's Bros Bro's BROs Brossman Brotha brothas Brothel brother Brother? Brother’s Brotherhood brother-in-law Brotherload brotherly brothers brother's Brothers Brother's broug brought brow brown BrownBunny brown-haired brownie Brownies Browning's Brownlacey Brown's Browse Browser Browsin Browsing Brrr bru Bruan Bruce Bruenette Brugal Bruised Bruises Bruja Brulee Bruna Bruna's Brunch Brunessa Brunet Brunett brunette Brunette Rides Brunette’s brunettes brunette's Brunettes Brunette's Bruni Bruninha Brunna brunnete brunnette Bruno Bruno's brush Brushed brushes brushing Brushstroke Bruta1ity brutal brutalised brutality Brutalize brutalized brutally brutalNon-scripted brute-make love brute-make loveed brut-make loveed bruthas Bryan Bryana Bryant Bryce Bryci Brylee Bryn Brynn Brysen B's Bsg bts BTS-20 BTS-A BTS-Abigail BTS-All BTS-Almost BTS-Amateurs BTS-An BTS-Ana BTS-Anal BTS-Angelic BTS-Animal BTS-Aren't BTS-Ariel BTS-Booty BTS-Bootyed BTS-Audrey BTS-Back BTS-Bathroom BTS-Beautiful BTS-Bella BTS-Better BTS-Bisexual BTS-Chick BTS-Black BTS-Blacked BTS-Blanche BTS-Blow BTS-Bonnie BTS-Bree BTS-Bumhole BTS-Carmen BTS-Carter BTS-Casting BTS-Cayenne BTS-Cindy BTS-Coming BTS-Cooking BTS-Cream BTS-Deep BTS-Dentist BTS-Destruction BTS-Detention BTS-Dirty BTS-Disco BTS-Dolls BTS-Don't BTS-Emily BTS-Evil BTS-Extra BTS-Facial BTS-Faithfully BTS-Family BTS-Fingers BTS-Foot BTS-Francesca BTS-Fun BTS-Gangland BTS-Gasp BTS-Getting BTS-Giant BTS-Girls BTS-Golden BTS-Graduation BTS-Harmony BTS-Help BTS-HI BTS-Horny BTS-Hose BTS-Hot BTS-I BTS-I'm BTS-It's BTS-Jenaveve BTS-Jessie BTS-Joanna BTS-Join BTS-Keisha BTS-Korene BTS-Late BTS-Legs BTS-Lena BTS-Lessons BTS-LeWood BTS-LeWood's BTS-Lex BTS-Lexi BTSLex's BTS-Living BTS-Lovely BTS-Lucky BTS-Luscious BTS-Martial BTS-Maureen BTS-May BTS-Me BTS-Michelle BTS-MILFs BTS-Mommy BTS-Mommy's BTS-Mother BTS-Mother's BTS-My BTS-Mz BTS-Never BTS-New BTS-No BTS-North BTS-Nova BTS-Oooh BTS-Our BTS-Paint BTS-Paula BTS-Phoenix's BTS-Pie BTS-Pink BTS-POV BTS-Power BTS-Private BTS-Prom BTS-Psycho BTS-Puppet BTS-Pure BTS-Red BTS-Regan BTS-Remote BTS-Rocco BTS-Rocco's BTS-Sara BTS-Scarlett BTS-Seduced BTS-Seduction BTS-Sensual BTS-Sex BTS-Sexy BTS-Sharing BTS-She-Male BTS-Silvia BTS-Silvia's BTS-Sisters BTS-Sivia BTS-Slutty BTS-Sorority BTS-Steaming BTS-Stepmom's BTS-Straight BTS-Swallow BTS-Swim BTS-Tara BTS-Tasting BTS-Tea BTS-Teasing BTS-Tech BTS-The BTS-Tight BTS-Toni's BTS-Tooth BTS-Tori BTS-Toying BTS-Training BTS-Trans BTS-Transsexual BTS-Twerk BTS-Under BTS-Undressed BTS-Unexpected BTS-University BTS-Valentine BTS-Vanessa BTS-Vegas BTS-Victoria BTS-Volleyball BTS-Voluptuous BTS-Warm BTS-White BTS-Will BTS-You BTS-Young BTS-Your bu Bub Bubba bubbilicious bubble bubble_bums Bubble-booty Bubblebath Bubblebathfun Bubblebathorgasm Bubbleblower bubble-bum Bubblebum Bubble-bum Bubblefingers Bubblegum Bubble-loving bubbles Bubbles2 Bubbletoy Bubblewand Bubbley Bubblez Bubblicious Bubbliest Bubblin Bubbling bubbly Bub-Bubs Bubby Bubi Buca buck Bucked bucket buckets Buckin bucking Buckle Buckler bucks bud Buda Buda'07 Buda'08 Buda'10 Buda'11 Buda'12 Budai budapest budapuss Budd Budder Buddha buddies Buddie's Budding buddy buddy's Budene Budget buds Bueller Buellers Buen Buena Buenas Buenos buff Buffer buffet Buffett Buffin buffy BUFU bug Bugatti Buggered Buggy Bugman Bugs Bugsi's Build builder building Builds built Built-In Buisnbooty Bujoli Bukake bukakke bukkake Bukloj bulb Bulbous Buldocek Bulgari Bulgaria Bulgarian Bulge bulging Bulk bull Bull0-0 bull-johnsoned Bulldog Bulldoggy Bulldogs Bulldozed Bulldozer Bulldozing Bullet Bulletproof Bullets Bullfighter Bullhorn bullied bullies bullly's Bullpen bulls Bull's Bullseye Bullshit bully bullyBlond bullying bully's Bullys Bully's Bulma bum Bumblebee Bumbling Bumholes Bumlapping Bumm Bummer bump bumper Bumpher Bumpin Bumpin' Bumping Bumpkin Bumps Bumpy bums Bum's Bumtastic Bun Bunch Bunda bundle bungalow Bungee bunghole Bunk bunker Bunking Bunkmate Bunks Bunnie bunnies Bunnie's Bunnington bunny Bunny’s Bunnybooty Bunny's bunny-style buns Bunz Buongiorno Buoy Buoys Burbs Burden burger Burgers burglar burglar’s Burglarize burglars burglar's Burglars burglary Burgundy buried Buries Burke Burke's Burlesque Burley-Quinn Burly Burma burn Burned burner Burnett Burnin Burnin' burning burns Burp Burrito burst bursting Bursts Burton Burundanga bury Burying Bury's bus BusBoy Busenwunder bush Bushand bushes bushhiking Bushido Bushless Bushmeat Bushvibe Bushwalker Bushwalking bushweed bushwhacked bushy bushzilla business Businesslady businessman Businessman's businessmen businesswoman Businesswoman's Busker BusMan Busom Bussom Bussy bust Bustani busted Bustedon Buster busters buster's Busters bustier bustiere' Bustiest Bustin Bustin' busting Bustle Bustout Bust-out busts busty busty-ness busy Busybodies Busybra but BUTACA Butch Butcher Butcher0-0 Butders butler Butler? Butler’s butler's bum Bum? Buma bumabulous Bumafly Bumaholic Bumaholics Bum-bang Bum-Blasted Bum-Blessed bum-cheeks Bum-crazed Bumdad bumer Bumercup bumercups Bumered Bumerflies Bum-erflies bumerfly Bumerflylips Bumerfly's Bumerican Bumering Bumermilk bumers Bumerz Bumfinger bummake love Bum-make love Bummake loveed Bum-Make loveed Bummake loveer Bummake loveers Bummake loveer's bummake loveing bum-make loveing Bummake loveing Bum-Make loveing Bummake loves Bum-Make loves bumhole Bumholes Buming bumload Bumman Bummans Bumman's Bummore bumock Bumocks bumon bumons Bumplay Bumplays Bumplayscom bumplug bum-plug Bumplug bum-pluggin Bumpluggin Bumplugs Bum-plugs Bumricks Bumrose bumroses bums Bum's BUMS bumsex Bum-Sex Bumshake Bumslut Bumsluts Bum-Slut's bum-stretching Bumstruck Bumwoman Bumwomen Buuren buxom Buxome Buxomy buy Buyer Buyers Buyer's buying buys Buzonga-rama buzz Buzz-Cut Buzzed buzzer Buzzes Buzzin buzzing Buzzonga Buzzy BV BV's BW bwc BWD BWF by bye Bye-Bye Bygone Byrne Byron Bysmark Byvasa BZ bz_plus_sample20 BZ001 BZ002 BZ003 BZ004 BZ005 BZ006 BZ007 BZ008 BZ009 BZ010 BZ011 BZ012 BZ013 BZ014 BZ015 BZ016 BZ017 BZ018 BZ019 BZ020 BZ021 BZ022 c C20489 C3P0 C5 ca CA$HH cab Cabaeva Caballo cabana Cabang-A-Bro Cabaret cabbie cabbies Cabbie's cabby Cabby's Cabecera Caber Cabie cabin Cabinet cable Cabo CABODAY Caboose Cachier Cachonda Cadabra Caddy Caddyshag Cade Caden Cadence cadet Cadets Cadey Café cafe Cafelita Caffee Camake loveteria cag cage caged Cages Cagey Caging Cailean Caimanes Caimax Cain Cain's Cairo Cait Caitlin Caitlyn Cajun cake Cake? cakealicious Cakefun Cakes Cakes's CAL Cala Calabre Calada Calamity Calathe Cale Caleb Calendar Cale's Caley Calhoun Cali Cali’s calibe caliber Calibre Calibre's Calick Calico Calico's Calidad caliente Caliente’s Califorina California Californian Californication Calify Caliginosity Caligirl Calis Cali's Calisi Calista Calisthenics Calisyn call Call Scene call? Callahan Callaway Calle called Caller callgirl Call-Girl Callie Callies Callie's calling Callipygian Calliste Calloway calls Calls? Callum call-up calm Calmant calmed Calming Calmy Calogera calories Calun Calvert Calvert’s Calvert's Calves Calvet Calvin Caly Calypso Calza cam Cam? Cama CAMARA camarad Camargo Cambio Camden came came? camel Cameltoe Camel-Toe Cameltoes Cameo camera Camerafun cameraman camera-man Cameraman cameramans Cameraman's Cameraphone cameras Camera-shy camera--ZOOOM Cameron Camerons Cameron's Camerons's Cameryn CameTo camgirl Camgirls Cam-girls CamGirls Cami Camil Camila Camile Camilla Camillaafter Camilla's Camille Camille's Cami's Camisa Camisia Camisole Cammer Cammie Cammille Cammille's Camming Cammo Cammy Camo Camofingers Camofingers1 Camofingers2 Camostrip Camouflage Camouflaged Camouflages camp campaign Campaignin Campaigning Campamento Campbell camper Campers Campfire campground Campgrounds camping campo Campos Campside Campsite campus camcat Camrie Camryn Cams Camshow can CAN?? can’t Canada Canada loves anal Canada? Canadanal Canadesi Canadian Canadians canal Canali canals Canapa Canara Canas Cancel Canceled Canceling Cancelled Canceller Cancho Cancino Cancun Candace Candace's Candalyn Candee Candel Candela Candelabra Candelight Candelit Candi Candice Candice's Candid candidate Candidates Candidly Candie Candied candies Candii Candis Candise Candisummers candle Candlecoochie Candlelight Candlelit Candle-lit Candlelove Candleloving candles Candlestick Candletoy Candlewax Candor Candra candy Candy? Candy’s Candybelle Candycane Candy-Coated candy-colored Candygirl Candyland Candy-licious Candy's Candy-sweet cane caned CaneDay Canela Canela’s Canela's CanelaThe canes Canine caning caningMade Caniora Cannibal Cannoli cannon cannons cannot Cano Canoe Canon Canon's Canoodling canopy cans cant can't Cant Can't cantaloupe cantaloupes Cantina Canuck canvas canyon Caomei Caomei's Cap Capable capacity Capades Capcan Cape Capella Capelli Caper Capers Capistrano Capital Capo Capone Capones Cappelli Cappers Cappuccino Capra Capri Caprice Capricious Caprimature Caprio Capris Caps capt captain Captain’s captains Captain's captivate Captivated Captivates Captivating Captivation captive captivity captor capture captured captures Capturing car Car? Cara Caraballo caramel Caramelle caravan Carb card Cardboard cardgames Cardille Cardinale cardio Cardiogasm Cardo Cardova cards Cardwell care career Carefree Careful carefully Caregiver Careless Caren Carerra Cares Cares? caresif caress Caressa Caresse Caressed Caressers caresses caressing Caretaker Carey Cargo Cari Caribbean Caribe Caricias Carie Carin Carina Carina's Carine caring Carioca Carissa carjacked Carl Carla Carla4Garda Carla's Carleigh Carley Carli Carlisto Carlito Carlo Carlos Carlton Carly Carlyn Carlynn Carman Carmel Carmela carmelicious Carmeline Carmelita Carmell Carmella Carmella's Carmen Carmens Carmen's Carmichael Carmina Carmine Carnage carnal Carnale Carnalis Carnality Carnevale Carni carnival Carnivorous Carnosa Caro Caroggio carol Carole Carolin Carolina Carolina's Caroline Caroline's Caroll Carolline Carol's Carolyn Carolyne carousal Carouse Carousel Carpa Carpe carpenter carpenter's Carpenters Carpenter's carpet Carpetfun Carpetmunchers Carpetspread Carpool Carr Carre Carrera Carrera's CarrerBOOTY Carrere carriage Carrie carried Carriera carries Carrie's Carrine Carrington Carrmen Carro Carroll carrot Carrothole carrots carry Carry's cars Carshow Carso Carson Cart Cartagra carte carted cartel Carter Carters Carter's Cartier Cartoon Carts cartwheel Cartwheeler Cartwheels Cartwright Carumba Caruso Carvalho Carved Carves Carving Carwases carwash Cary Cas casa Casada Casana Casani Casanova Casca Cascade case Casey Caseys Casey's cash Cash-hungry Cashier Cashier's Cashing Cashmere Cashmere's Cashmire Casi casing casino Casinos Casio casitng Caso Casper Cbooty Cbootyady Cbootyandra Cbootyandras Cbootyandra's Cbootyette CBOOTYh Cbootyi Cbootyian Cbootyia's Cbootyidey Cbootyidi Cbootyidy Cbootyidy's cbootyie Cbootyies Cbootyie's Cbootyini CBOOTYINIHard cBOOTYtle Cbootyy Cbootyye Cbootyy's cast Castaway Castaways casted Castelli Castello caster casti castin casting Castings castle Castles Castle's Castling CASTRATION Castro Castro's casts casual casually Casualties cat Cat? Catalan Catalimes Catalina Catalonia Catania Catarina CatBOOTYtrophe Catastrophe catastrophic catastrophically CataCANic Catcalling catch Catch'em catcher Catchers catches Catching Cate category Cater caterer Caterine Catering catfight Catfighting Catfish Catfished Catfishing Catgirl Cathaleen Catharios Cathartic Cathedra Catherina Catherine Catherine's Catheterize Cathia Catholic Cathrine Cathy Cathy's Caties Catigory5 Catlyn CATRINA Catrine Catryn cats cat's Cats Cat's CatSitter catsuit Catt Cattiva cattle Cattleprod Cattle-prod? Cattleprodded catty Catwalk Catwoman Catwoodman Caty Caucasian caught Caught? caught_on_camera CaughtBoundaries Cauke Cauldron Caulfield cause caused causes Causing Caution cautious Cavali Cavalli Cavallo Cavalry Cavani Cavanni Cavanni's cave Cavegirl Cavemen cavern Cavernous Caves Cave-Whore Cavialean Caviar Cavities Cavity CAVR Cayden Cayenne Cayenne's Cayla Caylian Cayo Cayton Cazden Cazen Cazzo CBS CBT CC CCOK CC's C-Cup Cds Ce Cease Cece Cecelia Cece's Cecil Cecila Cecila’s Cecile Cecilia Cecilia's Cecily Cedella Cedric Cee CEHoe Ceiling Ceira Celeb celebrate celebrates Celebrating celebration celebrity Celeb's Celena Celest Celeste Celestia Celestial Celestine Celesto Celia Celibacy? Celina Celinange Celina's Celine cell cellar Cellblock Cellia Cellist Cellmate Cellmates Cello Cellophan cellphone Cellular Cemetery census Census? Cent center Centerfold Centerfolds Centerpiece centipede Centipenis Central Centre Centric century CEO CEOhhh Ceo's Ceramic Cerania Cereal Cerealorgasm Ceremonies Ceremony Cerna Cerrera certain certainly Certification certified Cervix Cesar Ceviche Ceylon CeylonBlondie1-5 CF Cfnm cfnmbootyage CFNMasturbation ch Ch1 Ch2 cha Chacha chachas chad Chade's chain chained chaining Chainlink chains Chains? chair Chair? Chair1 Chair2 Chairclitrub Chaircream Chairbanana Chairdong Chairfinger Chairfingers Chairfun Chairhole Chairlove Chairmasturbation chairNeck chairOrgasm Chairpanties Chairpink Chairplay Chairplay1 Chairplay2 Chairpleasure Chaircat Chairrubbing chairs Chairs1 Chairs2 Chairstrip Chaircans Chairtoy Chairty Chairvibe Chaise Chakra Chakras Chalice Chalizo Chalk Chalkboard challenge Challenge? Challenged Challenger challenges challenging Cham chamber Chambermaid Chambers Chambre Chamille Chamille's champ champagne Champaign champange Champayne champion championinto ChampionNon-Scripted championRookie champions ChampionSeriously championship championships CHAMPIONSMore CHAMPONSHIPVENDETTA Champs chance chances Chance's Chancing Chandelier Chandler chanel Chanele Chanel-ing Chanell Chanelle Chanel's change changed Changeover changer Changeroom changes Change-Up changing channel Channeling Channels Channing Channson Chanonne Chantal Chanta-Rose Chantell Chantelle Chanting Chanty Chaos Chaos's Chaotic chap chapel Chaperone Chapman Chapoteo Chaps Chapter Character Characteristics Charades charge Charged charger charging Charima Charina Chariot charisma Charismas Charisma's Charismatic Charitable Charity Charity's Charlee Charlee's Charlei Charlene Charles Charleston Charlette Charley Charley's Charli Charlie Charlie's Charli's Charlize Charlon Charlott Charlotta Charlotta's charlotte Charlottes Charlotte's Charly Charlys Charlyse Charlyze charm Charmaine Charmane Charmed Charmel Charmelle Charmelle's Charmer Charmer's Charmeure charming charms Charnelle Charolette Charro Charwomen Chary Chary's chas chase Chase’s Chased Chaser Chasers Chases Chase's Chasey Chasi Chasin chasing Chasity chasm Chbootyis Chaste Chaster Chastised chastisement chascany Chascany's chat Chateau Chatroom Chats chatting chatty Chaty Chauffeur Chauffeurs Chavez Chavon Chavon's Chayanne Chayen Chayenne Chaynes Chayse Chayse'ing Chaz Chazz Che cheap cheap-booty Cheaper chearleader cheat cheated cheater Cheater’s Cheaters Cheater's cheatin cheating Cheating? cheating-booty cheats Chechick Chechik Chechik's check checked Checker Checkered checkers Checkin checking Checkmate CheckMates Checkmating Check-Out Checkroom checks checkup check-up Checkup Check-Up Checky Cheek Cheek-Peeking cheeks Cheeks's cheeky cheer Cheermelons Cheerful Cheergirl Cheering cheerleader Cheerleader?? cheerleaders cheerleader's Cheerleaders Cheerleader's Cheerleading cheerleads Cheerlickers cheers Cheertoy Cheer-Up Cheese Cheesecake Cheesetoy Cheeta cheetah Cheezy chef Chef’s Chefa Chefs Chef's Chekc Chelsea chelsea's Chelsey Chelsey's Chelsie Chelsie's Chelsy Chemise chemistry Chenille Chenin Chennin Cherchez Cheree Cherelle Cherie Cheries Cherie's Cherish Cherished Cherokee Cherokee's cherrie cherries cherrilicious Cherrries cherry Cherry-Eve Cherryl cherrypoppens Cherrys Cherry's Cherub Chery cheryl ches Chesly chess Chessie Chesscat Chesstoy Chess-ty chest chest? Chested Chester Chester's Chesticles Chestnuts chests chesty chet Chet's Chevallier Chevelle Chevys chew chewed chewing chews Chewy Cheyanne Cheyene cheyenne Cheyenne's Cheyne Chi Chianti Chiara Chiarore Chic chica Chicago Chicana Chicane Chicas chich Chichi chick chickas chicken Chickenhead chicks chick's Chicks Chick's chicks? ChickSicilian chickster Chicky chico Chie Chief Chief's Chie's Chiffon Chigusa Chika Chikita Child Childbearing childhood Childish Chile Chilean Chili chill Chill? Chilled Chilli chillin Chillin' chilling chillout chill-out Chillout Chill-out chills chilly Chillz chime Chimera Chiminea Chimney chin China Chinara China's Chinatown Chinchilla Chinese Chinga Chinita Chinny Chintia Chip Chippendale Chipper Chips Chiquita Chiropractor Chirstmas Chis chiseled Chisty Chit Chitchat Chivalry chix Chix's Chiyoko Chleo Chloe Chloe’s Chloee Chloes Chloe's Chloey Chloie Chlorine chocha chocholate chock Chocked Chock-Full Chocky choco chocoalte chocolate Chocolatebooty ChocolateGimmy chocolates Chocolatey Choi choice Choices choir Choirboy choke choked Choker Chokers chokes Chokey choking Choky Choky's Chola Cholita chonga chongalicious Chongas Chonies Choo Choo-Choo choose Choose? chooses choosing Chop Chop-Johnsons Chopper Choppers Chops chopstick Chord Chords Chore Choreography chores Choreta Choripan Chorizo Chorreándose Chorros chose Chosen Chow Chow-Down chowing Chris Chriselle ChrismBOOTY Chrismukkah Chrissie chrissy Christ Christa Christal Christall Christel Christelle christen Christening Christens Christen's Christey Christi Christian Christiana Christiana's Christianne Christiansen christie Christien Christie's Christin Christina Christina's Christine Christine's christmas ChristmBooty Christm-Booty ChristmBOOTY Christmbootyhe Christoph Christopher Christoph's Christou Christy Christy's Chritsmas Chroma chrome Chronic chronicles Chrystal Chrystall Chrystin Chu Chub chubby Chubette Chuck Chuckies chugger Chugs Chuiiii Chula Chulas Chulita Chulucila Chum Chummy Chun Chung Chunga Chunks chunky chupa-chups Chupando church Churn Churned Churnin Churning Churns Churrasco Churro chute Chyanne Chyna ChynaWhite Cia Ciao Ciara Cibime Cici Ciciliya Cieli Cielo Ciera Cierra cig cigar Cigarette cigarettes Cigarillo Cigarrettes? Cigarro Cigars Ciggy Cigogniatella Cikita Cilla Cimmerian Cinammon Cincinnati Cinco Cindee Cinderella Cinderella’s Cinderella's Cindi Cindy Cindyrella Cindy's CINEDOE cinema Cinemas Cinematic Cinephile Cinn Cinna Cinnamon Cinn's Cinq Cinquecento Cinthia Cinthia's Cintia Cintia's Cintija Cinturinha Cinturone Cip Cipka Cipper Cipriana Cipriana's Circa circle Circles Circling Circuit Circuito Circular circus Ciri CIRIS Cirque Cis Cisgender Cita Citadel Citah Citation Cited Cithina cities Citizen citizenship Citrus city Civil cj CJ's CKM cla clad Claidia claim Claimed Claiming Claims Clain Clair Claire Claire's Clairette Clairvoyance Clairvoyant clam clamp clamped clamps Clams clan clan? Clanddi Clandestine clap Clapper Clappin Clapping claps claquent Clara Clara's Clarck Clare Clarence Clarice Clarig clarinet Claris Clarise Clarissa Clarisse Claritas Clarity Clark Clark’s Clarke Clarks Clark's Clarkson CLARO Clary Clase Clash Clashing clbooty Cl-Booty CLBOOTY Clbooty? clbootyes clbootyic Cl-booty-ic CLBOOTYIC Clbootyica clbootyical Clbootyically Clbootyico clbootyics Clbootyiest Clbootyified Clbootyifieds clbootymate clbootymates clbootyroom Clbootyy clbootyyand Claude claudia Claudia's claudio Claus clause Claustrophobe Claustrophobia Claustrophobic Claw clawing Clay Claymore Clayton clea clean Clean? cleaned cleaner Cleaners Cleanest Cleani cleanie cleaning Cleanliness CLEANnDIRTY cleans Cleanse Cleanser Clean-Shaved clean-shaven Cleansing cleanup clean-up Cleanup Clean-up clear Clearance Cleardong Cleared Clearing clearly Cleartip Clea's cleavage Cleavage? Cleavages Cleaved Cleina Clemens Clement Clementine Cleo Cleo-Clap-Booty Cleopatra Cleopatra's Cleo's clerk Clerks Cleveland Clever Cliche click Clicker Clicks client Client Before Client’s Clienta clients client's Clients Client's Clif Cliff Cliffea cliffs Climactic Climate climax Climaxers climaxes climaxing ClimaXXX Climb Climber Climbin Climbing climbs Clinch Cline Cling Clinger Clingier clinic clinical Clinically Clint Clinton Clio clip Clipboard clipped Clipping clips Clisto clit clitacular Clitannica Cliterate Clit-fingering Clitfun Clitical clit-kiss clitLet's Clitlicking Clitman Clitmas Clitness clitoral clitorama Clitorides clitoris clitourist Clitrub clits Clit's Clitter clitters Clittickler Clittilating clitty Clitvibe Clit-Wand clock clocked Clocks clockwork Cloe Cloee Cloei Clone Cloned Clones Clonesploitation close closed Closely Closeness closer Closers closes Closest closet Closet? Closeted Closettalk closeup Close-up close-ups Closeups Close-Ups closing Closure cloth Clothed clothes Clothes? Clothespin clothespins clothing cloths cloud Cloud-Castles Clouds Cloudy Clout Clove clover clown Clowning Clowns Clown's club club; Clubber clubber's Clubbin clubbing clubby Clubin Clublife clubsandycom clueless Clues Clumsy Clunge Clunkers Cluster clustermake love clutch clutches Clutching clutz Clyde C'mere CMNM C'mon co coach Coacharoo Coaches Coaching Coachs Coach's Coal Coast coaster coat coated Coating coats Coax coaxes coaxing cob cobbler Cobra Cobwebs Coby coc Co-Captain Coccos Cochella Cochons member C-O-C-K member? Member’s member-ada's Member-addict Member-addicted Member-Addiction Member-A-Banana-Doo Memberadile Memberafornia Memberaholic Member-a-Licious Memberamania Memberamole Memberaphrenia Member-Arm Memberasian Memberateel Memberation Member-Attack Memberazoid Member-Beating Memberblocking Memberblocks Member-Bot Memberboy memberBrutal Member-building Memberbuster Member-Calling MemberCam Membercentration Membercoaster Member-Craving Member-crazed member-crazy Memberdazian Member-ditions Member-Dogs membered Memberenstein Memberer Memberervention Memberet Member-expert Memberfather Memberfidential Memberfight Membermake loveing Memberfun Memberhanded Memberhandling memberhardening member-hardening Member-Holding Memberhole member-hungry member-hunt Memberin Membering Membering? Member-Inn member'll Memberload member-lover member-loving Member-Lunch Member-Mad Membermas Membermaster Member-Milking member-napped Membernapped MemberNatbootyia Memberness Memberney Membero Member-obsessed Memberolate Memberold member-o-thon Memberout Memberpelia Memberpit member-pleaser Member-pleasing memberpop Memberporn member-praising member-punished Memberraiser member-ride member-rider memberriding member-riding Memberriding Member-riding Memberrin memberring_toss members member's Members Member's Members? member-sharing memberShe membershower member-shy membersicle member-sicle Membersicle membersicles Memberslapped member-slobbering MembersLoves Member-Slut Member-smacked Membersmith Membersmoker Member-Sports membersshe Memberstar MEMBERSTARR Member-Sticks Memberstuffed Member-Stuffs member-su Membersuck Member-Suck-Come-Back membersucker member-sucker Membersucker Membersuckers membersucking member-sucking Membersucking Member-sucking MemberSucking Member-Sucking Membersuckingly Member-Suffocated Membersultation membersville membertache membertail Member-Tail MEMBERtail membertails member-taker Membertease Memberteasing MEMBERtion 'Membertip membertoberfest Membertoy member-treatement MemberTS Memberupational Memberwarming Memberwash Memberwhore Member-Whore Member-Worshiping membery Memberzilla Coco Cocoa Cocoa-licious coconut coconuts Cocoon Cocos Coco's cocscious cocsk coctail Coctomom Coda Coddie code code? Codename Coder codes Codeys Codi Codie Codi's Cody Cody's Coeath coed co-ed Coed Co-ed coeds coed's Coeds Coed's Co-eds Co-ed's Co-Eds Coelho Coen coerced Coercion Coeur cof coffee coffee? Coffeeshop Coffeetime Coge Cogeme Cogen Coger Cogerse Cogida Cogidas Cogidón Cogidos Cogiendo Cogiendola Cognizance Cohen Cohstly Coin Coincidence Coincidences coition Coitus Cojas Cojo Cokolady Cola colada Colby cold Cole Colegio Coleman Cole's Colette Colette's Colibri Colin Collab Collaboration Collar Collared Collaring Collateral Colle colleague colleagues Collect Collecting collection collector collector’s Collectors Collects Colleen college college-girl College's Collegial collegian collegiate collide Collider Collie Collin Collins collision Collusion Colm Colmea Colombia colombian Colombiana Colombianas Colombians Colombian's Colon Colonel Colony color Color? Colorado colored Colorful colorful_coochies colors Colorvibe colossal Colossus Colour coloured Colourful Colours Colt Colter Colter's Colton Colucci Columbian Column Columnist Columns com Coma Comatose Comb Combat combatantsSexiest combinatio combination combination? combined combines Combining combo Combos Combustion come comeback Comeback's comedian Comedienne Comedy Come-Hither Comely Come-Policias comer Comers comes Come's Comet Cometh Comeuppance comfort comfortable Comfort-able Comfortable? Comforter Comforting comforts comfy comfy? comic Comicbook ComicBookNerd666 Comics Comiéndose Comilan coming command Commanded Commandeering Commander commander's commanding commando commands Comme Commence Commencement commentaries Commentary Commercial Commiserations commission Commissions Commitment committed committee commodity common Common? Communal Communication Communion Community Commute Commuters como Compagnia Compañeras Compañero Compañeros companion Companions company Compare compares Comparing Comparison Comparte Compartiendo Compbootyionate compatibility Compcutie Compelled Compelling compensates Compensation compete Competing compecanion compecanive Compecanor compecanors compecanve compilation complain Complaint Complaints Complementary Complementing complete complete? Completed completely Completion Completo Complex Complexion Complicated Complicit compliment Complimentary compliments Comply Compomises Compound Compra Compromise Compromised Compromising Compton compulsion Compulsive computer Computergirl Computerlove Computertoy Computervibe comrade COMT con Con-Artist's conceive Conceived Concentrado concentrate Concentrating Concentration Concept Conception Concern Concerned concerns concert Concert? Concerto Concessie Conchita Concierge Conclave conclusion Conmembertion Con-Member-Tion concoction Concrete concubine Conda Condición Condiment CONDIMENTS Condition conditioner Conditioning condo condom Condoms Condor Conduciendo Conduct conductor Cone Coner confectioner conferance conference Confés Confess confesses confession Confessional Confessionals confessions Confesso confidence confident Confidential confidently Confiding Confined confirm confirmed confirms Confiscated conflict Conflicted Conflicts Conflixxx Confluence Confront Confrontation Confronted Confronting Confronts confused ConfusedAn Confusion Congor Congrats congratulates Congratulations Congressman's Conie Coniza Conjugal conjured Conjuring Conlatio Connbootyeur Conne Connect connected Connecticut Connecting connection Connections Connects Connell Conner Conner’s Conners Conner's Connie Connie's Conning Conniver Connoisseur connoisseur1 Connoisseurs Connor Connorligula Conny Connys Conoce Conor Coños Conquer Conquered Conquerer Conquering Conqueror conquerors conquers conquest conquests conquistadora Conrad Conroy cons Conscience conscious conscious? consciousnessA consecration Consensual Consent Consentia Consenting consequences Consequential Conservation conservative Conservatives consider Consideration Consignment Consika consolation console Consoled consoles Consoling Consort consortium conspiracy Constance Constanse constant constantly Constrained constrict Constriction Constrictor Construal Construct Constructawhore construction Constructive Consult Consultancy Consultant consultation Consulting Consumer Consumes Consuming Consummate Consummating Consumption cont contact Contain Contained container Contatte Contaxis Contemplating Contemporary Contempt contender content Contentment Contento Contessa contest contestant Conti Continents continue Continued continues Continuing Continuous continuously Continuum ConCanioning contorted Contortion Contortionist Contortionistcom Contorts Contour Contours contr contraband Contraception contraceptive contract contractor Contractor5 Contractors Contracts Contractual Contrbootyt Contrast Contrasted Contrasting contrasts Contreras Contribution control control' Control Control? Controladora ControlBoth Controlla Controlled Controller Controlling Controls controversial contruction Conundrum Convalescence Convalescing Convenciendo Convenience Convenient Convent Convention conversation conversion Convert converted Converter Convertible Converting Converts convict Conviction convince convinced convinces Convincing Convo Convulsing Cony Coo cooch coochie Coochies Coochy Coocoo Coo-Coo cook cooked Cookery cookie cookie? cookies Cookie's Cookies? cookin Cookin? cooking Cooking? cooking_with_katana Cookoff Cookout cooks cool Coolbabe Cooldown Cooled Cooler Coolest Cooling Coolness Cools Co-Op Cooper Cooperations Cooper's Coordination cooter Cooters cooze Coozy cop Cop? Copafeel Cop-a-Feel Copier Copine Coping copper Copper's Coppertone Copping cops cop's Cops Cop's COPS Cops? copulating Copulation Copy copy-book Copycats Coquendam coquette Coquettes Coquettish Cora Coracao Coral Coralie Coralyn Cora's Corazon Corba Corbin corbusier Cord Cordeindo Cordella cordially cords core Coreena Corey Cori Corin Corina Corinna Corinna's Corinne Corizo cork Corking Corkscrew Corly corn corn? Cornbread Corncob Corncobcat Cornbanana Cornejo Cornelia corner Cornered CornerMonday Corners Cornfed Corn-Fed Cornflower cornholded Cornhole Cornholed Cornholers cornrolls Cornucopia Corny Corona Coronation Coronavirus Corpo Corporal corporate Corporation Corps Corpse Corre correct corrected Correcting correction Correctional Corrective correctly Corrects Correpecanion Correr corridor Corrine Corrupt corrupted Corrupting Corruption corrupts corset Corsets Cort Cortes Cortés Cortez Cortknee Cortney Corvette Cory Cory's Cosette Cosier Cosima Cosmetic cosmetologist Cosmia Cosmic Cosmo Cosmos Cosplay cosplayer Cosplaying Cosset Cost Costa Co-Stars Costello Costiero Costina Costly costs costume Costumed Costumes cosy Cotillion COTM Coton Cotone Cottage Cotten cotton Cottonpanties Coty cou Couc couch Couch2 Couchbeads Couchclit Couchclitrub Couchmember Couchcooch Couchcoochie Couchcookie Couchcream Couchbanana Couchdong coucher Couches Couchfinger Couchfingers Couchfun Couchhole Couching Couchlips Couchlove Couchmasturbation Couchpink Couchplay Couchplay1 Couchplay2 Couchpleasure Couchcat Couchrub Couchrubbing1 Couchrubbing2 Couchrubdown Couchsocks1 Couchsocks2 Couchspread Couchsurfer Couchsurfing Couchtalk Couchtease Couchteaser Couchtoy Couchtoy2 Couchtoys Couchvibe cougar cougar_or_kitten Cougariffic Cougar-In-Law Cougarland CougarRecruitscom Cougars Cougar's Cougarville Cough could couldn couldn't Couldnt Couldn't Council Coundown Counrty Counsel counseling Counselling Counsellor counselor Counselors Counselor's count countdown counter Counterblow Countermember Counters Counterspread countertop Countertopbanana Countertopdong Countertopplay Countertoy Countervibe Countess Counting country Country… Countryman's countryside Counts county Coupel couple Couple’s Coupledom Couple-Friends couples Couple's coupleStripped Coupling Coupon Courage Courageous courier Courier2 course court courtesan Courthouse Courting Courtland Courtlyn courtney Courtney's Courtroom Courtship Courtside Courtyard cousin cousins Cousin's couture Couverture Cova Cova's cove Covelli coven Covenant cover Cover? Coverage coverall coverd CoverDeeper covered Covergirl Covergirls Covering covers Covert Covet Coveted COVID Covy cow cow? Cowabunga cowboy Cowboys Cowers cowgirl cowgirls cowoker coworker co-worker Coworker Co-worker coworkers co-workers Coworkers Coworker's Co-Workers Co-Worker's Cowpoke Cox CoxFinger CoxMake loveed CoxFull Cox's Coxx Coxxx Coxxxx Coxz Coy coyness Cozies cozy Cozying CP Cpr CPX cra Craaaaaazy Cracio crack cracked Cracker Crackers cracking Crack-Jacking Cracks Crack-tivating Cradle craft Crafted CraftGood Crafting Crafts Craftsman crafty Crag Craig Craigslist cram Cramhole Crammathon crammed Cramming Cramorama Cramp Cramped Crampie Cramping Cramps crams Crane Cranium Crank Crankability Cranky Cranne Crannies Cranny crap Crappy crash crasher Crashers crashes Crashing Crater Cravate crave craved Craven craver cravers craves cravin craving cravings Crawford Crawl Crawler Crawling crawls craze Crazed Crazier Crazies Craziest crazy Crazynails Crazzers Creador Creagan cream Cream2 Creama Creamapie Cream-Coated cream-covered creamed Creamer Creamers Cream-Filled Creamfinger Creamfun Creamin Creamin' creaming Creamlove creampie cream-Pie Creampie Cream-Pie CREAMPIE Creampie? creampied Creampie'd CreamPied creampiee creampie'ed creampies cream-pies Creampies Creampie's CreamPies Cream-Pies Creamcat Creampying creams Cream's creamsicle Creamteen Creamcans Creamtoy Creamvibe creamy Creamybanana Creapie creapied create created creates Creating Creation Creations creative Creativity Creator Creature Credentials credit Credits Credosta creed Creek creep Creeper Creepers Creepin Creeping Creeps Creepshot Creepshots Creepshow creepy Cremapie creme Cremosa Crempie Crempiee Crempies Crenia Crepe Crescendo Creta Crevice crew crewed crib Cribs Cricket cries crime Crimefighters Crimefighter's Crimes criminal Criminally Crimped crimper crimson Crippled Crippler Cris crisis Crispy Criss-cross Crissie crissy Crissy's Crist Crista Cristal Cristalia Cristalina Cristi Cristian Cristin Cristina Cristine Cristini Cristy Critic Croatia Croatian Crocker Croft Croft's Croissant Croix Crook Crooked Crooks crop cropped Croquet cross crossage cross-age Crossage Cross-age Crossdresser's crossdresses Crossdressing Crossed crosses Crossfire Crossfit Crossmake love Crossing Crossover cross-partner Crossroads Cross-Training Crosswalk Crossword crotch CROTCHES crotchless Crotchlesspanties Crouching Crouz crow crowd crowded Crowd's crown Crowne Crowned Crowne's Crowning Crrystal cruce Crucible Crucifi3d crucified Crucimake loveed Crude Crue cruel Cruelty cruise cruiser cruisers Cruises Cruise's cruisin Cruisin' cruising cruisy Crumpet Crump-cans Crunch crusade Crusader crush Crushed Crusher Crushers Crushin crushing Crusing Crust Crusted Crutch Cruz CruzCami Cruzin' Cruzing Cruz'ing Cruz'n cry cry? Crybaby crying Crypt Crysstal Crysta crystal Crystalis Crystall Crystallum Crystal's Crysteen Crystl C's CSI Csilla Csuka Cthulhu Ctrl cu CU4 Cuando Cuarentena Cuarta Cuarteto Cuatro Cub Cuba cuban Cubana Cubanita Cubanitas cubes Cubicle Cubicolo Cubs Cuca Cucci cuck Cuckage Cuckboys Cucked Cuck-held cuckhold Cuck-Husband Cucking cuckold CuckoldAn Cuckolded cuckolding Cuckoldress cuckolds cuckold's Cuckolds Cuckold's Cuckoo Cuckqueen Cucks cuddl cuddle Cuddler Cuddles Cuddling Cuddly Cudna cue Cue-less Cuerda cues Cuff cuffed Cuffing cuffs cuisine Cuke cul Culantro Culear Culen culinary Culito Cullen Cullet Cullote Culminate culmination culo Culona Culos Culo's cult Cultivator Cultural Culture Culver Cunilingus Cuninlingus Cunni cunnie cunnies Cunnilinguists cunnilingus Cunnilini Cunning Cunningham cunny cup Cupboard Cupcake Cupcakes Cupid Cupidity Cupids Cupid's cups cup's Cups Cup-Stretching Cura Curb curbed Curbside cure cured cured? cures Curfew Curiel curing Curiosities curiosity curious Curious? curiousity Curiously curl Curler Curlers Curlia Curls curly Curly-haired Curranta Curricular Curriculum Curry Currying curse Cursed CurseGia Curt Curtai Curtain curtains Curtin Curtis curvaceous Curvacious curvalicious Curve curvealicious curved curves Curves' Curvetoy Curvey Curvier curvy Curvy-Booty curvylicious cushi Cushin cushion Custard Custodial Custodian Custodian's Custody custom Custome customer customers Customer's Customs Cuswapping cut Cut? Cutbacks cute Cute' CUTE Cute Friend cute? cute-a-licious Cute-Bootyed Cutebraids Cutelingerie Cute-looking cuteness Cutepanties cutest cutester Cutey cutie Cutie' CUTIE cutie’s Cutie-blondie cutie-pie Cutiepie cuties cutie's Cuties Cuties' Cutie's CutiesGalore CutiesGalorecom Cutleries Cutlery Cutoffs Cut-offs cuts Cutters cuttest Cut-Throat Cuttie Cutties Cuttin Cutting Cutty Cuty Cuve Cuvee cuz Cuzzler Cyan Cyara cyber Cybergranny Cybersex Cyberteen Cybill Cyborg Cycene Cycle Cycles Cycling cyclist Cyclone cyclops Cyd Cyd's Cyle Cyndi Cyndi's Cynical Cyns Cynthia Cynthia’s Cynthya Cyntia Cypher Cyprus Cyrstal Cyrus Cyrus's Cytherea Cytherea's Cytheria CZ331 Czarina Cze czech Czech’s Czech'08 Czech'09 Czech'10 Czech'11 Czech'12 CzechHunter Czech-ie Czeching Czech-ing Czechmate Czechmates Czechs Czech's d D*** d’oeuvre D’s D20 D8 da d'Abramov Dacada Dachs Daciesa Dacota dad dad? dad’s Dadcrush daddies daddy Daddy' 'Daddy daddy? Daddy’s Daddy-Dom Daddy-Make love daddy's Daddys Daddy's Dade Dadivoso dad's Dads Dad's Dae Dafeny Daffodils Dafne Dagger Daggers Dagmar Dahl Dahlia Dahliah Dahlia's Dahly Dai Daiana Daikiri Dailany daily Daines Dainty Daiquiri Daire D'Aire Daire's Dairy Dai's Daisies daisy Daisy? Daisychain Daisydukes Daisy's Daizy Daizy's Dajen Dak Dakina Dakoda Dakota Dakotas Dakota's Dal Dalatika Dalaunay Dale Dali Dalia Dalila Dallas Dalle Dally Dalny Dalong Dalroas Dalton Dalush Dalushious Daly dam Dama Damadedos damage Damaged Damages Damaris dame Damelo dames Dame's DAMES DamesHer Damian Damien damion damn damned Damnit Damo Damoika Damon Damon? Damone Damon's D'Amour Damp damsel Damsels Dan Dana Danae Danali Danarama Danas Dana's Danaya Dancando dance dance? DanceAnd danced Dancefloor Dance-Off dancer dancer? dancers Dancer's dances Dances1 Dances2 Danceslotion Dancespread Dancestrip1 Dancestrip2 Dancey Dancin dancing Dancingjeans Dancingstrip1 Dancingstrip2 Dancoj dandelion Dandy Dane Dane's Dang D'Angelo danger Danger’s Dangerbooty dangerous Dangerously Dangers Danger's Danger-Veruca Dangle Dangling Dani Dania Danica Danicas Daniel Daniela Daniela's Daniele Daniella Daniellas Danielle Danielle's Danielly Daniels Danielsova Dani-Make loveing-Daniels Danii Danika Danis Dani's Dank Danlee Danleku Danna Danni Danniels Danni's Danny DannyBoy Dannys Danny's Dans Dan's Danseuse Dansing dante Dantes Dantric danube Dany Dany's Daor Daor's dap DAP? DAP_make loveing DAP+P Dap+Cat DAP+Vag DAP3on1 DAPArwen's DAP-certified DAP'd dap'ed Daped DAP'ed DAP'edSZ618 Dapes DAP-Fest DAPGAPES Daphne Daphnee Daphne's Daphnie Daphynne Dap'n'Roses dapped dappes D'Apres d'apres-midi DAPs DAPV Daquiri Dara Dara's Darby Darby's Darcee's Darci Darcia Darcia's Darcie Darcie's DArclyte Darcy dare Dare? Dare” dared daredevil dares Dare's Daria Darian Daria's Dariel Darien Darin Darina daring Darius Darjaneling dark Dark? Darkangel Darker darkest dark-haired Darkholme Darkko Darkko's Darkly darkness Darko Darkroom Dark's Darkside DarkX Darla Darlene Darlin Darlin' darling darlings darling's Darlings Darling's darn Darrel Darren Darryl Darts Darya Daryl Daryl's daryn Daryna Darzsarika Das dasani Dash Dash’s Dasha Dasha's Dashing Dashuka Dasi Dasia's Dasilva DASP Dbooty Dbootyis Dasy Dat date Dates Date's Date-Swap dating Datse D'Atteinte daughter daughter? daughter’s Daughter-in-Law Daughterly Daughter-Mom daughter's Daughters Daughter's Daugther Daugther's Daunting Dava Davani Dave Davey Davia David Davida David's Davie Davila D'Avila D'Avilla Davina Davina's Davinci Davis Davis-Fitt Davon DAVP Davy Dawgzzz Dawkins dawn Dawning Dawns Dawn's Dawson Dax day Day' DAY day? Daya Daya’s dayAftercare Dayana Dayanara Dayanne daybed Daybeddong Daybedcat Daybedrub Daybedspread Daybedtoy Daybreak Daycare Daydream daydreamer daydreaming daydreams Daye Daylene Daylene's daylight daylights Day-Live DayLIVE DayLynn Dayna Dayne Day-off days Day's Daysie Daysy Daytime Dayton Daytona Dayzjha Daza Daze Dazia D'azia Dazzle Dazzler Dazzles Dazzling DBJ D-Cup D-cups Dd D'd DD DD-Cup DDD DDDs DDD's DDF DDs DD's DD'sWill de De4th Dea Deacon Deacon’s dead deadbeat Deadliest Deadline Deadly Deadpool Deadra Deaf Deaky deal Deal? dealer Dealers Dealer's Dealing deals Deal's dealt deams dean Deana DeAngelo Deanna Dean-riding Dean's deap dear Dear? dearest Dearly Dearmond DeArmondImmobile Dearmond's DeArmondSpread DeArmondTrapped death Deauxma Deb Debacle Debajo Debased Debasement Debate Debater Debaters Debauchee Debaucherous debauchery Debauching Debbie Debbie's Debby De'Bella Debello Debi Debilitating Debora Deborah Debowe Debra Debs debt debtor Debts debut debutant debutante Debutantes Debuting debuts DEBUMeam Decade decadence Decadency Decadent Decades DeCapri DeCarlo Deceit Deceitful deceive Deceived deceiving December Decembers December's Decency Deceptacon Deception Deceptions Deceptive Deceptively Decesions decide decided decides decieving decimated Decimation Decimator decision Decisione Decisions Decisive Deck Decker Declan Declaration Declare Decline Decluttering Deco Deco' Decollaring Decompress Deconstructing De-constructing Decorate decorating Decoration Decorative Decorator Decoy Ded Dede Dede's dedicated DEDOS dee Dee-cent Deed DE'ed Deedee deeds Deejay Deel Deelicious Deelight Dee-manding Deen Deena Deeni Deens Deen's deep Dee-P DEEP Deep? DeepAn Deep-Cleavaged Deep-Johnsoned Deepen deeper Deepest Deepfinger deep-fingered Deep-fingering Deepfingers Deep-Ho deeply Deeporgasm Dee-posit DeepPrison Deepthoat Deepthorats deepthroat deep-throat Deepthroat Deep-throat DeepThroat Deep-Throat DEEPTHROAT deepthroated deep-throated Deepthroated Deep-Throated deepthroater Deep-Throater Deep-Throater's Deepthroatfrenzy deepthroating Deep-throating Deepthroating's deep-throatist deepthroats deep-throats Deepthroats Deep-Throats Deep-Woods Deepy Deer Dees Dee's Deez Def Defaced defeat defeated Defective DeFeet Defeets Defendi defense Defenseless Defete Defiance defiant Defied Defies defiled Defilement Defiling Define Defined Defining definit definitely definition Definitive deflorated deflorating defloration deflorators deflower deflowered de-flowered Deflowered defloweret Deflowering De-Flowering deflowers Defrancesca Defrancesco defy Defying DeGarden degradation Degrade Degraded Degrades degrading Degre degree degrees Degrey Degrey? DeGreyOrgasmal Degustation Degustia Dei Deicide's Deirdre Deithe deja Deja-Vu DejaWhooooooohooooo Dejeuner DeJour Dekoian del Delacroix Delacuze Delage Delane Delaney Delano Delano's Delatori Delatossa Delatosso Delatta Delaunay Delaure Delaware delay Delayed Delbenai Delcroix Dele delectable Delectably Delectation Delegate DeLeon Delete Deleted Deli Delia Deliah Deliberately delicacy delicate Delicately Delicato Delice Delice's Deliciosa delicious Deliciously deliciousness deligh delight De-Light Delighted delightful Delightfully delights Delight's Delila Delilah Delilah's Delinquent Delinquents Delirious Deliriously Delite deliver delivered Deliveries Delivering delivers delivery Delivery Guy Deliveryman Della Dellai Dellai's Della's Delmar Delmonico Delor Delotta Delphi Delphina Delphine Delprado Delray Delta Deltas Delta's Deltore Deltore's Deluca Deluge Delush's Delusion Delusional Delusions Delux deluxe Deluxe's demand demanded Demanding demands Demarco Demeaning Demeanor Demellza Demented Demento Demer DeMer's demi Demia Demida Demi's demise Demitri Demitri's Demmy Demmy's Demo Demoed demolish demolished Demolishers Demolishing Demolition demon Demon? Demone Demonia DeMonia's Demonic demons Demon's demonstrate demonstrates Demonstrating demonstration DeMoore Demore Demoted Demotion Demure Demystified den Dena Denae Denae's Dendro Deni denial Denice Denicek's denied Deniese denim Denis Denisa Denise Denise's Deniska Denisse Deniz Denmark Denni Dennis Denta dental Dentata dentist dentist? Dentists Denver Denvile Denville Denx Deny Denyle De'Nyle Denys Deomm department Departmental Departure depend Dependance Dependent Deployment deported Deportment deposit Depositing Deposits Depot depraved Depraving Depravity depressed depression Deprivation Deprived depth depths Deputy Der Dera Dera's derby Dereck Derek Derek's Derelict Dereven Derick Deris Dermott De-Robed Deron Derrek Derrick Derricks Derriere Derrieres Dert-eeh DeRusky Derza Derza's Des Desanges Desani Desantis Descending Descent desciption Describe describes description Desensitized Deserae Deseray desert Deserted Deserts deserve deserved deserves deserving Deshiri Desi Desia Design Designated Designed designer designers Designing desirable Desirae desire desired desiree Desiree's Desireful desires Desiring Desirous Desist desk Deskbanana Deskfinger Deskfinger2 Deskfingers Deskfingers2 Deskcat Desktease Desktop Deskvibe Desmond Desolate Desoriente Desorra DeSouza desperate desperately Desperation Despertar Despídeme Despiértame despite Desrey dessert Dessert? Desserts dessous destination destinations destinaton destined Destinee destiny destiny's Desto destoyed de-stress Destress De-Stressers destroy destroyed Destroyer Destroyers destroying destroys destrozado destructed destruction destructive Destruir detachable Detached Detail Detailing Details Detained Detalla detective detective's Detectives Detengan detension detention determinationSaffron determine Determined Determining Dethrone Dethroned Detonation detour Detours Dettwiller Deuce Deuces Deus Deutera Deutsche Deutschland Deux Deva DeVale devastated Devastates devastating Devastatingly Devastation Devastator Devaun Development Deven Deveraux Devereaux Devestation Devi Deviance deviant Deviants device Devicebondage DeviceBondagecom devices Devide devil Devil’s Deviled devilish Devilish-suckening Deville Deville's Devillish devils Devil's Devin devine DE-VINE Devine's Devinn Devious Devirginization Devirginize Devirginized de-virginizes Devirginizes Devis DeVita Devo Devoe Devon DevonLee Devons Devon's Devora devoted Devotes devotion Devour Devoured Devourer devouring devours Devyn Devyn's dew Dewey Dewy Dex Dexter Dexterity Dexter's Dey Deyny Deytrois Dez Dezeray dharma's Dhoe di Dia Día Diabla Diablita Diablo Diabolic Diabolical diabolically Diabolique Diagnos-booty diagnose Diagnosis Diagnosis; Diairies Diakosmo Dial Dial-A Dial-a-Date Dialing Dialogue Diamanti diamond Diamond; Diamond-Day diamonds Diamond's DiamondThe Diana Diana's Diane Diangelo Dianna Dianne diapers Diaries diary DiaryI diary's Dias Diastasi Diaz Diazz Dibbs Dibs Dic Dicapri Dicaprio Dice johnson D-I-C-K johnson? johnson’s Johnsonalicious Johnsonam Johnsonamentary johnsonconnection johnson-down Johnsondown Johnson-down johnsoned Johnsoned-down Johnsonen Johnsonens Johnsonerators Johnsonerdown JohnsonFan JohnsonFriction Johnsonhancement Johnsonhead Johnsonie johnsonin johnsonin' Johnsonin johnsoning johnsonlashing Johnsonlicious Johnsonline Johnsonlish JOHNSONlivery Johnsonly Johnsonmas Johnsonmatized Johnsonness Johnsonnosis Johnson-O-Gram Johnsonorette Johnsonotine Johnson-Riding Johnsonrupting johnsons Johnson's JOHNSONS johnsons? Johnsonsmissal Johnsonspection Johnsonstraction Johnson-stributor Johnsonstruction Johnsonsturbance Johnsonsuckers Johnsonsucking Johnson-Swingin johnsontating Johnsontation Johnson-tation johnson-teasing Johnsontection JOHNSONtector Johnsontention Johnsontrap Johnsonus johnsony Johnsony's Dicopu dictates Dictating Dictation Dictator did did? Didas Diddle Diddled Diddler Diddles Diddling DiDevi Didgeridoo Didi didn didn’ didn't Didnt Didn't Dido die Died Diego Diego's Diem Diem's Dies Diesel Diesel's diet Dietrich Diezel dif Difeo differe difference Difference? Differences different Difficult Difficulties Diffusion Difrancesco DIFS dig Digalyn Digest digger diggers diggin Digging Digital digits Digivanni Dignity Digression digs Dii Dijana Diji Dike Dikes Dikki Dila Dilan banana banana? banana_drone Banana1 Banana2 Bananachair Bananacouch Bananacream bananacycle Bananacycles Bananadiva Bananaer Bananaers Bananafitness Bananaforme banana-make love Bananamake love Bananafun Bananafun1 Bananafun2 Bananain' bananaing Bananalaundry Bananalove Bananaplay Banana-pleasing Banana-riding banana-rific bananas banana's Bananas Banana's Bananasit Bananasqueeze Bananateen Bananatime1 Bananatoy Bananatwo Bananavibe Bananazer dildum Dilection Dilemma Dilf DILFs Diligence Diligenis Diligent diligently Dill dillan Dillion Dillions Dillion's Dillon Dillon's Dilo Diloa Dim Dima Dimarco Dimarco's DiMarcoThe Dimas dime dimension Dimensions Dimepiece dimes Dimez Dimitri Dimitri's Dimitry Dimond Dimonty Dimpled dimples Dina Dinae Dinamico Dinara Dine Dined diner Dinero dines ding Dingalinger Dingo Dingy Dinida dining Diningroom Diningroomdelight Diniz Dinker dinner dinner? DinnerFisting DinnerReminding Dinners Dinner's DinnerTable Dino Dino-Sized Dinov Dionne Dionne's Dionta Dionysian Dior Diora Diore Diore's Dior-itized Dior's Diosa dip Diploma Diplomacy Diplomatic Diplomat's dipped Dipper dippers Dipper's Dippied dippin dippin' Dippin dipping dips Diraya Dire direct directed directing direction directions directly director directors Director's Directs Dirk dirrrrty Dirrrty Dirrty dirt Dirtier dirtiest dirtty dirty Dirtyfinger DirtyHuge Dirty-minded Dirty-Mouthed Dirty-talking Dis Dis? disadvantage Disagree disappe disappear disappearance disappearing DISAPPEARS disappoint Disappointed Disappointing Disarm Disarmed Disaster Disasters disc Discerning Discharged disciple Disciplina Disciplinarian Disciplinary discipline disciplined disciplines Disciplining disclosure Dis-Clothe-Her disco Disconnected Discotheque discount discounts Discourse discover discovered discoveries discovering discovers discovery Discreet Discretion Disculpa Discusses discussing discussion Discussions disease Disfrutar disgrace disgraced Disgraceful Disgraces Disgruntled Disguise Disguised Disguises Disgusting dish Dishes Dishing Dishonest Dishonor Dishwasher Dishwashing dislikes Dismissal Disney Disneyland Disobedience disobedient Disobey Disobeying disobeys Disorder Disorderly Disparo Dispatch Dispenser Dispenses Displacement display Displayed displaying displays Displease Displeasure Disposa Disposal Disposition Dispuesto Dispute Disqualified Disquiet Disrespect Disrespectful Disrespecting Disrobe Disrobed Disrobing Disruption Disruptive Dissapoints Dissatisfied Disseminate Disses Dissolute distance Distancing Distant Distensione Distorted Distorter distrac Distract distracted Distracting distractingll distraction distractions distracts distraught Distress Distressed District Disturb Disturbance Disturbang Disturbed Disturbing Dita ditch ditched ditches Ditching Ditona Dittle DITTO Ditty diva Divan Divano divas Diva's dive Divers Diverse Diversion Diversionary diversions Diversity Divertida dives divide Divided Divination divine Divineas Divinely Divine's diving Divinis Divinity Divino divisionVeteran Divo divorce Divorcé Divorce? Divorce-bound divorced divorcee Divorcée Divorcees Divsis Dix Dixias Dixie Dixie’s Dixie's Dixon Dixon's DIY Diya Diyana Dizel Dizzie dizzy Dizzying Dj Djefucmi Djeniffer Djiana DJing Djpink Djcat DJ's DJThe Djulianaa DL Dleon Dmitry DMs DMV DNA Dnay DNothing do Do? Doan Doans Doble Dobrila doc Doc? Doc’s Docciare Docciatura Docile dock Dockside Dockworker Dockworkers docs doc's Docs Doc's doctor doctor? Doctor’s Doctorate Doctoring doctors doctor's Doctors Doctor's Docu-Film Dodds Dodge Dodgeball dodgeballs doe Doe-eyed does doesn doesn' doesn’t doesnt doesn't Doesnt Doesn't d'Oeuvres dog Dog’s Dogboy dogged doggie doggie-style Doggin' Dogging doggy doggystyle doggy-style Doggystyle Doggy-style dogs Dog's Dogsitter Dogsitting Dohmer doi doin doing Doing? doing?? DoIntroducing doitforyourbrother Do-It-Yourselfer Dojo Dolce Dolce's Dolcezza Dolci Dole doles Dolf doll dollar Dollars Dollas Dollce dolled dollface Doll-faced Dollhouse dollie Dollification Dolling Dollop dolls doll's Dolls Doll's dolly Dolly's Dollywood Dollz Dolore Dolores dolphin Dolphins Dolphintoy D'olya dom Domain Domanda Domark Dombootytic DomCon Dome Domenica Domenique domestic Dom-estic Domesticated domina dominachick Dominance dominance100% dominant dominant? Dominante dominas Domina's Dominasian Dominasians Dominata dominate dominated DominateHer dominates dominating domination Domination… dominationand Dominative dominator Dominators Dominatriqszzzz dominatrix Dominca Domingo Dominic Dominica Dominican Dominicana Dominicans Dominica's Dominick Dominik Dominika Dominikka Dominique Dominno Dominno's Domino Dominoes Domino's Dominus domme Dommed Domme-ination dommes Domme's DOMMING doms Dom's don don?t don’t Dona Donald Dona's Donate donated Donates Donation donations done done? Donell doneYou dong Donga Dongalicious Donged Donger Dongfun Donghole dongin Donging Dongky Donglove Dongs Dong's Dongzilla donjon Donk Donk-a-Donk donkey Donkeys donna DonnaBell Donnas Donna's DonnaTrapped DonnaWorld Donnie Donny Donor Donors Donovan Don's dont don't Dont Don't Don't? Dontcha Don'ts Donut Donuts Doo Doodle Doodles Doodling Doom Doomsday door doorbooty Doorbell doorBrutal Doorfingers1 Doorfingers2 Doorman Doorman's Doormat doors Door's doorstep Door-to-Door doorway Doorwayorgasm Dopamine Doppelbanger Doppelmake loveer Doppelganger Dora Dora's Dore Doreen Dorev Dori Doria Dorian Dorina Dorina’s Doris Doris's dork Dorka Dorks Dorky Dorky's dorm dorm_party_zone dormitory Dormmates dorms Dornelles Dorothe Dorothea Dorothy Dorothy's Dors Dory Dos Dosage dose Doses Do-Si-Do dosis Dossier Dot Dote Dotfingers dots dotted dotti doube double Double' DOUBLE Double_Fisting double_or_nothing Double-Anal Doubleanalized Doubleanalyzed DoubleB Double-Blow Double-Booked Double-busted Doublemember Double-Membered Double-Cream Doublecross Doubled Double-D Double-D`s Double-Johnson Double-Johnsoning Double-digged Doublebanana Double-Dip Double-dipped Double-dipping doubledong double-dong Doubledong Double-Dong Double-Dong's double-drilled Double-drilling Double-D's double-ended Doubleended Doubleended2 double-ender Doubleface Doublefinger DoubleFist double-make love doublemake loveed double-make loveed Doublemake loveed Double-make loveed double-make loveing Doublemake loveing Double-make loveing Double-Handed Double-headed Doubleheader Double-Loving Double-Newbie-Make loveing-Machines-Robot-Ram-Session Double-Nipple Double-Nutted Double-O-Sexy double-penetrated Double-Penetrates Double-Penetration double-pleasing Doublepleasure Double-Rainbow doubles Double's Double-Shift Double-sided Double-squirting Doublesse doublestuffed Double-stuffed double-suck double-team double-teamed Doubleteamed Double-teamed Double-Teaming DoubleTrouble Double-Vag Double-Vaginal doublicious Doubling doubt Doubtfire Doubtmake loveer doubtful Douce Doucement Douceur douche Douchebag Douchy Doug dough doughnut Doughnuts Doughy Dougie Douglas Douple doused Dousing Doux Dova Dova’s Dove Dovemake loveing Dover Doves Dovey dow Dowdy down Down For down? Downblouse DownBoss Downhill Down-Home Downing downloadable Down'n Downpour Downright Downs Downs' Downsizing downstairs downstairs? Downtime Downton downtown Downward Dox; doxies doxy Dozed Dozen Dozing dp DP' DP_ing Dp’d DP’ed DP+AFist DP+DAP DP5145 DP-Curious Dp'd DPd DP'd dped dp'ed Dped Dp'ed DPed DP'ed DP'edRS10 Dp'ing DPleased DP-Lover DPP DPP+ANAL dp's DPs DP's Dr Dracula's Draft drag Dragana dragged Dragon Dragon0-0The Dragon2-0 Dragon2-0vsTara Dragon5-2 DRAGONIntroducing Dragonlilly Dragonlily DragonLilyHard DragonLily's Dragons Dragon's Dragons0-0 Dragons1-0 DragonsBrutal Dragonvs Drag-Race drags drain drained Drainer Drainers draining drains drake drakes drake's Drakes Drake's drama Draning Drapery Drastic Draven draw Drawer drawers drawing drawn draws DrDeepthroat dre Drea Dread Dread's dream dream? Dreamboat Dreamcatcher dreamed dreamer Dreamera dreamers Dreammake love Dreamgasm dreamgirl Dreamgirls Dreamin Dreamin' dreaming dreamland Dreamquest dreams Dream's DREAMS dreamsicle Dreamt Dreamtime Dreamwork dreamworld dreamy Dreamz Dredd Dredd’s Dredd's drench drenched Drenches Dresden Dresden's dress Dress? Dressbanana1 Dressbanana2 dressed dresser Dresserkitty dresses Dressfingers dressing Dressingroom Dressmaker Dressplay Dresstrip dress-up Dressup Dress-Up Dressupfun Drew Drey dribble Drielly Dries Drift Drifter drifting drill Drill-Do DRILLDO drilled Drillers Drillin drilling Drillings drills drill-sergeant Drimla drink drinker drinkershe Drinkfinger drinking drinks drip Drippin dripping drips drive Drive-By Drive-in driven driver drivers Driver's drives driveway Drivin driving Drizzle Drizzled drizzling Drizzy Droids drone Droned Drone-Hunter drool Drool-Filled drooling Drools Droolz drop Droplets Drop-off Dropout Dropouts dropped Dropper Droppin Droppin' dropping Droppings drops Drought drove drown Drowned Drowning Drowns Drozd DrPresley's Dru Drug Drugs Druid Druid0-0 Drum Drum’n’Bbooty Drumbeat Drummer Drumming Drumond Drums Drumstick Drunk Drunken Druuna dry dryer Dryerkitty Drying Drywall Ds D's DSD DSL DSLs DSO Dt DTAF D-tention DTF du dual Duality Duarte Duarte's Duarth dub Duba Dubai Dubai's Dubble Dubois Dubrova's DUBUI Ducati Ducati's Ducatti Ducha Duch-BOOTY duchess duck Duckhead Duckling ducky Duct duct-taped Duda dude dude’s dudes dude's Dudes Dude's Duds Due Duece Duel Dueling Duenya Dues duet Duett Duette Duex Dugs Duh DUI DuJour duke dukes Dukka Dulce Dulcinea dull Dull23 Dullkight Dulsinesa Dulsineya Dumaire dumb Dumballs Dumbbooty Dumbbell Dumbmake love Dummies Dummy dump dumped Dumper Dumping Dumpling dumps Dumpster Dun Duname Duncan Dune dungeon Dungeons Dunia DUnit Dunk dunked Dunkin Dunks Dunn Dunya duo duo's Duped Duper Duplicity Duplika Dupree Dupri Dupuis Dur Dura Duran Duress Durganova Durham duri during Duro Durose Durring Dushenka Dusk Dust Dusted Duster Dustin Dusting Dusts dusty Dusya Dutch duties Dutiful Dutra Dutxa duty Duval Duvall Duvalle DuvalleLicious Duvalle's Duvet Duvets Duvy Duvy's Duxe Duxe's Duxxx Duz DV Dv8 dvd DVP DVP+A DVP'd DVPdouble DVP'ed Dwarf Dweeb's Dwellers Dwight D'ya Dyanna Dye Dyeing Dyer Dyer's dying dyke Dykeachusetts Dyked Dykenamic dykes Dyke-town Dylan Dylan-Day Dylann Dylann's Dylan's Dymes dynamic Dynamics dynamite Dynamo Dynam-O Dynasty Dynie Dynomite DYNO-mite Dyogrammaton Dysmake lovetional dysfunction Dystopian dystopic Dz e E1 E10 E11 E12 E13 E14 E15 E16 E17 E18 E19 E2 E20 E21 E3 E4 E5 E6 E7 E8 E9 ea eac each eachother Eadie eager eagerly Eagerness eagle Ear Ear? earlier early Early? earn earned earning earns earrings ears earth Earth? Earthling earthly Earthquake Earthy ease eases Easier Easiest easily Easing east East2 East3 Eastasi Easte easter eastern Easter's Easton Eastwick Eastwood easy easy-going Easy-Peasy eat Eat? Eate eaten eater Eaters eatery eatin eating eatout eats eatter Eaves eavesdropping Eaze Ebba Ebenezer Ebonita's ebony ebony's Ebonys Ebony's eMelonStore Ebulient Ec Eccentric Echo Echoes Eclectic Ecletic Eclipse Economics Ecos Ecstacy ecstasy ecstatic Ectasy E-cup ed edating Eddi Eddie Eddie? Eddie's Eddition Eddy eden Eden’s Eden's edge edged Edgemaster Edger Edges Edge's edging Edgy edible Ediction Edin edina Edi-Quit? Edison Edit Edita edited Edith edition Editor edits Edi-Whore Edo Eduarda Educando Educate Educated Educates Educating education educational Educator Edward Edwards Edwige Edyn EE EEE Eek Eenie Eff Effect Effective Effects Effervescent efficient Effie Effie-cient Effie's effort Effortless Effortlessly efforts Effy Efula egg eggplant Eggroll eggs Eggstravaganza Egless Egnatia Ego Egoias Egos Egotistical Egypt Egyptian Eh eh? Ehf-Eye-Ehn-Eee Ehh ei Eidyia Eiffel eight eighteen Eighteenth eighteen-year-old Eighth Eighties EIGHTNew Eileen Eilish Eilsa Eimi Ein eine Einve Eisley either Eivissa ejaculate Ejaculates Ejaculating Ejaculation Ejaculator Ekaterina Ekina el Ela elaborately Elaina Elaina's Elaine Elana Elania Elasias Elastic Elastik Elation Elatis Elaura Elber Elbow elbows Elcida Elder elderly Elders Eldery Ele Eleanor Eleanor's Elecro Elecrto-DP Elected Election Electo Electra Electre electric Electrical Electrically Electric-haired electrician electricians electricity electrified electrifying electro ElectroAnal Electro-Anally-Destroyed Electro-Anal-Slut Electro-Ballerina Electro-BDSM Electro-bootcamp ElectroBoy Electro-Christmas Electrocise Electromember Electro-Control Electrocuted electrocutes electro-domination Electrodomination Electro-Fem-Make loveing Electro-Fisted Electromake love Electromake loveed Electro-make loveed ELECTROMAKE LOVEED Electro-Make love-Fest Electro-Make loveing Electromake loves Electrogasms Electro-hazed Electro-Lesbian Electro-Lez Electro-Limits Electronic Electro-Orgasms Electro-Painslut Electro-Pet ElectroPlug Electropunished electrosex Electro-sex electrosexed Electrosexes Electroshock Electroslave Electro-Slave electroslut Electrosluts Electroslutscom electro-stim Electro-strap-on Electro-Submissive Electro-torture Electrsluts Elegance Elegancia elegant Elegantly Elektra Elektrafying Element Elemental elementary Elementas elements Elen Elena Elena's Elenora Elen's Eleonora elephant Elesimin Elevage Elevate Elevated Elevated' elevator Eleven Eleviax Elexis Elextia Elextrosexes elf Elfie elfs elf's Elga Elha Eli Eliana Elias Elie Elijah elimination eliminationLoser Elin Elina Elindi Elinor Elinor's Elios Elis Elisa Elisabet Elisabeth Elisabetta Elisaveta Elise Elisha Elishka Eliska Eliska's Elisse elite Elites Elium elixir Eliza elizabeth Eliza's Elizaveta Elke Ella Ella's Elle Elleha Ellen Ellena Elleny Ellery Elles Elle's Elley Elli Ellia Ellie Ellie's Ellin Ellina Ellington Ellinis Elliot Elliott Elliptic Elliptical Ellis Ellison Ellwood Elly Elmerita Elmeritta Elmer's Elnara Eloa Elojianias Elona Elouisa EL-O-V-E Elsa else elsethis Elson Elton Elusive elven Elves Elvgren Elvira Elvis Elya Elycia Elysa Elyse Elysee Elysium em Em’ Ema Email E-mail emails E-male Emanuel Emanuelle Emasculate Emasculation Embace Embargo Embark Embarking Embarks Embarrbooty embarrbootyed Embarrbootying Ember Embers Ember's Embezzlement Embezzler Emblem Embrace Embraced Embraces Embracing Emeche Emelie eMemories Emerald Emerald's Emerge emergency Emerode Emerson Emery Emi Emiko Emilee Emilee's Emili Emilia Emiliana Emilianna Emilianna's Emilia's Emilie Emilio Emily Emily's Emino Eminse Emiri's Emitia Emjay Emma Emma’s Emmanuelle Emma's Emmett Emmi Emmily Emmy Emo Emoke Emoke's Emon Emori Emory Emosexual emotion Emotional emotionally emotions Empacando Empapada Empera Emphasis Emphatic Empire Empiria employee employees Employee's employer Employers Employment Emporium Empowered Empowering Empowerment Empress empties Emptiness empty EMS EMT Emuna Emy Emylia Emy's en Ena Enact enamorada Enamored Enamour Enamoured enc Encanta Encased Encasement enchant enchanted Enchanter Enchanting Enchantress Enchantresses Enchants enco encore encounter encounters encouragement Encourages Encouragment Encuentra Encyclezpedia end Endearment Endeavor Endeavors ended ender ending Ending? ending” endings endless endlessly Endorphin ends Endurance endure endures enduring Enebadeyhea enema enemas Enemies Enemy Energea Energetic Energize Energizer energy Enfermera Enfermere Enfoque enforcement Enforcer engage engaged Engaged? Engagement engages Engaging Engel Engi engine Engineering Engineers engines England England's Englisch? english English? Englishman Englishmen engulf Engulfs enhanced enhancement Enhancer Enigma Enigmatic Eniko enjoy Enjoyably enjoyed Enjoyin enjoying enjoyment enjoys enjoy's Enjoys Enkalis enlarged Enlargement Enlighten Enlightenment Enn Ennie Enny Enojada Enolla Enorme Enormes enormous Enormously enoug enough enough? Enough's Enquire Enrica Enrich Enrique Ensada Enséñame Enseñandole Enslaved Enslavement enslaves Enslaving Ensnare Ensnared Ensnares Ensues ensure ensures entanglement enter entered entering Enterprise enters entertaiment entertain entertained entertaining entertainment entertains Enthrall Enthralling enthusiasm enthusibootytic enthusiast enthusiastic Enthusiastically Enthusiasts Entice Enticed Enticement Enticers Entices Enticing entire Encanled Entourage entrance en-trance Entrance Entranced entre Entree Entrenamiento entrepreneur Entrepreneurial Entretenido Entrevistando Entries Entry Entwine Entwined Envidia Envious envy Envy-Me Enyjoing Enyoj Enza Enzo Eolika Ep ep1 Ep-1 ep10 ep11 ep12 ep13 ep14 ep15 ep16 ep17 ep18 ep2 Ep-2 ep3 Ep-3 ep4 Ep-4 ep5 Ep-5 ep6 Ep-6 ep7 Ep-7 ep8 Ep-8 ep9 Ephigenia Ephrasi epic Épico Epicurean's Epilogue Epiphany episode Episodes epitome Epoch Epps eps Epulari equal equally equals Equation Equestrian equipment Equipped er era Eradius eras Erase Érase Erasers Eraxmus Erect erected erectile erection Erection-Maker erections Ereti Ergonomic Erian Eric erica Erica's Erick Ericka Erickson EricksonThe Eric's Erik erika Erikas Erika's Erike Erin Erina Erina's Erinn Erin's Ernesta Ernie ero-action Erodict Eroge Erogenous Eroica Eromeni Eros Erotias erotic erotica EroticaX eroticism Eroticismic Erotico Erotics Erotik Erotique Erotisi Erotsis Erox Errand errands Errin Error Ertisi Erupt eruption eruptions Erupts Erzsebet es Escaladies Escalated Escalating Escalayer Escándalo escapade escapades escape Escaped Escapee escapes Escaping Escapism Escapist Escobar Escondidas escort Escort? Escorted Escorting Escorts Escort's Escotes escrewed ese Esenia Esida Esis Eskade Eskimo ESL Esm Esme Esmeralda Esmerelda Esmi ESOL Esoteric Espana Espanol Espanola Especial especially Esperanse's Esperanza Esperar Espere Esperenza espionage Esposa Esposas esposo Esprit Espuma Espume essay essence Essential essentials Essentias Essex Essy esta está Estacionamiento Estacy estate Estates Esteban Esteem Estella Estelle Ester Esther E-Stim Estírame Es-tiremos Esto Estrada Estranged estrella Estremis Estrés Estreya Estuary Estudiante Estudios Esu Eszter Et Etain Etalia's Etarot etc ETENDO Eternal Eternalis Eternally Eternian Eternitas Eternity Eth Ethan Ether Ethereal Ethic Ethics Ethnic Ethni-city Ethno Etiquette Etna Etoile Etretat Etrev Ets Eufrat Eufrats Eufrat's Eugene Eugenia Eugenya Eujenya Eunique Euphoria Euphoric Eurasian euro Eurobabe Euro-Filth Euro-Milf Europe European european_teen_hardcore Europeans Europe's Euro-Punk Euros Euros? eurosex euro-slut Euroslut Euro-Teen Euroticas Eutihia Eutoco ev eva Eva Eva Evah Evaluate Evaluation Evaluations Evalution Evan Evangeline Evangelion Evanni Evans Evan's Eva's eve Eve? Evelin Evelina Evelina's Eveline Eveline's Eveling Evelin's Evelyn Evelyn’s Evelyne Evelyne's Evelynn Evelyn's even evening evenings Evening's Evens Event Eventful Eventide EventPart events eventThe eventVendetta ever ever? ever-atingle Everett Everett's Everglade Everglades Everhard Everheart ever-horny Ever-Hungry Ever-Incredible Everitts Everlasting Everlating Everlina Everly Evermoore Evermore everrrr Everson every 'Every EVERY everybody Everybody's everyday everyone Everyone's everything everything? EverythingBum Everythings Everything's everythingtwo Everyting Everyway everywhere Everywhere; Eves Eve's Evesa Evette Evey Evgen Evgenia Evgeniy Evgeniya Evi Evia Evianis Eviction Evidence Evie Evie Olson Evie's evil Eviliax Evils Evilution Evilyn Evins Evita Evol evolution Evolved Evolving Evy Ewan Ewig ex Ex_girlfriends Ex_Husband's exact exactly Exacts Exage exam Exam-blue Exámenes examination Examine examined examiner Examiners examines examining Exam-muscles examp Example exams Ex-Babysitter Ex-Banker's Ex-BF's ex-bosses Ex-Boyfriend's Excavation Excavations Excavator Excellence excellent Except Exception Exceptional excercise Excerpt Excess Excessive exchange exchanges Exchanging Ex-Cheerleader exci Excitable excitation excite excited excited? excitement excites exciting Exclusiv exclusive exclusively Exclusives Ex-con ex-cons excruciating excursion excursions excuse Excuses execution Executions executive exercise Exercise1 Exercise2 Exercisebike Exercisefun Exercisegirl exercises exercises? Exercisetoy Exercisevibe Exercising Exertion Exes Ex-Gay Exgf Ex-Girl ex-girlfriend Ex-girlfriend's ex-gymnast Exhale exhausted Exhausting exhaustion Exhausts Exhibit exhibition exhibitionism exhibitionist exhibitionists Exhilarated Exhilarating Exhilaration Ex-Housewife ex-husband Exile Exiled Exilis Exist Exit Ex-lovers exlusive Ex-Machina Ex-Marine Ex-Marines Ex-Military Ex-Model exo Exonaration Exorcise Exorcising exorcism exotic Exotica Exotics Exoticx Exotix expand Expander Expanding expands expect Expect? Expectarea expectation expectations expected expecting EXPECTO expedition expeditions Expel Expelled Expensive experien experience experience? experienceAbused experienced experienceFantasy experiences experiencing experiment Experimental experimentation Experimentations Experimented Experimenting ExperimentLIVE experiments expert expertise expertly experts expert's Expert-Tease Expiation expirienced explain Explains explicit Explode exploder explodes Exploding exploit exploited Exploiting exploits exploration explorations Explorative Exploratory explore explored Explorer explorers explores Explore's exploring explosion Explosions explosive Explosively expo Ex-Porn Export Exposé expose exposed exposedthis exposes ExpoSexo exposing Exposition exposure Express expressing Expression Expressionist expulsion exquisite Ex's Ex-Shame Ex-stripper Ex-Suegra extase Extasi Extasis extasy Extend Extended Extension extensions Extorted Extortion extra extra? Extract extracted Extracting Extraction Extraction' Extractor Extracts Extracurricular Extracurriculars Extradition Extra-hot Extra-Jordaniary Extramarital Extra-Marital Extraño extraordinair extraordinaire Extraordinar Extraordinarie Extraordinary extras Extravagant extravaganza Extrema extreme extremely Extremes Extremity Exubera Exuberant Exudes Exus Ex-virgin Ex-Wife Ex-Wife's Ex-Wives Exx eXXXam Exxxceptions eXXXchange Exxxciting Exxxotic Exxxotica eXXXplorations eXXXplosive Exxxpress Exxxta eXXXtra Exxxtrasmall eXXXtravaganza eye Eyeball eyeballs Eyebrow eye-candy Eyecandy Eye-candy eye-catching eyed Eyeful Eyegasm eyeglbootyes eyelash Eyeliner Eye-Opening eyes Eyes? eyesgolden eyesight Eye-to-eye Eye-watering Ezhen Ezra Ezster f F*ck F@k fa Fab Fabel Fabiane Fabio Fabiola Fable Fabled Fabric Fabrizio Fabula fabulous Faby Facade Faccia face face? Face’s Faceturkey faced Face-Down Face-Down-Booty-Up Face-First facemake love Face-make love facemake loveed face-make loveed Facemake loveed Face-make loveed FaceMake loveed Face-Make loveed face-make loveing Facemake loveing Face-make loveing faceful Facella Facemuck face-painted faces Face-Saturating Face-sit face-sitter Facesitter Face-sitter facesitting Face-sitting Face-Slapping face-smothering Face-Soaking face-to-cat facial Facial'd facialed Facialised facialized Facialized' facials FACIL Facilitator Facility facing fact factor factory Factory's Facts Fad Fade Fadeny Fades Fado Fae Faeries Faery Fae's Fahrenheit fail Failed Failing fails failure Failures Faina faint fair Fairchild Faire Fairer fairest fairie fairies Fair-Weathered fairy fairytale faith faithful Faithfully fake Fake? Fakehub Faker fakes Fakin faking Falaise Fa-la-l Falcao Falco Falcon Falcon's Falikoz Falizia fall Fallaciously Fallen Falling Fallon falls False Falsely Faltoya Faltoyano Fam FamChaser fame Fame' FAME famed Famena Famer Familial familiar familiar? Families family Family's Familystrokes Famished famous Famous? Famously fan fanatic fanatics Fanatka fanboy Fanboys Fanboy's fancied fancies fancy Fancy Francy sucks Fandango FanFirst Fangirl Fangirls Fangs Fanlingerie Fannie Fanning fanny Fancat fans Fan's FANS Fansexual Fanta fantas Fantasee fantasi Fantasia Fantasía Fantasias fantasie fantasies fantasize fantasized fantasizes fantasizing Fantbootyee FantBOOTYtic fantastic Fan-tastic FANTASTIC Fantastical Fantastically Fantastico Fantastik Fantastisch Fantasty fantasy Fantasy; Fantasy? Fantasytime Fantazome Fantazy Fantina Fany Fap fapping Fappy Faq far Fara Farah Faraway fare Farel farewell Fargua Farias Faris farm Farmboy Farmer Farmers Farmer's Farmgirl Farmhouse Farmin' Farmland Faro Farrah Farrah's Farrell Farris Farsk fart farting farts Farwell Fascinating Fascination Fascinators fashion fashion_frenzy Fashionable Fashionably fashioned fashionista Fashionistas Fashionists fashions Fbootyhionably fast Fast And Easy fasten faster Fasterova Fasterovation Fast-Food fat Fatal fatale Fatale's fate Fate? Fates father Father’s Father-In-Law Fatherly father's Fathers Father's Fatigue Fatigued FATIKA Fatima Fatter Fatter? Fattest fatty Fatzilla faucet Faucetmasturbation Fauci fault Faultless Faust Faustine Fauve Faux Fauxcest Fauxmance Fauxtographer Fav fave favor Favorita favorite Favoritefingers Favoriteglbootytoy favorites favorrite Favors favour favourite favourites Favouritism favours Fawn Fawna Fawndeli Fawning Fawny Fawx Fawx's Fay Faye Faye's Faye-sers Fayez Fayth Fayyes FBI Fbi1 Fbitwo fck fck'd F-cup F-Cupper fe fear Feared Feargasms Fearing Fearless fears fearsome feast Feaster Feasting feastival Feasts feat FeatBrooklyn feather Featherlight Feathers featherweight featherweights Featherweights2 Featherweigth Feats feature feature’s FeatureAmerica's featured features Featurette featuring Feb Febby Febby’s Febby's Febe's February Feb's fed federal Federica Feds fee feed Feed? Feeder feeding Feedings feeds FeedThe feel feel? Feelers Feelgood Feelgood's feeli Feelin Feelin' feeling feeling_herself feelings feels Feely fees feet Feet? Feetish feet-loving Feetogenic Feetpink feetsies Feetures feigning feigns Feildwork Fein feisty Felaktig Felated Felatio Felecia Felice Felicia felicia's Felicias Felicity Felicity's Felina feline Felinias Felipa Felisias Felix Felix's Feliz Feliza fell fella fellas fellatio fellow fellows Felon Felony Felony'd Felony'ed Felony's FelonySomeone's FelonyThe felt Felucci fem female females Fembot Femcy femdom FemDomme Feminin Feminine Femininity feminist Feminization Feminized femme FemmeDom Femmes fence Fences fend Fender Fendi Fenestra Fenetre Feng Fenix Fennec Fennixia Fenomeno Fenox fenwick Feodo Fer Feral Ferarri Fergalicious Ferme Fern Fernanda Fernandes Fernandez Fernandi Fernandinha Fernando ferocious Ferociously Ferrah Ferrara Ferrara's Ferrari FERRARIThe Ferraz Ferre Ferreira ferrer Ferrera Ferrera's Ferreri Ferrero ferret Ferretti Ferri Ferriana Ferris Ferro Ferry Fertile Fertility Fertilizer Ferty Fervent Fervor Fervour Fesser Fesser's fest festival festive Festivities Festivity fet Fetching Fetisch fetish fetishes Fetishest fetishism fetish-ism Fetishism fetishist Fetishista fetishists Fetishist's Fettered Fetti Feud Fevari fever Fevered Feverish Feverishly Fever's few Fey Fey's FEZ Ff FFkitty FFM FHM Fi fiance fiancé fiancee fiancée Fiancee's fiance's fiancé's Fianle Fiasco Fiasko Fiat Fiax Fib Fickle Fiction Fiddle fiddler fiddles Fiddling Fidelity fidget Fidgeting Fidgets Fidikeia field Fields Field's fiend Fiendish Fiends Fiend's Fiera fierce fiery fiesta fiesty Fifi Fifteen Fifth Fifties Fifty Fifty-two FIFun Figging fight fighter Fighters fighting fights Fignering Figueroa figure Figurehead Figures Fijiria File files Filet Filing Filipina Filipino Filippa fill Filla Fille filled filledtaking Filler Fill'er fillies Fillin Fill-In filling Filling? Fillmore fills Fills Her Filly film filmed filming filmmaking filmOut films Filter filth filthiest filthy fin final finale FinalMake loveing Finalist Finality Finalized finally Finalmente finals finalsA Finance Finances financial Financially find Finders finding finds fine Fine-Booty Fine-bootyed finely Finer Fines finesse finest Fine-Tuning finger Fingeraction fingerbang fingerbanged finger-banged Fingerbanger Fingerbanging Finger-banging Fingerbangs Fingerbed Fingerbedfun Fingerchair Fingerchat Fingercouch Fingercouch1 Fingercouch2 Fingercream Fingerdeep Fingerdelight Fingerbanana fingered Fingerfrenzy Fingermake love Fingermake loveed Fingermake loveer Fingermake loveing Finger-Make loveing fingermake loves Fingerfun Fingerfun1 Fingerfun2 Fingerhole Fingeri fingering Fingerings Fingerlicious Fingerlick Finger-lickin Fingerlickn Fingerlove Fingerpink Fingerplay Fingerplay1 Fingerplay2 Fingerpound Fingerrub fingers Finger's FIngers fingers? fingers?All Fingers1 Fingers2 Fingersaurus Fingersdeep Fingersdeep2 Fingersin Fingersin1 Fingersin2 Fingerspaz Fingerstwo Fingertalk Fingertease Fingerteen Fingertime Fingertips Fingerkitty finish finished Finisher finishes Finishing Finland finn Finne Finnish Fino Fintesso Fiolet Fiona Fiore Fiorentino Fiori Fiorissima fir Fira firaplace Firasa fire Fireball firecracker firecrotch fired Fired? Firefighter Firefighters Firefly Firehouse Firelight Fireman's firend's fireplace Fireplacedance Fireplacefun fires Fire's FIRES Fireside Firestarter Firestone Firestorm Firework fireworks Fireworks? firey Firing firm firm-bodied firmest Firmly firs first First? FirstA First-clbooty first-ever Firstfingers FirstGape Firsthand firstHogtied Firstie firsting first-IR-and-DP first-place Firstrabbit1 Firstrabbit2 firsts first-time Firsttime First-time firsttimer first-timer Firsttimer First-timer firsttimers first-timers Firsttimetoy Firsty Fisa Fiseca fisging Fish fished Fisher Fisherman Fishermans Fisher's Fishes Fishin Fishin' fishing Fishline fishnet Fishnetbeauty Fishnetdress Fishnetfinger fishnets Fisikal fising fisiting fisitng fist FIST+MAKE LOVEING fisted FistedTara fister Fisters Fistmake loveed fistful Fistfull fisting Fisting? Fisting?? Fistings Fisting's FISTINGThe Fisting-Virginity Fistivity fists fit Fit? Fitball Fitch Fitgerald fitness Fitnessfingers fits Fitt fitted Fittest fitting FitXXX Fitzergood five Five-O Fivesome Five-Star five-way Fiveway fix Fixated fixation Fixations Fixe fixed Fixer Fixer' Fixer-Upper fixes fixin' Fixin fixing Fixins Fix-it Fixx Fixxxer Fl Flaccid Flag Flagrante Flail flair flame Flamenco Flamer flames FlameThe Flamez flaming flamingo Flamingos Flana Flapper flaps Flare Flared flash Flashback Flashbacks Flashcard Flashed flasher flashers flashes flashing Flashlight Flashpoint flashy flat Flatline flatmate flatmates Flattie Flatties Flattie's Flaunt flaunting flaunts Flava Flavia Flavis flavor flavored flavorings flavors Flavour flavour? Flavours Flaw Flawless Flawlessly Flaxi Flaxy Flea Fleeced flees Fleet Fleeting Fleischfabrik Fleiss flesh Fleshmember fleshed Fleshjack Fleshlight fleshy Fleurette Fleurs Flex Flexablefingerfun Flexed flexes Flexfingers Flexi flexibility flexible Flexie Flexin flexing flex-test Flextime FleXXXibility Flexy Flexy's flick Flicker Flicking Flicks flies flight Flights Flimes fling Flingcom flinger flings Flint Flintstones Flip Flip-flop Flip-Make love Flip-Make loves Flippant Flipping flips flirt Flirtacious Flirtation Flirtations flirtatiou Flirtatious Flirter flirting flirts Flirt's flirty Flirtz Flix Flixxx Flo Floare Float Float? Floatation Floaters Floatie floaties Floating flog flogged floggedNipples flogger flogging flogs Flood Floodgates Flooding floods floor Floor? Floora Floorbooty Floorcooch Floored Floorfellow Floorfinger Floorfingers Floorflex Floorfun Floorgasm Floororgasm Floorpie Floorplay Floorcat Floorrubbing Floors Floortoy Floorvibe Floozie Floozies floozy Flop Flopper Floppers floppy Floppytoy Flor Flora Floral Floralia Floranc Florancia Florane Floranse Florante Flore Florea Florence Florencia Florera Flores Floresgala Florida Floridas Floridian Floridian's Florina Florinda Florist Florophilia Floss Flossing Flotation flour flourished flow flower Flowerbed1 Flowerbed2 Flowerbra Flowercouch Flowerfinger Flowering Flowerpower Flowercat flowers Flower's Flowers1 Flowers2 Flowerskirt Flowerthong flowery flowing flows Floxia Floya Floyd Flu Flub Fluent fluff fluffer Fluffing Fluffs fluffy Fluid Fluid? Fluidity Fluids Flujo Fluk Flunking Fluorescent flush flustered flute flutes Flutist Flutter fly Flyer Flyers Flyest flying Flynn Flynn's Flynt flys Flyswatter FM FM001 FM002 FM003 FM004 FM005 FM006 FM007 FM008 FM009 FM010 FM011 FM012 F-Machine Fmodels fo Fo’ foam Foamy focus Focused focuses Focusing Foe Fofie Fog fogy Fold folded Folder folding Foldout folds Foliage Folk folklore folks Follar Follbooty Follies follow followed Followers following follows Folly Folsom Foments fond Fondeling Fonder Fondia Fondle fondled Fondler Fondlers fondles fondling Fondue Fong Fonic Fontaine Fontana Fontes Fonthys Fontini foo food Foodfight Foodfun Foodie Foodstuffs Foodtruck Foojob fool Fool’s fooled Foolin Foolin' Fooling Foolish fools fool's Fools Fool's Foosball Foosballers Fooseball FoostieBabes foot footage Foot-Booty-Make loveing Footbal football Footballas footballer Footballing Footballs Footdance Footed Footer FootFet footfetish foot-fetish FOOTFETISH Footfob Footmake love Foot-Make loveed Footie Footies foot'-ile footing Foot-Jerking footjob Foot-job Footjobbers Footjobbing Foot-Jobbing Footjobs Footlocker Footlong Foot-Long Footloose footman Footman's Footmodel Footography footplay Foot-Play Footprints Foots-A-Make lovein' Footscapde Footsex footsie Footsiebabes footsies Foot-Sniffing Footsploitation Footsteps Footstool Footsy Foot-Teaser Footwear Footwhores Footworship FootWorshipcom footy Foqual for For Agent's Help for? For… Forbidden Forcast force forced Forcefully Forces Ford Ford's Fore Forecast Forecasting Foreclosure Forehead foreign foreigner Foreigners Forema Foreman Foreman's Forenoon foreplay Foreplaying Foreplays Forero Foreskin forest Forester's Forestfingers foretells Foreva forever Forever? Forever-stunning forget Forgetful Forget-me-not forgets forgetting Forging Forgivable forgive forgiveness Forgives forgoes forgot forgotten Fork Forked Forked-Tongue Forks forlorn form Formal Formar Formation formed former Formerly Formidable Forming Formula formulated Fornic-Asian Fornicates Fornicating fornication Fornications Forno ForPsychology forrest Forsaken Fort forte Forte's forth Fortis Forcanude Fortnight Fortuna Fortuna's fortunate fortune Forty Forty-love Forum Forver forward forward's forwardThe Forza Foster Fostering Fosters Foster's Foto Fotógrafo Fotos fou Foul Foulari Foulmouthed Foul-Mouthed Foun found Foundation Foundations Foundmember fountain Fountains four Four-Eyed Fourfinger Fourfingers Fourgy Four-Hand Four-Hands Fourne's fourplay fours foursome Four-some foursomeRS056 Four-Step fourth fourway four-way Fourway Four-way Fovea fox Fox? Fox1 FoxBack Foxed foxes FoxGENESIS foxhole Foxi Foxies Foxii fox's Foxtail Foxx Foxxi Foxx's Foxxx Foxxxies Foxxx's Foxxxy Foxxy Foxxy's FoxxySeducing foxy Foxy's Foyer fr Fractions Fracture Fraga Fragile Fragments Fragrance Fragrant frame framed frames Framing Fran français France Frances Francesca Francesca's Franceska Francheazca Franchesca Francheska Franchezca Franchezca's Franchezka Francis Francisco Franciska Franciska? Franciska's Franck Franco Francoise Franco's Francsca Francy Francys Frank Frankenmember Frankenjohnson Frankenslut Frankenstein Frank-footer Frankie Frankie's Franklin Franklin? Franks Frank's Franky Franny Franny's Frantastic Frantic franticly Franziska Franziska's frat fratatas Fraternity Fraternization Fraternizing Frathouse Fraud freak Freaked Freakin Freaking Freakout freaks Freaksbee Freakshow Freakum freaky Freckle freckled Freckle-faced freckles Fred Freddie Freddy Frederica free Freed freedom Freedom? Freefall Free-Flowing Free-For-All Freeing Freelance Freeloader freely FreeOnes frees Free-Spirited Freestyle Freetime Free-Useful Freeway Freewill Freeze Freezes Freienwalde freight freind Freja french 'French French-Asian Frenchie Frenchie Anissa Kate Tight Juicy Cat Filled Frenchie's Frenchmaid Frenchman French-Pressed Frenchy Frenemies Frenetic frenzie frenzied frenzy Frequent Fresco fresh fresh_paint Fresh’s Freshblonde Freshen Freshened Freshening fresh-faced Freshgirl freshly freshman Freshmeat Freshmen freshness Freshcat Fret Freud Freudian Freuds Frey Freya Freya's Freye Fri Friction Frida Frida's friday Fridays Frideric fridge Fried friend friend' Friend friend? friend’s friendly friends friend's Friends Friend's Friends? Friends?? friendship friendsover Friendzone Friend-Zone Fries Friggin' frigging Fright Frightful Frigid frill Frills Frilly Fringe Fringed Frisbee Frisco frisk Frisked Friskie Frisking frisky frist Frito Frivol Frivola Frivolity Frivolous Frizzy fro frock Frog Frogface Froggy Froid Frolic Frolick Frolickers frolicking frolics from From? Fronat front Frontal frontier Frontin frontside Frost frosted frosting Frost's Frosty Frotandoselo Frothy Frottage frown Frozen fruit Fruitful fruition Fruitoil fruits fruit-shake fruity Frujina frustrated Frustration frustrations frutata frutatas Frutis Frutti Fruttissima FRUTY Fryer Frying F's FS001 FS002 FS004 FS005 FS006 FS007 FS008 FS009 FS010 FS011 FS012 FS013 FS015 FS016 FS017 FS018 FS019 FS020 FS021 FS022 FS023 FS024 FS025 FS026 FS027 FS028 FS029 FS030 FS031 FS032 FS033 FS034 FS035 FS036 FS037 FS038 FS039 ft FT5 FTA FTM FTW FTY fu Fuchsia make love F-U-C-K make love? Make loveability Make loveabilly make loveable Make loveall Make love-all-you-want Make love-Along Make love-a-lot Make lovealution make loveathon make love-a-thon Make loveathon Make love-A-Thon Make loveation Make loveaway Make love-ball Make lovebit Make loveboy Make loveboys Make love-Break make love-buddy Make lovebuddy Make love-Buddy Make lovebunny make loved MAKE LOVE'D Make love-damental Make lovedate Make love-Date Make loveday Make love-day make lovedoll Make love-doll Make lovedolls Make love-Dominated make lovedown make lovee make loveed make loveed? make loveedCherry make loveedFlogged make loveedNon-Scritped Make loveed-With Make loveemon make loveen make loveening make loveenings Make loveenstein make loveer Make loveerella Make loveerfly make loveeria make loveers Make loveer's Make loveery make lovees Make loveeth Make love-Face Make lovefeast make lovefest make love-fest Make lovefest Make love-fest Make loveFest Make love-Fest Make lovefinger Make love-fit make lovehole make love-hole make loveholes make love-hungry Make loveidy Make loveie make loveign make lovein make lovein' Make lovein Make lovein' MAKE LOVEIN MAKE LOVEIN' make loveing make loveing? make loveing_euro_milf Make loveingA Make loveingFired make loveingmachine make loveingmachines Make loveing-Machines MAKE LOVEINGMACHINES Make loveingMachinescom make loveingof Make loveings Make loveing's make loveingThe make love-it Make lovelined Make lovelist make love-love make love-loving make lovemachine make love-machine Make lovemance Make love-mas make love-me Make loveme1 Make loveo Make love-Off Make loveography Make lovepbooty Make lovepointment Make love-punished Make love-raising make loves Make lovesall Make lovesaw Make love-schooled Make lovesgiving Make love-Slopped make loveslut Make lovesluts Make love-Squirting Make lovestarter make love-stretched Make lovestyle make lovetastic Make loveTeam Make lovetion Make lovetions Make lovetivity Make lovetoberfest Make lovetory Make lovetown make love-toy Make lovetoy Make lovetoys Make loveture Make loveu Make loveula Make love-Up Make loveus Make loveventure Make loveWhoreberfest Make lovey Make lovezilla Fudge Fuego Fuel Fuente Fuentes Fuerte Fufilling Fufillment Fugicans Fugitiva Fugitive Fugitives Fukabod Fukalaties Fukin fulfil fulfill fulfilled fulfilling Fulfillingshower Fulfillment fulfills Fulfilment Fulfils full full? full0mouth full-bodied Full-Body Fuller Fullest Full-figured Fulls Full-Service Full-time fully Fully-clothed Fuma Fumble fun fun? Funbag Funbags funbox Function Functional Fund fundamental fundamentals funday Funbanana Fundraiser Fundraising funds Funeral funfair Funfingers Funinthekitchen Funk Funkenstein Funky Funkytown Fun-loving funnel Funneled Funnelled Funner funny Funoodle Funroom Funsie Funsize fun-sized Funsized Fun-Sized Fun-Steve funtime Funtine Funtoy Funvibe Funvibrator FunWith Funzies Fun-zy Fuochi FUPA fur Für furburger Fur-burger Furburgers furious Furiously Furiya furniture furpie Furrious furry Furry-Bushed Furryfinger Furrycat further Furtive fury Fuse Fusion Fuss? Fussball Fussy Futbol Futile Futomomo Futon Futonfun future Futuristic Fux Fuxpress Fuzion Fuzz Fuzzfingers fuzzies Fuzzy Fuzzypuss FWB FYERFLI Fyre Fyres F-Zone g G33K ga Gabanna Gabba Gabbano Gabbi Gabbie Gabbie's Gabbriella Gabby Gabe Gab-Fest Gabgbang Gabi Gabina Gabi's Gables Gabor Gabriel Gabriela Gabriela's Gabriele Gabriella Gabriellas Gabriella's Gabrielle Gabrielli gabriels Gabriely Gabrim Gaby gadget Gadgeteer Gadget-maniac gadgets Gael Gaffe gag gaga Gage Gaged Gagfest gagged gaggedmade gaggedNipples gaggedWhite gagger Gaggging Gaggin gagging Gagland gags Gaia Gaia's Gaidinian Gaiety Gail Gaillardise Gail's Gain gains Gaite Gaiters Gakuin gal Gala Galactic Galactical Galano Galanti Gala's Galateo Ga-Laura galaxy Galen Gali Galina Galkina Gallant Gallardo Galleas gallery Galley Gallians Gallic Gallitia Gallixias gallon gallons Gallow galore gals Gal's Galtian Galvanize gam Gambe Gambit Gamble Gambler gambler's Gamblers Gamble's gambling game Game? Gameday Gameplay gamer gamer_girls Gamers games Gaming gams Gamus ganbang Ganbanged Gandgang Ganell gang Gangback gangband gangbang gang-bang Gangbang Gang-bang GangBang Gang-Bang GANGBANG GangBang?? gangbanged Gang-Banged Gangbanger gang-bangers Gangbangers GangbangFirst Gangbanging Gang-Banging Gangbangs ganged gangmake loveed gangland Gangs Gangsta Gangstas gangster Gangsters Gangster's gap Gap? Gapable gape Gape' GAPE gaped gapefart gapefarting Gape-Farting gapefarts GapeLandcom Gapeolexa Gaper gapers gapes Gapes'n'Roses GAPESPROLAPSE Gapeteers Gapezone Gapin gaping Gapolexa gapped Gaps garage garage's garbage Garcia García Garcia’s Garcon Garde Gardell garden Gardenea gardener gardeners gardener's Gardeners Gardener's Gardenias gardening Gardeno Gardens Garden's Gardentouches Gardner Gardner’s Gareth Garett Gargantuan Gargle Gargles gargling Gariella Garin Garnet Garrett Gart Garter garterbelt Garters Garth Gartner Gary Garza Garza's gas Gash Gasket Gaslighting Gasm Gasman Gasolina gasoline Gasp gasps Gbootyet Gbootying Gate Gatekeeper Gates Gathering Gator's Gattina Gattu Gaucha Gauge Gaultier Gauntlet Gautier gave Gavin Gaviria Gaviria's Gawk Gawker Gawking gay Gay? Gaybait Gaybors gayelles Gaykakke Gaykkake Gaylifenetwork Gaymates Gaymer Gaynor Gay's Gaytrix Gayville Gaywatch gaze Gazebo Gazed Gazelle gazing gazmask Gazonga gazongas Gazumba gazungas Gb GB' GBQ ge Geane gear Gears Gearshift gearstick G'ed Gee geek geeks Geek's geeky Geena Geezer Geezers Geezer's Geiser geisha Geisha's Geizer Geizer? gel Gel? Gela Gelato Gelya Gelyn gem Gema Gemini Gemini? Gemini's Gemma Gems Gemstone Gen Gender Gene General generation Generational generations genero Generosity Generous Generously Genesis Genessis Geneva Genevieve Genevieve's Genevievre Genice Genie Genies Geni's Genital Genitalia Genitals genius genocide Gente Genteel Gentil Gentilla Gentilly gentle gentleman Gentleman’s gentleman's gentlemen gentlemen's Gentlepleasure Gentlewomen's Gentley gently Gentrified genuine Genya Geoff geography Geometric geometry George George's Georgia Georgiana Georgia's Georgie Georgie's Georgina Georgio Geovanna's Gera Gerald Geraldine Gerber Gerda Geri Geriatric Gerina Germ Germain german Germans German-style Germany Germiona Germione Germophobe Gerson Gerson's gest Gesture Gestures get get? getaway Get-Away Get-A-Way Getflix'n Get-Out gets get's Gets Get's GETS getsFisted Getter Getti gettin gettin’ getting get-together Getty Gettysburg getz G-Extreme geyser Geyshila gf GFE G-Force Gfs Gf's GG gg006 GG023 GG025 gg044 gg046 GG064 gg080 gg083 gg087exclusive gg088 GG093 GG094 gg097exclusive GG099 GG103 GG104 gg106 GG109 GG110 GG111 GG114 GG115 GG122 GG123 GG125 GG127 GG128 gg129 GG132 gg133 GG135 GG136 GG137 GG138 GG141 GG142 GG143 gg150 gg151 GG156 gg157 gg158 gg159 gg160 GG162 gg164 GG167 GG168 GG169 GG170 GG174 GG175 gg178 GG179 gg180 gg182 gg184 gg186 gg187 gg191 GG196 GG198 GG199 GG200 GG202 gg203 gg204 GG206 gg207 GG208 GG209 GG210 GG211 GG213 GG215 GG216 GG217 GG218 GG220 GG223 GG224 gg225 GG226 GG227 GG228 GG230 GG231 GG232 GG233 GG234 GG236 GG238 GG239 GG241 GG242 GG244 GG246 GG247 GG248 GG250 GG253 GG254 GG255 GG257 GG258 GG260 GG261 GG262 GG264 GG265 gg267 gg269 gg270 GG271 GG272 gg273 GG276 GG277 GG279 GG280 gg281 GG283 GG284 GG285 gg286 gg287 gg289 GG290 GG291 GG293 GG294 gg295 gg296 gg298 gg299 GG300 GG301 gg302 GG304 GG305 GG306 gg307 gg308 GG310 gg311 gg312 GG314 GG316 gg317 gg318 gg321 gg322 GG324 GG325 gg326 GG338 GG342 gg344 GG345 gg346 GG347 gg348 gg349 gg350 gg351 gg352 GG354 GG355 GG356 gg362 gg367 gg368 gg370 gg371 gg372 gg373 gg374 gg377 gg379 GG380 GG381 GG387 gg389 GG392 GG393 GG395 GG396 GG398 GG399 gg402 GG408 gg410 gg411 GG413 gg414 GG415 gg417 GG418 gg419 gg420 gg421 gg422 gg423 GG450 GG459 GG480 GG481 GG482 gg483 GG484 GG486 GG488 gg489 GG490 gg491 gg494 gg498 gg501 gg502 gg509 gg510 gg511 gg513 gg514 gg515 gg517 gg518 gg519 gg520 gg521 gg522 GG524 gg526 gg528 gg530 gg531 GG532 gg535 gg536 gg537 gg539 gg541 gg547 gg549 gg553 gg555 GG556 gg558 gg561exclusive GGB GGDP GgLab Ggorgeous Ghettman Ghetto ghost Ghostbusters Ghosted Ghostlusters Ghostly Ghosts Ghouls gi Gia Gía Giaciglio Giacomo Giada Giana gianna Gianna’s Giannas Gianna's Gianni giant Giantess Gia's Gibson Gibson's Gicane Giddy Giddyup Gideon gidget Giepky gift gifted gifts Gift-Wrapped gig Gigalo Gigante gigantic GIGANTICA Gigan-Can Gigantor Giggle Gigglegasm giggler Giggles Giggling giggly Giggy Gigi Gigi's gigolo Gigolos Gil Gilbert Gild Gilded Gilf GILFs GILF's Gili Gillis Gilty Gimme Gimmie Gimp Gin Gina Gina’s Ginas Gina's Ginebra Ginette ginger Gingerly Gingers Ginger's Gingervitis Gingie Ginna gino Ginomous Ginormous Ginta Gio GIO001 GIO002 GIO003 GIO005 GIO006 GIO011 GIO012 GIO013 GIO014 GIO015 GIO016 GIO017 GIO019 GIO021 GIO022 GIO023 GIO025 GIO030 GIO032 GIO033 GIO035 GIO036 GIO037 GIO038 GIO039 GIO040 GIO041 GIO042 GIO044 GIO045 GIO046 GIO047 GIO048 GIO049 GIO050 GIO051 GIO052 GIO053 GIO054 GIO055 GIO056 GIO057 GIO058 GIO059 GIO060 GIO061 GIO062 GIO063 GIO064 GIO065 GIO066 GIO067 GIO068 GIO069 GIO070 GIO071 GIO072 GIO073 GIO074 GIO075 GIO076 GIO077 GIO078 GIO079 GIO080 GIO081 GIO082 GIO083 GIO084 GIO085 GIO086 GIO087 GIO088 GIO089 GIO090 GIO091 GIO092 GIO093 GIO094 GIO095 GIO096 GIO097 GIO098 GIO099 GIO100 GIO1000 GIO1001 GIO1002 GIO1003 GIO1004 GIO1005 GIO1006 GIO1007 GIO1008 GIO1009 GIO101 GIO1010 GIO1011 GIO1012 GIO1013 GIO1014 GIO1015 GIO1016 GIO1017 GIO1018 GIO1019 GIO102 GIO1020 GIO1021 GIO1022 GIO1023 GIO1024 GIO1025 GIO1026 GIO1027 GIO1028 GIO1029 GIO103 GIO1030 GIO1031 GIO1032 GIO1033 GIO1034 GIO1035 GIO1036 GIO1037 GIO1038 GIO1039 GIO104 GIO1040 GIO1041 GIO1042 GIO1043 GIO1044 GIO1045 GIO1046 GIO1047 GIO1048 GIO1049 GIO105 GIO1050 GIO1051 GIO1052 GIO1053 GIO1054 GIO1055 GIO1056 GIO1057 GIO1058 GIO1059 GIO106 GIO1060 GIO1061 GIO1062 GIO1063 GIO1064 GIO1065 GIO1066 GIO1067 GIO1068 GIO1069 GIO107 GIO1070 GIO1071 GIO1072 GIO1073 GIO1074 GIO1075 GIO1076 GIO1077 GIO1078 GIO1079 GIO108 GIO1080 GIO1081 GIO1082 GIO1083 GIO1084 GIO1085 GIO1086 GIO1087 GIO1088 GIO1089 GIO109 GIO1090 GIO1091 GIO1092 GIO1093 GIO1094 GIO1095 GIO1096 GIO1097 GIO1098 GIO1099 GIO110 GIO1100 GIO1101 GIO1102 GIO1103 GIO1104 GIO1105 GIO1106 GIO1107 GIO1108 GIO1109 GIO111 GIO1110 GIO1111 GIO1112 GIO1113 GIO1114 GIO1115 GIO1116 GIO1117 GIO1118 GIO1119 GIO112 GIO1120 GIO1121 GIO1122 GIO1123 GIO1124 GIO1125 GIO1126 GIO1127 GIO1128 GIO1129 GIO113 GIO1130 GIO1131 GIO1132 GIO1133 GIO1134 GIO1135 GIO1136 GIO1137 GIO1138 GIO1139 GIO114 GIO1140 GIO1141 GIO1142 GIO1143 GIO1144 GIO1145 GIO1146 GIO1147 GIO1148 GIO1149 GIO115 GIO1150 GIO1151 GIO1152 GIO1153 GIO1154 GIO1155 GIO1156 GIO1157 GIO1158 GIO1159 GIO116 GIO1160 GIO1161 GIO1162 GIO1163 GIO1164 GIO1165 GIO1166 GIO1167 GIO1168 GIO1169 GIO117 GIO1170 GIO1172 GIO1173 GIO1174 GIO1175 GIO1176 GIO1177 GIO1178 GIO1179 GIO118 GIO1180 GIO1181 GIO1182 GIO1183 GIO1184 GIO1185 GIO1186 GIO1187 GIO1188 GIO1189 GIO119 GIO1190 GIO1191 GIO1192 GIO1193 GIO1194 GIO1195 GIO1196 GIO1197 GIO1198 GIO1199 GIO120 GIO1200 GIO1201 GIO1202 GIO1203 GIO1204 GIO1205 GIO1206 GIO1207 GIO1208 GIO1209 GIO121 GIO1210 GIO1211 GIO1212 GIO1213 GIO1214 GIO1215 GIO1216 GIO1217 GIO1218 GIO1219 GIO122 GIO1220 GIO1221 GIO1222 GIO1223 GIO1224 GIO1225 GIO1226 GIO1227 GIO1228 GIO1229 GIO123 GIO1230 GIO1231 GIO1232 GIO1233 GIO1234 GIO1235 GIO1236 GIO1237 GIO1238 GIO1239 GIO124 GIO1240 GIO1241 GIO1242 GIO1243 GIO1244 GIO1245 GIO1246 GIO1247 GIO1248 GIO1249 GIO125 GIO1250 GIO1251 GIO1252 GIO1253 GIO1254 GIO1255 GIO1256 GIO1257 GIO1258 GIO1259 GIO126 GIO1260 GIO1261 GIO1262 GIO1263 GIO1265 GIO1266 GIO1267 GIO1268 GIO1269 GIO127 GIO1270 GIO1271 GIO1272 GIO1273 GIO1274 GIO1275 GIO1276 GIO1277 GIO1278 GIO1279 GIO128 GIO1280 GIO1281 GIO1282 GIO1283 GIO1284 GIO1285 GIO1286 GIO1287 GIO1288 GIO1289 GIO129 GIO1290 GIO1291 GIO1292 GIO1293 GIO1294 GIO1295 GIO1296 GIO1297 GIO1298 GIO1299 GIO130 GIO1300 GIO1301 GIO1302 GIO1303 GIO1304 GIO1305 GIO1306 GIO1307 GIO1308 GIO1309 GIO131 GIO1310 GIO1311 GIO1312 GIO1313 GIO1314 GIO1315 GIO1316 GIO1317 GIO1318 GIO1319 GIO132 GIO1320 GIO1321 GIO1322 GIO1323 GIO1324 GIO1325 GIO1326 GIO1327 GIO1328 GIO1329 GIO133 GIO1330 GIO1331 GIO1332 GIO1333 GIO1334 GIO1335 GIO1336 GIO1337 GIO1338 GIO1339 GIO134 GIO1340 GIO1341 GIO1342 GIO1343 GIO1344 GIO1345 GIO1346 GIO1347 GIO1348 GIO1349 GIO135 GIO1350 GIO1351 GIO1352 GIO1353 GIO1354 GIO1355 GIO1356 GIO1357 GIO1358 GIO1359 GIO136 GIO1360 GIO1361 GIO1362 GIO1363 GIO1364 GIO1365 GIO1366 GIO1367 GIO1368 GIO1369 GIO137 GIO1370 GIO1371 GIO1372 GIO1373 GIO1374 GIO1375 GIO1376 GIO1377 GIO1378 GIO1379 GIO138 GIO1380 GIO1381 GIO1382 GIO1383 GIO1384 GIO1385 GIO1386 GIO1387 GIO1388 GIO1389 GIO139 GIO1390 GIO1391 GIO1392 GIO1393 GIO1394 GIO1395 GIO1396 GIO1397 GIO1398 GIO1399 GIO140 GIO1400 GIO1401 GIO1402 GIO1403 GIO1404 GIO1405 GIO1406 GIO1407 GIO1408 GIO1409 GIO141 GIO1410 GIO1411 GIO1412 GIO1413 GIO1414 GIO1415 GIO1416 GIO1417 GIO1418 GIO1419 GIO142 GIO1420 GIO1421 GIO1422 GIO1423 GIO1424 GIO1425 GIO1426 GIO1427 GIO1428 GIO1429 GIO143 GIO1430 GIO1431 GIO1432 GIO1433 GIO1434 GIO1435 GIO1436 GIO1437 GIO1438 GIO1439 GIO144 GIO1440 GIO1441 GIO1442 GIO1443 GIO1444 GIO1445 GIO1446 GIO1447 GIO1448 GIO1449 GIO145 GIO1450 GIO1451 GIO1452 GIO1453 GIO1454 GIO1455 GIO1456 GIO1457 GIO1458 GIO1459 GIO146 GIO1460 GIO1461 GIO1462 GIO1463 GIO1464 GIO1465 GIO1466 GIO1467 GIO1468 GIO1469 GIO147 GIO1470 GIO1471 GIO1472 GIO1473 GIO1474 GIO1475 GIO1476 GIO1477 GIO1478 GIO1479 GIO148 GIO1480 GIO1481 GIO1482 GIO1483 GIO1484 GIO1485 GIO1486 GIO1487 GIO1488 GIO1489 GIO149 GIO1490 GIO1491 GIO1492 GIO1493 GIO1494 GIO1495 GIO1496 GIO1497 GIO1498 GIO1499 GIO150 GIO1500 GIO1501 GIO1502 GIO1503 GIO1504 GIO1505 GIO1506 GIO1507 GIO1508 GIO1509 GIO151 GIO1510 GIO1511 GIO1512 GIO1513 GIO1514 GIO1515 GIO1516 GIO1517 GIO1518 GIO1519 GIO152 GIO1520 GIO1521 GIO1522 GIO1523 GIO1524 GIO1525 GIO1526 GIO1527 GIO1528 GIO1529 GIO153 GIO1530 GIO1531 GIO1532 GIO1533 GIO1534 GIO1535 GIO1536 GIO1537 GIO1538 GIO1539 GIO154 GIO1540 GIO1541 GIO1542 GIO1543 GIO1544 GIO1545 GIO1546 GIO1547 GIO1549 GIO155 GIO1550 GIO1551 GIO1552 GIO1553 GIO1554 GIO1555 GIO1556 GIO1557 GIO1558 GIO1559 GIO156 GIO1561 GIO1562 GIO1563 GIO1566 GIO1567 GIO1568 GIO1569 GIO157 GIO1570 GIO1571 GIO1574 GIO1575 GIO1576 GIO1578 GIO1579 GIO158 GIO1580 GIO1582 GIO1583 GIO1585 GIO1586 GIO1587 GIO159 GIO1590 GIO1591 GIO1592 GIO1593 GIO1594 GIO1595 GIO1596 GIO1597 GIO1598 GIO1599 GIO160 GIO1601 GIO1602 GIO1604 GIO1605 GIO1606 GIO1607 GIO1608 GIO161 GIO162 GIO163 GIO164 GIO1642 GIO1649 GIO165 GIO166 GIO167 GIO1673 GIO168 GIO1687 GIO169 GIO170 GIO171 GIO172 GIO173 GIO174 GIO175 GIO176 GIO177 GIO178 GIO179 GIO18 GIO180 GIO181 GIO182 GIO183 GIO184 GIO185 GIO186 GIO187 GIO188 GIO189 GIO190 GIO191 GIO192 GIO193 GIO194 GIO195 GIO196 GIO197 GIO198 GIO199 GIO20 GIO200 GIO201 GIO202 GIO203 GIO204 GIO205 GIO206 GIO207 GIO208 GIO209 GIO210 GIO211 GIO212 GIO213 GIO214 GIO215 GIO216 GIO217 GIO218 GIO219 GIO220 GIO221 GIO222 GIO223 GIO224 GIO225 GIO226 GIO227 GIO228 GIO229 GIO230 GIO231 GIO232 GIO233 GIO234 GIO235 GIO236 GIO237 GIO238 GIO239 GIO24 GIO240 GIO241 GIO242 GIO243 GIO244 GIO245 GIO246 GIO247 GIO248 GIO249 GIO250 GIO251 GIO252 GIO253 GIO254 GIO255 GIO256 GIO257 GIO258 GIO259 GIO26 GIO260 GIO261 GIO262 GIO263 GIO264 GIO265 GIO266 GIO267 GIO268 GIO269 GIO27 GIO270 GIO271 GIO272 GIO273 GIO274 GIO275 GIO276 GIO277 GIO278 GIO28 GIO280 GIO281 GIO282 GIO283 GIO284 GIO285 GIO286 GIO287 GIO288 GIO289 GIO29 GIO290 GIO291 GIO292 GIO293 GIO294 GIO295 GIO296 GIO297 GIO298 GIO299 GIO300 GIO301 GIO302 GIO303 GIO304 GIO305 GIO306 GIO307 GIO308 GIO309 GIO31 GIO310 GIO311 GIO312 GIO313 GIO314 GIO315 GIO316 GIO317 GIO318 GIO319 GIO320 GIO321 GIO322 GIO323 GIO324 GIO325 GIO326 GIO327 GIO328 GIO329 GIO330 GIO331 GIO332 GIO333 GIO334 GIO335 GIO336 GIO337 GIO338 GIO339 GIO34 GIO340 GIO341 GIO342 GIO343 GIO344 GIO345 GIO346 GIO347 GIO348 GIO349 GIO350 GIO351 GIO352 GIO353 GIO354 GIO355 GIO356 GIO357 GIO358 GIO359 GIO360 GIO361 GIO362 GIO363 GIO364 GIO365 GIO366 GIO367 GIO368 GIO369 GIO370 GIO371 GIO372 GIO373 GIO374 GIO375 GIO376 GIO377 GIO378 GIO379 GIO380 GIO381 GIO382 GIO383 GIO384 GIO385 GIO386 GIO387 GIO388 GIO389 GIO390 GIO391 GIO392 GIO393 GIO394 GIO395 GIO396 GIO397 GIO398 GIO399 GIO400 GIO401 GIO402 GIO403 GIO404 GIO405 GIO406 GIO407 GIO408 GIO409 GIO410 GIO411 GIO412 GIO413 GIO414 GIO415 GIO416 GIO417 GIO418 GIO419 GIO420 GIO421 GIO423 GIO424 GIO425 GIO426 GIO427 GIO428 GIO429 GIO430 GIO431 GIO432 GIO433 GIO434 GIO435 GIO436 GIO437 GIO438 GIO439 GIO440 GIO441 GIO442 GIO443 GIO444 GIO445 GIO446 GIO447 GIO448 GIO449 GIO450 GIO451 GIO452 GIO453 GIO454 GIO455 GIO456 GIO457 GIO458 GIO459 GIO460 GIO461 GIO462 GIO463 GIO464 GIO465 GIO466 GIO467 GIO468 GIO469 GIO470 GIO471 GIO472 GIO473 GIO474 GIO475 GIO476 GIO477 GIO478 GIO479 GIO480 GIO481 GIO482 GIO483 GIO484 GIO485 GIO486 GIO487 GIO488 GIO489 GIO490 GIO491 GIO492 GIO493 GIO494 GIO495 GIO496 GIO497 GIO498 GIO499 GIO500 GIO501 GIO502 GIO503 GIO504 GIO505 GIO506 GIO507 GIO508 GIO509 GIO510 GIO511 GIO512 GIO513 GIO514 GIO515 GIO516 GIO517 GIO518 GIO519 GIO521 GIO522 GIO523 GIO524 GIO525 GIO526 GIO528 GIO529 GIO530 GIO531 GIO532 GIO533 GIO534 GIO536 GIO538 GIO539 GIO541 GIO542 GIO544 GIO545 GIO547 GIO548 GIO549 GIO550 GIO552 GIO554 GIO556 GIO557 GIO558 GIO559 GIO560 GIO561 GIO562 GIO564 GIO565 GIO566 GIO567 GIO569 GIO571 GIO572 GIO573 GIO574 GIO575 GIO576 GIO577 GIO578 GIO579 GIO580 GIO581 GIO582 GIO583 GIO584 GIO586 GIO587 GIO588 GIO589 GIO590 GIO591 GIO592 GIO594 GIO595 GIO596 GIO597 GIO598 GIO599 GIO600 GIO601 GIO602 GIO603 GIO605 GIO606 GIO607 GIO608 GIO609 GIO610 GIO611 GIO612 GIO613 GIO614 GIO615 GIO616 GIO617 GIO618 GIO620 GIO621 GIO622 GIO623 GIO624 GIO625 GIO626 GIO628 GIO629 GIO631 GIO632 GIO633 GIO634 GIO636 GIO637 GIO638 GIO639 GIO640 GIO641 GIO642 GIO643 GIO644 GIO645 GIO646 GIO647 GIO648 GIO649 GIO650 GIO651 GIO652 GIO653 GIO654 GIO655 GIO656 GIO657 GIO658 GIO659 GIO660 GIO661 GIO662 GIO663 GIO664 GIO666 GIO667 GIO668 GIO669 GIO670 GIO671 GIO672 GIO673 GIO674 GIO675 GIO676 GIO677 GIO678 GIO679 GIO680 GIO681 GIO682 GIO683 GIO684 GIO685 GIO686 GIO687 GIO688 GIO689 GIO690 GIO691 GIO692 GIO693 GIO694 GIO695 GIO696 GIO697 GIO698 GIO699 GIO700 GIO701 GIO702 GIO703 GIO704 GIO705 GIO706 GIO707 GIO708 GIO709 GIO710 GIO711 GIO712 GIO713 GIO714 GIO715 GIO716 GIO717 GIO718 GIO719 GIO720 GIO721 GIO722 GIO723 GIO724 GIO725 GIO726 GIO727 GIO728 GIO730 GIO731 GIO732 GIO733 GIO734 GIO735 GIO736 GIO737 GIO738 GIO739 GIO740 GIO741 GIO742 GIO743 GIO744 GIO745 GIO746 GIO747 GIO748 GIO749 GIO750 GIO751 GIO752 GIO753 GIO754 GIO755 GIO756 GIO757 GIO758 GIO759 GIO760 GIO761 GIO762 GIO763 GIO764 GIO765 GIO766 GIO767 GIO768 GIO769 GIO770 GIO771 GIO772 GIO773 GIO774 GIO775 GIO776 GIO777 GIO778 GIO779 GIO780 GIO781 GIO782 GIO783 GIO784 GIO786 GIO787 GIO788 GIO789 GIO790 GIO791 GIO792 GIO793 GIO794 GIO795 GIO796 GIO797 GIO798 GIO799 GIO800 GIO801 GIO802 GIO803 GIO804 GIO805 GIO806 GIO807 GIO808 GIO809 GIO810 GIO811 GIO812 GIO813 GIO814 GIO815 GIO816 GIO817 GIO819 GIO820 GIO821 GIO822 GIO823 GIO824 GIO825 GIO826 GIO827 GIO828 GIO829 GIO830 GIO831 GIO832 GIO833 GIO834 GIO835 GIO836 GIO837 GIO838 GIO840 GIO841 GIO842 GIO843 GIO844 GIO845 GIO846 GIO847 GIO848 GIO849 GIO850 GIO851 GIO853 GIO855 GIO856 GIO857 GIO858 GIO859 GIO860 GIO861 GIO862 GIO863 GIO864 GIO865 GIO866 GIO867 GIO868 GIO869 GIO870 GIO871 GIO872 GIO873 GIO874 GIO875 GIO876 GIO877 GIO878 GIO879 GIO880 GIO881 GIO883 GIO884 GIO885 GIO886 GIO887 GIO888 GIO889 GIO890 GIO891 GIO892 GIO893 GIO894 GIO895 GIO896 GIO897 GIO898 GIO899 GIO901 GIO903 GIO905 GIO906 GIO907 GIO908 GIO909 GIO910 GIO911 GIO912 GIO913 GIO914 GIO915 GIO916 GIO917 GIO918 GIO919 GIO920 GIO921 GIO922 GIO923 GIO924 GIO925 GIO926 GIO927 GIO928 GIO929 GIO930 GIO931 GIO932 GIO933 GIO934 GIO935 GIO936 GIO937 GIO938 GIO939 GIO940 GIO941 GIO942 GIO943 GIO944 GIO945 GIO946 GIO947 GIO948 GIO949 GIO950 GIO951 GIO952 GIO953 GIO954 GIO955 GIO956 GIO957 GIO958 GIO959 GIO960 GIO961 GIO962 GIO963 GIO964 GIO965 GIO966 GIO967 GIO968 GIO969 GIO970 GIO971 GIO972 GIO973 GIO974 GIO975 GIO976 GIO977 GIO978 GIO979 GIO980 GIO981 GIO982 GIO983 GIO984 GIO985 GIO986 GIO987 GIO988 GIO989 GIO990 GIO991 GIO992 GIO993 GIO994 GIO995 GIO996 GIO997 GIO998 GIO999 Giochi Gioia Gioia's Giorgeous Giorgia Giorgiana Giorgio Giorgio's Giotto Giovana Giovanna Giovanni Gipsy gir Gira Girando girl Girl' GIrl GIRL** girl? girl`s girl’s girl+1 GirlBang girl-boy Girl-Brutal girlcore Girlfest Girlfight girlfrie girlfriend Girl-Friend GIRLfriend Girlfriend? Girlfriend’s Girlfriend-make loveing girlfriends girlfriend's Girlfriends Girlfriend's Girlfriend-selling girl-girl girl-girls girlie girlies girlish girlMade GirlMUST girl-next-door Girl-Next-Door’s girl-on-girl Girlongirl Girl-on-girl GirlOrgasm Girlriend? girls girl's Girls Girl's GIRLS Girls? Girlscout girlsex Girlshower girls-night-out Girlsshower Girlstuckonmember Girlsway Girlsway's girlThe girly girlТs girsex girth girthy Gisela Gisele Giselle Giselle's Gisha Gisias Gism Gisselle Gisselle's Gissonias Gita Gita's Git-Go Gitta Gitta's gitties Givana Givanna give Giveaway given Givens giver gives Give's GiveYourMoneyToWomen Givin giving Giz Gizelle Gizm Gizmo Gizzelle gl GL001 GL002 GL003 GL004 GL005 GL006 GL007 GL008 GL009 GL010 GL011 GL012 GL013 GL014 GL015 GL016 GL017 GL018 GL019 GL020 GL021 GL022 GL023 GL024 GL025 GL026 GL027 GL028 GL029 GL030 GL031 GL032 GL033 GL034 GL035 GL036 GL037 GL038 GL039 GL040 GL041 GL042 GL043 GL044 GL045 GL046 GL047 GL048 GL049 GL050 GL051 GL052 GL053 GL054 GL055 GL056 GL057 GL058 GL059 GL060 GL061 GL062 GL063 GL064 GL065 GL066 GL067 GL068 GL069 GL070 GL071 GL072 GL073 GL074 GL075 GL076 GL077 GL078 GL079 GL080 GL081 GL082 GL083 GL084 GL085 GL086 GL087 GL088 GL089 GL090 GL091 GL092 GL093 GL094 GL095 GL096 GL097 GL098 GL099 GL100 GL101 GL102 GL103 GL104 GL105 GL106 GL107 GL108 GL109 GL110 GL111 GL112 GL113 GL114 GL115 GL116 GL117 GL118 GL119 GL120 GL121 GL122 GL123 GL124 GL125 GL126 GL127 GL128 GL129 GL130 GL131 GL132 GL133 GL134 GL135 GL136 GL137 GL138 GL139 GL140 GL141 GL142 GL143 GL144 GL145 GL146 GL147 GL148 GL149 GL150 GL151 GL152 GL153 GL154 GL155 GL156 GL157 GL158 GL159 GL160 GL161 GL162 GL163 GL164 GL165 GL166 GL167 GL168 GL169 GL170 GL171 GL172 GL173 GL174 GL175 GL176 GL177 GL178 GL179 GL180 GL181 GL182 GL183 GL184 GL185 GL186 GL187 GL188 GL189 GL190 GL191 GL192 GL193 GL194 GL195 GL196 GL197 GL198 GL199 GL200 GL201 GL202 GL203 GL204 GL205 GL206 GL207 GL208 GL209 GL210 GL211 GL212 GL213 GL214 GL215 GL216 GL217 GL218 GL219 GL220 GL221 GL222 GL223 GL224 GL225 GL226 GL227 GL228 GL229 GL230 GL231 GL232 GL233 GL234 GL235 GL236 GL237 GL238 GL239 GL240 GL241 GL242 GL243 GL244 GL245 GL246 GL247 GL248 GL249 GL250 GL251 GL252 GL253 GL254 GL255 GL256 GL257 GL258 GL259 GL260 GL261 GL262 GL263 GL264 GL265 GL266 GL267 GL268 GL269 GL270 GL271 GL272 GL273 GL274 GL275 GL276 GL277 GL278 GL279 GL280 GL281 GL282 GL283 GL284 GL285 GL286 GL287 GL288 GL289 GL290 GL291 GL292 GL293 GL294 GL295 GL296 GL297 GL298 GL299 GL300 GL301 GL302 GL303 GL304 GL305 GL306 GL307 GL308 GL309 GL310 GL311 GL312 GL313 GL314 GL315 GL316 GL317 GL318 GL319 GL320 GL321 GL322 GL323 GL324 GL325 GL326 GL327 GL328 GL329 GL330 GL331 GL332 GL333 GL334 GL335 GL336 GL337 GL338 GL339 GL340 GL341 GL342 GL343 GL344 GL345 GL346 GL347 GL348 GL349 GL350 GL351 GL352 GL353 GL354 GL355 GL356 GL357 GL358 GL359 GL360 GL361 GL362 GL363 GL364 GL365 GL366 GL367 GL368 GL369 GL370 GL371 GL372 GL373 GL374 GL376 GL377 GL378 GL379 GL380 GL381 GL382 GL383 GL385 GL386 GL387 GL388 GL389 GL390 GL392 GL393 Glacier glad Glad-He-Ate-Her Gladiator Gladiators Gladly Gladys glam Glam-Anal Glamas Glamazon Glam-But-Nasty glamcore Glam-Core glamgal Glamkore glamorous Glamorously glamour Glamour-Booty glamoured Glamourous Glamours Glams Glam's Glance Gland Glands Glans Glasha glbooty Glbootybeauty Glbootymember Glbootyjohnson Glbootybanana Glbootybanana1 Glbootybanana2 Glbootydong Glbootydoor glbootyes Glbootyhole Glbootycat Glbootytable Glbootytoy Glbootyy Glaze glazed Glazee Glazin' glazing Gleam Gleaming gleams Glee Gleeful Glee's Glen Glenda Glenn Glide Glider Glides Glimmer Glimmering glimpse glisten glisteni Glistening Glister Glitter Glittering Glitters Glittery Glitz Global globe globes Glock Glom Glomming gloom Gloomy Gloria Glorification glorious Gloriously glory gloryhole Gloryholes Gloss Glo-Up glove Gloved Glover gloves glow Glower Glower's Glowing Glows Glowsticks Glowup Glubayana Glue glutes Gluteus Gluttony Gnardians Gnarly Gnawing Gnome Gnomes go go? goal Goalie Goalposts Goals goatee Goatmilkers Gob gobble gobbler gobbles gobblin gobbling Goblin Goblins Goca god GodBOOTY Goddes goddess Goddess’ Goddess13-7 Goddess6-3The GoddessAnnette goddesses Goddesses0-2 GoddessesRound Goddess-next-door Goddess's Goddness godess godesses Godessess Godfather Godiva Godliness Godly Godmother gods Godsend Godshack Godzilla Godzilla's Goege goer Goergeous goers goes goe's Goes gofer Goggle Goggles go-go Gogo Go-go GoGo Go-Go Go-goo Goil goin Goin' going GoKart Go-Kart gold Goldberg golddigger Gold-digger Gold-Digging Goldea golden Goldenbeads Goldenchair Golden-Clad Goldenbanana Goldendong Goldengirl Golden-Haired Goldenshower Goldentoy Goldenvibe Goldessa Goldeu Goldfinger Goldmake loveer Goldi Goldimembers goldie Goldielocks Goldie's Goldilocks Goldiloxxx Goldis Goldmilf Goldnerova Goldone1 Goldone2 Golds Gold's Goldsilky Goldtop Goldtoy Goldtoy1 Goldtoy2 Goldye golf Golfer Golfers Golfind Golfing Golfinger Goliath Goliatheena Goliaths Golly Golubeva Gomes Gomez Gomez's Gonads Gondola gone Gong gonna Gonne Gonz Gonzales Gonzalez gonzo Gonzocom goo good Good? goodbye good-bye Goodbye Gooder good-make loveing Goodgirl goodie goodies Goodmorning good-natured goodness Goodnight goods Goodtimes Goodvibe Goodwife goody Goody-Good Gooed gooey Goof'd GooGoo Goons GOOOAALLL goood GOOOOAAAALLLLLLL Goop goose goosebumps gor Gordon Gordos Gorg gorge Gorged gorgeous Gorgeously gorgeous-ness Gorging Gosh gossip Gossiping Gossips got Gotcha Goteam goth Goth-Chick gothey gothic GOTM Gots Got's gotta gotten Gotti gourmet Governale Governess Governor Gown Gozaimasu Gozinya Gozzi GP001 GP002 GP003 GP004 GP005 GP006 GP007 GP008 GP009 GP010 GP011 GP012 GP013 GP014 GP015 GP016 GP017 GP019 GP020 GP021 GP022 GP023 GP024 GP025 GP026 GP027 GP028 GP029 GP030 GP031 GP032 GP033 GP034 GP035 GP036 GP037 GP038 GP039 GP040 GP041 GP042 GP043 GP044 GP045 GP046 GP047 GP048 GP049 GP050 GP051 GP052 GP053 GP054 GP055 GP056 GP057 GP058 GP059 GP060 GP061 GP062 GP063 GP064 GP065 GP066 GP067 GP068 GP069 GP070 GP071 GP072 GP073 GP074 GP075 GP076 GP077 GP078 GP079 GP080 GP081 GP082 GP083 GP084 GP085 GP086 GP087 GP088 GP089 GP090 GP091 GP092 GP093 GP094 GP095 GP096 GP097 GP098 GP099 GP100 GP1000 GP1001 GP1002 GP1003 GP1004 GP1005 GP1006 GP1007 GP1008 GP1009 GP101 GP1010 GP1011 GP1012 GP1013 GP1014 GP1015 GP1016 GP1017 GP1018 GP1019 GP102 GP1020 GP1021 GP1022 GP1023 GP1024 GP1025 GP1026 GP1027 GP1028 GP1029 GP103 GP1030 GP1031 GP1032 GP1033 GP1034 GP1035 GP1036 GP1037 GP1038 GP104 GP1040 GP1042 GP1043 GP1044 GP1045 GP1046 GP1047 GP1048 GP1049 GP105 GP1050 GP1051 GP1052 GP1053 GP1054 GP1055 GP1056 GP1057 GP1058 GP1059 GP106 GP1060 GP1061 GP1062 GP1063 GP1064 GP1065 GP1066 GP1067 GP1068 GP1069 GP107 GP1070 GP1071 GP1072 GP1073 GP1074 GP1075 GP1076 GP1077 GP1078 GP1079 GP108 GP1080 GP1081 GP1082 GP1083 GP1084 GP1085 GP1086 GP1087 GP1088 GP1089 GP109 GP1090 GP1091 GP1092 GP1093 GP1095 GP1096 GP1097 GP1098 GP1099 GP110 GP1100 GP1101 GP1102 GP1103 GP1104 GP1106 GP1107 GP1108 GP1109 GP111 GP1110 GP1111 GP1113 GP1114 GP1115 GP1116 GP1117 GP1118 GP1119 GP112 GP1120 GP1121 GP1122 GP1123 GP1124 GP1125 GP1126 GP1127 GP1128 GP1129 GP113 GP1130 GP1131 GP1132 GP1133 GP1134 GP1135 GP1136 GP1137 GP1139 GP114 GP1140 GP1141 GP1142 GP1143 GP1144 GP1145 GP1146 GP1147 GP1148 GP1149 GP115 GP1150 GP1151 GP1152 GP1153 GP1154 GP1155 GP1156 GP1157 GP1159 GP116 GP1160 GP1161 GP1162 GP1163 GP1164 GP1165 GP1166 GP1167 GP1168 GP1169 GP117 GP1170 GP1171 GP1172 GP1173 GP1174 GP1175 GP1176 GP1177 GP1178 GP118 GP1180 GP1181 GP1182 GP1183 GP1184 GP1185 GP1186 GP1187 GP1188 GP1189 GP119 GP1190 GP1191 GP1192 GP1193 GP1194 GP1195 GP1196 GP1197 GP1198 GP1199 GP120 GP1200 GP1201 GP1202 GP1203 GP1204 GP1205 GP1206 GP1207 GP1208 GP1209 GP121 GP1210 GP1211 GP1212 GP1213 GP1214 GP1215 GP1216 GP1217 GP1218 GP1219 GP122 GP1220 GP1221 GP1222 GP1223 GP1224 GP1225 GP1226 GP1227 GP1228 GP1229 GP123 GP1230 GP1231 GP1232 GP1234 GP1236 GP1237 GP1238 GP1239 GP124 GP1240 GP1241 GP1242 GP1243 GP1244 GP1245 GP1246 GP1247 GP1248 GP1249 GP125 GP1250 GP1251 GP1252 GP1253 GP1254 GP1255 GP1256 GP1257 GP1258 GP1259 GP126 GP1260 GP1261 GP1262 GP1263 GP1264 GP1265 GP1266 GP1267 GP1268 GP1269 GP127 GP1270 GP1271 GP1272 GP1273 GP1274 GP1275 GP1276 GP1277 GP1278 GP1279 GP128 GP1280 GP1281 GP1282 GP1283 GP1284 GP1285 GP1286 GP1287 GP1288 GP1289 GP129 GP1290 GP1291 GP1292 GP1293 GP1294 GP1295 GP1296 GP1297 GP1298 GP1299 GP130 GP1300 GP1301 GP1302 GP1303 GP1304 GP1305 GP1306 GP1307 GP1309 GP131 GP1310 GP1311 GP1312 GP1313 GP1315 GP1316 GP1317 GP1318 GP1319 GP132 GP1320 GP1321 GP1323 GP1324 GP1325 GP1326 GP1327 GP1328 GP1329 GP133 GP1330 GP1332 GP1333 GP1334 GP1335 GP1336 GP1337 GP1338 GP1339 GP134 GP1340 GP1341 GP1342 GP1343 GP1344 GP1345 GP1346 GP1347 GP1349 GP135 GP1350 GP1351 GP1352 GP1353 GP1354 GP1355 GP1356 GP1357 GP1358 GP1359 GP136 GP1360 GP1361 GP1362 GP1364 GP1365 GP1367 GP1369 GP137 GP1370 GP1371 GP1372 GP1373 GP1374 GP1375 GP1376 GP1377 GP1379 GP138 GP1380 GP1381 GP1382 GP1383 GP1384 GP1385 GP1386 GP1387 GP1388 GP1389 GP139 GP1390 GP1392 GP1393 GP1394 GP1395 GP1396 GP1397 GP1398 GP1399 GP140 GP1400 GP1401 GP1402 GP1403 GP1404 GP1405 GP1406 GP1407 GP1408 GP1409 GP141 GP1410 GP1411 GP1412 GP1413 GP1414 GP1415 GP1416 GP1417 GP1419 GP142 GP1420 GP1421 GP1422 GP1423 GP1424 GP1425 GP1426 GP1427 GP1428 GP1429 GP143 GP1430 GP1433 GP1434 GP1439 GP144 GP1440 GP1443 GP1446 GP1447 GP1448 GP1449 GP145 GP1450 GP1452 GP1458 GP1459 GP146 GP1460 GP1461 GP1465 GP147 GP1471 GP1475 GP1478 GP148 GP1481 GP1485 GP149 GP1490 GP1491 GP1495 GP1498 GP150 GP1508 GP151 GP1512 GP152 GP1520 GP1526 GP1529 GP153 GP1532 GP1537 GP1539 GP154 GP1540 GP1547 GP155 GP1553 GP1554 GP1556 GP1558 GP156 GP1561 GP1563 GP1564 GP1567 GP157 GP1572 GP1573 GP1579 GP158 GP1580 GP1581 GP1582 GP1588 GP1589 GP159 GP1596 GP1598 GP1599 GP160 GP1601 GP1603 GP1606 GP1608 GP161 GP1611 GP1613 GP1616 GP1619 GP162 GP1622 GP1626 GP1628 GP163 GP1632 GP1633 GP1635 GP1637 GP1638 GP164 GP1641 GP1642 GP1645 GP1648 GP1649 GP165 GP1652 GP1653 GP1655 GP1658 GP166 GP1661 GP1665 GP1668 GP1669 GP167 GP1671 GP1673 GP1675 GP1676 GP1679 GP168 GP1682 GP1683 GP1686 GP1689 GP169 GP1691 GP1692 GP1696 GP170 GP1700 GP1701 GP171 GP172 GP173 GP174 GP175 GP176 GP177 GP178 GP179 GP180 GP181 GP182 GP183 GP184 GP185 GP186 GP187 GP188 GP189 GP190 GP191 GP192 GP193 GP194 GP195 GP196 GP197 GP198 GP199 GP200 GP201 GP202 GP203 GP204 GP205 GP206 GP207 GP208 GP209 GP210 GP211 GP212 GP213 GP214 GP215 GP216 GP217 GP218 GP219 GP220 GP221 GP222 GP223 GP224 GP225 GP226 GP227 GP228 GP229 GP230 GP231 GP232 GP233 GP234 GP235 GP236 GP237 GP238 GP239 GP240 GP241 GP242 GP243 GP244 GP245 GP246 GP247 GP248 GP249 GP250 GP251 GP252 GP253 GP254 GP255 GP256 GP257 GP258 GP259 GP260 GP261 GP262 GP263 GP264 GP265 GP266 GP267 GP268 GP269 GP270 GP271 GP272 GP273 GP274 GP275 GP276 GP277 GP278 GP279 GP280 GP281 GP282 GP283 GP284 GP285 GP286 GP287 GP288 GP289 GP290 GP291 GP292 GP293 GP294 GP295 GP296 GP297 GP298 GP299 GP300 GP301 GP302 GP303 GP304 GP305 GP306 GP307 GP308 GP309 GP310 GP311 GP312 GP313 GP314 GP315 GP316 GP317 GP318 GP319 GP320 GP321 GP322 GP323 GP324 GP325 GP326 GP327 GP328 GP329 GP330 GP331 GP332 GP333 GP334 GP335 GP336 GP337 GP338 GP339 GP340 GP341 GP342 GP343 GP344 GP345 GP346 GP347 GP348 GP349 GP350 GP351 GP352 GP353 GP354 GP355 GP356 GP357 GP358 GP359 GP360 GP361 GP362 GP363 GP364 GP365 GP366 GP367 GP368 GP369 GP370 GP371 GP372 GP373 GP374 GP375 GP376 GP377 GP378 GP379 GP380 GP381 GP382 GP383 GP384 GP385 GP386 GP387 GP388 GP389 GP390 GP391 GP392 GP393 GP394 GP395 GP396 GP397 GP398 GP399 GP400 GP401 GP402 GP403 GP404 GP405 GP406 GP407 GP408 GP409 GP410 GP411 GP412 GP413 GP414 GP415 GP416 GP417 GP418 GP419 GP420 GP421 GP422 GP423 GP424 GP425 GP426 GP427 GP428 GP429 GP430 GP431 GP432 GP433 GP434 GP435 GP436 GP437 GP438 GP439 GP440 GP441 GP442 GP443 GP444 GP445 GP446 GP447 GP448 GP449 GP450 GP451 GP452 GP453 GP454 GP455 GP456 GP457 GP458 GP459 GP460 GP461 GP462 GP463 GP464 GP465 GP466 GP467 GP468 GP469 GP470 GP471 GP472 GP473 GP474 GP475 GP476 GP477 GP478 GP479 GP480 GP481 GP482 GP483 GP484 GP485 GP486 GP487 GP488 GP489 GP490 GP491 GP492 GP493 GP494 GP495 GP496 GP497 GP498 GP499 GP500 GP501 GP502 GP503 GP504 GP505 GP506 GP507 GP508 GP509 GP510 GP511 GP512 GP513 GP514 GP515 GP516 GP517 GP518 GP519 GP520 GP521 GP522 GP523 GP524 GP525 GP526 GP527 GP528 GP529 GP530 GP531 GP532 GP533 GP534 GP535 GP536 GP537 GP538 GP539 GP540 GP541 GP542 GP543 GP544 GP545 GP546 GP547 GP548 GP549 GP550 GP551 GP552 GP553 GP554 GP556 GP557 GP558 GP559 GP560 GP561 GP562 GP563 GP564 GP565 GP566 GP567 GP568 GP569 GP570 GP571 GP572 GP573 GP574 GP575 GP576 GP577 GP578 GP579 GP580 GP581 GP582 GP583 GP584 GP585 GP586 GP587 GP588 GP589 GP590 GP591 GP592 GP593 GP594 GP595 GP596 GP597 GP598 GP599 GP600 GP601 GP602 GP603 GP604 GP605 GP606 GP607 GP608 GP609 GP610 GP611 GP612 GP613 GP614 GP615 GP616 GP617 GP618 GP619 GP620 GP621 GP622 GP623 GP624 GP625 GP626 GP627 GP628 GP629 GP630 GP631 GP632 GP633 GP634 GP635 GP636 GP637 GP638 GP639 GP640 GP641 GP642 GP643 GP644 GP645 GP646 GP647 GP648 GP649 GP650 GP651 GP652 GP653 GP654 GP655 GP656 GP657 GP658 GP659 GP660 GP661 GP662 GP663 GP664 GP665 GP666 GP667 GP668 GP669 GP670 GP671 GP672 GP673 GP674 GP675 GP676 GP677 GP678 GP679 GP680 GP681 GP682 GP683 GP684 GP685 GP686 GP687 GP688 GP689 GP690 GP691 GP692 GP693 GP694 GP695 GP696 GP697 GP698 GP699 GP700 GP701 GP702 GP703 GP704 GP705 GP706 GP707 GP708 GP709 GP710 GP711 GP712 GP713 GP714 GP715 GP716 GP717 GP718 GP719 GP720 GP721 GP722 GP723 GP724 GP725 GP726 GP727 GP728 GP729 GP730 GP731 GP732 GP733 GP734 GP735 GP736 GP737 GP738 GP739 GP740 GP741 GP742 GP743 GP744 GP745 GP746 GP747 GP748 GP749 GP750 GP751 GP752 GP759 GP760 GP761 GP762 GP763 GP764 GP765 GP766 GP767 GP768 GP769 GP770 GP771 GP772 GP773 GP774 GP775 GP776 GP777 GP778 GP779 GP780 GP781 GP782 GP783 GP784 GP785 GP786 GP787 GP788 GP789 GP790 GP791 GP792 GP793 GP794 GP795 GP796 GP797 GP798 GP799 GP800 GP801 GP802 GP803 GP804 GP805 GP806 GP807 GP808 GP809 GP810 GP811 GP812 GP813 GP814 GP815 GP816 GP817 GP818 GP819 GP820 GP821 GP822 GP823 GP824 GP825 GP826 GP827 GP828 GP829 GP830 GP831 GP832 GP833 GP834 GP835 GP836 GP837 GP838 GP839 GP840 GP841 GP842 GP843 GP844 GP845 GP846 GP847 GP848 GP849 GP850 GP851 GP852 GP853 GP854 GP855 GP856 GP857 GP858 GP859 GP860 GP861 GP862 GP863 GP864 GP865 GP866 GP867 GP868 GP869 GP870 GP871 GP872 GP873 GP874 GP875 GP876 GP877 GP878 GP879 GP880 GP881 GP882 GP883 GP884 GP885 GP886 GP887 GP888 GP889 GP890 GP891 GP892 GP893 GP894 GP895 GP896 GP897 GP898 GP899 GP900 GP901 GP902 GP903 GP904 GP905 GP906 GP907 GP908 GP909 GP910 GP911 GP912 GP913 GP914 GP915 GP916 GP917 GP918 GP920 GP921 GP922 GP923 GP924 GP925 GP926 GP927 GP928 GP929 GP930 GP931 GP932 GP933 GP934 GP935 GP936 GP937 GP938 GP939 GP940 GP941 GP942 GP943 GP944 GP945 GP946 GP947 GP948 GP949 GP950 GP951 GP952 GP953 GP954 GP955 GP956 GP957 GP958 GP959 GP960 GP961 GP962 GP963 GP964 GP965 GP966 GP967 GP968 GP969 GP970 GP971 GP972 GP973 GP974 GP975 GP976 GP977 GP978 GP979 GP980 GP981 GP982 GP983 GP984 GP985 GP986 GP987 GP988 GP989 GP990 GP991 GP992 GP993 GP994 GP995 GP996 GP997 GP998 GP999 GPA GPS Gr8 Grab grabbed Grabber Grabbers Grabbin grabbing Grabbit Grabby Grab-It grabs grace graceful Gracefully Grace's Graci Gracias Gracie Gracie's gracing Gracio Gracious grad grade grades Grades? Grading Graduate graduated Graduates Graduating Graduation Grady Grae Graf Grafenberg Graff graffiti Graham Graham's Gram Gramma Gran grand Grandads Grandchild's Granddaughter’s Granddaughter's Grande 'Grande grandes Grande's Grandey grandfather Grandi grandma grandmas Grandma's grandmotherfirst Grandmother's grandpa Grandparents grandpa's Grandpas Grandpa's Grand's Grandson grandson's Granger Granger's Grannie grannies granny Grannymake loveer Granny-make loveer granny's Gran's Grant granted Grantia Granting grants Grape grapefruit grapes Grapes1 Grapes2 grapevines Graphic Graphically Grappa Grappler Grappler0-0The Grappler6-4 Grapplers Grappling grapvines Gras Grase Grasp grasping grbooty Grbootyfield Grbootyfingers Grbootyo grateful Gratification Gratifies Gratify Gratio Gratis gracanude Grave Gravel Graves Grave-y Graveyard Gravitiy gravity Gravy Gray Gray-Bearded Graycouch Graylandia Grayroom Grays Gray's Grayson Grayvibrator Graze Grazia Graziella grazing Grazy Greampie grease greased Greasy great greatest Greatful GreatGapes greatly Greatness Greats greazy Greece greedy Greek green Greencard Greenchair Greenchair2 Greenbanana Greene Greener Greenery Greenest green-eyed Greenfinger Greengreen1 Greengreen2 Greenmachine Greenmachine2 Greenpanties Greenpanties1 Greenpanties2 Greenpants Greenroom Greens Green's Greenshirt Greentop Greentoy Greenvelle Greenvibe Greenville greet greeting greetings Greets Greg Gregg Gregory Grenarz Greta Greta's Gretchen Gretta Grety grew Grey Grey’s grey-haired Greys Grey's Greyson Grid Gridiron Grief Griffin Griffin's Griffith Griffith's Griffol Grifos grill Grilled Grilling gril's Grim Grimes Grimlock Grin Grinch Grind Grinder Grinders Grindhouse Grindin' grinding grinds G-Ring Grinning Grins grip grips Grisha Grisha's Gristle gritty Groan groans grocer Groceries Grocery Groggy Groin Groins groo Grooling Groom Groomed Groomers Grooming Grooms groomsmen Groove Groovin Groovy Grope grope_and_poke Groped Groper Gropin' groping gropist Gros Gross Grotto ground Grounded Grounds group groupie groupies Groupies? Groupist Groupists Groupsex Grout Grove Grovel Groveling Grow Grow? Grower growing grown grows Growth Grrl Grrrr-eat Grub Grubber Grubbing Gruda grudge grueling Grunts Grunya G's G's? GSI g-spot G-spot? G-Spots g-string Gstring G-string Gstringteen GTFO GTL GTR Guadalajara Guage Guantana-hoe guarantee guaranteed guarantees guard Guard’s Guardado Guardados guardian Guardiana Guardians Guarding guards Guard's Gucci Guerra guess guessing guest guesthouse guests guff; Guffy guidance Guidanceinto guide Guided guides Guiding Guidos Guilhermina Guiliana's Guillette Guilt Guiltless Guilty Guilty? Guimaraes guitar Guitarfun Guitarist Guitars Gulch Gullet Gulliana Gullible Gulnea Gulp Gulped Gulping gulps gum Gumbo Gumby Gumdrop gummy gun Gunn Gunner Gunner's Gunns Gunn's guns Guns? Gunta Guppie Gurgle Gurgling Gurl guru gurus Gus Gush Gusher Gushers gushes gushing Gushy Gusta Gustas Gustav Gustavo Gustavo's Gusto gustozo Gut Gutierrez Gutierrez's guts Gutter Gutterballs Gutterman-files guy guy; Guy? guy?s guy’s guy=hot GuyAgain Guyanna Guy-Curious guy-next-door guys guy's Guys Guy�s Guys Guy's guys? guysdouble Guyta Guzman Guzzeler Guzzle guzzler Guzzlers Guzzles guzzlin guzzling Gwardo Gwen Gwendoline Gwyneth Gya Gyanti Gyaru Gya's gym Gym? Gymmelonie Gymnasjohnson Gymnasim Gymnbootytics gymnast Gymnast? Gymnast4-5 Gymnast5-5 GymnastCurrent gymnastic gymnastics gymnastREAL gymnasts Gymnasty Gymnist gymroom Gym's Gymtoy Gyn Gyna Gynecological gynecologist gynecology Gyno Gyongy Gyorgy Gypsy Gypsy's Gyration Gyro h H-2-HO H2o H3ll4SL00tz ha Ha’s Habana Había Habib Habit Habitación Habitat Habitats Habits Habitude Hablas Hablo hace Hacienda Haciéndolo Hack Hacked Hacker Hacks had hadcore Hades Hadid Hadjara Hadley Hadley's Hadn't Hag Haggles Hags haha HAHC Haide Haight Haikus hail Haileey hailey Hailey's Haili Haily hair Hairbrush Haircut Haircuts Hairdo hairdresser Hairdressers haired Hairpie hairs HAIRSPRAY Hairstyling Hairstylist hairy hairy-cat Hairy's Hairykitty Haitian Haize Hajni Hal Halee Halena haley Haley's half Half-lesbian half-naked Halftime Half-Time Halfway Halia Halie Halili Halina hall Hall’s Halle Hallelujah Halle's Hallo? Hallowanking Halloway halloween Hallowe'en HALLOWEEN halloweeny halloweiner Hallowiener Hallcat Halls hallsof Halltoy hallucinates hallway Hall-Way Hallwayfun Hallwaystripper Hally Hally's Halo Halona Halsted Halston ham HamaSam Hambre Hamburger Hamilfton Hamiltoe Hamilton HamiltonThe hammer hammered Hammer-Make love Hammerin Hammering Hammer's Hammie hammies Hammock Hamper Hams Hamsel Hamyna Han Hana Hanah Hana's hand Hand? handbag Handmelons handcuffed Handcuffing handcuffs handed Handee handful Handfulls Handfuls Handicam Handiwork handy handys handle Handled handler handles handlin handling handmade Handmaidens Handmaid's Handomatic Hand-picked Handprints hands Hands? Handsaw Hands-free handshake handsome Handsomely hands-on Handsy Handwriting handy handyman handyman's handymen Handywoman's hang hanger hangers Hangin hanging Hangout hangover Hangup Hanjob Hank Hanka Hankering Hanky Hanna Hannah Hannah's Hanna's Hans Hanson happen happen? happened happening Hap-penis happens Happenstance happier happiest happily happiness happy Happy? happy_halloween Happycamper Happy-Ending har Hara Harajuku harbooty Harbootyed harBOOTYment Harbootyment' Harbor harcore Harcourt hard -Hard HARD hard? Hard-Booty Hardball hard-boat hard-bodied Hardbodied hardbody Hard-Member hardcode hardcor hardcore Hard-core HARDCORE hardcore? Hardcore1 Hardcore2 Hardcore3 Hardcoreblooper Hardcorebeej HardcoreBTS hardcoreThe Hardcorevibe Hardcroe hardCruelest Harddrive harden Hardened hardening hardens harder Harder? hardest Hardin Harding hardly Hardnik hardon hard-on Hard-Ons Hardore hardpulled hardware Hardwood Hardworking Hard-working Hardworkingbabe HardX Hardy harem Harkmoore Harleen Harlequin harlet Harley Harleys Harley's harlot Harlots Harlow Harlowe Harlow's Harm Harmless Harmoni Harmonic Har-Monicca Harmonie Harmonious Harmonize harmony Harmony’s Harmony's harness Harper Harper's Harpoonhottie Harrbootyment Harrington Harris Harrison Harry Harsh harsh-make loveing Hart Hart? Hartley Hartley's Hartlova Hartman Hart-on Hart's Harua Haruhi Haruka Haruna's Harvard harvest Harvey has Hase Hase's Hashtag hasn't Hasta Hasti hat Hatch Hatchet hate Hated Hatemake love Hate-Make love Hatemake loveing Hate-Monger Haters hates Hath Hats Hatter Haughty Haul Haulin Haunted Haunting Haus? Hause Hausser Haute hav Havana Havanah have have? haven havent haven't Havent Haven't Havin having Havoc Havoc's Haw Hawaii Hawaiian Hawaii's hawk HawkHandsome Hawkins Hawks Hawt Hawthorne hay Haydee Hayden Hayes Haylee Hayley Hayli Haylie hayloft Hayride Hays HaysPart Haytni Hazard Hazardous haze hazed hazel Hazel's Hazer Hazes Haze's Hazey Hazing Hazy Hazzard H-Cup H-Cups HD HDV he he’ll he’s head head? headache Headaches HeadAnd headed header HeadEx Headfirst Headgear Headhunter heading Headknocker Headless Headlight Headlights Headliner Headlines headlock headlocks Headmaster Headmaster? Headmaster's Headmistress Headphones Headquarters Headrush heads headshop Headshot Headshots Headspace Headstand Headstrong head-thrashing Headucation Heady Heal Healed Healer healing heals health healthcare healthy Heaping hear heard Hears heart heartA Heartache Heartbeat Heartbeats Heartbreak Heartbreaker Heartbreakers Hearted Heartfelt Hearth Heartpanties hearts Heart's Heart-Shaped HeartShut Heartskirt Heartthrob Heartwarming Hearty heat Heated Heath Heathenous heather Heathers Heather's Heatin heating heats Heatwave heaven Heaven? heavenly heavens Heaven's Heaven-sent heavily Heaving heavy Heavy-turkeyed Heavy-Hanging Heavy-Hootered Heavyweight Heck Heckler Hectate Hector Heddie hedges Hedo Hedonism Hedonista Hedonistas Hedonistic Hedvika heel heeled heeling heels Heelsandstockings Heelsplay1 Heelsplay2 Heelsshoot heely Heena Hef's Hefty Hei Heidi Heidi's Heidy Height Heighten Heights Heine heiney heinie Heinous Heiress Heirloom heist Hekix Hela held Helen Helena Helena? Helena’s Helena's Helene Helfire Helga Helian Helimemberter Helicopter Helimas Helionix Helios Helisika hell hella Hell-Candy Hellcat Hellcats Hellen Hellfire Hellfire's Hellhound Helli Hellie Hellish hellIsis hellLosers hellMade hello Hello Brooke Wylde Hellooo Hellooooo HelloVeronica Hellraiser Hells Hell's hellThe Helluva Hellvira Helly helmet help help? helped helper helpers helpful helping helpings helpless helplessFingered helplessly Helplessness helplessPowerful helplessStripped helps Helsing Helsinki? Helvetia Hemingway Hempburne Hen Hendrick hendrix Hendrix's Henedy Henessy Henessy's Henger Hengher Hengst Heni Henley Henna Hennessey Hennessy Hennesy Henriett Henrietta Henriette Henry Hens Hentai her -Her HER her; her? Her-Booty-Meant herCries Hercules Herda here here? here's Heres Here's Heretical Hermana Hermanastro Hermanita Hermano Hermionie Hermit Hermosa Hermosas Hermyna hero Heroes Heroically Heroin Heroína Heroine Heroines Hero's Herrera Herrera's Herringbone Herrlich hers herse herself Hershey Herst herthen herCans he's Hesitant hesitation Hettie's Hetty Heven Hevika Hexe Hexxx Hey Heydays Heys Heywood HG012 HG013 HG014 HG015 HG017 HG018 HG019 HG020 HG021 HG022 HG023 HG024 HG025 HG026 HG027 HG028 HG029 HG030 HG031 HG032 HG033 HG034 HG035 HH HH-cup hi hialeah hiatus Hibachi Hibernation Hibiscus Hiboys Hicks Hicksville hidden Hiddenly hide Hide-and-seek Hideaway Hideous Hideout hides Hide-The-Thong hiding Hiearchy Hielo high Highbrow high-clbooty higheels High-end High-Energy Higher highest high-heel high-heeled high-in High-life Highlight highlighter Highlighting Highlight's highly High-Maintenence Highness High-Power High-Powered High-Quality Highrise High-rise highs Highschool high-speed High-Stakes high-style High-Sugar high-tops High-Waisted highway highways Hija Hijab Hijack Hi-Jacker hijacks HIJASTRO hi-jinks Hijinks Hi-jinks Hijinx Hijinxs Hijo Hikaru's hike Hiked-Up hiker Hikers hikes hiking Hilary Hilda Hilix hill Hillary Hillarys Hillary's Hillbillly Hillbilly Hilled hills Hill's Hills's Hillton Hilo Hilson Hilt Hilton Hilton's him him? Himalayas Him--And Hime Hime’s Himedorei Himera Hime's Himita himself Hina Hinade Hinata Hind Hindsight Hiney Hinnian Hint hints hip Hip-Hop Hip-hope hippie Hippies Hippo Hippy hips Hipster Hipsters hiquita hire hired hiree Hiremia Hires hiring Hiroko Hirst Hirsute his Hispania Hispanic Histoire Historical history hit Hitachi Hitch hitchmembering Hitched hitcher Hitches Hitchmake loveing hitchhiker hitchhiker's Hitchhikers Hitchhiker's hitchhiking Hitch-Hiking hitchiker Hitchin Hitching Hither Hithoxia Hitman Hitomi Hitomi's hits Hitter Hitters Hittin hitting HIUGE Hix Hix? Hix's Hiyori HLF Hmm hmmmm hms125 hms127 ho Ho… Hoagie Hoarder Hoarding hobbies hobby Hobie Hobo Hockey Hockey-Can Hocus Hodessa Ho-Down hoe Hoedown Hoelidays hoes Hoe's HOES Hoesiery Hoetel HOE-tel Hog Hogar Hogger Hogging Hogh Hogited hogtie hogtied hogtiedAnal Hogtiedand HogtiedBig HogTiedcom Hogtied's HogtiedSucks HogtiedThe HogtiedTying HogtiedWe hogtiefinger Hogwarts Hohan Ho-ho-hot Hokey Hola Holashower hold Holden holder Holding holds hold-up hole Hole? Holed Holefinger Hole-in-One Hole-istic holes holes?Pretty Holey Hole-y Holi Holic holiday Ho-liday HOLIDAY holiday_hottness holidays Holiday's HOLIDAYS Holidayslast holidaze Holier Holiest Holistic Holland Hollander Hollanderanal Holland's Holli Holliday hollie Hollow Holloway holly 'Holly holly? Holly’s Hollyday Holly-Days Holly-Land Hollys Holly's HOLLYWEIRD Holly-Whore hollywood Hollywood's HollyWould Holm Holmes Hologram Holographic Holosexual holster Holy Holyjohnson Holy-hotness Holyjeans Holz Homage Homance home home? Homebody homeboy Homebreaker Home-Brew Homebuyers Home-Called Homecoming Home-EC Homegrown homeless homemade home-made Homemade Home-made homemaker Homeowner Homer Homeroom Homer's Homerun Homeschool Home-school Homeschooled Home-Schooled HomeSharing Home-Sharing Homesick Hometown homevideo homevids homework Homeworks Homewrecker Homewreckers Homewrecking homie homies Homie's Homme Homo HomoErotic Homonym HomoPod Honcho Honduran hones honest Honestly honey Honey' Honey? Honey’s Honeycomb Honeycups Honeydrop honeyed honeylee honeymoon Honeymooners honeypot honey-pot Honeypot honeys honey's Honeys Honey's HONEYS Honey-Tressed Honeywell Hong Honies Honig Honk Honkers Honking Honky Honney Honoka Honoka's Honolulu Honor Honors Honour Honour's Hoo Hooch hoochie Hoochies hood hoodie Hoodrat Hood-Rats hoods hook hookah Hookahgirl Hookahhottie Hookahvibe Hooke hooked hooked_on_latinas hooker Hookers Hooker's Hookey Hookie Hookin Hooking hooks Hookuh hookup hook-up Hookup Hook-up Hookups hooky Hoola Hoola-Hoop hoop Hooped Hooping hoops Hoopty Hooray Hoosier hooter Hooterrific hooters Hooterville hooterween hoover hop Hopalot hope hoped Hopeful Hopefuls hopeless hopelessly hopes Hope's Hop-hop hoping Hopkins Hopper hopping Hoppy hops hopscotch Hop-scotching Hopskeet hor Horde hordes horizon horizons Horizont Horizontal Hormone hormones Hormy horn Hornball hornballin horn-balls Horndog Horne Horned Horney Hornicitis hornie hornier horniest Horniest? horniness Hornio Horno Horno's Hornstein horny horny? Hornycratic Hornyness Hornyteen horoscope Horrible horrific Horror Horrors Hors horse Horseback HorseCowboy horseHard horseplay Horseplaying horserider horse-riding Horses Horseshoe Horsin Horsing Hos Ho's HOs hose hosed Hoser hoses Hose-Wearing Hosiery Hosing Hospitable hospital hospitality Hospitalized host hostage Hostages Hosted hostel Hostess Hostesses Hostile hosts hot hot? hot_dog_stand Hot-Bootyed hot-blooded Hot-Bodied Hotbox Hotcore hotdog hotdogging hotel Hotelroom hotels=Horny Hotie Hotkinkyjo Hotlanta Hotline Hotly hotness Hot'n'horny Hotoni Hotpants Hotpinkcat Hotrod hots Hot's Hotshot HotSpot hotster Hott Hot-tempered hotter Hotter? hottest hottie Hottie' HOTTIE Hottie’s Hottieland hotties hottie's Hotties Hotties' Hottie's Hotties? Hottness hottster Hottub Hot-Tub Hottubaction hotty hotwife Hotwife's Hotwives HotYlek Houdini hound hour hour? Hourglbooty hours Hourwife house house? house_auditions Houseboy Houseboys Housebroken Housecall housecalls Houseguest Household Househusband housekeeper housekeeper's Housekeepers Housekeeper's Housekeeping housemaid Housemaid's Housemates House-Owned House-Party Housepet Houses Housesitter Housesitting House-Sitting Housewarming housewife housewifes Housewife's Housewive housewives housework Housework? Housing Housmaid Houston houswifes hovering how Howard Howdy Howell Howell's However Howie Howl Howling hows How's Hoyt HR HR' Hrisanta Hrs Hsu HT HTML huband's Hubba Hubbie Hubbie's hubby hubby's Huddle Hudson Hudson's HudsonThe Huff hug Hugarian huge Huge-Meloned Huge-Member Hugemember's huge-johnsoned Hugedong HugeGapes Hugely HUGE-MAMMARIES huge-meloned Hugest Huge-Canted Hugevibe Huggin Hugging Hugh Hughes Hugo hugs Huh Hui hula Hulahoop Hula-Hooping Hulk hum human Humanitarian humanity Humans humble Hum-Bug-Her humdinger Húmedo humilated humilation humiliate humiliated humiliatedLong humiliates humiliating humiliation Humiliaton Humility Hummer Humming Hummingbird humms humongous Humor hump hump-a-lot Humpday Humped Humper Humpers Humpette Hump-happy Humpin Humpin' humping Humpme Humps Hump-Starting Humpy Hunagarian hundred hung hungarian Hungarian? Hungarians Hungarian's hungary Hungary's hunger Hungers Hunger-soothing HUNGover hungry Hungy hunk hunks hunk's Hunks Hunk's Hunni Hunnie Hunnies hunny huns hunt Hunted hunter Hunterland hunters Hunter's Huntin' hunting HuntingCandidate Huntley HuntLorelei Huntress hunts Hunt's Huntsman Hupnosis Hurdle Hurley Hurrah Hurricane hurries hurry hurrywait hurt hurt? Hurtin hurting hurts husbanb's husband Husband? Husband??? husband’s husbands husband's Husbands Husband's Hush Hussie Hussy Hustla HustlaBall hustle Hustled hustler Hustlers Hustler's Hustles Hustling hut Huxley HXC hybrid Hyde Hyde-The-Salami Hydi Hydie Hydii Hydra Hydrated Hydraulic hydroplane Hygiene Hygor hymen Hynten Hype Hyped Hypemares Hyper Hyperactive Hypersexuality Hypnagogia Hypno hypnotherapist hypnotic Hypnotica Hypnotiq hypnotize Hypnotized Hypnotizing Hypocritical hysteria Hyteen i I I' I̵ I? I?m I’ I’ll I’m I’ve Iâ?? Iâ??m Iamalexis ian Ibarra Ibarra's Ibiza iBone Icarus ice ice-cream Icecream Icecreamplay Ice-Cube Iced Ice'd Icehot iceicebaby Iceing Icekitchen Icelandic Iceman Icenipples1 Icecat Ices Icey Ich ichelle Ichijo Ichika icicle Icicle's Icing icky iMember Icon Iconic icons I-Cup Icy Id I'd ID ID? Ida Idaho idea ideal Idealize ideas Idee Identical Idencany Ideph Idilius Idiot Idiota idiots Idle idly Idol Idolas Idolatry Idols IDs? idyll Idyllic Ielza Ieva Ievina if if? Ifamora iMake loveable IG Iggy Ignea ignite Ignites ignition Ignorant Ignore ignores Ignories Igor Iguacu Ihra II Iibhabhu III Iily Ikan Ikon Ikonas Il Ilabete Ilan Ilana Ildi Ildico Ildy Ileen Ilia Ilia’s Ilias Ilina Iljimae ill i'll Ill I'll Illegal Illicit Illikas illness ILLUMINATED Illumination illusion Illusionist Illusions Illustrated Illustrious Ilna Ilona Ilona’s Ilona's Ilova Ilze Im I'm 'Im I'M Ima Imaculas image Imagery Images imagi imaginable Imaginary Imaginasian imagination Imaginations imagine Imagined imagines imagining Imaginings Iman Imani iMasturbate Imbibing Imbued Imitates Imitating immaculate Immanuel immature immediate Immediately immense Immerse Immersed immersion Immersive Immigrant immigrants immigration Immobility immobilization Immobilized immobilizing Immoral Immortal Immunity imoaN Imogene impact impacted Impacto Impale impaled impales Impaling Impbootyioned Impatiens Impatient Impeccable Impede Imperfection Impish Implant Implanta implants Implements implodes Impomptu import importance Important Important? Imported Imports impose impossible Imposter Impostor Impotence impound Impounding Impregnate Impregnated Impregnating Impregnation Impress impressed impresses Impressing Impression Impressionable Impressions impressive imprints Imprisonment Impromptu Improper Improv improve improvement Improvements improves Improvin Improving Improvisation Improvise Improvised Impudent Impugn Impulse Impulses Impulsive Impulsiveness Impure Impurity Im-pussible in in Aaliyah's in; in? in_the_garden_of_eden In…But Ina Inadequacy Inadequate In-A-Gadda-Da-Vida Inalka Inamorata Inappropriate Inari Ina's Inasikaias Inaugural Inauguration Inbo Inc Incandesce Incandescence Incantu incentive Incentives inception Incestuous inch inches Inches--No Inchworm Incident Incision Incite Include included includes including inclusive inmembernito Incognito income Incomparable Incompetent Inconceivable incongruity Incongruous Inconvenient Incorporated incredible incredibly Incubus Inculcation Indecency Indecent Indecisive indeed Indemni-Ds Indemnity Indenfor Independance Independant independence Independencia Independent In-Depth indescribable InDesiree Indestructible India Indian Indiana Indianna Indianna's Indians India's Indica Injohnsonment Indietro Indifference Indigo Indigo's Indira Indirect Indiscreción Indiscretion Indiscretions Indonesian indoor Indoors Indra Inducement inducer induces inducing Induct inducted indulge Indulgence Indulgences Indulgent indulges indulging Industrial industry Indy Ineffable Ines inescapable Inessa inevitable inexperienced Inexplicable In-family infamous Infantile Infatuate infatuated Infatuating Infatuation Infectious Inference Infernal Inferno Infestation Infidelicanty Infidelity Infiel Infield infiltrates Infiltrating Infiltration Infinite Infinitea Infinity Infirmary Inflatable Inflate Inflated inflates Inflict inflicted Inflicts influence Influenced Influencer Influencers Influences Influencia info info\ inform Informal Informant Information InFRICTION Infuse Infused Ing Inga Inga's Ingenious Ingenue Inglourious Ingredient Ingredients Ingrid Inhale inhales In-Her Inherent Inherit Inheritance Inheriting Inheritor In-Her-Peace In-her-view Inhibition inhibitions in-home Inhuman Inia Inilian inimitable Initha initiate Initiated initiates initiating initiation Initiations Initiative injected injection Injections injured Injuries injury ink Inka inked inked-up In-Kleined Inko Ink's Inky In-Law In-Lawful In-Laws Inledning in--Lexy in-love Inmaris inmate inmates InMe InMy inn Inna Innaki Innapropriate Inna's Innate inner Innerself Innerspace Inner-Space INNES Inness Innessa innie Innings Innnocence inno innoce innocenc innocence Innocence? Innocencia innocent 'Innocent INNOCENT Innocent? Innocentfun innocently InnocentTrinity innocentWe Innovation Innovations Innuendo Inny In-Out Inpromptu Inquisition inquisitive In-Room Ins Insanal insane insanely insanity insanity? insanityWe Insatiability insatiable insatiables Insatiably insatiate insatiately Inseamly Insecurity Insemination Inseminator insert Insertables inserted Inserters insertibles inserting insertion insertions inserts Insex Insextion Insiatiable inside Inside-Her Inside-Out Insider Insieme insight Insights Insignis Insinuation Insist Insolia Insomnia Insomniac Insomnio inspect inspected Inspect-Hers Inspecting inspection Inspections Inspector inspectors Inspects inspiration Inspirational inspirations Inspirazione inspire inspired inspires inspiring Insta Insta-boner Instafilm Instagirl Instagram Installation Installed Installer installment Installments Insta-MILF Instance instant instantly Insta-Stalker Instatiable instead Instigating Instigation Instigator Instinct Instinctive Instincts Inscanute Instruct instruction instructional instructions instructor instructors instructor's Instructors Instructor's Instructs instrume instrument Instrumental Instruments Instuctions Insubordinate Insubordination Insulo insurance insured Insurgent In-sync int Inta intake IntakeExtreme IntakeFeatured IntakeSado-Masochists IntakeSelecting IntakeThe IntakeThree Intame Inte intel Intellectuals Intelligence intence intense intensely Intenseminivibe intensifies Intensify Intensions Intensita' intensity intensive Intensively Intent Intention intentions Intenxa inter Interacial Interaction Interactions Interactive Interceptado Intercepted Interchange Interconnected intercourse Interdict interest interested interesting interests Interface Interference Intergalactic Interim interior Interiors Interlaced interlocked Interlude interludes Intermezzo intermixed Inter-mixed intern internal Intern-al Internally international interne internet Inter-net InternetsCom internetThe Interning intern's Interns Intern's internse internship Internships Interoffice interogati Interogating Inter-Oral Interpreter's Interprets interracial Interracial' INTERRACIAL interraciall Interrogate Interrogated interrogates Interrogating interrogation Interrogator interrupt interrupted Interrupting Interruption Interruptions interrupts intertwined Interupted Interval Intervenes Intervention Interventionzz interview interview' Interview Interview? Interview1 Interview2 Interview3 INTERVIEW-A INTERVIEW-Abigail INTERVIEW-Alix INTERVIEW-Brooklyn interviewed interviewee Interviewfun Interviewing INTERVIEW-Mommy INTERVIEW-Nurse INTERVIEW-Penny interviews INTERVIEWS_Corrupt INTERVIEWS_Couples INTERVIEWS-A INTERVIEWS-Abigail INTERVIEWS-Alana INTERVIEWS-Alina INTERVIEWS-Ana INTERVIEW-Sara INTERVIEWS-Ariana INTERVIEWS-August INTERVIEWS-Bree INTERVIEWS-Carmen INTERVIEWS-Carter INTERVIEWS-Cherie INTERVIEWS-Coming INTERVIEWS-Dana INTERVIEWS-Emma INTERVIEWS-Holly INTERVIEWS-India INTERVIEWS-It's INTERVIEWS-Janice INTERVIEWS-Kota INTERVIEWS-Lena INTERVIEWS-Lollipop INTERVIEWS-Misty INTERVIEWS-Mom's INTERVIEWS-Nina INTERVIEWS-No INTERVIEWS-Phoenix INTERVIEWS-Samantha INTERVIEWS-Sara INTERVIEWS-Sasha INTERVIEWS-Serena INTERVIEWS-Shyla INTERVIEWS-Stills INTERVIEWS-Swim INTERVIEWS-Tanya INTERVIEWS-Tara INTERVIEWS-Teanna INTERVIEWS-Tech INTERVIEWS-Teen INTERVIEWS-Tiff INTERVIEWS-Under INTERVIEWS-Vanessa INTERVIEW-Tasha Interviewtwo INTERVIEW-Vanessa inTHE Intima intimacy intimate intimately Intimidates Intimita Intimity Intimity? Intimo incanation In-Can-Pendence Intl into Intolerable intoOblivion intoSexual Intovis In-town intoxicate intoxicates Intoxicating intoxication Intra-Office Intrigante Intrigue intrigued intriguing Intro introduc introduce introduced Introduces Introducing IntroducingKenna IntroducingValentina introduction Introspection Introspective intruder Intruders Intruding intrusion Intrusive Intuition Inutility invade invaded Invader Invaders invades Invading Invasian invasion Invasión Invasive Invented Invention Inventions inventive INVERTAMAKE LOVEED inverted invest Investigadora investigated investigates Investigating investigation Investigations investigator Invescanure Investment Investor Investor-In-Law invisible invitation invite invited invites inviting Invito involved involves Inward InWay Io Ioana Ioana's Iodinegirl ion Iona Ionella Iowan iPhone Iplay ir Ira Ira's Ireland Ireland; IrelandMake loveed IrelandPorn IrelandSpread Iren Irena Irene Irenes Irenka Iresistable Irina Irina's Iris Irisela Irish Irisha IRL Irma Iroha's iron Ironfingers ironing Irons IronToy Irrational Irreconcilable irrepressable Irresilian Irresistable irresistible irresistibly Irresponsible Irresponsible? Irrigator IRS Iruki is Is? Isa Isaac Isaacs Isabel Isabela Isabeli Isabell isabella isabelladior Isabellas Isabella's Isabelle Isabelli Isabell's Isabelly Isabel's Isadora Isaiah Isamar isCATEGORY Ischia Ishii IsHustling Isiah Isiah's isis Isis's Isizzu Iskra Isla island islands Isles isn’t isn't Isnt Isn't Isobel Isolation iSpank Israel Israeli-American Issabella Issac Issak issue issues Issues' ist Istanbul IsThe iSZ1532 it it It it? It`s It‘s it’s Itâ??s Itaka Italia italian Italiana Italia's Italy itch Itchin itching itMember it-Countdown item Itenoe Itinero It'll Itmenias ItNow Ito itR its it's Its It�s Its It's Itsal Itself Itsy Itsy-Bitsy Itty Itty-bitty It'z Iuno IV IV001 IV002 IV003 IV004 IV005 IV006 IV007 IV008 IV009 IV010 IV011 IV012 IV013 IV0134 IV0135 IV014 IV015 IV017 IV018 IV019 IV020 IV021 IV022 IV023 IV024 IV025 IV026 IV027 IV028 IV029 IV030 IV031 IV032 IV033 IV034 IV035 IV036 IV037 IV038 IV039 IV040 IV041 IV042 IV043 IV044 IV045 IV046 IV047 IV048 IV049 IV050 IV051 IV052 IV053 IV054 IV055 IV056 IV057 IV058 IV059 IV060 IV061 IV062 IV063 IV064 IV065 IV066 IV067 IV068 IV069 IV070 IV071 IV073 IV074 IV075 IV076 IV077 IV078 IV079 IV080 IV081 IV082 IV083 IV084 IV085 IV086 IV087 IV088 IV089 IV090 IV091 IV092 IV093 IV094 IV095 IV096 IV097 IV098 IV099 IV100 IV101 IV102 IV103 IV104 IV105 IV106 IV107 IV108 IV109 IV110 IV111 IV112 IV113 IV114 IV115 IV116 IV117 IV118 IV119 IV120 IV121 IV122 IV123 IV124 IV126 IV127 IV128 IV129 IV130 IV131 IV132 IV133 IV136 IV137 IV138 IV139 IV140 IV141 IV142 IV143 IV144 IV145 IV146 IV147 IV148 IV149 IV150 IV152 IV153 IV154 IV155 IV156 IV157 IV158 IV159 IV160 IV161 IV162 IV163 IV164 IV165 IV166 IV167 IV168 IV169 IV170 IV171 IV172 IV173 IV174 IV175 IV176 IV177 IV178 IV179 IV180 IV181 IV182 IV183 IV184 IV185 IV186 IV188 IV189 IV190 IV191 IV192 IV193 IV194 IV195 IV196 IV197 IV198 IV199 IV200 IV201 IV202 IV203 IV204 IV205 IV206 IV207 IV208 IV209 IV210 IV211 IV212 IV213 IV214 IV215 IV216 IV217 IV218 IV219 IV220 IV221 IV222 IV223 IV224 IV225 IV226 IV227 IV228 IV229 IV230 IV231 IV232 IV233 IV234 IV235 IV236 IV237 IV238 IV239 IV240 IV241 IV242 IV243 IV244 IV245 IV246 IV247 IV248 IV249 IV250 IV251 IV252 IV253 IV254 IV255 IV256 IV257 IV258 IV259 IV260 IV261 IV262 IV263 IV264 IV265 IV266 IV267 IV268 IV269 IV270 IV271 IV272 IV273 IV274 IV275 IV276 IV277 IV278 IV279 IV280 IV281 IV282 IV283 IV284 IV285 IV286 IV287 IV288 IV289 IV290 IV291 IV292 IV293 IV294 IV295 IV296 IV297 IV298 IV299 IV300 IV301 IV302 IV303 IV304 IV305 IV306 IV307 IV308 IV309 IV310 IV311 IV312 IV313 IV314 IV315 IV316 IV317 IV318 IV319 IV320 IV321 IV322 IV323 IV324 IV325 IV326 IV327 IV328 IV329 IV330 IV331 IV332 IV333 IV334 IV335 IV336 IV337 IV338 IV339 IV340 IV341 IV342 IV343 IV344 IV345 IV346 IV347 IV348 IV349 IV350 IV351 IV352 IV353 IV354 IV355 IV356 IV357 IV358 IV359 IV360 IV361 IV362 IV363 IV364 IV365 IV366 IV367 IV368 IV369 IV370 IV371 IV372 IV373 IV374 IV375 IV376 IV377 IV378 IV379 IV380 IV381 IV383 IV384 IV385 IV386 IV387 IV388 IV389 IV390 IV391 IV392 IV393 IV394 IV395 IV396 IV397 IV398 IV399 IV400 IV401 IV402 IV403 IV404 IV405 IV406 IV407 IV408 IV409 IV410 IV411 IV412 IV413 IV414 IV415 IV416 IV417 IV418 IV419 IV420 IV421 IV422 IV423 IV424 IV425 IV426 IV427 IV428 IV429 IV430 IV431 IV432 IV433 IV434 IV435 IV436 IV437 IV438 IV439 IV440 IV441 IV442 IV443 IV444 IV445 IV446 IV447 IV448 IV449 IV450 IV451 IV452 IV453 IV454 IV455 IV456 IV457 IV458 IV459 IV460 IV461 IV462 IV463 IV464 IV465 IV466 IV467 IV468 IV469 IV470 IV471 IV472 IV473 IV474 IV475 IV476 IV477 IV478 IV479 IV480 IV481 IV482 IV483 IV484 IV485 IV486 IV487 IV488 IV489 IV490 IV491 IV492 IV493 IV495 IV496 IV497 IV498 IV499 IV500 IV501 IV502 IV503 IV504 IV505 IV506 IV507 IV508 IV509 IV510 IV511 IV512 IV513 IV514 IV515 IV516 IV517 IV518 IV519 IV520 IV521 IV522 IV523 IV524 IV525 IV530 Iva Ivan Ivana Ivana's Ivanka Ivanna Ivanova Ivans Ive I've Ives Ives's Iveta Iveta's Ivette Ivette's Ivetti Ivey Ivi Ivija Ivilis Ivon Ivona Ivonne Ivories ivory Ivory's Ivy Ivy65279; Ivys Ivy's Iwia Iwia's IX Iya Iyana Iyesna Iyeva Iyulta Iz Iza Izabela Izabella Izabelly Izadora Izamar Izamar's Iza's Izi Izy Izy-Bella Izzy Izzy-bella j Ja Jabber Jaboos Jace Jacen jack jacked Jackeline Jackelyn Jacker Jacket Jackhammer Jackhammered Jackhammering Jacki jackie Jackie? Jackie's Jackin jacking Jack-It Jackknife Jacklyn Jackman Jackmon Jackoff Jack-off Jackoffanator jackpot jacks Jack's Jackson Jacky Jaclene's Jaclin Jacline Jaclyn Jaclyn's Jacme jacob Jacobs jacquelin Jacqueline Jacques jacusi Jacusy jacuzzi Jacuzzitoy Jacy Jacyline Jada Jada’s Jada-Da Jada-Dee Jadan Jadas Jada's Jade Jade’s Jaded Jadee's Jaden JadenThe JAdePart Jades Jade's Jadis Jadora Jadore Jadorlabit Jadyn Jae Jae’s Jaeline Jaelyn Jae's Jager Jagger Jagger's Jag-Her Jaguar Jahna Jahoobie jahoobies Jai Jaiden Jaie Jaiere jail Jailbait Jailbird Jailbirds jailbreak Jailbreaks jailhouse Jailing Jailor Jaime Jaimes Jaimie jaimy Jaine Jaite Jake Jakeline Jakie Jakki Jakob jakuzzi Jalace Jalapeno Jalif Jalique jam Jama Jamacia Jamaica Jamaican Jamal Jamás Jambalaya Jamboree James JamesDay Jameson James's JamesSophistication Jamey jamie Jamie's Jamison Jamma jammed Jammen Jammer JAMMIES jammin Jammin’ jamming jams Jamsson Jana Janae Janay Jandi Jane Jane? Jane’s Jane1 Janea Janeiro Janelle Janelle's Janes Jane's Janessa Janessa's Janet Janeth Janet's Janett Janette Janeva Janey Janice Janie Janika Janilla Janine janitor Janitorial Janitor's Janna Janne's Jannete Janny Jansen Jansen's Janson Janson's Jantzen Jantzen's January January's Jany Japan Japanese JapanMarica Jaquelin Jaqueline Jaquelyn Jaquline Jar Jarako Jared Jarek Jargon Jark Jarod JARS Jarushka Jas Jasime Jaslene Jaslin Jasline Jasmeen jasmin Jasmina Jasmine Jasmines Jasmine's Jasmin's Jasmyn Jasmyne JASNA jason Jason's Jasper Jbootyica Jbootyie Jaszmina Jaunt Javen Javier Jaw Jawbreakers Jaw-dropping JAWS Jax Jaxin Jaxon Jaxton Jaxx Jaxxa Jaxxx jay Jay’s Jayce Jaycee Jaycie Jayda Jayda's Jayde Jayded Jayden Jaydence Jayden's Jaye Jay-ed Jayes Jaye's Jayla Jaylee Jaylee's Jaylen Jaylene Jaylene's Jaylie Jaylin Jaylyn Jaylynn Jayma Jayme Jaymes Jaymus Jayn jayna Jayna's Jayne Jayne's Jayogen Jay's Jayson Jayy Jazella Jazling's Jazlyn Jazmeen jazmin Jazmine Jazmyn Jazmyne Jazmyn's Jazy jazz Jazzi Jazzmin Jazzy JC JDx3 Je jealous Jealous? jealousy Jean Jeanette Jeanie Jeanie? Jeanine Jean-Luc Jeannie jeans Jean's Jeans2 Jeanshorts Jeanskirt Jeanskirt2 Jeanspanties Jeanspanties2 Jeansstrip Jeanstrip1 Jeb Jecica Jecika Jed Jedi Jeep Jeeves Jeez Jefa Jefe Jeff Jeffrey Jeggings Jekyll Jelena Jelentes Jelice Jello jelly Jem Jemini Jemma Jem's Jen Jena Jenae Jenaveve Jenaveve's Jenelle Jenessa Jenet Jenevieve Jenga Jeni Jenia Jenifer Jenla Jenn Jenna Jennacide Jennas Jenna's Jenne Jenner Jenner's jenni Jennie Jennifer Jennifer? Jennifers Jennifer's Jenning Jennings Jenni's jenny Jennyfer Jennyfer's Jenny's Jens0n Jensen Jensen's Jenson Jenson's Jentina Jeny Jenya JenyBaby Jeny's Jeoparjohnson Jeopardy jer Jera Jeremiah Jeremie Jeremy Jeremy's Jericha jerk Jerkaholics jerked Jerker jerkin Jerkin' jerking jerkoff jerks Jerky Jermaine Jerri Jerrika Jerry Jersey Jerseys jerzi Jerzi's Jerzy Jesebella Jesibelle Jeska Jess Jessa Jessa’s Jessae Jessa's Jesse Jesse’s Jesse's Jessi jessica jessica's Jessicas Jessica's Jessicca Jessie Jessie? Jessies Jessie's Jessika Jessi's Jessop Jess's Jessy Jessyca Jessye Jessyka Jessy's Jester Jester0-0 Jesting Jesus Jet Jett Jett's Jeunes Jeva jewel Jewel’s Jeweldefinitely Jewell Jewelled Jewells jewelry jewels Jewel's Jewels's Jewelz Jewing Jewish Jews Jey Jeycy Jezabel Jezabelle Jezabel's Jeze Jezebel Jezebelle Jezel Jezelle Jezus Jezzabel Jezzabelle Jezzicat Jhenifer Jhons Jia Jia's Jibber Jigging jiggle Jiggled Jiggler Jigglers Jiggles jigglin Jiggling Jiggly jiggy jigs Jigsaws Jill Jill' Jill’s Jilled Jillian Jillian's Jillin Jills Jill's Jilly jilted Jim Jimena Jimmie Jimmy Jimmy's Jina jingle Jingles Jinx Jiselle jism Jitka Jitsu Jitters Jiu Jive Jiz jizm jizz Jizzardry Jizz-Cuzi Jizzcuzzi jizzed Jizzelle jizzes Jizzing Jizzle Jizzm Jizzperiment Jizz-Queen Jizzwold jizzy Jizzz JJ JJ-cup JL Jmac J-mac JMac JMac? Jmac’s JMac's Jme Jo Joachim Joan Joana Joana's Joanie Joann Joanna Joanna's Joanne Joanne's Joaquin job job? Jobber jobs Jobs? Jocelyn Jocelyne Jocelynn Joceyln jock Jocked Jockey Jockeys jocks Jock's Jockstrap Jocky Joclyn Joclynn Jodi Jodidamente Jodie Jody Jody's Joe Joel Joelean Joelle Joey Joey's Jog jogger Jogger’s Joggin jogging jogging_make love_buddies Johana Johane Johanna Johanne Johansen Johanson Johanson's Johanssen Johanssen's Johansson John Johnni Johnnie Johnny Johnny? Johnnys Johnny's John's johnson Johnsons Johnson's JOI Joie join joined joining joins joint Jojo Jojo's joke Joker jokers joking Jolatu Jolean Jolee Jolene Joleyn joli Jolie Jolie's Joli's Jolle Jollee Jollies jolly Jolly's Jon Jonah Jonas Jonathan Jonelle Jones Jone's Jonesen Joneses Jonesing Jones's Jonez Jonni jonny Jons Jonz Jooey Jooging Joones Joons Jorani jordan Jordana Jordan-Live Jordan's Jorden Jordi jordi_gets_layton JordiElNinoPolla44541_316 Jordin Jordin=Amazing Jordin's Jordi's Jordon Jordy Jordyn Jordynn Jored Jorge Jori Jorja Jornad Jo's Joseline Joseline's Joselyn Joseph Josephine Josephine's Josette Josh Josh's Joshua Josi Josiah Josie Josline Joslyn Joslyn's Joss Josta Josy Joue Jouet jour JOUR- Cat Jourdan journalist journey Joven joy Joya Joyance Joyce Joyeux joyful Joyous Joyride Joy-ride' joys Joy's Joystick Joysticker joy-sticks Joysticks Jozephine Jozy JP Jr J's JS Jt JT's ju Juan Juanita Juanitas Juan's Jucy Judas Judd Jude judge judgement Judi Judit Judith Juditta Judo Judy Judy's judystar Judyt's Juega Juegos Juelz jug Jugmake loveed Jugmake loveer jugg juggabum JUGGcuzzi Juggernaut juggmake loveed JuggMake loveer Juggmake loveers Juggle juggler jugglin Juggling jugg-ly Jugg-of-War juggs juggs? Juggy Juggz Jugosa jugs Jugtastic juice Juiced Juicer Juicers juices Juiciest Juicing juicy juicy-booty JuicyBut Juicybanana Juicyfinger Juicyfingers Juicyhole Juicypink Juicytoy Juicykitty Juicyvibe Juja Juju Jukebox Jukut Jul Julea Julep Jules Jules's Juli Julia Julian Juliana Juliana's Julianna Julianna's Julia's julie Julieann Julies Julie's Juliet Julieta Juliets Juliet's Juliett Juliette Juliette's Julio Julissa Julius Juliya Julliete Julliett july July's Julytis Julz Juman Jumbo jumbolicious Jumbos jump Jump? Jumped Jumper Jumpin jumping Jumprope Jumpropetoy jumps Jumpstart Jumpsuit Jumptoy junction June June’s June's Jung jungle Junglebanana Jungleland Junglecat Jungletoy Junior Juniper junk junkie junkies Junky junkyard Jupiter Jura Juramento Jurbootyic Jurek Jureka Jureka's Jury jus just 'Just JUST Juste justice Justified Justify Justin Justina Justine Justs Justtalk Jusy Juuuust Juviel Juy JW01 Jynn Jynn's Jynx Jynx'd Jynx's Jynz k Kabiria kabobs Kace Kacee Kacey Kacey's Kaci Kacie Kacie's Kaciesta Kacy Kacy's Kade Kaden Kadence Kaden's Kade's Kady Kae Kaedyn Kaelon Kaely Kaelyn Kage Kagney Kagney's Kahill Kahlen Kahlista Kahrlie Kahuna kai Kaia Kaihatsu Kail Kaila Kailani Kailani's Kailey Kaily Kain Kaine Kainoa Kaira Kai's Kaisa Kaisa's Kaiserin's Kaisey Kaisha Kait Kaiti Kaitlin Kaitlyn Kaitlynn Kaitu Kaity Kaiya Kajira Kakes Kakey Kakku Kal Kala Kalaban Kalani Kalea Kaleah Kaleb Kalen Kalena Kalena's Kaley Kali Kalian Kaliana Kalib Kalifornia Kalina Kalina's Kalis Kali's Kalisy Kallie Kalliny Kalliny's Kallisto Kaltava Kalvetti Kaly Kama Kamasutra Kameia Kameya kami Kamikatze Kamikaze Kamila Kamilla Kamille Kamille's Kamilly Kami's Kamitia Kammia Kammy Kamryn Kamys Kan Kanada Kanalu Kanda Kandace Kandall kandi Kandice Kandie Kandi's Kandi-sweet Kandy Kandy's Kane Kane-Final Kane's Kangaroo Kangaroo's Kani Kanon's Kansas Kansen Kanye's Kao Kaori Kaos Kapers Kappa Kapri Kapris Kapriznaya Kara Karah Karamb Karamel Karaoke Karaoke? Karaoku Kara's Karatai Karate Karea Karel Karela Karen Karen's Karera Karerra's Kari Karie Karim Karin karina Karina's Karing Karisa Karisma Karissa Karissa's Karl Karla Karla's Karlee Karlee's Karlie Karlies Karlie's karly karma Karma? Karma’s Karma's Karmen Karmen's Karoke Karol Karola Karolin Karolina Karoline Karolin's Karoll Karoly Karpri Karrera Karrina Karrlie Karrlie's Karro Karry Karson Karson's Kart Kartel Karter Karter's karting Karyn karyna Kasandra Kasanov Kasanova Kasanya Kase Kasey Kaseys Kasey's Kash Kasha Kasia Kaslo Kbootyandra Kbootyey kbootyey's Kbootyi Kbootyid Kbootyidy Kbootyidy's Kbootyie Kbootyin Kbootyius Kbootyondra Kbootyondra's Kbootyy Kbootyyana Kastle Kastravec kat Kata Katachi Katala Katala's Katalin Katalina Katalina's Kataliza Katalyn Katalynix Katalynka Katana Katana's Katarina Katarina's Katarinka Katava Katchings Kate Kateikyoushi Katelyn Katerina Katerina's Kates Kate's KateXXXX Katey Kath Kathalina Katharina Katharine Kathe Katherine Kathi Kathia Kathia's Kathleen Kathryn Kathrynn Kathy Kati Katia Katia's Katie Katie's Katija Katin Katinka Katiy Katja Katka Katkam Katlein Katlyn Kato Katok Katra Katreena Katrin Katrina katrina_pays_her_rent Katrina‘s Katrina’s Katrinas Katrina's Katrin's Kats Kat's Katseye Katseyes Katsumi Katsuni Katsuni-Melissa Katsuni's Katt Katt’s KattHow Kattie Katti's Katt's Katty Katty's Katusha Katy Katya Katya's Katy's Katzerl kauai Kavalli Kavane Kavelle Kavelli Kavika Kawaii Kawanni Kawany kay Kayak Kaycee Kayden Kaydence Kaydenized Kayden's Kaye kayla Kaylah Kaylani Kaylani's Kayla's Kayle Kaylee Kaylee's Kayleigh Kayleigh's Kayley Kayli Kaylie Kaylyn Kaylynn kayme Kayne Kay's Kaytlin Kayy Kayy’s Kay-Yay kaz Kazakh Kazakhstan-Russian KC K-Dawg ke Kea Keagan Keahola Kean Keana Keane Keanna Keanni Keat kebab Kecey Keeani Keefe Keegan Keegan's Keelings Keely Keely’s keen Keena Keensahra keep Keepaway Keep'em keeper keepers Keepin Keeping keeps Keester Kefrem Kegels Kegger Kehlani Keifer Keiko Keilani Keilani's Keira Keiran Keiran's Keira's Keisha Keisha's keister Keith Keiyra Keizy Keke Kelana Kelay KelayThat's Kelemen Keli Keli's Keller Kelley Kelli Kellie Kellin Kelli's kelly Kelly’s Kelly0-0 Kellyfind's Kellyfire Kellyi kelly's Kellys Kelly's kellywells Kelsey Kelsi Kelsi’s Kelsie Kelsi's Kelter Kelvin Ken Kendal Kendall Kendall's Kendra KendraLust3246_1006 Kendras Kendra's Kendrick Kendyll Kenmake lovey Keni Kenig Kenley Kenna Kennan Kenna's Kennedy Kennel Kennels Kenner Kenneth Kenny Kensey Kensia Kensley Kent Kenton Kentucky Kenya Kenza Kenzi Kenzie Kenzies Kenzie's Kenzi's Keoki kept Kerara Kerchief Keri Kerio Kerkove kerra Kerry Kerry's Kert Kerti Kery Kesha Kesha's Kesidy Kessef Kessie Kessyling Kestos Kethalo Ketty Kety Kety's Keutbooty Kevin key key? Keyboard Keyce Keyes Keyla Keylar Keyless Keymore keys Keys's Keyty Keyz Khadija Khadisha Khaide Khaleesi Khalifa Khalifa's Khalira Khan Khapri Kharlie Kharlies Khia Khloe Khloe’s Khloe's Khole Khyanna Ki Kia Kiana Kianna Kianna's Kiara Kiara's Kiarra Kiaya kick Kick-Booty Kickboxer Kickboxing kicked kickedThen Kickin Kicking kicking_off_the_new_year kicks kid kidding Kidnap Kidnapped Kidnapping kid's Kids kielbasa kielbasas Kiera Kieran Kiera's Kiere Kierra Kierstin Kiev Kik Kika Kiki Kikis Kiki's Kilemian Kiley Kileys Kilgore kill Killa Killed killer Killer0-0 Killer4-1 killers Killian killin Killing Killroy Kills Kilted Kilts Kim Kimber Kimberee Kimberlee Kimberley Kimberlli kimberly Kimberly's Kimber's Kimbery Kimbey Kimbo Kimeleane Kimiwagu Kimm Kimmi Kimmie kimmy Kimmys Kimmy's Kimono Kimora Kimora's Kims Kim's Kimura KimXXX kin Kina Kina's Kincade Kincade's Kincaid kind kinda kindergarten Kindling Kindly Kindness Kindred kinds KindYasmin Kinean Kinely king Kinga kingdom Kings King's Kingsley Kingston Kingstone kink Kinkaid kink-a-thon Kinkcom Kinked Kinker kinkiest Kin-Killer-Cade Kinkiness KinkMan KinkMen kinks kinkster Kinkster’s Kinksters kinky Kinkycat KinkySpa Kinley Kinna Kinnasias Kinnky Kino Kinski Kinski's Kinsley Kinuski Kinuski's Kinzie Kinzy Kinzyjo Kip Kipp Kipper Kir Kira Kira’s Kiras Kira's Kiray Kirby Kirei Kirill Kirin Kirishima Kiriztina Kirk Kirra Kirschley Kirschner Kirshley Kirsten Kirstey Kirsty kis Kisa Kisabon Kisaku Kiska KiskaNastja Kismet kiss kiss? Kissa Kissable Kissa's kissed kisser Kissers Kisser's kisses Kissin' kissing Kisspanties Kissy kit Kita Kitana Kitana's kitchen Kitchen? Kitchen1 Kitchen2 Kitchenbikini Kitchenblack Kitchenbubbles Kitchenclit Kitchenmember Kitchencooch Kitchencookie Kitchencounter Kitchencream Kitchencutie Kitchenbanana Kitchenbanana2 Kitchendong Kitchenfinger Kitchenfingers Kitchenfun Kitchenheels Kitchenkun Kitchenlove Kitchenoil Kitchenorgasm Kitchenpink Kitchenplay Kitchenplaytime Kitchenpleasure Kitchencat Kitchenrub Kitchen's Kitchenspread Kitchenstrip Kitchenstrip1 Kitchenstrip2 Kitchentable Kitchentalk Kitchentalks Kitchenteen Kitchencans Kitchentoy Kitchentoys Kitchenkitty Kitchenvibe Kitchenvibrator Kitchenvid kite Kites Kitkat Kitsen Kitsuen kitten Kitten0-0 Kitten0-1 kittens Kitti Kittie Kitties Kittie's Kittin Kittina Kittina's Kittinish kitty Kitty’s Kittyblue Kittyblue2 kittycat Kitty-Cat Kitty-Kitty Kittys Kitty's Kiu Kiuchi Kiwi Kiya Kiyanna ki-yay Kizzy K-JUGS Klaman Klambi Klamias Klara Klarafication Klarafied Klaras Klara's Klarisa Klarissa Klbooty Klbootyic Klbootyy Klaudia Klaudia's Klavdya Klay Klaymour Kleavage Kleen Kleevage Klein Klein's Kleio Kleio's Klementine Klenot Klenot's Kleopatra Kleopatra's Kleptomaniac Klien Klimax Kline Klint Kloe Kloes Kloey Klon-dyke Kloten Klub Klutz Klyde Klynn kn Knead Kneaded Kneading knee Kneehighs Knee-Highs kneel kneeled kneeling kneels knees Kneesocks kneesSquirts knew knickers knife knight Knightley Knightly Knights Knight's Knit Knite Knitting Knives kno knob Knobbers Knobbing Knobbit Knobs knock Knockboot knocked Knocked-up Knocker knockerhood knockers Knockin Knockin' knocking Knock-knock knockout Knock-Out Knockouts knocks Knot knots Knotted Knotting Knotty know Know? knowing knowledge known knows know's Knows Knox Knox’s Knoxville Knoxx Knoxxx Knuckle Knuckling KO Koal Kobain Kobayakawa Kobe Kobi Koboldt Kobolt Koby Koda Kodi Kodis Kody Koga Kohana Kohl Koi Koika Koinesa Kokcast Kokie Kokkine Koko Ko-ko Kokohontas Kokone Kokoro Koks Kolby Kole Kolida Kolt Kombat Kombatant Kombat's Komet Komic Kon Kong Konga Konn Konno Konnor Kony Kooky Koos Kora Koral Kora's Kord Korean Korena Korene Korene's Kori Korina Korina's Koritsi Kornelia Kornelia? Korra Korra's Korrs Kors Korss Kort Korti Kortney Kortney's Kortny Kory Kosame Kosame’s Kosher Koshka Koster Kostia Kot Kota KOUCH Kougar Koukej Kourtney Kova Kovacs Kovalick Kova's Kovi Kox Kox's Koxxx Kpop Krampus Kranky Kravanna Kraves Kraving Krazy Kream Kreams Kreatris kreme Kressler Krey Krilus Kringle Krios Kris Kriss Krissie Krissy Krissy's Krist krista Kristal Kristall Kristar Krista's Kristen Kristens Kristen's Kristi Kristian Kristie Kristin kristina Kristinas Kristina's Kristine Kristof Kristofer Kristy Kristyna Kristy's Krisztian Krisztina Kriztina Kroff Kross Krsti Krunch Krunk Krysta Krystal Krystal' Krysta's Krystina Krystol Krysty Krystyna Krytal Ksara Ksenia Ksenija Kseniya Ksu Ksucha KsuColt Ksurina ksusha Kta Ku Kuche Kuckmal Kudanfer Kuhnya Kulani Kum Kumbaya Kummings Kums Kum's Kung Kupcakes Kurl Kurt Kurtis Kush Kush's Kushy Kwang Kya Kyaa Kyaa's Kyah Kyanna Kyara Kyla Kylan Kyle Kylea Kylee Kyleen Kylee's Kyleigh Kyler Kyler's Kyle's Kylie Kylie’s Kylie's Kym Kymber Kymberlee Kymora Kyra Kyras Kyra's Kyrashina Kyrgyzstan Kyrin Kyro kyttie l L·O·L·A L0la la là Laa lab LaBarbara Labeau LaBeauDay LaBeauFinal Labeau's label labels labia Lab-ia Labial Labias Labor laboratory Laborer Labour Labyrinths lace Laced Lacerrific Lace's Lacesocks lacey Lacey2 Laceypanties Lacey's Lachelle Lachelle-Shocked Lachlan Laci lacie Lacing Laci's Lack Lackey Lacks Lamember Lacourt Lacourt's LaCroft Lacroix LaCroix's Lacrosse Lactate Lactates lactating Lactation Lactose Lacuna lacy Lacyee Lacy's lad Lada Lada's Ladd ladder Laddercat Laddie Ladie ladies Ladies’ Ladiocha Ladles Ladonna Ladora lads lady Lady? Ladyboy Ladyboys Ladyboy's ladyfingers Ladyhood ladykiller Ladyland? lady's Ladys Lady's Lady-Wrecker Laela Laela's Laele Laeticia Laecania Lafee LaFemmeDC Lafferty Lafouine laFox Lagina Lagina's Lago Lagoon Lagoona Lagrimas Lai laid Laiema Laiken Laikesis Laila Lailonni Laima Laimiga Lain Laine Lainey Lair Laistner lait Laka Lakai lake Lakefront Lakehurst's Lakers Lakeside Lake-Side Laki Lakko Lakme lala Lala’s Lalovv Laly Lam Lamb Lambchops Lambo Lame Lament Lamina LaMore l'amour Lamour L'amour Lamoure Lamour's lamp Lampada Lampoons Lamy Lan Lana Lana’s Lana's Lance Lancer Lanchester's Lancome land landed Landers landing landlady landlord Landlords Landlord's Landmark Landon lands Landscape landscaper Landscaping lane Lanes Lane's Lanette Lanewood Laney Lang Langdon Lange Langer Lange's Langford Lang-ster language Languages languid Languorous Lani Lanie Lanik's Lanina Lank Lanka Lanky Lanna Lanne Lannie Lanny Lansing Lantern Lanxxx Lanz Lanza Laoura lap LAPD Lapdance Lapdancer's Lapiedra Lapped Lappers Lappi lapping laps Lapse laptop Laput Lara Laraan Larah Lara's L'Arc Larceny Lareina Larem’s Laren large largest Larimar Larin La'Rin Larisa Larissa Larissa's Lark Larker Larkin Larkin's Larkson Larn Larnock Larocco Laroche LARP LARPing Larra Lars l'art Larue Laryne Larynt Laryssa las LA's LaSage Lascito Lasciva Lascivia Lascivious Laser Lash Lashey Lashiene lashing lashings Lasirena Lasirena69 Laska lbooty Lbootyies last Last? Laster Lastine Lasting Last-Minute Lasts Lasvegas Laszlo Lat Lata Latch late Lateena Lately Lately? Lateness Latenight later latest latex Latex-Clad latexed Latexlover latexwear latex-wearing Latexxx Lather Lathered Latherers Lathering lathers Lathery Lathin Lati Latimore latin latina Latina' LATINA Latina’s latina-looking latinas Latina's latino Latinos Lati's Lacanude Lato Latoya Latria Latrine latte Latvia Latvian Laty Lau laude Laudely Lauderdale Laugh laughed Laughing Laughs launch Launched launches Launching Laundering Laundr-O-Buns laundromat laundry Laundryfun Laundrylove laura Laura’s Laurah Lauralai Lauralyn Laura's Laure Laure’s Laureen Laurel Lauren Lauren’s Laurence Lauren's Laurent Laurianne Laurie Laurita Lauro Lauro's Lauryn Lauryn's Lauryn-TestedFirst Lava Lavaggio Lavalamp Lavanda Lavany Lavation Lavatory Lavay Lave LaVeaux lavender Lavendo LaVere Laveviews Lavey Lavey's Lavico Lavikan Lavina Lavinia Lavish Lavita law Law’s Law+ Lawanda Lawanda's Lawbreakers Lawda Lawful Lawless lawn lawnchair lawn-chair Lawnchair Lawnfingers lawns Lawnschair Lawrence laws Law's Lawson Lawsuit lawyer lawyers Lawyer's lay Layci Layden's layed Layer Layers Layin laying layla Laylah laylalei Laylani Layla's Layloni Layl's Layn Layna Layna's Layne Layo Layout layover lays Lay's Laysa Layton Lazing Lazure lazy lbs LC le lea lead Leada leadAin't leader leaders leading leads Leadych leaf Leafing league leagues leah Leah’s LeahOne Leah's LeahThe leak Leak? Leaked Leaking leaky Leal Leal's Lealtad lean Leana Leander Leandra Leane leanella Leaning Leann Leanna Leanna's Leanne Leanne´s Leanne’s Leanni leans Leap Leapord Lear learn Learned learner learners Learnin learning learns Learn's learnt Lea's lease Leash Leashed Leasing least leather Leather-Clad Leatherfinger1 Leatherfinger2 Leathers Leaticia Leau L'EAU leav leave leave-it leaver's leaves leaving LeBeau Lebelle Lebelle's Lebowski Lección Lecerf LeChance leche Lecher Lecherous Lechery Lechter Leclair Leclaire LeCroix lecture lectures Lecturing Led Leda Leda's Lediana lee Leea Leeane Leeanne LeeBOOTY LeeBreaking Lee'd Lee'ds Leeg leegy Leela Leeloo Leena Leenda Leene LeeNO Leenuh Lee's LeeSensory LeeStrappado LeeWater Leeway Leeya Leeza Lefleur Leflour left Lefty leg Legacy Legado legal Legalicious Legalize Legally LegalPorno Legami legend legendary legends legged leggings leggy Legion Legit Leglani Leg-Lock Legman's legs Legs; Legs-A-Poppin' legseager Legsex Legwarmers Legwear Leha lei lei’d lei'd Leid Leiddi Leidy Leif Leigh Leigh-d Leighlani Leigh's Leight Leighton Leih Leihla Leila Leila’s Leilani Leilanis Leilani's Leila's Leili Leionni Lei's leisure Leisurely Leisures Lekaten Leksi Leksya Lektion Lela Lelani LelaniExotic Lelani's Lelas Le-Le-Le-LELA Lella Lelya Lemay Lemisis Lemme Lemmore Lemon lemonade Lemonlime Lemons Lemos Lena Lena's lend Lending lends Lenee Lenee' Lenee's Lenehan leng Length lengths lengthy Lengua Lenin Lenina Lenix Lenka Lenka's Lenko Lenko's Lenna Lennon Lennon's Lennox Lenny LeNoir Lenore Lenox lens Lenses Lentamente Lento Lenvin Leny Leo Leon Leona Leona? Leona's Leone Leonella Leonelle Leones Leoni Leonie Leonora Leon's Leony leopard Leopardesses Leopardlove Leopardprint Leopard-Print Leopard's leotard leotards Lepidoptera leprechaun Lepti Lera Lerissa lerk Les Le's Lesbaliens Lesbehonest lesbi Lesbia lesbian -Lesbian LESBIAN lesbian? Lesbian’s Lesbianal lesbianism lesbians lesbian's Lesbians Lesbian's LESBIANS lesbians? Lesbians’ Lesbians1 Lesbians2 LesbianX Lesbiennes lesbifriends lesbo lesbos lesdom Lesha Lesia Lesian Lesley Lesli Leslie Leslie's Le'Slut Lesperansa Lesperansa's less 'Less Lesser lesson lesson? Lessonand LessonMake love lessons Lesson's lesssons Lester Lestni Lesya let let’s Lethal Leticia Leticiya's Lecania Letizia lets let's Lets Let's Letscook Letstalk letter Letters lettin letting Lettino Letty Letyte Levanta Levantado Levay's level Leveling levels Lever leverage Leveraged Levi Levina Levine Levi's Lew lewd Lewdness Lewinski Lewis LeWood LeWood's Lex Lex’s Lexa Lexas Lex'd Lexecutioner Lexi Lexie Lexie's Lexii Lexing Lexing0-0 Lexington Lexis Lexi's Lex's Lexus Lexx Lexxi Lexxis Lexxi's Lexxus Lexxxi Lexxxi's Lexxxus Lexy Lexy's Ley Leya Leya's Leyla Leyla's Leylou lez Lez-booty LezB lez-babes Lezbabes Lezbian Lezbo LezMelonies Lezbos Lezcoin Lezcuties LezCutiescom lezdom Lez-domme Lezian Lezis Lezley Lezlie lezze lezzie lezzies Lezzy L-fox li Li’s Lia Liah liaison liaisons Liam Liana Liandra Lianna Lianna's liar Liason Liatre Libation Libby Libellula Liberal Liberando Liberate liberated LIberating Liberation Liberations Liberian Libero Liberta Liberte Liberties libertine Libertines Liberty Libidineux libido libidos Libidus Libit Libitis librarian library Libre Libro licalatinpuss Licealias licence license Licensed licentious Lichelle Lichelle's Licie Licious lick lickable Lick-A-Melon LickALike lickalottapuss lick-a-thon Lickathon licked licker Lickerdale lickers Lickfest Licki-Make lovea lickin Lickin' licking lickings Lickity Lickout licks Licks? Licky Licno Licorice Licx Lida Lidi Lidia Lidian Lidiane Lido Lidya lie Lied Liefde Lielani Liena Lien's lies Lieta Lieutenant Liev life Life? Life… life-changing Lifegaurd Lifegaurd's lifeguard lifeguard's lifelong Life's Lifesaving lifestyle lifestyler Lifestyles lifetime lifetimes lift lifted Lifter lifters Lifting lifts Liga Ligaments light Lightening lightens Lighter Lightfall Lighthouse Lighting Lightning Lightcat lights Light-skinned Lightspeed lightweight Lightweights Lik Lika like Like? liked likely Liken likes like's Likes Likey Liking lil Li'l LIL Lil’ Lila Lilac Lilah Lila's Lili Lilia Lilian Liliana Liliane Lilianne Lilia's Lilien Lilies Lili's Lilit lilith Lilith's Lilla Lilli Lillian Lilliane Lilliane's Lillianne Lillies Lillike Lilly Lillyiana Lilly's Lilo Liloo Lilou Lilu Lilu's Lilvibe lily Lilya Lilyan Lilyana Lilyanna Lilyan's Lilys Lily's Lima limber Limbering Limbo limbs lime Limelight Limena Limiania limit Limitations Limited limitFlogged Limitless limits Limo LimoScene limousine Limp Limpia Limpieza Limtis lin Lina Linares Lina's Lincoln Lincon Linda Linda's Lindsay Lindsey Lindsey's Lindy line Lineax Lined linen liner lines Linet Linet's Linfa Ling Lingam Linger lingerie Lingerie1 Lingerie2 Lingeriefun Lingeries Lingerie's Lingering Lingers LingImpaled Lingo Ling's LingSuch Linguist linguists Linias Linikas Lining Linings link Linked Linking LinLin Linn Linn's LinoA Lin's LINT Lintuko Linx Linz Linzee Lio lion Liona Liona's Lioness Lionn lions lion's Lions Lion's Lionsmane lip Lipoldino Lipped Lipps lips Lipsmacking lipstick Liquer liquid Liquids Liquis Liquor Lira Liriope Lis Li's lisa Lisa's Lisboa Lisel Lisette Lisey Lisey's Lispy Liss Lissa list Lista listen Listener listening Listening? Listens lister Listing lit Lita Litanies Lita's Lite Literal literally Literary Literate literature lithe Lithuania Lithuanian Litigante Lit's litt Litte litter Litterbug littering little Little? Littlecoochie Littlered littles Little's Littlest Littletoy1 Littletoy2 Litto Liu Liv live Livecam lived Livefeed Live-In Lively liverpool lives Livestream Livestreaming Livia Livin Livin' living livingroom Livingston Liv's Lix Liya Liyera Liyla liz Liza Lizamania Lizard Lizards Liza's Lizaveta Lizette Lizi Lizka Liz's Lizy Lizz Lizzie Lizzies Lizz's Lizzy ll Llana Llegando Llegar lll Lluvia Lmonde lo load loaded Loading loads Load-us loan Loand Loaned Loans Loarn Loaves Loba Lobby Lobbyist Lobo Lobotomy Lobov Lobov's Loca local Locals location Lochai Lochai's Lock lockdown Locke locked locker Lockerroom lockers Locke's Lockett Lockhart Locking Lockjaw locks locksFinger Locksmith Lockup Lockwood loco lodge loft lofty log Logan Logans Logan's Logero Logger Loggers Logic Logistics Logjammin Logun Lohan Lohany Loida loins Lois Lok Lokita lola Lola-Chanel Lolana Lola's lolipop lolita Lolitka Lolla Lollapalooza Lolli Lollimember Lolli-Lolli lollipop Lolli-pop Lollipopper lollipops Lolli-Pops Lolli's Lolly Lolly’s Lollypop Lolly's lolo Lombard Lomeli Lomias Lominar Lona londie london Londoner Londons London's Londons; Londres Londyn Lone Loneliness lonely lonely_player Loner lonesome long Long? Long-awaited Long-Distance Longdress longer Longest long-haired Longhaired Long-haired Longhorn Longin Longing Longings Long-lasting Longleg long-legged Longlegs longs Long's longstocking Long-Stockings long-term longtime long-time Longtime Long-time Long-Tongue Longwood Loni Lonie's Loni's Lonnie loo Lood look Look? lookalike Look-alike Look-a-like Look-Alike Lookalikes Looke looked Looker looki lookin looking Lookout looks Looky Loom Looming Looms Loona Looner Loony Looooong loop Loophole Loops loose loosen Loosened Loosening Loosens looses Loosey loosing Loot Looters Lopes Lopez Lopez's Loppes Loquita Lor Lora Loraine Lora's Lord Lord’s Lordly Lords Lord's Lordship Lordy Lore Loree Loreen Lorelei Lorelei's Loreley Loren Lorena Lorenia Lorenn Lorenn's Loren's Lorenzi Lorenzini Lorenzo Lorenzza Lore's Loretta Lori Lorien Lorinian Lorna Lorraine Lorraine's Lorrane Lory los lose Lose? Losens loser losers Loser's loses losing Loso loss lost lot Lotharios lotion Lotion2 Lotiondance Lotioned Lotionfinger Lotionfingers Lotionfun Lotionhole Lotionlove Lotionmusic Lotionnipples Lotionplay Lotionrabbit Lotionrub Lotionrubdown Lotions Lotionspread Lotionstretch Lotionstrip Lotioncans Lotiontoes Lotiontoy Lotiontoy2 Lotionvids lots lotsa Lotsalotion Lott lotta Lotta's Lottery Lotti Lottie Lottis Lotto Lotty Lotty's Lotus Lou loud Louder loudest loudly Loudmouth Loudmouths Louie Louis Louisa Louise LoulaLou Loulou Loungchair lounge Lounger Loungers Loungin Loungin' lounging Lourdes Loureen Louse Lousy Louvel Lova Lovato love love? Love_Hate Love’s Love0-0vsThea Love1-0vsAlly Love2-0vsTara Love3-0vsKrystal Love4-0vsHolly Loveballs Lovebirds loved LoveDice lovedoll Lovedream Lovee Lovefest Lovejoy Lovelace Loveless Lovelier lovelies Loveliness Lovell Lovelle Lovelna Lovelock Love-Love lovely lovely_day_for_an_orgy lovely_lucia Lovelybanana Lovelylace Lovely's lovemaker lovemaking Loven Lovena Lovenia Lovens Lovenz LovePart lover Lover' Lover’s loverboy LoverMan lovers lover's Lovers Lover's loves Love's LOVES Loveseat Loveseatoy Loves'Em LoveSensi lovess LovesTo Lovestruck love-train Lovette Lovey Lovey-Dovey Lovia lovin lovin' Lovin Lovin' lovin’ loving lovingly Lovisa Lovit Lovita LovKox Lovliness Lovly Lovska low Lowden Lowdown Lowe Lower lowered lowering Lowers Lowest Lowkey Lowrider Lox Loxx Loxxx Loy Loyal Loyalty Loylita Lozaro LP LP's Ltd lu Lua Luana Luana's Luanna Luau Luba Lubana Lubber lube Lubed Lube-Dripping Lube-Farting Lube-O-Mania Lube-Oozing Luber Luberc lubes Lubetoy Lubice's Lubin' Lubing lubing_the_tube Lubov lubricated Lubricating Lubrication Lubricous Luc luca Lucas Luca's L'Uccello Lucci luccia Lucciono Luce Lucent Lucette Lucette's Lucha Luchik Luci Lucia Luciana Lucianna Lucia's Lucie Lucies Lucie's Lucifer Lucille lucious Lucius luck Lucke Lucked luckiest Luckily lucks lucky Lucky? Lucky's Luckystar Lucle Lucretia Lucy Lucyka Lucy's Luda Ludmila Ludwiga Ludy Lug Luggage Lui Luigina Luis Luisa Luisa's Luissa Luiza Luka Lukas Luke Lukics Lula Lullaby Lullu Lullu's L'Ultimo Lulu Lului Lulu's Luma lumb Lumber Lumberjack Lumberjack's Lumberjill lumberman Lumbersexual Lumbersexuals Lumia Lumina Luminated Luminescense Luminosias Luminus lump Lumps luna Luna’s Lunaaaaaaaaaaaaa Luna-Day Lunar Lunas Luna's Luna-tic lunch Lunchbreak Luncheon Lunchtime Luncinka Lunesa Lunettes Lungo Lupe Lupin Lupita lure lured lures Lure's Luring Lurker Lurks Lus Luschious Luscios luscious lusciousness Luscivious Lusconi Lusful lush Lushes Lushious Lusi Lusie Lusil Lusila Lussi Lussi's Lussy lust Lust' Lust? Lust-Crazed lustful lustfully Lustin Lusting Lustre Lustrous lusts Lust's lusty lusty_in_leggings lusty_landlord Lusy Lusya Lusy's Luther Lutro Lutro's Luukmee Luv Luvana Luvena Luvgood Luvin Luvly Luvrz Luvs Luv's luvv Luvv's lux Lux; Luxa Luxe Luxea Luxia Luxian Luxify Lux's Luxurie luxurious luxury Luxx Luxxx Luxy Luysan Luz Luzbel lV LW Ly Lya Lyall Lyana Lyanna Lya's Lyava Lyderi Lydia Lydie Lyen Lyft lying Lyla Lyla's Lyle Lylia Lylith Lylith's Lylla Lylyta Lyn Lyna Lyndon Lyndsay Lyndsey Lyndsy Lyne lynn Lynn? Lynna LynnBobbi LynnDay Lynn-Day Lynne Lynne's Lynn's Lynx Lynxxx Lyon Lyons Lyra Lyra's lyrics Lys Lysa Lyssa Lystra Lyudmilla m ma m-a Ma Ma’am MA001 MA002 MA003 MA004 MA005 MA006 MA007 MA008 MA009 MA010 MA011 MA012 MA013 MA014 MA015 MA016 MA017 MA018 MA019 MA020 MA021 MA022 MA023 MA024 MA025 MA026 MA027 MA028 MA029 MA030 MA031 MA032 MA033 MA034 MA035 MA036 MA037 MA038 MA039 MA040 MA041 MA042 MA043 MA044 MA045 MA046 MA047 MA048 MA049 MA050 MA051 MA052 MA053 MA054 MA055 MA056 MA057 MA058 MA059 MA060 MA061 MA062 MA063 MA064 MA065 MA066 MA067 MA068 MA069 MA070 MA071 MA072 MA073 MA074 MA075 MA076 MA077 MA078 MA079 MA080 MA081 MA082 MA083 MA084 MA085 MA086 MA087 MA088 MA089 MA090 MA091 MA092 MA093 MA094 MA095 MA096 MA097 MA098 MA099 MA100 MA101 MA102 MA103 MA104 MA105 MA106 MA107 MA108 MA109 MA110 MA111 MA112 MA113 MA114 MA115 MA116 MA117 MA118 Maako Maam Ma'am ma'am? Maara Maax Mabel Mabelle Mabel's Mac Macarena Macarena's Macaroni MacMeloner Macc maccy Mace Macey Mach Machado Machella Machina machine Machine? Machine0-0 Machine0-1 machined Machine-Make loveed Machine-Make loveing Machinehead machines Machines-Isis machineSkynet machining Machinist macho Maci Mack Mackenzee mackenzie Mackenzie's Macking Mack's Maclane Mac's Macy mad Madador Madalena Madam Madame Madame's Maddey Maddi Maddie Maddness Maddox Maddox's Maddron Maddux Maddy Maddy's made MadeInCanarias Madeleine Madeline Madeline's Madelyn Madelyne Madelyn's Mademoiselle Madeo Madge madhouse Madina Madisin Madison Madisons Madison's Madlen Madlena Madleyn Madlin madly MadMan Madmen madness Madonna Madori Madrastra Madrastras Madri Madrid Madysinn Mae Maeketa Maelynn Mae's Maestra maestro Maeva Mafalda mafia mag Magalie Magarette magazine Magazines Magda Magdalena Magdalene Magdi Magdolna Magdolna's Mageda Magella Magenta Maggie Maggies Maggie's Maggio magic magic? Magica magical Magically magician Magicians Magician's Magick Magicmonkey Magico Magicstick33 Magictoys Magicwand Magicwandfun Magicwandoil Magicwandplay Magicwandrub Magicwandsquirt Magie Magixxx Magling Magma Magna Magne magnet magnetic magnetism Magneto Magnificence magnificent Magnificently Magnifique Magnitude Magnolia Magnum Magnusson Magnusson's Magrinha Maguire Maguire's Magumbos Magy Magy's Mahaghani Mahmeloneh Mahem Mahina Mahlia Mahogany Mahoro Mahoro's Mai Maia Maia's maid Maid? Maid’s MAIDD Maiden Maidens maids maid's Maids Maid's Maiestas Maika Maikana Maiko mail Maila Mailbox Mailboy Mailed Mailing mailman Mail-order Mailroom main Maine mainstream Maint Maintaining Maintains maintenance Maira Mais Mai's Maisie Maison Maitland Maitresse Maja majestic Majesty Majesty's majical major Majorette Mak Makali makayla Makayla’s makayla's Makbota make make_you_a_man Make'em Makenna Makenzee Makeout makeover Make-Over maker makers makes Makeshift makeup make-up Makeup Make-up Makeups Maki Makin making Maki's Maklaryn Makoto Maktia Mal Malai Malao Malati Malaya Malaysia Malcolm Malcontent Maldives Maldonado male Male-Dom Malena Malena's male-pet males Maletero maleXslave malfunction Malgranda Mali Malia Maliakan Malia's Malibu Malica Malice Malicia Maliera Maligned Malina Malitia Malkova Malkova’s Malkova's mall Mall? mallet Mallorca Mallory Mallorys Malloy mallrat Mallrats MALO Malone Maloo Maloo's Malorie Malory Malpractice Maltease Malted Maltese Malvina Malvo Malwina Malya Malya's Mam mama Mamá Mama’s Mamacita Mamada Mamador Mamalicious mamalona Mamas Mama's Mamás Mamazon Mamazons Mamba Mambo Mame Mamecu Mami Mamie mamma Mammalian mammaries mammary Mamma's Mammograb Mammoth Mamories mams mam's Mams Mam-tastic man man- Man man? Man’s managed Management manager Manager’s Manager's manages Managing Manana Manarote Manauel's Man'c Mancini Man-Cream Mancy Mandatory Mandee Mandi Mandingo M-A-N-D-I-N-G-O Mandingo’s Mandingo's Mandorla Mandrinare Mandroid mandy Mandys Mandy's Mane man-eater Maneater Man-eater ManEater Man-Eater Maneaters Man-eating Manelli Maneuvers Manga Mangiatti Mangler Mangles Mango manhandle manhandled Manhandler Manhandles manhandling Manhattan manhood Man-Hungry man-hunt' Manhunters mania maniac Maniacs Maniturkey Manic Manicure Manicured Manifestation Manifesting manifests Manificence Manilla Manipulation Manipulative Manipulator Manjuice Mankind manly man-meat Manmeat Man-Milk Mann Mannequin Mannequins manner manners Manners? manning Manny Mano Manoew Manon Manor Manow mans man's Mans Man's Manscaper manservant manservant's mansion Man's-Man's Manson Manson's man-stud Mansur Mantequilla MANTIS Mantlepiece Manu Manual Manually Manuel Manuel’s Manuela Manuels Manuel's Manuels’s many MANYANA Manzanillo map Mapa Maple Maple's M'Appelle Maps Mar Mara Maraca Maracas marathon Marble Marbles Marc Marceau Marcel Marcela Marceline's Marcelinha Marcella Marcellina Marcellinha Marcelly Marcelo march MarchDirty Marche Marchelli Marchelly Marching March's MarchThe Marci Marcia Marco Marcona Marcus Marcy Mardi Mare Marea Maree Mareen Marelica Maren Marf Marga Margaret Margareta Margareth Margarethe Margareth's Margarita Margarita's Marge Margery Margherita Margitta Margo Margo's Margot Marguerita Marguetta Marhyan Mari Maria mariachi Mariah Mariah's Mariam Marian Mariana Mariann Marianna Marianne Maria's Marica Marica's Maridos marie Marie’s Mariemake loveed Mariel Marie-laure Marielou Marie's Marija Marijana Marije Marika Marille Marilyn Marilyn’s Marilyn's Marin Marina Marinan Marinas Marina's marinate Marine Marine? Marines Marinho Marinian Marinista Mario Marionette Marionettes Marisa Marisa's Marisha Mariska Marisol Marisole Marison Marissa Marissa's marital Maritrini Maritza Mariya Mariyu Marizza Marjay mark marked Marker Markered Markers market Marketa marketing Marketplace Marking Markings Markova marks Markup Markus Marky Marleigh Marlena Marlene Marley Marley's Marli Marlie Marlijane Marlowe Marlyn Marmaro Marmorino Marq? Marques Marquesine Marquetta Marquis Marquise Marquita Marquize Marra marriage 'Marriage MARRIAGE married Married? Marrlenas Marrum marry Marrying Mars Marseille Marsela Marselina Marsha Marshal Marshall Marsha's Marshmallow Marshmallowed Marshmallows Marsov Mart Marta Marta's Martell Marten Martha Martha's Marti martial Martin martina Martina's Martine Martinez Martini Martinis Martins Martix Marton Martoonis Marty Martyna Martyr Marushka Marusia Marusya marvel Marvellous marvelous marvels Marvin Marx Marxism Marxxx Mary Marya Maryann Mary-Ann Maryel Maryja Maryjane Maryjanes MaryJean Mary-Kate Marylin Maryline Maryln Mary's Marysole Mas Masacre Masaje Masajeando Masajista Masakowa mascara Mascot Masculin Masculinity-Training Maserati Maserati's Maseratti mash Masha MashaOlja Mashed Mashes Mashing Mashroom mask masked Masking masks mask's Masks Masochist masochistic mason MasonFormer Mason's MasonThe Masonu masquerade Masquerading Masqurade mbooty Mbootyacre Mbootyacred mbootyag mbootyage Mbootyage' mbootyage? mbootyage’s MbootyageCreepcom mbootyaged Mbootyager mbootyagers mbootyages mbootyaging Mbootyagynist mbootyes mbootyeur Mbootyeur? mbootyeurs mbootyeur's Mbootyeurs Mbootyeur's mbootyeuse mbootyeuse? Mbootyeuses Mbootyeuse's Mbootyey Mbootying mbootyive Mbootyivebanana mbootyively MBOOTYIVEWelcome Mbootymerized Mbootyo-Lick-My-Cat mbootysive Mbootyterpiece mbootyuese Mbootyumptions Mast master master’s master…then Masterbater Masterbates Masterbating masterbation Masterclbooty masterful Mastering masterpiece masters master's Masters Master's Masterson Masterson's Masterstroke masterworks Mastery Masticator mastrpiece mastur masturb masturba Mastur-Baiting Masturbatacular masturbate Masturbate? masturbated Masturbater masturbates Masturbati masturbating Masturbating? masturbation masturbations masturbation's Masturbations Masturbation's masturbator Masturbator1 Masturbator2 Masturbatory masturbatum Masturpiece Masuimi Masurbating mat mat? mat100% matador Matarazzo Matawhore match match? MATCHAriel Matched matches matching matchLosers matchmaker Match-Making matchThe matchup Match-up MATCHUP MatchYou mate Matene Matenon Mateo material Maternal mates matey matFinger math Mathea Mathematics Mathers Mathews Mathilda Mathilde Mathletes maths Matic Matiki Matilda Matilda's Matin Matinee Mating matNon-Scripted Matriarch Matri-Moly Matrimonial Matrimonio Matrimony Matrix mats matStill Matt matter matters Matthew Matthews Mattie mattress maturates mature Mature-lover matures Maturity Matylda Maude Maui mauling mauls Maureen Maurice Maurina Maus maven Mavens Maver Maverick max Maxe Maxed Maxi Maxim Maxima Maximas Maxim's maximum Maximus Maxine maxNasty Maxwell Maxx Maxximum Maxxx Maxxxed may maya Maya’s Mayan May-Anne Mayans Mayara Maya's maybe MaybeYes Mayday Mayde Maye MayeDay Mayer Mayers Mayes Mayfair Maygers mayhem May-Hem MayhemBooty Mayhem's MayhemThe Mayla Maylene Mayna Mayne Mayo Mayor Mayors Mays May's Mayshag Mayson Mayweather Maywood Maza Maze Maze's Mazesterbation Mazol Mazsa Mazy Mazz MAZZA Mazzaratie Mazzy Mazzy's M-Bargo Mc mc_mulanirivera MC2=Booty McAdams McCarthy McCarthyStruggles McClain McCray McCray's McCree McDaniels Mcgee mcjemeni McKayla McKenna Mckenzee McKenzi Mckenzie McKenzie’s Mckenzie's McKinnon McLain McLane McLaren McPipe Mccat McQueen McRae McSlutty M-Cup MD Mdalexandria me Me' ME Me Scene me? Mea meadow Meadowlark Meadows Meagan meal Meals Mealtime mean mean? Meaner Meanest Meania meaning Meaningful means meant Meanwhile measure Measured measurements measures Measuring meat meat? Meatball Meatballas Meatballs? Meatboy meateater meated Meater meat'ing Meating 'Meating 'Meat'ing Meatloaf meats Meat's meatsDay meatsPrincess Meatstick Meatsword meaty MeatYoung Mecha Mecha-Meat mechanic mechanical Mechanically mechanics mechanic's Mechanics Mechanic's Mechanique Mechanique-Day med medal Meddie Meddie's Meddison Meddler Meddling Medea Medellin media Medias Mediating Mediation Mediator Medic medical medically Medicare Medication medicinal medicine medicinefootjob Medics medieval Medina Medinad Medison Meditate Meditating meditation Meditative Medite Mediterranean medium Medley meds Medusa Medvedenko Meen Meenie meet meeting Meetings meets meetup Meg mega Mega-Meloned Mega-Melons Mega-busty MegaFarts Megan Megane megan's Megans Megan's Mega-Stacked Mega-Toy Megerinte Meggie Meggy Meghan Megia Megie Megisto Megnis Megu Megumi Megu's Megyn Mehise Mei MeI'm meister mejor Mekeilah Mekina Mekins Mekki Meko Mel Mela Melainny Melana Melancholia Melancholy Melane Melania Melanie Melanieeeee Melanies Melanie's Melany Melba melee Melika Melina Melinda Meliority Melisa Melissa melissa_loves_eating_out Melissa's Melita Melitia Meliza Mell Mella Mellaine Mellanie Mellanies Melle Mellie Mellikis Mellisa Mello Mellons Mellorar Mellow Mellowed Melly Melo Melodee Melodie melodies Melodiosi Melodrama melody Melody's melon melonas Melone Melone's Melonie melons Melon's Melony Melory Melrose Mel's melt Meltdown melted Melter Melting melts Melyna Melyna's membe member members Member's MEMBERS MEMBER'S membership Memberships Membrane Meme Memento Mementos Memoir Memoirs memorable MEMORIA Memorial memories Memorized memory Memphis Memphis's men Mena Menacing menage Ménage Menage-A-Trois Menagerie Menaje Menatel Mendelson Mendes Mendez Mendini Mendiny Mendosa Mendoza Mendoza's Meneth Menezes MenInPaincom Men-itation Menkui Men-on-Edgers Menow mens men's Mens Men's menstrual mental Mentality mentally mention Mentiras Mentoni mentor Mentors Mentorship menu meor meow Meows MeOWW mepositions Mer Mercana Mercedes Mercedesz Mercedez Mercer merchandise Merci Merciful Merciless mercilessly Mercurial Mercurio Mercury Mercutio mercy mere Meren Merengue Meretrix merger Merging Meri Meriah Merica's Merida Meridian Meriesa Merikas Merilee Merilyn Merino Merissa Merit Meritocracy merits Merlin Merlina mermaid mermaids Merri Merrick Merrie merrier Merriest Merriment Merrry merry Merryman Merszedes Mery Meryl Mes Me's Mesa Mesade Mesera mesh Meshed Meshing Mesica Mesmerised Mesmerize Mesmerized mesmerizer Mesmerizing mesmorized Mesok mess message Messages messall messed Messege messenger Messenger's Messes Messiah Messie Messing messy met Meta metal metalBrutally metalhelpless Metallic Metallica Metallurgy Metamorphosis Metart metemela Meter meters method methods Metro Meuri Mewg Mex Mexi Mexican Mexicana Mexicans? Mexico Mey Meyers Mey's Mezuri MFF Mgr mi mia Mia' MIA Mia Sucks Mia’s Miah miami MiamiBeach Miami's Mias Mia's mic Micah Micah's Micara Micha Michael Michaela Michaella Michaels Michael's MichaelsFormer Michaelswith Michah's Micha's Micheal Micheals Michel Michele Michell michelle Michelle-girl Michelles Michelle's Michelly Michigan Michova Michova's Mick Micka Mickaella Mickey Mickey's Mick's Micky Micky's Micro Microphone Mid Mida Mid-Afternoon mid-air Midame Midas midday Middl middle middle-aged Middleman Midget Midian Mid-Life midnight MidnightTs Midori midst Midsummer Midsummer's Midterm Mid-Terms Midtown MidWest Midwestern Midwife Midwinter Mid-Workout Miela might Mightily mighty MightyMistresscom migraine Migrant Miguel MiHaDoan Mihane Mihaylik Mihee Miho Miho's Miina's Mika Mikado Mikaela Mikaelle Mikako's Mikami mikayla Mikayla's Mike Mikela Mike's Mikey Mikeys Mikey's Mikhail Miki Mikita Mikka Mikkey Mikki Miko Mikoah Mikos Miko's Miku Miku's Miky Miky's Mila Mila’s Milada Miladies Milady Milah Milan Milana Milani Milano Milano's Milas Mila's Milcah Mild Mild? Mild's Mile mile-high Milena Milena's miles Milestone Miley Miley’s Miley's milf Milf' MiLF MILF' MILF? Milf’s MILF+Anal=Goodtimes Milf+Yoga= Milfalicious MILF-A-RIFFIC MILF-BBC MILF-Box Milfbury MILFCategory MILF-cation MilfCruisercom MILF-Elle Milfer Milf-Estate MILFfidelity MILFHer Milfhunting Milfier milfing MILF-In-Law Milfish milf-jaculation MILFLacey Milfland Milflicious MILFlife Milfman MILFMANIA MILF-next-door's Milfomaniac Milf-O-Maniacs Milfomanic MILFriendly milfs Milf's MILFs MILF's MILFS Milfshake MILFsitter MILFTALK Milftastic Milfy MILF-y MILFY Milgram's Mili Mili’s Milia Milian Milias Milina Milion Mili's military Military-Grade milk Milk? Milk+Chocolate Milka milked Milker Milkers Milk-Filled Milkfun Milkin milking Milk-Lubed Milkmaid Milkmaids Milkman Milkman's milks milkshake Milk-woman milky Mill Milla Millan Milla's Millena Millenium Millennia Millennials Millennium miller Miller's Milli Millian Millie million millionaire Millionaire? Million-Dollar Millions Millón Millonaria Mills Milly Milly's Milo Milsa Milton Milu Milu's Mily Mime Mimi Mimicry Mimis Mimi's Mimosa Mimy min Mina Minage Minaj Minal Minamos Minardi Minardi's Minarotte Minas Mina's Minat mind Minda Mind-bending mindblowing mind-blowing Mindblowing Mind-blowing Mind-Body minded Mindmake love Mindmake loveed Mind-Make loveed Mindmake loveing Mindful Mindi mindless Mindo MindReader minds Mind-Spinning Mindy Mindy's mine Mine? Mineshaft Miney Mingle Mingus mini Miniature minigangbang mini-gangbang mini-golf Mini-Market minimum mining minions Minirabbit miniskirt mini-skirt Miniskirt Mini-Skirt miniskirts Ministry Mini-tournament Minitoy mini-vacation minivan Minivibe Miniwand Mink Minka Minka's Minks Minks-Live Minnesota Minnie Minnie's Minor Minore Minority Minsk Mint Minty minute minutes minx Minxes Minxy Miosotis Mira Mirabel Mirabella Mirabel's miracle MIRA-cle Miracles Miraculous Miraculously mirador Mirage Mirame Miran Miranda Miranda's Miran's Mira's Mirayn Mirela Mirella Miriam Mirinda Mirka Miroir Miroslava mirror Mirror1 Mirror2 Mirrora Mirrorb Mirrorc Mirrorcle Mirrordong mirrored Mirrorfingers Mirrorfun Mirrorfun1 Mirrorfun2 Mirroring Mirrorplay Mirrorcat mirrors Mirror's Mirrortoy Mirrorvibe Mirta Mirta's mis Misa Misa-f Misappropriation Misbehave Misbehaved misbehavers Misbehaves Misbehavin' misbehaving Misbehavior Misbehaviour Miscellanea Mischa Mischa's Mischel Mischelle Mischel's Mischief Mischievous Mischievously Misconception Misconduct Misdeed Mise Misel miserable Misery Misfit Misha Misha? Mishap Mishaps Misha's Mishelle Mishka Mishka's Mishy Mishy's Misletoe Mismatches miss missed misses Missi Missies missile missin'? missing mission missionary Mississippi Missles Misstep Missus missy Missy’s Missypink Missy's mist mistake Mistaken mistakes Mister Mister? MisterWant Misti MistiDawn Misti's Mistiya Mistle Mistleblow mistlejohnson Mistletoe Mistreated Mistreatment mistress Mistresses mistress's MistressTs Misty Misty’s Misty's Misunderstanding Misuzu Misy Mitch Mitchel Mitchell Miteva Mithiani Mitias Mitos Mitsiia Mitsuki Mitt mitten Mitzi Mitzy Miu Miura's Miu's Mivina mix mixed Mixers Mixes Mixi Mixin mixing Mixi's Mixology mixture Mixup Mix-up Miya Miyabi Miyamme miyu Miyuki Mizu Mizuna Mizz MJ MK Mke M'lady? MLB MLIB MLK Mlle M'lord? mm MMA M-Man MMF Mm'Kay mmm MMMF Mmmia mmmm Mmmmm MmmMmm Mn Mnemonica mo moan Moana moaned Moaner Moaners moaning moans mob mobile Mobster Mobsters Mobster's Moby Mobybanana Mobybanana2 Mobytoy Mobytoy2 Mocca Mocha Mock Mod Moda Mode model model' Model model? Model’s Modelbabe model-esque Modelesque modeling Modella Modelling models model's Models Model's modern Moderna Modest modest-looking Modification modified Modnoy Moe Moeller MOFO Mofos Mo-Girls Mogul mohawk Mohogany Mohr Moi moi? Moi-Meme Moira Moire moist moistened Moister Moistiza Moisture Moisturize Moisturizes Mojado Mojave Mojito Mojo Moka Moka's Mokhov Molbooty Mold Moldavian Mole molestation molested Molester Moley Molivi Moll Molli Mollis Molloy Molly Mollys Molly's Moly mom mom? Mom’s Moma Mom-Daughter moment moments Moment's Momentum Mominator Mominatrix momma mommas Momma's Mommie Mommies mommy Mommys Mommy's Mommy-Son mom-next-door Mom-Night Momo Momoko Momos moms mom's Moms Mom's Momsen Momshell Moms-In-Law MomSwap momy Mon Mona Mona’s Monaco Monae Monaee Monaghan Monalee Monalee's Monaliza Monarch Mona's Monastery Monchi Monchi's Monday Mondays Monday's Mondo mone Monela Monelli Monet Monetizing money money' Money Money? moneyand MoneyBalls moneymaker money-making money's Mongolian Moni Monic monica Monicas Monica's Monicka Monik Monika Monikas Monika's monique Monique's Monir Monitor monkey Monkeying Monkeyrocker Monkeytoy Monna Monochromic Monogamy Monologue Monroe Monroe’s MonroeLook Monroe's Monrow Monroy Monsoon Monstars monsted monster Monstermember Monsterjohnson monsterous MonsterProlapse monsters Monster's monstre Monstro monstrous Monstruo Monta Montada Montag Montage Montana Monte Montego Monteiro Montenegro Montenegro's Montero Montes Montgomery month month? MonthHere's Monthly months Montmartre Montreal Montse Montsrous Monty Monument Monumental Monus Mony mood moods moon Moon’s Moondance Moone Moone' Mooned Moone's Mooning moonlight Moonlights MoonNR278 moons Moon's Moonshine Moonstruck Moor Moore Moore? MooreBooming Moorehead MooreHuge MooreOiled Moores Moore's MooreSpicy Moors Mooseknuckle mooseknuckles mop moping mopped Mopper Mopping mops mor Mora Morado Moraes Moral MORALE Morales Morals Morango Morante Moratlia Moray more more? Moreau Morefingers Morel Morelotion Morena Morenita Moreno Moreno's More's Moresex moresome moresomes morethan Moretta Moretta's Moretti Morg Morgalny Morgan Morgan? Morgana Morgan-Baller Morgane Morgann Morgans Morgan's Mo'rgasms Morgen Morghan Mori Moriah Moriah's Morich Moriel; Moring Morir Mormon Mormons Morn Morna Morna's mornin morning mornings Morning's Morningstar Morning-Time Moroccan Morocha Morphed Morpheus Morr Morre Morris Morrison Morsel Mort mortal mortgage Morticia Morven mos Mosaic Moscow Moses Moss most Moster Mostest mostly Mosuli motel Motela Moth motha Mothamake lovea mother Mother’s Motherboard Mother-Daughter Mothermake loveer mothermake loveers Mothermake loveing Mother-In-Law Mother-In-Law's Mother-In-Lust Motherless Motherload Motherlover Motherly mother's Mothers Mother's motion Motions Motivated Motivates Motivating motivation motivational Motivator Motive Motives MOTM Moto Motomura motor Motorbike motorbikes motorboat Motorboating Motorbunny motorcycle motorcycles Motorist Motorized Motoro motors MOTYLIA Mouche Mouna mounatin Mound mounds Mounia mount mountain Mountainous mountains Mounted Mounth Mountian Mountians Mountin mounting Mountings mounts Mour Moura Moure mourning mouse mousetraps Mousse mouth mouth? mouth-and-sole mouth-booty mouth-banged Mouthed mouthmake loveed Mouth-Make loveed mouthful Mouthful= Mouthfull Mouth-Full mouthfull? mouthfulls mouthfuls Mouthful's mouths mouth's Mouths Mouth's Mouth-To-Mommy Mouth-To-Mouth mouth-to-cat Mouthwash mouth-watering Mouthwatering Mouth-watering mouthy move moved Moveie movement Mover movers moves movie MovieA movies Movin moving moving_in_on_busty_neighbor Moviovi mowin mows Moxie Moxxie Moxy Mozaika MP Mr Mrblue Mrbunny MrCreep Mrpink Mrpinky Mrs Mrs? MrsAlexander MrsClaus's MrsLuv's Mrwiggles Ms M's MsLondon MsRose Muay much Much? Mucha Mucho Muchos Mud Muddy Mudshark's Muerte Mueve muff Muffdiver Muffdiving Muff-Diving Muffet mufffin muffin Muffin' muffin? Muffing muffins Muffin-Top muff-pleaser Muffs Mug mugged Mugler Mugur Muhica Mujer Mujeres Mukbang Muladhara Mulani mulanirivera_re Mulani's mulato Mulatto Mulch Mulder Mule Mules Mulino Mulisa Muller Mult Multe Multi Multicolored Multi-Course multijohnson multi-johnson Multijohnson Multi-johnson Multi-Flood multimillion Multi-orgasm Multiorgasmic Multi-orgasmic multi-orgasms Multi-Person multiple MultipleAirplane MultipleFacial Multiples Multiply multi-popped Multipurpose Multisquirt Multi-Squirt Mulcanask Mulcanasking multi-tasks Mulcanasks Mulcanoy mum Mummification mummified Mummify Mummy Mummy's Mums Mum's Munch munchable Munched Muncher munchers munches Munchies munchin munching Munchkin Munchkinland Mundial Mungary mungry Municipal Munkey Munroe Mur Murder Murka Murphy Murray MUSA musc muscle Musclebate muscled muscle-head Muscle-MILF musclemy muscles Muscly muscular muse Muse’s Muses Muse's museum Mushroom music Musica Musical musician Musicians Music-inspired Musicpanties1 Musicpanties2 Musicspread Musicstrip1 Musicstrip2 Musictouch1 Musictouch2 Musicvibe Musing Musings Musink Musk Musketeers Musky Muslim must Mustache Mustang Mustard Must-Have must-see musturbates Must've Must-Watch Mutant Muted Muthas Mutherload Muti Mutiny Mutton mutual Mutually muy Muza Muzzled MVP MVCat my mya Myah Mya's Mymelonies Mymelons MyMember Myeon Myers Myfingers Mykonos Mylee Mylena Mylen's Myles Mylka Myluv Mynor Mynx Myra Myranda Myra's Myrelly Myriam Myrka Myrna Myrnajoy Myrtille Myrtle myself Myshell Mysocks Mysophobia Myst Mystere Mysteries Mysteriosuly mysterious Mystery Mysti Mystic Mystica mystical Mystique Myth Mythical Mythos Mytoy1 Mytoy2 Mz n n`dap N00b N00bs; Na Nabakova Nacci Nacci's Nacho Nachos Nacho's Nacole Nad nada Nadezhda Nadi nadia Nadias Nadia's Nadin Nadina Nadine Nadira Nadja Nadya Nadya's Naey Naghavi Nagini Nagini's Nagy Nah Nahtanha Naia Naia's Naidyne Naika nail nailed Nailhead Nailin nailing Nailpolish nails Naira's naive Naïve Najra Nakai naked Naked? Nakedandwet Nakedcom nakedness Nakedtalk Nakia Nakita Nakya Nala Nala's Nalga Nalgas nam namastanal Namastay-On-The-Member Namaste Namat name name? named names Nami Namisi Namlyn Nan Nana Nancy Nancy's Nanda Nani Nanjo Nannccy Nanney nannie Nannies nanny Nanny’s Nanny's Nanny-to-Porn Nano Nanoe Nanpa Naoimi naomi Naomie Naomie's Naomi's Naomy nap Nap? Napa Napier Napis Napoli Napped Nappi Napping Nappi's Napsturbate Naptime Naranja Narc Narcissa Narcissism Narcissist Narcissistic Narcissus Nard Narga Nari Nariah Nari's Narkiss Narlie Narra Narrated narrow Narrowtoy1 Narrowtoy2 Narsea Narumiya nas Nash Nasha Nashville Nasita Nbooty Nbootysty Nbootyy Nasta Nastaha Nastia Nastie nastier Nasties nastiest Nastja nasty Nastya Nastya's Nasty-Booty Nastyhka NastyShagging Nat Nata Natacha Natalee Natali Natalia Natalia? Natalias Natalia's Natalie Natalie’s Natalies Natalie's Natalija Natalija’s Nataliya Natalli Natallie Natalli's Nataly Natalya Natalya's Nataly's Natana Nataran Natascha Natasha Natasha's Natashia Natbootyia Natbootyia's Natch Nate Nathalie Nathaly Nathan Nathaniel Nathan's Nathany Nathon Natia Nation National native Nativias Natsha Natsuss Natsy NATTI Natty Nattys Natty's natual Natura natural Natural-Meloned Natural-born Naturale Naturalist Naturally NaturalMILF Naturalcat naturals Naturaly nature Naturel natures Nature's Natureteen naturist Naturly Naturoslut Natusia Natusya Naty nau Naudi naudia Naudya naug naughtier Naughties naughtiest Naughtily naughtiness naughty Naughty? Naughtyfishnet Naughtygirl Naughtykitchen NaughtyMag Naughtyness Naughtynurse Naughtynympho Naughtyschoolgirl Naughy naugthy Naugthys Nausty nautica Nautical Nautica's Nautral Nautural Nava-Hoes Navajo Navaro Navarro Naveen Navigate Navigating Navy Nawlins N'awlins Naxy Nayasha Naymod Nayomi naпve N-cups NDA ne Neal Neapolitan near nearly Neat Neaveh Nebbouh Necesita Necessary necessities neck Neckin' necklace necklaces Necktie Necro Necromantic Necronomimember nectar Ned Neecie need Needa needed Needful Needing needle needs Needs--And Needy Neeka Neela Neeo Neeo's Neesa neew Nefarious Neglected Neglectful Neglecting negligee Negligence Negociaciones negotiates negotiations Negotiator Negrao Negro neigbor Neigbourhood Neige Neighboorhood Neighboors neighbor neighbor? neighbor’s neighborh neighborhood Neighboring neighborly neighbors neighbor's Neighbors Neighbor's Neighbors’ Neighborwhore neighbour neighbourhood Neighbourly Neighbours Neighbrohood Neil Neill Neilla neither Nek Nekane Nekane's Neko's Nell Nella Nella's Nelli Nellie Nellification Nellifiied Nelli's Nelly nelly's Nelson Nelya Nelya's nemesis Nemyo Nena Nenas Nenetl's Neo Neon Neona Neonas nephew nephews nephew's nerd Nerd? nerds nerd's Nerds Nerd's nerdy Nerdz Nerea Nereid Nerf Neri Neriah Nerine Nero Nerve nerves Nerville nervou nervous Nervously Nervs Ness Nessa Nessaja Nessi Nesso Nessy nest Nestee Nesters Nesti Nestling Nestor Nesty Nesty? Nesty's net Neta Netjohnsons Netdress Netfingers nether Netherlands Netorare Nets Netta netted Netting Netty Netu Network Neu nev Nevada Nevadah Nevaeh Nevaeh-nevaehland Nevaeh's Neveah Nevena never Never-Before-Seen Neverending Never-ending Nevermore n'Everything Neves new New? Newb newbie Newbie’s newbies Newbie's Newborns Newbs Newby newcomer Newcomer’s newcomers newcomer's Newcomers Newcomer's newcommer New-Daddy newest newly Newlywed Newlyweds Newman New'n'nasty Newrabbit1 news NewsCast newsdesk newspaper Newspapers Newstand Newtoys Newvibe next Next? next-door Nextdoor Next-Door Ngahau ni Nia Niana Nia's nibbl Nibble Nibbler nibbles Nibbling Nic Nica Nicci Niccole nice nice? nicely Nice's nicest Nicholas Nichole Nic-Hole Nichole's Nichols Nicholson Nici Nick Nicka Nickel Nickels Nickey Nicki Nickie Nickle Nicko Nickol Nicks Nick's Nicky Nicky's Nicley Nico Nicol Nicola Nicolai Nicolas nicole Nicole’s Nicole's Nicolett Nicoletta Nicolette Nicolette's Nicoli Nicoline Nicoll Nicolly Nicols Nicoly Nicotine Niece Niece's Nielsen Nievez Niffy nifty Nigel Nigella Nigga Nigga's Nigh night night? NightAnal nightcap nightclub Nightcrawler Nighter NightFeature Nightfinger Nightgown Nightguzzler nightie Nighties Nightime Nightingale Nightlife Night-Life Nightly nightmare Nightmare4-0 Nightmare5-0The Nightmares nights Night's Nightshift Nightstick Nightsuckers nighttime night-time Nighttime Night-time nightvision Nighty Nighty1 Nighty2 Nigora Nigora's Nik Nika Nikara Nikara's Nika's Nike Nikea Niki Niki’s Niki's Nikita Nikita's Nikitta Nikka nikki Nikki? Nikkie Nikkis Nikki's Nikkita's Nikko Nikky Nikkys Nikky's Niko Nikol Nikola Nikole Nikole's Nikolett Nikoletta Nikolla Nikolly Nikol's Niky Nikyta Nila Nila's nile Nile's Nilla Nilla's Nillox Nils Nilsson nimble Nina Nina’s Nina's nine Ninel Ninelly Niner Niñera nineteen Nineteenth nineteen-year-old Nineteen-y-o Ninfe's Ninfo Ning ninja Ninja9-0 Ninjas Ninja's Ninja's1-0 Ninouska Nior Nip Nippity nipple Nippled Nippledon Nipple-licking nippleodeon Nipple-pierced nipples nipples? NipplesBrutal nipplesHas Nippley Nippleys nipplicious Nippples Nipps nips Nira Nirvana Nirvanal Nirvana's Nisha Nishino Nita Nita's Nite Nitro Nitty Nix Nixie Nixon Nixon's Niya Nizmir NK NK's no No? No1 No1986744 No2 No2231568 No4469525 No6698547 No69 No7485960 Noa Noah Noapte Nob Nobili Noble nobody nobody's Nobodys Nobody's Nobs Nobu Noche Nockers Nocturnal Nodding Noe Noel Noell Noelle Noelle's Noel's Noemi Noemie Noemilk Noemy Noey Nog Nogueira Noir Noire Noire's Noiret Noir's noise Noises noisy Nok Nolan Nola's Noleta No-Limits Nollie Nomad Nomar Nomi Nominated Nominee Nomizo Nomura Nomy non Nona Noname non-biased non-conversationalist None Non-Fiction Noni Nonna Nonny No-Nonsense nonscripted non-scripted Non-Smoking nonstop non-stop Nonstop Non-stop non-stopPain Noob Noobes Noodle Noodles Nook Nookie Nookies Nooks Nooky noon Nooner No-orgasm Nopanties NoCat Nora Norah Nora's Norby Nord Nordic NORE NoRestForTheBooty Norhman Norina norma normal Normalized' Normally Norman Normandie Norma's north Northern North's Norton Norway nos nose Nosed Nosesta Nosey Nosh Nostalgia nostalgic Nostalgie nosy not not? Notable notch note Notebook Notes not-her-boyfriend Nothin nothing nothingness Nothings Nothing's notice noticed Noticing notorious notoriously Not-Quite-Aunt Not-So-Friendly not-so-innocent not-so-secret Not-So-Timid Nottingham Notty Notty's Nourishing Nouveau Nouvelle Nov Nova Novack Novaes Novag Novais Novak Novalie Nova's Novea Noveau novel Novelas Novels November November's Novia novice Novice's Novio Novitas Nov's now now? Nowak Nowhere Nox Noxiania Noxx Nozomi Nozomi's Nozzle NR001 NR002 NR003 NR004 NR005 NR006 NR007 NR008 NR009 NR010 NR011 NR012 NR013 NR014 NR015 NR016 NR017 NR018 NR019 NR020 NR021 NR022 NR023 NR024 NR025 NR026 NR027 NR028 NR029 NR030 NR031 NR032 NR033 NR034 NR035 NR036 NR037 NR038 NR039 NR040 NR041 NR042 NR043 NR044 NR045 NR046 NR047 NR048 NR049 NR050 NR051 NR052 NR053 NR054 NR055 NR056 NR057 NR058 NR059 NR060 NR061 NR062 NR063 NR064 NR065 NR066 NR067 NR068 NR069 NR070 NR071 NR072 NR073 NR074 NR075 NR076 NR077 NR078 NR079 NR080 NR081 NR082 NR083 NR084 NR085 NR086 NR087 NR088 NR089 NR090 NR091 NR092 NR093 NR094 NR095 NR096 NR097 NR098 NR099 NR100 NR101 NR102 NR103 NR104 NR105 NR106 NR107 NR108 NR109 NR110 NR111 NR112 NR113 NR114 NR115 NR116 NR117 NR118 NR119 NR120 NR121 NR122 NR123 NR124 NR125 NR126 NR127 NR128 NR129 NR130 NR131 NR132 NR133 NR134 NR135 NR136 NR137 NR138 NR139 NR140 NR141 NR142 NR143 NR144 NR145 NR146 NR147 NR148 NR149 NR150 NR151 NR152 NR153 NR154 NR156 NR157 NR158 NR160 NR161 NR162 NR163 NR164 NR165 NR166 NR167 NR168 NR169 NR170 NR171 NR172 NR173 NR174 NR175 NR176 NR177 NR178 NR179 NR180 NR181 NR182 NR183 NR184 NR185 NR186 NR187 NR188 NR189 NR190 NR191 NR192 NR193 NR194 NR195 NR196 NR197 NR198 NR199 NR200 NR201 NR202 NR203 NR204 NR205 NR206 NR208 NR209 NR210 NR211 NR212 NR213 NR214 NR215 NR216 NR217 NR218 NR219 NR220 NR221 NR222 NR223 NR224 NR225 NR226 NR227 NR228 NR229 NR230 NR231 NR232 NR233 NR234 NR235 NR236 NR237 NR238 NR239 NR240 NR241 NR242 NR243 NR244 NR245 NR246 NR247 NR248 NR249 NR250 NR251 NR252 NR253 NR254 NR255 NR256 NR257 NR258 NR259 NR260 NR261 NR262 NR263 NR264 NR265 NR266 NR267 NR268 NR269 NR270 NR271 NR272 NR273 NR274 NR275 NR276 NR277 NR279 NR280 NR281 NR282 NR283 NR284 NR285 NR286 NR287 NR288 NR289 NR290 NR291 NR292 NR293 NR294 NR295 NR296 NR297 NR299 NR300 NR301 NR302 NR303 NR304 NR305 NR306 NR307 NR308 NR309 NR310 NR311 NR312 NR313 NR314 NR315 NR316 NR317 NR318 NR319 NR320 NR321 NR322 NR323 NR324 NR325 NR326 NR327 NR328 NR329 NR330 NR331 NR332 NR333 NR334 NR335 NR336 NR337 NR338 NR339 NR340 NR341 NR342 NR343 NR344 NR345 NR346 NR347 NR348 NR349 NR350 NR351 NR352 NR353 NR354 NR355 NR356 NR357 NR358 NR359 NR360 NR361 NR362 NR364 NR365 NR366 NR367 NR368 NR369 NR370 NR371 NR372 NR373 NR374 NR375 NR377 NR378 NR379 NR380 NR381 NR382 NR383 NR384 NR385 NR386 NR387 NR388 NR389 NR390 NR391 NSA NSFW Nth Ntyce Nu Nuar Nub Nubian nubile nubiles Nubilestrip Nuclear nud NudaFightClub nude NudeFighClub NudefightClub nudefightclubcom nudes Nudeterraneo Nudey Nudie Nudies Nudism nudist Nudista Nudists nudity Nueva Nuff Nuisance Nuit nulled num Numb number numbers Numerani Numero Número Nummies num-nums nums nun nunchucks Nun-Chucks Nunchuk Nuns Nunsploitation Nuptials nurse Nurse? Nursed nursery nurses nurse's Nurses Nurse's NURSES Nursie nursing Nurture nuru Nuru? Nuru-Gasmic Nury nut nutbuster Nutcracker Nut-Cracker nutella Nutflix Nuthouse Nutjob Nutley's nutload Nutritional Nutritious nuts Nuts? nutsack Nutsucker Nutt Nutted Nuttfill Nuttin Nutting Nutt's nutty Nutz nuyorico Nuzzle nuzzles Nuzzling nwecomer ny Nya Nyc Nyce Nychole NYDP NYE Nyeema Nyikita Nyla Nyla's nylon Nylon-clad Nylonlover nylons Nym nymph nymphette nympho Nympho-Chondriac Nympho-Insomniac Nympholepsy Nymphomanager Nymphomania nymphomaniac Nymph-o-maniac Nymphomaniac's NymphoManiacs nymphos nymphs Nymphsomnia Nympomaniac Nyna Nyobi Nyomi Nyomi's Nypho Nyx o 'O O’Reilly O’Reilly’s Oakley Oaks Oara Oasie Oasis Oath OB Obama O'Banyon Obcasio obedience obedient obediently Obelisk Obession Obey Obeying obeys OBGYN object Objectification Objectified objectify Objectives Objects Obligations oblige Obliges Oblivian oblivion Oblivious Obnoxious Obscene OBSCENITY Obscuration Obscure Obsenities Observation Observe Observer Observers obsessed Obsesses obsession obsessions Obsessive Obstacle Obstruction Obvious Obzira Ocbootyion occasion occasions occbootyion Occbootyions Occhi Occult Occupancy Occupied Occupy Occupying ocean Ocean' Oceana Oceane Oceanic Oceans Ocean's Oceanside Oceanview Ocelo Ocho o'clock O'Member O'Connell O'Connor O'Connor's Oct octane Octavia October OctoberFest Octomom Octocats Octocat's od Oda Odanost Odare O'dare Odd Oddities Oddjobs Odds Oddcanties Oddyssey Ode O'dell Odessa Odette Odeur Odile Odile's Odin Odio Odmiana Odyssey of O'Face Ofelia Oferta off off? off…and Offbeat Off-Duty Offence Offender Offenders Offense Offense? offer offered offering Offerings offers offic office Office? Officebreak Officefingers Officeorgasm Officepleasure officer officer? officers Officeteen Officetoy official Officially off-limits offline Offroad offRS079 Offshore Offstage Oficina ofSlave often OMAKE LOVE ogle Ogled Ogling Ogre Ogzija oh oh_brother_dont_make love_my_gf Ohana O'Hara Ohayoo Ohh Ohhh OHHHH Ohhhhh Ohio Oh-Oh O'Horny? Ohs Oh's Oh-So-Limber Oh-so-pro Oi oil oil_wrestling Oilmelons Oilbanana Oil-Drenched oiled Oiledfun oiledCat oiled-up Oilfinger Oilfingers Oilfun oiling Oilled Oil-Lubed Oilpleasure Oilrub oils Oil-Slick Oil-Soaked Oiltoy Oiltoys oily Oilybodyrub Oinare Oingo Oink Oinks Oishi OJ ok OK? Okami Okay Okey Okie Oklahoma Oksana Oksy Oktavia OktoberTurkey OktoberTurkeys Oktoberfest Oktobermake loveed ol Ola Olas old oldBoth Oldboyardee Olde older Older-Woman Oldest oldie Oldies old-new olds old's Olds Old's oldschool Old-school Old-young OldYoung Old-Young ole Olena Oleo Olesia Olesya Olesya's Oleum Olga Olia Olimpia Oliva Olive Oliveira Oliver Olivia Olivia’s Olivia's Oliviya Oliviya's Olivya Olja Olja2 Olla Olle Olli Ollie Ollies Ollivia Olousian O'lovely OL's Olsen Olsen’s Olson Olya Olympia Olympic Olympics Olympus Olyvia Omar Omar's Ombrage Omega Omg Omidee Omilia Omradet Omsin O-My-God on on? on1 on--and On-Call on-camera on-campus on-carpet once Once' once_in_awhile Onde Ondeto Ondorio one one? One-actress One-Boned Onee-san One-Eyed oneil O'neil O'Neil-ing One-Night One-on-One Onepiece OneReal ones One's one-sided Onesie OneSlave One-Stop ONEThe OneTight one-time One-timer One-Way one-woman One-zy On-Her Onia onion Onirica Oniva onkitchen online On-Location Onlookers only Onna Onoma Onsen onstage On-Stage On-The-Job onto Ony O'n'Y O-n-Y Onyx oo ooey Ooga Ooh Ooh-La-La Ooo OOO`s Oooh Ooooh Oooooo Ooops Ooopsie OOOWEEE Oops Oosawa Ooze Ooze'n oozes Oozing op Opacity Opal open opened opener OpenerVendetta11-3 opening Open-minded Open-Mouthed opens opera Operated Operatic operating operation operator Ophelia Ophelie opinion Opinions Op-op-optometrist OPP OPP? Oppa Opponent Opponents Opportunist Opportunite opportunities opportunity oppose opposite opposites Opsss optical Optima Optimistic Option optional options Opulence or or? Ora Oracle oral Oralfice OralficePart Oralists orally orange Orange1 Orange2 Orangedress Orangefinger Orangeflowers Orangegrove Orangelingerie Orangepanty oranges Oranges? Orangetop Orasa Oratory Orbit Orbital Orbs Orchard orchestrates Orchestrating Orchid Orchidea ordeal order Order? ordered ordering Orderlies Orderly orders Ordinary O'Reilley Oreilly O'reilly OReilly O'Reilly Oreilly's O'Reilly's O'ReilyUntil O-Ren oreo Oretha Orgams Organ? Organic Organica organize Organizer Organs orgasm ORGASMAGEDDON Orgasm-a-thon ORGASMATHON Orgasmatose ORGASMATRON orgasmed Orgasmerator orgasmic Orgasmicfaction orgasming Orgasmo orgasm-oldie orgasms orgasms?A orgasmsExtreme OrgasmsScreaming orgasmswhile orgasm-tools Orgasmtron orgasm-ville orgazma Orgazmatron orgiastic Orgies Orgspams orgy Orgyriffic Orhidea Orian Oriana Orianna Orient OrienTAIL oriental Orientation Oriente oriented orifice Orifices Origin original Originals Origins O'Riley Orina O-Ring Oriole Orion Orion's Orismus Orita Orlando Orlane Orleans Orlov Orlov's Ornament ornaments Ornella Orphanage Orpheus Orsay Orsay's Orsi Orsolya Orsolya's Orspasm Orssi Ortega Ortega’s Orth Orthodontic Orthodontics Ortiz Orto O'Ryan Os O's Osa Osada Oscar Oscillations Oserino Oshea Oshte Osiris Oso O'Spheres Ossa Ostanyes Ostena Ostentativo Ostrica ot Otaku Othelia Othello other Other’s others other's Others Other's Otis O-Canty OTK Otowa Otra Otter Otto ottoman Ottomana Ottomanorgasm ou Ouan ouch Ouchy Oui Ouija Ou-Lorena Ounce our Ouranos Ours ourselves Ouside out out' Out Out out? Outage OUTAKES-TS Outback outburst Outcome Out-Creeping outdoor Outdoorfingers OutdoorfingersBTS Outdoorfun Outdooring Outdoormagic Outdoorplay Outdoorpleasure Outdoorcat Outdoorrub outdoors Outdoorsy Outdoorteen Outdoortouches Outdoortoy Outdoorvibe Outed Outer Outercourse Outfield outfit outfits Outfoxed Outmake loveing Out-Horn outie Outing Outlandishly Outlast Outlaw Outlaws Outline Outlines outNamaste Outnumbered Out-of-this-world Out-of-town outoor OutOr outrageous Outreach outs outside Outsideaction Outsideplaytime Outsidepleasure outsides Outsidetease Outsidetoy Outsmarting Outsourcing outstanding outta Outtage outtakes Out-takes OUTTAKES OUTTAKES-A OUTTAKES-Anal OUTTAKES-Belladonna's OUTTAKES-Cream OUTTAKES-Deep OUTTAKES-Gape OUTTAKES-Girl OUTTAKES-Lil OUTTAKES-Milk OUTTAKES-Pretty OUTTAKES-Cat OUTTAKES-Slutty OUTTAKES-Strap OUTTAKES-TS OUTTAKES-Winking Out-Whores outwits outz Ova Oval Ovaries ovation ove Oven over over? over4 Overachiever Overachievers Overall Overalls Overbearing Overboard Overcast overcome overcomes overcoming overCrotch Overdose overdrive overdue Over-Easy Overexploited Overexposed Overfall overfloat Overflood Overflow OVERFLOWED Overflowing overflows overMake loveed overg Overgrown Overhead overhears Overheated Overheating Overindulges overjoyed Overkill overload Overloaded OverloadEvery Overlooked Overly Overnight overOH Overpower overpowered Overprotecting Overprotective Override Overrides overs Overseas Oversexed Overshare over-siz Oversized Oversnatch overSquirting Overstayed Overstep Overstuffed Overtaken Overtakes Over-The-Shoulder Over-The-Top Overthink overtime Overwatchers overwhelm overwhelmed Overwhelming overwhile overwork Overworked overxxxposure Overzealous Owe owed Owen Owens owes Owl own Owned owner Owner's Owning owns Ox Oxana Oxiana Oxijana Oxuanna's Oxy Oxygen Oxygen? Oye Oyeloca Oynx Oyster oz Ozaki Ozio Ozzie p P001 P002 P003 P004 P005 P006 p1 p2 P3 P4 P90Sex pa PA? Pablo Pace paced paces Pachino Pacific Pacifica Pacifico pacifier pack packag package packages Packaging packed Packer Packers packin Packin' packing packs Paco Pact Pad Padded paddle paddled Paddling Paddys Paddy's Padova padrastro padres Pads Paella Pag Paganelli Page Page’s pageant pages Paging Pago Pagota Pai paid Paige Paiges Paige's pain Painal Painball Paine painful Painfull Painfully Pains painslut paint Paintball Paintballers paintballin paintbrush painted painter Painters Painter's painting paintings paints Painttoy pair Paired pairing Pairings pairs Paisa Paisley Paisley's Paizuri pajama Pajamababe Pajamafun Pajamas Pajamas1 Pajam'Booty Pajaritas Pajave Pajixian Pak Pakistani pal Pala Palabras palace Palate Palavering Palazzo pale Palemon Palenisko Pale-skinned Paleta Palette Palin Palinuro palm Palma Palmas Palmer Palmer's Palmistry Palmita Palms Paloma Palomino Palooza PALOOZAAAAWESOME Pals Paltrova Paltrova's Palvotra's Pam Pamela Pamela's Pamella Pampas pamper Pampered pampering Pampers Pam's pan Panacea Panamanian panaroma Panax Panaxeia Pancake Pancakes Pancake-tipped Panchina Pancho Panda PANDAMONIUM Pandas pandemic Pandemonium Pandora Pandoras Pandora's Pang Panic Panic's Panky Panni Panocha Panorama panoramic pans Pantera Panteras Panth panther Panthera Panthera's panthers Pantie Pantied panties panties? panties_down Panties2 Pantiesplay Pantiessocks1 PantiesWe're Pantihose Pantiless Panting Pantomime pants Pants? pants_prank_gone_wrong Pantsing Pantsuit panty Pantyheels1 Pantyheels2 PantyHOES pantyholicious pantyhose Pantyhosed Pantyhoser pantyhoses Pantyless Panty-less Pantymaniac PantyMan's Pantyplay Pantys Panty-Sniffer Pantyspread Pantystuff Pao Paola Paola's Paouk Papa Papá Paparazzi Papas Papaveri Papavero Papaya paper Paperboy Paperhanger Papers Papertrail Paperwork Papi Papi's Pappa paps Par para Parable Parachute Parada Parade paraded Parades Paradis Paradisaic paradise Paradise's Paradisiac Paradiso Paramake love Paraguayan paralegal Paralized Parallel Paralyzed Paramedic Paramedic's Paramour Paramours Paranoid Paranormal Parasol Parasol1 Parasol2 Parcker Parcker's Pardon Parecian Parent Parental Parenting parents Parent's Parents? Parent-Teacher Pareo Parfait Paris Paris? Parisch Parish parisian Pariss park Parke Parked Parker Parkers Parker's Parkin parking Parks Parlez Parliamentary parlor parlour Parodies Parody parole parolee Parque Parrish parrot part part1 Part-1 part2 Part-2 part3 Part-3 Part4 Part-4 Parte parted Partia Partialism partially Participant Participates particularly Partie Partiers Parties Parting Partly partner partners Partner's partnership parts Part-Time party party? PartyConsort PartyDiamonds Party-Exploring partygirl Partygoer Partygoers partying party's partys_over Partytime Parusa Parvati Parvin Pary Parys pas PA's Pasa Pasarie Pascal Paseia Pasha Pasion Pasión Pasito Pason pbooty pbootyage Pbootyed pbootyenger pbootyengers pbootyes Pbootying pbootyion pbootyional pbootyionate pbootyionated pbootyionately Pbootyion-bound Pbootyione Pbootyion-filled Pbootyion-HD Pbootyionistas pbootyions Pbootyion's Pbootyive pbootyport Pbootytime Pbootyword past pasta Pastel Pastels Pasties pastime Pastimes Pastor Pastors Pastries pastry pasttime Pastures Pasuna Pat Patadas Patap Pataski patch Pat-Down Paternal Paternity path path' Path Pathetic Pathfinder Pathos Paths Patick's patience patient Patientia patiently patients patient's Patients Patient's patio Patiofun Patiopink Patiocat Patiostrip Patiotoy Patootie Patr Patriarchy patricia Patricia's Patrick Patrick’s Patricks Patrick's patriotic Patriotica Patriots Patrisha Patritcy Patrizia patrol Patron patrons Pats Pat's Patted Patti Patties Pattinson Patty Patty’s Pattys Patty's Paty Paul Paul’s Paula Paula's Paulina Pauline Paulo Paul's Pause Pavel Pavers Pavilion Pavlina Pavlova Pavslut pawg Pawn paws Pax Paxionalis Paxionaria Paxioni Pax's Paxton pay payback Payback's Paycheck pay-day Payday Pay-For-Play paying Payless Payload payment Payments Payne payoff pays Payton Payton’s Paytons Payton's Pc Pct PD PDA PDed pe Peac peace PeaceLove peach peach? Peachbloom peaches Peachess Peachez Peach's Peachtacular Peachtree peachy pea-MEMBER Peamember Peamembering Peak peaks Pea-Nist Peanut Pear Pear-fect pearl Pearlescent Pearlin Pearlized Pearlcat pearls Pearl's Pearly Pears peas peasant Peasants Pebblean Pecan Peccadillo Peccati pecker pecker? peckers Pecks Pecosa Peculiar Ped Pedagogue pedagogy pedal Pedaling Peddler Peddlers Peddles Pedentes Pedestal pedestrian Pedi Pedicure Pedicured Pedro peds pee PEEach pee-cake pee'd peeeeee peeing peek Peekaboo Peek-a-boo Peek-a-Melon Peek-A-Bush Peeker peekers peeking peeks Peel Peeler Peeling Peels Peen Peen-ata peep peeped Peepee Pee-Pee peeper peepers Peeper's Peephole Peepin Peepin' peeping Peeping-Tom Pee-pod Peeps Peepshow peer Peering pees peeter PeEve Peevert Peg Pega pegged Pegging Peggy pegs Peida Pelba Pele Pele's Peligro Pellenia Pelli Pelon Pelt Pelvic Pemiha Pen Pena Peña Penado Penal penalty Penance Penatration Penchant penchants pencil Pendant Pendragon Pendulum Pendulums Penelopa penelope Penelope? Penelope's Peneloppe peneration penetate Penetracion Penetrando penetrate penetrated penetratedSkull penetrates penetrating penetration penetrations penetrative penetrator Penetrators penettration Penhouse Penile penis penises Penisgate Penisin Penn Penned Penni Penny Penny's pen-pal Penpals Pencat Pens Pensando Pensano Pensia Pension Pent Penthouse Pentola Pentration Pent-Up people Peoples People's Pep Pepe Pepes pepper peppering Pepperoni Peppers Pepper's Peppy Pequeña per Perceive Percent perception Perceptive Perch Perchance Percolator Percy Perder Perdiendo Perdition Perdure Perez Perez's perfe perfect Perfectgift Perfecting perfection Perfectionist perfectly Perfectcans Perfekta Perferct perfo Perforated perform performance Performances performed performer performers performing performs Perfumada perfume Perhaps perhaps? Peridot Peril Perils Perimeter period Periodical Peris Periscope Periscoping perk Perked Perking perks perky Perkyteen Perl Perla Perlea Perles Permagape Permanent Permiscuous permission Permutations Perola Peron Perova Perp Perpension Perpetrator Perpetual Perpetually perra Perri Perri's Perro Perry Perrys Perry's PerryVision Pers-Anal Perscribes Persevere Persevering Persia Persian Persia's Persiko persimmon Persine Persist Persistant persistence Persistent person Persona personal Personalities personality Personalized Personally Personified Personnels Persons perspective persuaded persuades Persuadiendo Persuading Persuajaun persuasion Persuasive Persuation pert Pertu Perturbed Peruvian perv Perv-Anon Perved perverse perversion perversions perversity pervert Pervertables perverted Pervertido perverts Pervesion Pervin Perving pervs Perv's PERVsonal pervy Pesaro Peso Pesquisa pest Pestering pet Peta Peta’s Petal Petals Peta's Petch Pete peter Peters Peter's Petersburg Peterson Pete's petetration Pet-Ho Petina pecan Pecana pecane Pecanes Pecane's Pecaneteen Pecanion Pecanioner Pecanioners Pecanioning pecanions Pecans Petr Petra Petram Petra's Petraska Petrify Petrin Petrol Petronela Petrov Petrova Petrovicky pets Petshop petting Petty Petulance Peyton Pezzini PF PF's Phallic Phallus Phangasm Phantasm Phantom Pharma Pharmacist Pharmacy Pharoah's Phase Phases phat Phat-Booty Phat-Bootyed Phatjohnson's Phatt Phaver PhD PhJohnson Phelpz Phenix Phenix's Phenom phenomenal phenomenon Phenominal Pheona Pheromone Phet Phil Philanderer Philip Philippe Philips phillies Phillip Phillips Phillips's Philly Philosophical philosophy Phinico Phire Pho Phobia Phobic Phoebe phoenix Phoenix's Phoenixxx phone Phone? Phonebooth phoned Phoneix Phone-order Phonecat phones Phones? Phonesex Phonestrip1 Phonestrip2 Phony Phot-ho photo Photomemberied Photocopy Photog Photogenic Photogra-Perv photographer Photographer’s photographer's Photographic photographs photography Photogs Photog's photo-lover photos Photosession Photosessions Photosets photoshoot Photo-Shoot Photoshooting Photoshoots Photosynthesex PHP phrase Phuket Phylicia Phyllisha Phylloma phys physical physically Physicals Physician's physics physio Physiotherapy physique Pi Pi? Piacere Piaf Piaff Piaff's Pianino Pianist Pianists Pianist's piano pianoand Pianocat Piatz pic Picante Picbootyo Picco Piccole pick picked picked-up Picker pickers Pickin Picking Pickings pickle pickles Pick-Me-Up Pickpockets picks pickup pick-up Pickup Pick-up PickUp Pick-Up pickuper Pickups Pick-ups Picky picnic Picnicker Picnickers Pimembersso pics picture Picture? picture_perfect_euro_babe Picture-Perfect pictures Picturesque Picutre pie Pie? piece Pieces pied Pieds Pielda pier Pierce pierced Piercedangel Pierced-cat piercing Piercingly piercings Pierre Pierson pies Pietra Pie-Trap Pietro pig Piggie Piggies piggy Piggyback piggyPart pigiama Piglet Pigs Pigtail pigtailed pig-tailed Pigtailed Pig-tailed pigtails Pigtailtoy Pijamada Pikachu Pikahoe PikaPies pike Pikea pilates pile Piledrive piledriver Pile-Driver Piledrives Piledriving Pile-driving pilgrims pill pillow Pillowcase Pillowfight Pillow-Fight Pillowfighting pillows Pillows2 Pillowstoy Pills pilot pilot's Pilots Pim pimp pimped Pimpin Pimpin' Pimping Pimpology Pimp's pin pina Pinata Pinattas pinball Pinch Pinching Pinco Pine Pineapple pineapples Pineda Pine-etration Pines Ping Pinga Pingpong Pingpongos Pinheiro pining pink Pink? Pinkbed Pinkbedplay Pinkbedtoy Pinkbikini Pinkblot Pinkbra Pinkbraplay Pinkchat Pinkmember Pink'd Pinkdance Pinkbanana Pinkdot Pinkdress Pinkdressshirt Pinker Pinkerton pinkest Pinkfinger Pinkfingers Pinkfloor Pinkflower Pinkglbooty Pinkgstring Pink-Hair Pink-haired Pinkhearts Pinkheels Pinkhole Pinkish Pinklace Pinklingerie Pinklips Pinklove Pinkmirror Pinkmusic Pink'N pinkness Pinknightgown Pinknighties Pinko Pinkpanties Pinkpanties2 Pinkpanty Pinkpink Pinkplaidpanties Pinkplay Pinkpleasure Pinkcat Pinkrabbit Pinks Pink's Pinksheer Pinkskirt Pinkspread Pinkspread1 Pinksuit Pinkswirl Pinkthong Pinktop Pinktop2 Pinktoy Pinktoy1 Pinktoy2 Pinktoyfun Pinkunder Pinkundies Pinkundies1 Pinkundies2 Pinkvibe Pinkviberator Pinkvibrator Pinkvibrator1 Pinkvibrator2 Pinkwhite pinky Pinkyvibe Pinna pinned Pinning Pinos Pinosea pins pint Pint? Pinta Pint-sized Pintura pinup Pin-up Pinups Pinup's Pin-Ups Pinx Pioneer Piotr Piovorno Pipa pipe Piped Pipe-ing Pipeline pipeperr Piper Piperfawn Pipers Piper's pipes Pipi Piping Pippa Piquant Piquante Piqued Piranha pirate Pirate0-0The Pirate12-1The Pirates Pirate's Pirates0-3 Pirates's0-1 Pirelli Pirez Piros Piroshka Pirouette Pirouettes piss pissed pisser pisses pissin pissing Pissings pissmop pissMyBootyOff pissy pistol Pistols Pistol's Piston Pit pitch Pitched Pitcher Pitchin Pitching Pitiful Pits Pitstop Pitt Pittsburg's Pity pix PixandVideocom Pixel pixie Pixie-Haired Pixie's pizza Pizzaboy's Pizza-Delivery Pizzazz PJ Pjs PJ's pl pla plac place Place? Placebo Placed Placement placeNon-scripted Placer places Placidus Placiz Plad-Skirted plaeasing Plaga Plagebabe Plagiarism Plagiarist plaid Plaid2 Plaiddress Plaidfingers Plaidskirt plain plains Plaisir Plaisirs plait Plam's plan Plan-demonium Plane planet Planetaria planetary planets Plank Planking planned planner planning plans Plant Plantain Planting Plantplay Plants Plasata Pl-Booty Plbootyter Plaster plastere plastered Plastering plastic Plastics plate Platered Plates Platform platforms Platina Platinum Platinum's Platonic Platoon platter Plaud Plaudo Plavati Plavusa play Play? playa Playa' Play-along Playana Playback Playback's Playball Playbook playboy playdate Play-Doh Playdolls played player Player2 Playerbanana players Player's playful Playfull Playfully playfulness Playgirl playground Play'h'er Playhouse Playin Playin' playing 'Playing Playing? PlayingBoss PlayingTeacher Playingwithfood Playingwithherfood Playing-Yoga Playland Playlist Playmate Playmates Playoffs Playpals Playroom plays Play's playspace PlayStation plaything Playthings playtime Playtoy playwith Playwithme Playwithme2 Plaza plea Plead pleads pleasant Pleasantly pleasantries Pleasantry please please? pleaseand pleased pleased_to_lick Pleasent pleaser Pleasers pleases pleasin Pleasin' pleasing pleasur pleasurable Pleasurably pleasure Pleasure' Pleasure? Pleasurebath pleasured Pleasure-Filled Pleasureful Pleasurehole Pleasureland pleasureMbootyive Pleasurement Pleasurers pleasures Pleasure's PLEASURES Pleasure-Seeker Pleasuresome Pleasuretown Pleasureville pleasuring Pleated pleather Plebe Plebian pledge Pledges Pledge's Pledging Pleezer Pleiadeans Pleiades Plena Plener Plenilunia plenty Plesir Plesira Plesure Pletiena Plew pliable Pliant Plight Plllllease Plomo Plot Plots Ploughs Plow plowed Plower Plow-Her plowing Plows Ploy ploys Pluck plucked Plucking plug Plugaru plugged Pluggers pluggin plugging Plug-In plugs Plum plumber plumbers plumber's Plumbers Plumber's plumbing Plumelet plump Plumped Plumper Plumpcans Plumpy Plundered Plundering Plunders plunge plunger Plungers plunges Plunging Plural plus Plush Plz PM PMAO PMS po Poax pocahontas Pocajones Pocha Pochatonas pocka pocket Pocket? Pocketrocket Pockets pocketSuck Pockmarks Pocoho Pod Podcast Podcastin Podiatric Podiatry Podium poem poet Poetic Poetics Poetique Poetry pogo Poigne point pointCan pointe pointed pointer Pointers Pointing pointMade Point-of-View Points pointy Poised poison Pokahontas poke Poke’Slut Poke-a-Dots poked poke-her Pokehergeist Pokemon Pokecat poker pokes Pokey Pok-her pokin poking Pola Polaines Polana Poland Polar Polarama Polaris Polarity Polaroid pole Poledance Poledancer Polena poles Polette police Police' policeman policemans Policeman's policewoman Policewoman’s Policewoman's Policy Polijohnsons Polina Polina's poling polish polished polisher polishes polishing Polite Political politician politicians Politician's Politics Polka Polkadotcat Polkadots Polkadots2 Poll Polla pollas Pollinating Pollinic Polls Polly Polo Poltergayst Poltrona polvo Poly Polyamor-Booty Polyamory Polygraph Polynesia Polynesian pom Pomegranaterub pompadour Pompino poms Pon Ponciano Pond Pong Ponies Pons pony Ponytail ponytails Poodle Poodle-wacky Poofhat Pooh Pookluk pool pool' Pool pool? pool_boy_peeper Poolboy Poolboy’s Poolboys Poolboy's Poolbanana Poolean Poolfun Poolguy Poolhall Pool-Hopping Poolhouse Pool-house Pooling Pool-Man Poolo Poolphotoshoot Poolplaytime Poolcat PoolcatBTS Pools Pool's poolside Pool-Side POOLSIDE poolside_cat_persuasion Poolsidebabe Poolsidecutie Poolsidefingers PoolsidefingersBTS Poolsidefun Poolsideplay PoolsideSlut Pooltable Pooltime Pooltowel Pooltoy Pooltoys? Poolvibe poon Poonani Poonanny poondocks poonie poonies Poonjab Poons poontang Poon-Tang poop pooper Poophole poor Poot pooter pootie Pooty Pooty-tang pop Popcicle Popcorn Pope Popes Popo Popova popp popped popped? Poppens Popper Popperz Poppet poppin Poppin' Poppin’ popping Poppins Poppy Poppy's pops pop-shot Popshots popsicle Popsiclefun Popsiclecat Popsicles popu popular popular? Popularity POP-UP por Porcelain Porcelaine porch Porche Porcupine Porcupines Pore pores pork Porked Porkin porking Porkman Porkman's porks porn porn? pornBut Pornero Pornfadential Porn-Fantasy Pornhub Pornification Pornisity Pornland porno Pornocide Pornogothic Pornographer Pornographic Pornography Pornoman Pornomatic Pornocat Pornos PornoXX Porns Porn's Pornsluts pornstar Porn-Star PORNSTAR Pornstar? Pornstar’s pornstars pornstar's Pornstars Pornstar's PornStars Porn-Stars PORNSTARS Pornstarslikeitbig Porn-Store Porridge Porscha Porsche Porschea Porsha Porshe Port Portable Porte Porter portfolio Portia portion Portions Portland Portman Porto Portrait Portraits ports Portugal Portuguese Posare pose Poseable posed Poser poses Posessions Posesta Posey posh Posiente posin posing positiion position positionbj PositionBrutally Positioned Positioning PositionMake positions Positive Positively Possa Posse Posses Possessed Possessione Possessive Possi Possibilities possible possibly Possums post Postal Postcard Poster posterior posterity Post-Make love Post-Game Postgirl Post-graduate Posting postion Postions Post-Jogging postman Postman's Post-Match post-orgasm Post-Party Postponed Post-preggo Post-Shower Posture Post-Work Post-workout Post-Yoga pot potbootyium Potato Potatoes potential potient Potion Pot-O-Greens Potpouri Potpouri2 Potro Potter Pott'h'er Potting Potty-Mouth Pottypants Pouch Poulin Pounce Pounces Pouncing pound Poundable Poundage Pound-A-Thon pounded Pounded? Pounder pounderosa Poundin Poundin' pounding Pounding101 poundings pounds Pound-town Poupos pour Pourin pouring pours pout pouty pov POV' Povfingers pow Powder Powell power power-drilling Powered PowerMake love Power-Make love POWERMAKE LOVE power-make loveed Powermake loveed Power-make loveed Powermake loveing Power-make loveing powerful Powerhouse Powering Powerless Power-Plowing powers Power's Powertool POWPounding Pozo Pozzi PP PPP PP's pr Pra practical Practically practice Practiced practices practicing Practise Prada Pradah Prado Praesto Praga Prague Pragues Prague's Praha Prairie Praise Prance? prancing prank Pranked Prankers Pranking pranks Prankster Pranksters Prankster's Prat Prather Pratt Pray prayer prayerMade Prayers Praying Prays Pre Preacher Preachers Preacher's Precarious Preceptor Precinct precious Precipitation Precision precocious Pre-Dance Pre-Date Predator Predatory predicament Predicaments Predilection Pre-Dip Preeda Preening pre-facial prefer preference preferences Preferential Preferred prefers Pre-Festival Pre-Fiesta Pre-Folsom Pre-make love pre-make loveing pre-game Preggers Preggo Pregnancy pregnant Pregnant? Prego Preguntas Pre-Halloween Preheated Pre-Honeymoon Prejudice Prelim prelude Premarriage premature Premier Premiere Premisses premium Prensley Prenup Pre-Nup Pre-op Prep preparation preparations prepare prepared Preparedness prepares preparing Pre-Party Preperation Prepped Prepper Preppies Prepping Preppy preps Prerequisite Prerogative Pres Pre-Scene preschool Prescience Prescilia Prescott prescribe prescribed prescribes prescription Pre-Season Presence presens present presentation Presented Presenting presents PresentsKelly Preservation Preserve Preserved Preseting presets Pre-Sex President Presidential President's Preslee Presleigh Presley Presley's press Pressed Pressing Pressley Pressued pressure Pressure’s Pressured Pressures Prestatie Prestigious Presto Preston Prestons Preston's pre-study Presume Pretel pretend pretending pretends Pretiosa Preto Pre-Trip prettier pretties Prettiest Prettily pretty Pretty' pretty_pink PrettyDirty Prettyinpink Prettyman Prettypanties Prettypink Prettycat pretzel Pre-vacation prevent Preventive preview Pre-Wedding Pre-Workout prey Prezotte Preztelible Priapus price priceless Price's Pricey Pricilia Pricilla Pricing prick pricked pricker Prickie prickin Pricking Prickly Pricknabber pricks pride PRIER priest Priests Prim Prima primal Primary Primas Primavera prime primed Primer Primera Primetime Primia Primias Primp Primped Primping Prince Princes Prince's princess princesses Princesseven Princesspleasure Princess's principal Princi-Pal principals Principal's Principle Principles print prints Prinzzess prior Priorities priority Priro Priscila Priscilla Prisclia Priscylla Prise Prism prison prisoner prisoners Prisoner's Prissy pristine Pristine’s Pristy Pritty Priva privacy Privada Priv-Bootyy private Privates Private's Privation privilege Privileged Privileges Prix Priya Priyas Priya's prize Prized prizepound prizes pro probability Probable probably Pro-Baller Probando Probation probe Probe-Ation probed probes probing problem Problemo problems Problem-Solver Procedure procedure? procedures Proceed process Processing Proclaimed Proclivity procrastinate Procrastination Procrasturbating Procurer prod prodded prodessional Prodigal prodigy produce producer Producers Producer's produces Product production Productions Productive Productos Products Prof Profanity Profesor Profess profession professional Professionalism Professionals professor Professorial professor's Professors Professor's Proficient profile Profilin profit Profiteering Profits Profound Profundamente Profundo Profusely program Programmed Programming Progress Progression Prohibida Prohibition project Project? projectile Projecting Projection Projections Projector Prokopi PROLAPS prolapse Prolapsed prolapsefisting Prolapses Prolapsing Proletariat Prolonged prom Promenade Promesita Prominent Promiscuous promise promised promises promising Promo promoted Promoter Promoting promotion Promotional Promotions prone prong Pronia Pronto proof Prop propa Propellant Propelled proper properly property Prophetic Proportioned Proportions proposal Propose Proposes Proposing Proposition Pros Prose Prospect prospects Prosper prostate Prosthetic proscanute Proscanute? Proscanutes protect protecter Protecting protection Protective Protector Protects Protege Protegee protein protest Protestor Protocol Protocols Prototype Protuberances proud proudly Provance prove proved provement Proven Proverbs proves provide Provided Provider provides Providing Proving Provision Provita Provocateur Provocation provocative Provocatively Provoked provokes provoking Prower Prowess prowl Prowler Prowling Prowls Proxy PrrrrrNelli Prude Prudes Prudish Pruning Prurience Prurient Pryce prying PS Ps-al Pseudo Psico Psicologa PSL Psych Psyche Psychedelic psychiatrist Psychic Psy-Chic Psycho Psycho-Anal-ized Psychodrama Psychological Psychologist Psychology Psycho-Medical Psychopathic Psychosexual Psychotherapist Psychotherapy Psychotic Psylocke pt pt1 Pt-1 pt2 pt3 Pt4 PT5 PT6 PTA PTM Pu pub Pube-less pubic public Publicfinger publicly Publiccat Publisher Puck Pucker puckered Pucks pudding puddle puddles Pudera Pudgy PUEDO Puertas Puerto Puff Puffed puffies puffy Puffy-Nippled Puffynipples Puffycat Puffycans Puh puke Pulaski Pulcher pull pulled puller Pullin pulling Pullout pulls Pullum Pulmonary Pulp pulsates pulsating Pulsation pulse pulsing pulverized Pulverizer Puma Puma's pumbas Pumkin Pummel pummeled Pummeling pummels pump Pump? Pumpage pumped Pumper Pumpin Pumpin' pumping Pumpings's Pumpkin pumpkins pumps Pumptoy Pun punani punch Punching punish Punishable punished punisher punishes Punish-make loveing punishing punishment punishments punk Punked punk-emo Punking Punkrock Punk-rock punks Punk's Puño punshied Punt puntang Puntas Punter Punts Punxxx Puny PUNYtive Punzel Pup Pupae pupil Pupil's Puppet Puppeteer Puppetcat Puppets Puppies puppy Pups Puraj purchase Purchased purdy pure Pureheart Purely Purgatory Purge Purged purging Puri Purification Purified Purify Purin Purissima purity purple Purple2 Purplebath Purplebanana Purpledong Purplenails1 Purpleplay Purplepower Purplecat Purpleribbon Purples Purplething Purpletoy Purpletoy2 Purplevibe Purplevibrator Purplewand purpose purposes purr Purrfect Purr-fect Purring Purrr Purrrfect Purrrrfect purrrring purrrrr Purrrrrrrrrr purrrs purrs Purse Pursuajon Pursued Pursuing Pursuit Pursuits Purveyors pus Pus-C push pushed Pusher pushes Pushin pushing Pushover Push-Up pushy puss Pussan Pusscriptions Pussie cats cats? Pussika cat P-U-S-S-Y cat? cat_party Cat’s catand catAriel Catback Catbeads Catbottomboy Catcam catcat Catcats Catcats' Catcat's Catchat Cat-coaching Catcraft Catbanana cat-driller Cat-eaters Catfinger Catfingers Catfoot Catfooting Cat-Make love catmake loveer cat-make loveing Catfun Catgram Cathole Catinboots catJane Catkat catlicious cat-licking Catlicking Catlove Cat-loving catMade Catmania Catmans Catman's Catmobile catnice Cat-O Catology Cat-pierced Catpink Catplay catplays Cat-pleasing Catplug Catpoppin Catrub cats Cat's Catspread Cat-Squirting Cattalk Cattease Cattive Cat-To-Face Cat-To-Mouth Cattouches Cattoy CatTS Catvibe Catville Pusua Pusya put puta Putachen Putang Putas Pucana Put-Out puts putt Putter Puttin putting Puttputt Putt-Putt Putts putty Puurrfect Puzzle Puzzled Puzzling PVC Pwn'd PWND Pwns Pyjama Pyr Pyrah's Pyramid Pyromaniac Pysch Python Python's Q Q What QAP QB Qelibar Qt Qu Quad quadruple quadruple-zippered Quake Quakes Quakin Quaking Qualifications Qualified Qualified? Qualified??? qualities quality Quandary Quancany quantum QUAP Quaran-babes QuaranKink quarantine Quarantined Quarrel quarry Quarter quarterback Quartered Quarter-Final quarterfinals Quarterly Quarters Quartet Quatre que Quebec Quebecois Quebecoise Quedate Quédate Queef Queefing queef-o-matic queen Queen? Queen-Brittany Queendom QueenFormer Queen-Kristal QueenPin queens Queen's QUEENS queer Quena quench quenched Quenchers quenches Quenching Quentin quest Questa Questing question Question? Questionable questioned questions Quetesh qui quic quick quick? quickener Quickest quick-fire quickie Quickies Quickiy quickly Quicksand Quicksuck quicky Quid Quien Quieres Quiero quiet quietly quim quims Quin Quincy Quinn Quinno Quinns Quinn's Quintero Quinteros Quinton quirky quit quite quits Quitters Quitting Quiver Quiverin quivering quivers Quixote Quiz Quo Quota Quote Qutie r ra rabbit Rabbit? Rabbitbed Rabchickat Rabbitclit Rabbitcream Rabbitmake loveer Rabbitfun Rabbitfun1 Rabbithole Rabbitlove Rabbitplay Rabbits Rabbit's Rabbittime Rabbittoy RabbittoyBTS Rabbitvibe Rabiona race Racer Racers Races Racetrack Raceway Racey Rachael Rachaels Rachael's Racheal Rachel Rachele Rachele's Rachell Rachelle Rachels Rachel's Rachida Rachyda racial racing Racist rack racked Rack'em Racket Rackin Racking Racks Racquet Racquetball racy Rada Radar Radek Rader Radeva Radiance radiant Radiate Radiating Radical Radically Radient Radi-ho Radikal Radio RadioShag Radislava Radka Radke Radra Rady Rae Raee Raelynn Raelynn's Rae's Rafaela Rafaeli Rafaella Rafail Rafealy Raffaella Raffle Raft rag Ragan ragdoll Rag-Doll rage Rager rages Raggi Ragin raging Rags Rahda Rahnie Rah-Rah Rahyndee Rahyndee's Rai raid Raided Raiden Raider Raiders Raiding raids Raiin Rail railed Railen Railin railing Rails railway Raily rain Raina Rainb Rainblow RainBlow4k Rainboot rainbow Rainbows Rainbow's Rainbowstripes Rainbox RainCan Raincheck Raincoat Raine rained Rained-Out Raines Rainfall Raini Rainia raining rains Rain's rainy Raisa Raisa's raise Raised raiser raises Raising Raison Raissa Raissas Raje Rajni Rakel Raketa Rako Rally Ralph Ram Rama Ramalama Rambina Rambler Rambling Rambunctious Ramdy Rami Ramirez Ramiro rammed Rammer Rammin' ramming Ramon Ramon’s Ramona Ramondini Ramone Ramons Ramon's Ramos Ramp Rampage Rampaging Rampant Rampling Ramsey Ramu Ramzi Ramzi's Ranae Ranasim ranch Rancher Rancher's Rancho Rand Randall Randi Randii Rando random RandomActsOfBeejs randy Rane rang range Rangel ranger Rangers Rank ranked Ranked13TH RankedThe rankedvsMonica Rankings ranks Ranok Ransom Rant ranting Raoni Rap Rapace Rapaces Raphael Raphaela Raphaella Raphael's Rapid rapper Raptor rapture Rapturous Raquel Raquelle rare Raree Raree-Show raring Rarite Rarity Rasberry rascal Rascals Rashae Rasmali rastasex rat Rate Rated Rates Rather Rating Rato Rats Rattler Rauemi Raul Raul's raunch Raunchiest raunchy Rauncy ravage ravaged ravages Ravaging rave raven Raveness raven-haired Ravenna Ravenous Ravens Raven's Raven-tressed Raver Ravers Raves raving Ravish ravished ravishes Ravishess ravishing raw Rawdog Rawdogging Raw-Dogs RawDp Rawhide Rawr Raxx Raxxx Raxxx’s ray Raya Rayan Rayana Rayane Rayanne Rayas Raye Rayes Raye's Rayj Raylene Raylene's Rayles Raylin Raylyn Raymond Rayndee Rayne Rayne? RayneAnal RayneChained Raynes Rayne's RAYNES Rayno Rays Rayssa Rayven Rayveness Rayvenness Rayye Raz Raziona RB RC rd RD1-RD4 RD2 RD3 RD3? RD4 RDWho Re rea reach Reacher reaches Reaching Reacquainted react Reaction Reactions reacts Read Read? reader Readers readhead Readheads reading reading? reads ready ready? Reagan Reagans Reagan's Reagonomical real Real? Real?? Real-Meloned Realdoll Realease Realest realise Realism Realiteen reality Reality? Realizations realize realizes real-life really Realm Realms Real-State realtime realtor Realtor’s realtor's Realty Ream reamed Reaming reams Reaper Reaping rear Rear-end Rear-Ending Rearranged rears Rearview reason reasons rebootyure Reavealing Reball Rebate Rebeca Rebecca Rebecca’s Rebeccas Rebecca's Rebeka Rebekah Rebekka rebel Rebel' Rebel’s Rebelious Rebell rebellion Rebellious Rebels Rebel's Rebirth Rebka Rebooting Reborn Rebote rebound Rebounding Rebounds Rebumal Recall Recalling Recan Recapture rece receive Received receiver receives receiving Recente Recently Receptacle Reception Receptionist Recession Recibir recieve recipe Recipes Recipient Reciprocal Reciprocity Recital Reckless Recklessness Reckoning Reclamacio Reclamation Recline Reclined Reclines Recluse Reco recognise recognises Recognition Recognize recognized Recollect Recollection recommendation Recommended Recompencen Reconcile Reconciled Reconciliation Recondite Reconditioning Reconnaissance Reconnecting Reconnects Reconsider Reconstruct Reconstructed reconstruction record Recorda Record-Breaking recorder recording Recording? recordings Recovery Recrea recreation Recreational Recreations Rec-room recruit recruiter RecruiterAshlynn Recruiters Recruiting Recruitment Recruits rectal Rectally rectum Recuperation Recycled recycles Recycling red Red’s Redbed Redbones Redbra Redbra2 RedBrutal Redcape Redcarpet Redchair redcoats Redd Red-D Redjohnson Redbanana Reddress Redecorate Redecorators Redeeming Redefined Redefining redemption Redfingers red-haired Redhaired Red-haired red-handed red-hat redhead red-head Redhead Red-Head REDHEAD Redhead Siri Gets Redhead’s redheaded Red-headed Redhead-Next-Door redheads redhead's red-heads Redheads Redhead's red-hot Redhot Red-hot Rediscovers Redlight Redlingerie Redlipstick1 Redlipstick2 Redlipstick3 Redmond Redmond's Redneck Rednecks Redo Re-Do Redolence Redpearls Redplaid Redpolkadots Redred Redress Redroom Reds Red's Redshoes Redshoestoy Redshoestoy2 Redstockings Redtease Redtoy Reduce reduced Reduction Reduction? Redux Redvibe Redvibrator Redwoods REDY Redz Reece Reece's Reed Reeder Reed's ReedThe Reeducation Reedy'? Reeka Reel Reelin reeling reels Reena Reenacting Reenactment Re-enactment Reena's Reenergize Rees reese Reese-On Reese's Reeves ref Refer Referee Reference Referral Referred Refined Refining Reflected Reflecting reflection Reflections reflex Reflexes Reflexology ReflexXxions Reform Reformed Reformer refreshed Refreshers Refreshing refreshment Refreshments Refreshness Refrigerated refuge refugees Refund Refunds Refurbished refuse refused Refuses Regal Regalo Regan Regaño regards Regelli Reggeaton Reggie Re-Gifted Regina Regina's Regine Registered Registrar Registration Registry Reglas Regon Regreso Regret Regretless Regrets Regretted regula regular Regular? Regular's rehab Rehabilitation rehearsal rehired Rei Reich Reid Reid's Reif Reif's Reighlei Reign Reigning Reignite Reigniting Reigns Reign's Reiki Reiko Reiko's Reilly reimbursement Rein reina Reines Reinforcement Reins Reinvented Reinventing Reislin Reject Rejected Rejects Rejoice Rejuvenate rejuvenated Rejuvenating Reka Rekindle Rekindled Rekindles Rekindling rel Relación Relajar Related Relation relations relationship relationships Relative Relatively Relatives relaunch Relaunch-12 Relaunch-13 Relaunch-14 Relaunch-15 Relaunch-16 Relaunch-17 relax relaxation relaxed relaxes relaxin relaxing relaxology Relaxurbation Relay release release” Released Releaser releases Releasing relentless relentlessly Relexed relict relief relieve Relieved reliever relieves Relieving Religion religious Reliqua Relish Relive relives Reliving Reload reloaded Relota Reluctant Rem Rema remain Remains remarkable Remaster RemasterBlonde Remasterd remastered RemasteredFirst rematch remediation Remedies remedy rememb remember Rememberence remembering remembers Remi Remind Remindal Reminder Reminding Remington Reminisce Reminiscence Remission Remix remote Remote-Control Remotely removal Removals Remove removed removes Removing RemsteredYoga Remy Remys Remy's Rena Renae Renae's Renaissance Renata Renata's Renate Renato's Rendered Rendevous rendez rendezvous Rendez-vous Rene renee renegade Renewal Renewed Renewing Renitent Renna Rennt Reno Renovate Renovation renovations Renovator Renowned rent Rent? Rent’s Renta Rent-A-Ho rental Rent-A-Pornstar Rented Renters Renter's Renting rents Rent's Renyolds Reoccuring Reon rep repaid repair repaired repairman repairman's repairmen repairs reparation reparations Repay Repaying repays Repeat Repeated Repeatedly Replace replaced replacement replaces Replacing Replay Replenishing Replica Reply Repo Repopulate report reporter Reporting Reppin Represent Representation Repressed Reprimanded Reprisal Reproduction reprogrammed Reprogramming Reps Republic reputation Repute request requested requested* Requests Requiem Required Requirements Requires re-release Re-Released Rerouted Reruns Res Resa rescue rescued rescuer rescues Rescuing research Researches Researching Resembles Resepct Reservation Reservations reserve Reserves Reservoir Residence resident resist Resistance Resisting Resita Re-Sized Resolution resolutions Resolve resolved Resolving Resonate Resort Resorts Resourceful Resources respect Respectful Responding responds Response Responsibilities Responsibility rest rest? restaurant Restful Resting Resting? restless Restocking ReStockings Restomake loveers Restom Restrain restrained Restraint Restraints Restricted Restriction Restrictions restrictive restroom Restwhoreant result results Resume Resumed Resurrection Resuscitating Resuscitation Retail Retake Retaliation Retato Retentive Retire retired Retirement Retiring Retraction Retraining Re-Training Retreat Retribution retro Retrospect retun return returned Returnee’s Returning returns Returns's returnswith reunion Reuniones Reunite Reunited Reunited? Reuse rev Revamped Reve reveal revealed revealedthis Revealer revealing reveals Revees Revel Revelas Revelation Revelations Revelry revenge revenged REVENO Revere Reverence Reverend Reverie Reversal reverse review Reviewer Reviewers Reviews Revisited revisits Revitalized revoir Revolt Revolution Revolver Revolving revs Revved revving reward Reward? rewarded Rewarding rewards Rewash Rewind re-writes Rex Rexian Rex's Rey Reyes Reyez Reyka Reyna Reynolds Rey's Rez Rezika Rhannion Rhapsody Rharri Rheanna Rheina Rhet Rhetica Rhett Rhianna Rhiannon Rhinestone Rhoades Rhoades’ Rhoades's Rhodes Rhodes’ Rhodes's Rhombus Rhonda Rhound Rhrianna Rhyder Rhyese Rhylee Rhylee's Rhymes Rhyse Rhysee Rhythm Rhythmic Ri Ria Riall Riana Riante Ria's Riazi Rib Ribald ribbed ribbing ribbon Ribbons Ribeiro Ribetza Ribon's Ribs Rica Rican Ricarda Ricardo Ricardo's Rica's Ricci Ricci's Ricco Rice rich Richard Richards Richardsen Richardson Riche Richele Richelle Richelle's Riches Richey Richie Richman Richness Rich's richter Richy Rick Ricki Rickie Rickshaw Ricky Ricky's Rico Ricosf Ricshaw rid Rida Ridden Ridding Riddle Riddle's ride ride? ride_with_me Ride'em RideEm Ride-Her Ride-Him rider Ride'r Rider? Riders Rider's rides rideshare Ridge Ridged Ridgedbanana Ridgemont Ri-Johnson-ulous Ridiculed Ridiculous Ridiculously Ridin Ridin' riding riding? riding_dirty Ried Rieds Riesling Rifles rig Riga Rigged Righ right right? righteous righteously righteousness rights Rigid Rígido Rigin Rigor Rigorous Rigth Rihana Rihana's Rihanna Rihanna's Rihannon Rija Riker Rikk Rikke Rikki Riled Rilee Riley Riley’s Riley'd Rileys Riley's RileyThe Rily Rilynn rim Rima Rima's Rimers Rimes rimjob rimjobs Rimma Rimmed Rimmer Rimmers rimming Rimmy Rims Rin Rina Rinad Rinaldi Rina's Rinata ring Ring? ringer ringers Ringetsu ringing Ringmaster Ringpop rings ring's Rings Rinialta rink Rinse Rinsed Rinseoff Rinseoff2 Rinsing Rio Rion Rios Rio's Ríos Riot RiotGoth rip ripe Ripened Ripera Ripest ripped Ripped-up Ripper Rippin Ripping Rippling rips Risa Risa's rise riser Risers rises Risika rising Risingstar Risingstar's Risk risks risky Risque Risqué Rissa Ristorante Rita Ritam Rita's Ritchie Rite Rites Ritina Ritta Ritter ritual Ritual--Part Rituals Ritula Ritz Ritzy Rius Rival rivalries Rivalry rivals Rival's Rivas Rivas's river Rivera Riverboat Riveretta Riveria rivers River's riverside RiversThe Riviara Riviera Riviera's Rix Rixel Riya Riyanna Riza Rizzo Rizzo's RJ RJs Rk RND ro road Roadblock Road-Head Roadie Roadies Roadmap roads roadside roadtrip Roadtripper roam Roaming Roar roaring Roast roasted Roasting rob Roba Roba-Vergas RobaxaMAKE LOVE robbed 'Robbed robber Robber’s robbers robbery Robbie Robbie's Robbin robbing Robbins Robby Robbye Robby's Robe Robefun Robert Roberta Roberto Roberts Robert's robery Robetalk robics Robin Robins Robin's Robinson Robles Robomember robo-pork robot robotic Robotically robots Rob's Robson Robust robyn Roc Roca Roccaforte Roccia Rocco Rocco' Rocco's Roche Rochelle Rochelly Rocheux rock Rockabilly Rockabooty Rocke rocked rocker Rockers Rocker's rocket Rocketing Rocket-Launcher Rockette Rockford rock-hard Rocki Rockie rockin Rockin' rocking Rockledge Rock'n Rock'nDoll Rock-Nipples-Suffer rock'n'roll Rock-N-Roll Rocknrolla rocks Rockspring rockstar Rockwell Rockwood Rockwood's Rocky Rocky's rod Rod' Rodaci Rodding rode rodeo Rodeo's Roderick Rodgers Rodise Rodney Rodrigues Rodriguez Rodriguez's Rodriquez rods Rod's Rogen Rogen's Roger Rogers Roggie rogue Roguish Roja Rojas Rojo Rok Roka Rokie rol Roland role Rolemodel roleplay role-play Roleplay Role-Play Roleplaying roleplays roles Rolinda roll Rolland Rolland's rolled roller RollerBlade Rollerblading Rollercoaster roller-memberster Rollergasm Rollergirl Rollers Rollerskate Rollerskating Rollick Rollin rolling rolls Roma Romain Romaine Romain's Roman Romana romance romanced Romance's romancing Romanetta Romani Romania romanian Romanian’s Romania's Romani's Romano Romanoff Romanova Romanova's Roman's romant romantic Romantica Romantically Romantics Rombicana Rome Romee Romeo Romeos Romero Romi Romi’s Romina Romi's rommamates Rommmate romp Rompe Romper Rompers Rompin romping Romy Ron Roncero Ronda Ronda's Rondeur Rone Rone's Ronita Ronni Ronnie Ron's Ronta Roo roof Roofer rooftop Roof-Top Rooftopcans Rook rookie rookieBrutal rookies Rookie's room Room? roomate Roomates Roomfull Roomie roomies roommate Roommate’s roommates roommate's Roommates Roommate's rooms Roomy rooster roosters Root Rooting roots rope Ropebaby's roped ropeMade ropeOrgasms Ropeplay Roper ropes Rope-Tied Roping Roquero Rorie Rorschach Rory Rosa Rosae Rosalie Rosalina Rosalyn Rosana Rosanna Rosano Rosario rose Rose’s Rosea Roseanna rosebud rosebuds Rosebud's Rosebum RoseDay Rose-Day Rosee Roseias Rose-Live Rosella Rosemary Rosen Rosen's Rosepool Rosero roses Rose's Roses's Rosetta Rosewood Rosey Roshell Rosie Rosies Roslita Ross Rossa Rossella Rossella's Rosses Rossi Rossi's Rossi-Vision Rosso Rostik Roswell rosy Rosy-white Rosz Rotaco Rotating ROTC Roth Rotisserie Roto-Drilled Rotten rotten_experience_at_the_strip_club Rotten's Rotten-Uncontrollable Rotterdam Rotts rotund Rouge rough roughed Rougher roughest Rough-make loveing rough-make loves roughly Roulette round Round? Roundbooty Round-booty Rounded Roundest RoundPerfect rounds round-up Roundup Round-up Rouse rousing Rouso Rousse Rousso Route routine routines routins Roux roving row Rowan rowdy Rowe Rowen Rowena Rox Roxana Roxana's Roxane Roxanna Roxanne Roxanne's Roxee Roxee's Roxetta Roxette Roxi Roxie Roxii Roxina Roxing Roxi's Roxx Roxxi Roxxie Roxxx Roxxxie Roxxx's Roxxxy Roxxy Roxy Roxy's Roy Roy’s royal Royalblue Royale Royally royalty Royalty's Royce Roy's Roz Roza Rozas Roze Rozen Rozker RPG RPMs RR Rrrrock Rrrrrip RS001 RS002 RS003 RS004 RS005 RS006 RS015 RS018 RS019 RS023 RS024 RS033 RS036 RS037 RS038 RS039 RS040 RS042 RS051 RS054 RS055 RS058 RS059 RS062 RS064 RS066 RS067 RS068 RS069 RS07 RS070 RS071 RS073 RS074 RS076 RS078 RS08 RS080 RS081 RS082 RS083 RS084 RS085 RS086 RS087 RS088 RS089 RS090 RS091 RS092 RS093 RS094 RS095 RS096 RS097 RS098 RS099 RS100 RS101 RS102 RS103 RS104 RS105 RS106 RS107 RS108 RS109 RS11 RS110 RS111 RS112 RS113 RS114 RS115 RS116 RS117 RS118 RS119 RS12 RS120 RS121 RS122 RS123 RS124 RS125 RS126 RS127 RS128 RS129 RS13 RS130 RS131 RS132 RS133 RS134 RS135 RS136 RS137 RS138 RS139 RS14 RS140 RS141 RS142 RS143 RS144 RS145 RS146 RS147 RS148 RS149 RS150 RS151 RS152 RS153 RS154 RS155 RS156 RS157 RS158 RS159 RS16 RS160 RS161 RS162 RS163 RS164 RS165 RS166 RS167 RS168 RS169 RS17 RS170 RS171 RS172 RS173 RS174 RS176 RS177 RS178 RS179 RS180 RS181 RS182 RS183 RS184 RS185 RS186 RS187 RS188 RS189 RS190 RS192 RS193 RS195 RS196 RS197 RS199 RS20 RS200 RS201 RS202 RS203 RS204 RS205 RS206 RS207 RS208 RS209 RS21 RS210 RS211 RS212 RS213 RS214 RS215 RS216 RS217 RS218 RS219 RS22 RS220 RS221 RS222 RS223 RS224 RS225 RS226 RS227 RS228 RS229 RS231 RS232 RS233 RS234 RS235 RS236 RS237 RS239 RS240 RS241 RS242 RS243 RS244 RS245 RS246 RS247 RS248 RS249 RS25 RS250 RS251 RS252 RS253 RS254 RS255 RS256 RS257 RS258 RS259 RS26 RS260 RS261 RS262 RS263 RS264 RS265 RS266 RS267 RS268 RS27 RS270 RS271 RS272 RS273 RS274 RS275 RS277 RS278 RS28 RS29 RS30 RS31 RS32 RS34 RS35 RS41 RS43 RS44 RS45 RS46 RS47 RS48 RS49 RS50 RS52 RS53 RS57 RS60 RS61 RS63 RS65 RS72 RS75 RS77 RS9 Rtuh ru rub rub_the_pole RubADorothea Rub-a-Dub Rub-a-dub-dub RubAJessy Rub-A-Jug-Jug Rub-And-Tug-Tub rubbed rubber RubberDoll Rubbers Rubbertoy Rubbin Rubbin' rubbing Rubbing1 Rubbing2 rubdown rub-down Rubdown Rub-down Rubdown? Rubee Ruben Rubenesque Rubens Rubes Rubi Rubies Rubio Rubi's rub-n-tug Ruboff Rub-out rubs Ruburana Ruby Ruby’s Rubys Ruby's Ruca's Rucava Rucca Ruckus Rud Rude Rudy ruff Ruffle Ruffling RUFF'N'TUFF rug Rugby Rugbanana Rugfinger Rugplay Rugs1 Rugs2 Ruhl ruin ruined Ruining ruins Ruiz Ruka rule Ruler rules Ruling Rumba Rumble Rumbles Rumbling Rumia Ruminating Rumor Rumors rumour Rumours rump rumpalicious rumps Rumspringa Rumuri Rumzta run Runa Runaway Runaways Runaway's Rungs run-in Runnalingus Runner Runner's Runneth Runnin running Running? runs runway Rural Rurale Ruse rush Rushes rushing RushMoore Rush's Rusian Ruski Ruslan Russ Russel Russell Russell's Russet Russia russian russians Russian's Russkie Russo Russo's Rustic rusty Rut Ruth ruthless ruthlessly Ruuun Ruuuun Ruzzare RV Rx RX’s Rx's Rya Ryaan Ryad Ryan RyanIs Ryann Ryanne Ryans Ryan's Ryde Ryden Ryder Ryder's Rydes rye Rylan Rylee Ryley Rylie Rylie's Rylin Ryllei Ryo Ryon Rysie Ryta Ryu Ryzele Ryzell s S001 S002 S003 S004 S005 S006 S01E01 S01E02 S01E03 S02E01 S1 S10 S10E1 S10E10 S10E2 S10E3 S10E4 S10E5 S10E6 S10E7 S10E8 S10E9 S11 S11E1 S11E10 S11E2 S11E3 S11E4 S11E5 S11E6 S11E7 S11E8 S11E9 S12E1 S12E10 S12E2 S12E3 S12E4 S12E5 S12E6 S12E7 S12E8 S12E9 S13E1 S13E10 S13E2 S13E3 S13E4 S13E5 S13E6 S13E7 S13E8 S13E9 S14E1 S14E10 S14E3 S14E4 S14E5 S14E6 S14E7 S14E8 S14E9 S15E1 S15E2 S15E3 S15E4 S15E5 S15E6 S15E7 S15E8 S16E10 S16E3 S16E5 S16E6 S16E7 S16E8 S16E9 S17E1 S17E10 S17E2 S17E3 S17E4 S17E5 S17E6 S17E7 S17E8 S17E9 S18E1 S18E10 S18E2 S18E3 S18E4 S18E5 S18E6 S18E7 S18E8 S18E9 S19E1 S19E10 S19E2 S19E3 S19E4 S19E5 S19E6 S19E7 S19E8 S19E9 S1E1 S1E10 S1E2 S1E3 S1E4 S1E5 S1E6 S1E7 S1E8 S1E9 S2 S20E1 S20E10 S20E2 S20E3 S20E4 S20E5 S20E6 S20E7 S20E8 S20E9 S21E1 S21E10 S21E2 S21E3 S21E4 S21E5 S21E6 S21E7 S21E8 S21E9 S22E1 S2E1 S2E10 S2E2 S2E3 S2E4 S2E5 S2E6 S2E7 S2E8 S2E9 S3E1 S3E10 S3E2 S3E3 S3E4 S3E5 S3E6 S3E7 S3E8 S3E9 S4E1 S4E10 S4E2 S4E3 S4E4 S4E5 S4E6 S4E7 S4E8 S4E9 S5E1 S5E10 S5E2 S5E3 S5E4 S5E5 S5E6 S5E7 S5E8 S5E9 S6E1 S6E10 S6E2 S6E3 S6E4 S6E5 S6E6 S6E7 S6E8 S6E9 S7E1 S7E10 S7E2 S7E3 S7E4 S7E5 S7E6 S7E7 S7E8 S7E9 S8E1 S8E10 S8E2 S8E3 S8E4 S8E5 S8E6 S8E7 S8E8 S8E9 S9E1 S9E10 S9E2 S9E3 S9E4 S9E5 S9E6 S9E7 S9E8 S9E9 sa Saabo Saana Saavy Sab Saba Sabado Sabana Sabara Sabbia Sabby Sabelle Saber Sabien Sabina Sable Sabor Sabotage Sabreena sabrina sabrina's SabrinaVova Sabrine Sabrinka Sabrisse Sabrosa Sabryna Sac Saca sacapunta Sacha sack sacks Sacral Sacramento Sacred Sacrifice Sacrifices Sacrificial Sacrilegious Sacrilicious Sacs sad saddle Saddles Saddle-Up Sade Sadie Sadie’s Sadie's Sadique Sadism Sadist Sadist0-1 sadistic sadistic??? Sadistically Sadists Sadist's SadlyBut sadness Sado Safado safari safe safely Safest Safety Safeword Saffron Safi Safira Safire Safo Saga Sagami Sagara Sage Sage's Sahara Sahari Sahenka Sahmara Sai said Saige Saige20yr SaigeSexy SaigeTTOO Sail Sailboat sailing sailor Sailor? Sailors Sailor's Sails saint saint? Saint-Amour Saint-Amour's Saint-Patrick's Saints Saint's SaintThe Sainz sake Sakj Sakova Sakura Sakura's Sal SAL001 SAL002 SAL003 SAL004 SAL005 SAL006 SAL007 SAL008 SAL009 SAL011 SAL012 SAL013 SAL014 SAL016 SAL017 SAL018 SAL019 SAL020 SAL021 SAL022 SAL023 SAL024 SAL025 SAL026 SAL027 SAL028 SAL10 SAL15 Salacious salad Salad? salami salary Salat Salazar Salchicha Salchichon Saldus sale sales Salesgirl salesman salesman's Salesmanship Salesmen saleswoman Sales-Woman Saleswomen Saliery Salina Salinas Salina's Saline Salinia Saliva Salivates Salivating Salivation Sallai Salley Sally Sally's Salma Salome Salome's Salomja salon Salón Salonsecret Saloon Salopes Salottia Salsa Salt Salty salutations salute Saluting Salvadorian Salvaje Salvajes salvation Salvatore Salve sam Samanta Samanth Samantha Samantha2 Samantha's Samara Samaritan Samaritans Samatha's Samba Samba-Cubana Sambuca same Samhain Sami Samia Samie Samilla Samira Samira's Sami's Samm Sammi Sammie Sammie's Sammi's Sammy Sammy-Jayne Sammy's Samntha Samoan Samone Samora sample Samples Sampling Sampson Samson samsonite Samuel Samuella's Samurai Samurai-Make loveed Samy Samyer Samyra San Sana Sancha Sanches Sanchez Sanchez's Sanccany sanctuary sand Sanda Sandal sandals sandbox Sandee Sandee's Sander Sanders Sandi Sandie Sandis Sandman Sandobar Sandora Sandra Sandra's Sandrine Sandro's Sands Sandsational sandwich sandwiched Sandwiches Sandy Sandy? Sandy’s SandyChat Sandys Sandy's sane Sanger Sanie sanity Sanja Sanny santa SANTA?Come Santa’s Santana Santana’s Santanna's santas Santa's Santavibe Sante Santez Santhiago Santhiago's Santi Santiago Santini Santis Santi's Santo Santoro Santos sanwiched Sanya Sap Saphic Saphir Saphire Saphire's Sapore sapphic Sapphically sapphics Sapphira Sapphire Sapphires Sapphos Sar sara Sarabria Sarada Sarah sarah's Sarai SaraJay Saran Sara'nade Saran's Saras Sara's Saray Sarena Sarge Sarge's Sari Saria Sarina Saritha Sarr Sarrah Sarte Sartre Sartre's SAS Sasafrbooty Sascha Sascha's Sash Sasha Sashaa Sasha's Sashina Sashu Sasiavis saskia Sbooty Sbootyy sat Sata Satan Satanic Satan's Satellite satin Satine Satinella Satinrobe Satinrobe2 Satintoy satisfaction satisfactions satisfied satisfies Satismake love-tion satisfy Satisfyin' satisfying Sativa Sativa's Sativva Satomi Satomi's Saturday Saturdays Satynge sauce Saucey Saucier saucy Saul Sault sauna Saundra sausage sausage? sausages saute Sauvage Savabbah Savage Savagely Savages Savaging Savalas Savana Savanah Savana's Savanna Savannah Savannahs Savannah's Savannnah save saved Saver saves Saving Savings savior Savor Savorers Savoring savors savory Savoury Savvy Savvy's saw Sawaru Sawyer Sax say say? Saya Saya's Sayeh saying Sayonara says Sayurii sbanged Sc Sc1 Sc2 Sc3 Sc4 Scaffolding scale Scaleno scam scammer Scamming scams scan scandal Scandales Scandalous Scandals Scanner Scanno Scanties Scantonato Scantron Scape Scapes Scaping Scar scare Scarecrow scared Scaredy scares scarf Scarfs scaring Scaris Scarlet Scarlets Scarlet's Scarlett Scarlette Scarlette's Scarlett's Scarlit Scary Scarzan Scavenger Scavengers Scavilan Sce Scellen Scenario scene scene1 Scene2 Scene3 Scene4 scene901205 scene901206 scene901207 scene901208 scene90140 scene90141 scene90142 scene90143 scene90144 scene90367 scene90368 scene90369 scene90370 scene90371 scene90374 scene90375 scene90376 scene90377 scene90378 scene90379 scene90380 scene90381 scene90382 scene90383 scenery scenes Scenic Scent Scented scenting Scentual Scepter Sceptical sch Schaft Schedule Scheduled Schedules Scheme Scheming Schiffer Schilla Schinaider Schinider Schisma Schizzi Schlampe schlong Schlonged schlongs schlub Schmidt Schmitt Schmovie Schneider Schnitzel Scholar? scholarship Scholastic school School? schoolboy Schoolboys Schooled schoolgirl schoolgirls schoolgirl's Schoolgirls Schoolgirl's Schoolgirltoy Schoolgirltreat Schoolgirlupskirt Schoolhouse Schoolin schooling schoolmaster schoolmate Schoolroom schools School's Schoolvibe Schoolyard Schoolzone Schpitz Schtupping Schultz Schwartz Schwartz's Schwarz Schweider Sci science sciences Scientific Scientist scientists ScienCANS Sci-fi SciFi Sci-Fi Scimitar Scintillating scissor scissored scissoring scissors scissorsIs Sco scold Scolding Scooby scoop Scooped Scooping scoops Scoot scooter Scooters Scooting Scooty Scope Scoperta Scoping Scorching score Scored SCORELAND scores Scoring Scorn Scorned ScornedKrissy's Scorpio scorpion Scorpion0-3 Scorpion1-4 scorpions Scot scotch scotchin Scotching Scotish Scotland Scott Scotte Scottish Scott's Scotty Scotty's Scoundrel Scouring scout scouting scouts Scout's Scrambled Scrap Scrapbook scrapheap Scraping scrappy scratch Scratching Scrawny scream scream? screamed screamer Screamers screamin screaming screams scree screen screening screw ScrewberX screwdiver Screwdriver screwed screwing Screwl screws script scritped Scrotum scrub Scrub-a-dub Scrubbed Scrubbing Scrubs Scrum Scrum-dilly-icious scrumptious Scrumptuous Scuba Scuffle Sculler Scully Sculpt Sculpted Sculptura Sculptures Scyley se Se7en sea sea_munkeys Señoritas Seacoast Sea-Do Sea-Green Seagulls Seajay seal Sealed Sealer Sealing seals Seaman Seamed Seamen Seamless Seams Seamstress Seamus Sean seance Seang search searched searches searching Searde Seart seas Seashell seaside season season12 Seasonal Seasoned Seasonless seasons season's Seasons seat Seatbelt Seattle Sebastian Sebastian's Sebastien sec Secco secluded Seclusion Secolo second secondary seconds Secratary secret secret? Secretaria Secretarial Secretaries secretary Secret-Ary Secretary’s SecretaryIt's secretary's Secretforrest Secretions Secretive secretly Secreto Secretos secrets Secret's Section Sector secure secured Securing Securi-Cans security Security's Secur-Canty Seda Sedane Sedona Sedora seduce Seduce? seduced Seducer seducers seduces seducing Seductio seduction seductions Seductioon seductive seductively Seductiveness Seductora seductress Seduisante Seduses SEDUX Seduxia see See? seed Seeding seeds Seedy seeing seek Seeker seekers Seeker's seeking seeks seem Seemed seems seen sees Seesaw see-through Seethrough See-Through see-through's see-thru Seethru See-Worthy Segreto Seguna Segunda Seguro Seiber Seifuku Seinfeld Seins Seisen Seize seizes Seizure Seka Seka's Sekova Selah Selana Selardi Selby Select Selected Selection selects Selena Selenas Selena's Selene Selexia's Selextra self Self-Avowed Self-Care Self-Conscious Self-Defense Self-Esteem self-fisting Self-make love Self-gratification Selfibate selfie Selfie-Conscious Selfie-Esteem selfies Selfie-teeny Self-Indulgence Selfish Self-isolation Selfless Self-love Self-Pleasing Selfpleasure Self-Pleasure Self-Pleasured Self-Reflection Selfsatisfaction Self-Satisfying Self-Serve Self-Service Self-Serving Selfshooting Selfshot Self-Spanking Self-Starter Self-Suspension Self-Tied self-touching Selho Seliene Selikan Selina Selixa Selkes sell Seller Seller's selling Selling? Sellout sells Selma Selvaggia Selvaggia's Selviagga Semak Semejanza semen Semence Semen-Splashed Semester semi semi-final Semifinal Semi-final Semi-Finals semiglobes Semikols Seminar Seminole SemiSecret Semper Sempre Senator Senator's send Sender sending Sendoff Send-Off sends Sendy Sene Senior Seniorita Sennin Señor Senora Senorita Señorita Señorita's senos Senpai Sensa Sensacional sensation Sensation' sensational sensations Sensation-Seeking Sensazione Sense Sensei Senseiki senseless senselesss Senselss senses Sensi Sensibles Sensious Sensi's sensitive Sensitivity Senso Sensory sensu sensual Sensualia Sensualis Sensualist Sensualists sensuality sensually sensuous Sensuously Sensus sent Sentence Sentenced Senti Sentir Sentono separate Separation separe Sepatown Sephora Sept September September's Sequana sequel Sequin ser Sera Serafima Seraph Seraphine Sera's Serb Serbia Serbian Serbians Serbian's Sereia Seren serena serenades Serenading Serena's Serendipity serene Serengheti serenity Serenitys Serenity's Sereyna Serge Sergeant Sergeant's Sergei Sergey Sergio serial series Serilla Serilla? Serilla's Serina Serina's Serinity serio serious Serious? seriously Sermon Serpent Serpente's Serpentine Serpents Serrena servant Servants Servant's serve served Served? Server serves service Service? service” serviced services Servicin' servicing Servicio serving servings Servitude ses Sesame sesh sess session session' Session SessionFisted sessions SessionTesting Sesso set Seth Setian sets Setter setting Settings settle Settlement Settling Setup Set-up sev Seva seven Seven-Stud Seventh Seventy-Two Seven-Year several Severe severely Severence Severin sevice's Sevilla Sev's sew sewer Sewing sex sex; sex? sex_toy_party Sex+Finance Sex…FOREVER sex385 sex-addict Sex-addicted Sexadelic Sexagon Sex-A-Gram sexaholic Sexaholics Sexam Sexample Sex-apade Sexart Sexathon Sexaul sexbomb Sexbook Sexbot Sex-Bot sexcapade sexcapades Sexcellent Sexcercise Sexcess Sex-chatting sex-crazed Sexcstasy Sexdoll Sexecutive sex-ed Sexed Sex-Education sexercise Sex-ercise Sex-er-cise Sex-Ercise SEXercise Sexercise? sexercises Sexes Sex-filled sex-for-cash sex-fury sexgame sex-goddes Sexhibitionist sex-hungry Sexi Sexians sexier sexiest sexin Sex-in-a-car Sexinator Sexiness Sexing Sexinyourcitycom Sexist Sex-Kitten Sexless Sexlightenment Sexlove sex-loving sex-lust sex-m sex-machine Sexmachine Sex-Machine sexmachines Sexmas sex-mate Sexmate Sexmex sexnastics Sexo Sex-O Sexo? Sex-Obsessed Sexologist Sexolution Sex-O-Rama sexorcism Sexorcist Sexpectations Sexpelled Sexperate Sexperience SEXperiences Sexperiment sexperiments Sexpert Sexperts Sexpionage Sexpiration Sexploitation sexplorations Sexposed sexpot Sexpots Sexpot's Sexpress Sexpressionism Sexpresso SexPro Sexquisition SeX-Rays Sexsational Sexshark sexshop Sexsomnia Sexspeare Sexssion Sex-Starved Sex-scanute Sext Sextalk Sextalk1 Sextalk2 Sextalking sextape Sex-Tape Sextasy Sexteens Sexter Sexterminator Sextet Sexthics Sexthlete Sextia sexting sextini Sextion Sexton Sextortion sextoy sextoying sextoys Sex-Toys Sextra Sextracurricular Sextra-Curricular Sextravaganza Sextricity Sexts Sextury Sexty sexuaity sexual sexuality Sexualization sexually Sexually' Sexually-obsessed Sexualy Sex-Vid SexWax Sexway Sexwick Sex-work Sexworking Sexx Sexxlexia Sexxual Sexxx seXXXperience Sexxxploration Sexxxpose SeXXXretary SeXXXStar Sexxxton SeXXXtreme SeXXXual Sexxxy Sexxy SexxyBlack sexy 'Sexy SEXY Sexy Russian sexy_pirate Sexy-booty Sexydenimskirt Sexydressup Sexyheels Sexykitten Sexylingerie Sexymaid Sexyness Sexynurse Sexypanties Sexypink Sexyplayer Sexyrubdown Sexysanta Sexyschoolgirl Sexyshorts Sexysilver Sexysocks Sexystockings Sexystudent Sexytime Sexytoy sey Seychelles Seymour SF SF's Sgh Sgt Sgt? sh Sh*t Sha Shack Shackin Shackled shackles shade Shaded shades Shadoes shadow Shadowland Shadowplay Shadows Shady Shae Shafry shaft SHAFTED Shafting Shafts shag Shag? Shagadelic Shageroo shagged shaggin Shaggin' Shaggin’ shagging shaggy Shaggy's shags Shagwell Shai Shain Shaine Shakalaka shake Shake' Shakedown shaken Shaker Shakers shakes Shakila Shakin Shakin' shaking Shakira's Shakti Shalina Shalke shall Shallow Shalt Shaman shame Shamed Shamefaced Shameful Shamefully shameless Shamelessly Shamelessness Shaming Shamon Shampoo Shamrock Shan Shana Shananigans Shand Shane Shane's Shangri Shangri-la Shania Shanice Shanie Shanis Shank Shankar Shanke Shanna Shannon Shannons Shannon's Shannya Shan's Shantastic Shantel Shantels Shanti Shanti's shanty Shanya shape shaped shapely shapes Shapeshifter Shaping Shara Sharapova Shara's Shards share Shareable shared Sharee Sharers shares sharing shark Sharka Sharka's Sharkbait Sharks Shark's Sharok Sharon Sharon's sharp Sharpening sharpshooter Shar's Shasta Shataya Shatki Shattered Shattering Shauna shave shaved Shavedkitty Shavedcat Shavell Shavelle shaven shaven-haven Shaver Shavers shaves Shaves1 Shaves2 Shavin shaving Shavon Shaw Shawn Shawna Shawna's Shawnie Shaw's Shaw-Slut Shay Shaye Shayenne Shayla Shaylee Shayna Shayne Shays Shay's Shazam Shazia Shbang she she' She she? she’ She’ll she’s Shea Shead Sheala Shearing Shea's She-Boner Shebop She-member shed she'd Shed Shedding she-devil She-Devil? She-Devils She-Johnson Sheds Sheehan Sheen Sheena Sheena's Sheen's She-E-O Sheep Sheepish Sheeple Sheep's Sheepskin sheer Sheerbooty Sheerly Sheerskirt Sheertoy Sheerwhite Sheerwindow Sheet sheets Sheik Sheila Sheila's Shelby Sheldon Shelf Sheli she-lion shell she'll Shell She'll SHELL shell? Shelley Shelley's Shellgirls Shelly Shelly's Shelson shelter sheltered Shelter-in-cat Shelves Shelves2 Shemale She-male SheMales She-Males shenanigans shenanigan's Shenanigans Shenanigens Shennanigans Shepard Shepard's Sherazade Sherazadee Sheree Shereese Sheri Sherice Sheridan Sherif Sheriff Sheril Sherlock Sherly Sherman Sheroes Sheron Sherri Sherry Sherry's Sherwood Sheryl Sheryl's shes she's Shes She's ShesHorny Shespeaks SheSquats Shevasana Shevelle Shevon Shevon's ShevonThe She-Wolf Shey Sheyla Sheylla SHFT Shhh shhhhh Shhhhhhhhh Shi Shi? Shiatsu Shibari Shibori Shicoksu Shieffer Shield Shields Shiffer shift Shifter shifts Shift's Shikira Shila Shiloh Shimizu shimmer Shimmering Shin Shindig shine shine_me_off shines Shine's Shiney shining SHINJU Shinning shinny Shino's shiny Shiny1 Shiny2 Shinytoy Shione Shione's Ship shipment Shipped ships shiptease Shipwrecked Shira Shiri Shirley Shirma shirt Shirtless shish-kebab shit Shitless Shitty Shiva Shiver shivers Shizen Shizzle Shlong Shlya Shmondoms Shnizzy sho Shock shocked shocker shocking Shockingly shocks Shockspot shoe Shoechair Shoed Shoefetish shoe-make loveed shoe-less shoes Shoestoy Shoesucker Shojo Shona Shona's Shook shoot shoot 34d-24-34 Shoot19's ShootAbandoned shootamateur shootand Shootatom's ShootBreaking ShootBrutal shooter Shootin shooting shootNo Shootredlights shootReverse shoots shootShe ShootThe ShootWhere shop Shopaholic shopaholic’s Shopaholics Shopgirl ShopGrifter Shoplicker shoplifter Shoplifters Shoplifting Shoppe Shopper shoppers Shoppin shopping Shopping? shoppingsexsmiles shops shore Shoreline Shores short Shortage Shortblack Shortcake shortcomings Shortcut shorter Shortest shorthair Short-haired Short-Hared Shortie Shorting Shortly shorts Shorts2 Shortshorts Shortskirt Shortskirt1 Shortskirt2 Shortsplay1 Shortsplay2 Shortstop Shortstouch1 Shortstoy Shorty shot shot? Shotgun Shotgun? shots shou should Should? Shoulda shoulder shoulders shouldn't Shouldnt Shouldn't Shout Shoutout shove shoved shoves Shoving show Show? ShowAll Showbiz showBrutal showcase Showcased Showcases Showcasing showdown showed shower shower? Shower1 Shower2 Showerblast Showerbooty Showerbanana showered Showerfun Showerfun2 Showerglbooty Showerhead showering Showerlove Showerorgasm Showerplay Showerpleasure Showerpole Showerpower Showerroom Showerrub showers Showersnatch Showerspray1 Showerspray2 Showerteen showertime Showertime1 Showertime2 Showertoy Showertoy1 Showertoy2 Showertwo Showervibe Show-Final showgirl Showgirls showi showin showing Showme shown Show'N show-off Showoff Show-off Show-Offs showcat shows show's Shows Showstopper ShowStrict ShowThe showtime Shox Shphinchter-stretching Shredded Shreddin shredmill Shreds shrew Shrewd Shrima Shrimping Shrimpy Shrine shrink shucked Shudder-Member Shuddering shuffle Shui Shultz Shush shut Shutdown Shutter Shutterbug Shutting shuttle Shuttlemember shy Shy? Shy?? Shyla Shyla-la-la shylas Shyla's Shylina Shylove Shyly Shyn Shyne Shyness Shyra Shyrley Shy's si Sia Sialis Siam Siamese SIberia Siberian Sibian Sibilla Sibling Sibling’s Siblings Sibling's Siblings’ Sicilia Sicilian Sicilia's sick Sickest Sickness Sicle' sid side Sidemelon Side-By-Side Sidechick Sided Sidekick Sidelines Sidepiece sideplay sides Sideshow Sidetracked Sidewalk Sideways Side-Winder Sidnee Sidney Sidney's Sidonia Sidonie Sidra Sidra's Sie Sieb Siege Sielle-1 Sielle-2 Siempre Sienna SiennaMexico Siennas Sienna's Sierra Sierra's Siesta Siffredi Sifu Sigal Sigal's Sigh Sighing sighs sight Sighting sightings sights sightseeing sightseer sign Signal signals Signatory Signature Signed? Signin signing Signs Sigourney Sigyta Siiri s'il Sil S'il Sila Silas Sila's Silectico Silena silence Silenced Silences silent Silently Silexia Silhouette Silhoutte silicone Silienta silk Silken Silkpj Silktalking Silktalking2 silky Silky-Sheathed Silla silly Silva Silvana Silveira silver Silverback Silverbullet Silverchairs Silverbanana Silverfriend Silvercat Silver's Silverspecial Silverstone Silver-Tongued Silvertoy Silvervibe Silvervibrator Silverware Silvia silvia's SilviaStacy Silvie Silvie's Silvija Silvis Silviya Silvy sim Simari Simella Simera Simi Similar Similarities Similo Simira Simi's Simmering Simmers Simmons Simms Si-Moan Simoes Simon Simona Simona's Simone Simone's Simons Simon's Simony Simony's Simos Simp simple simply Simpson Simpsons Sims Simulacrum simulant Simulation Sim-ulation Simulator Simultaneous simultaneously sin Sina Sinaloa Sinbad since Sincere Sincerela Sincerely Sincerre Sinclair Sinclaire Sinclaire's Sinclair's sindee Sindee's Sindell Sinderella Sinderson Sindey Sindi sindy Sindy's Sinead Sinead's Sineplex Sinergy Sinetika SinFormer Sinfox sinful Sinfully sing Singalong Singame singer singing Singing-bird single singles Singletailing Singola sings Sinister sink Sinkati Sinking Sinkpink Sinks Sinlia Sinn sinnamon Sinnead Sinned Sinner sinners Sinner's Sinnful Sinnfully Sinning Sinns Sinn's Sinnsational Sinonimo Sinota Sinovia sins Sin's Sinsacion Sins's Sinstar sinterview Sintia Sinz Sioux Siouxie Siouxsie Sip Sippin Sipping sir Sir? Sira Sirale Sirale's Sire siren Sirena Sirena69 Sirene sirens Siren's Sires Siri Siri-ously Siri's Sirtari Sirvienta sis Sis?? Sisi Sislovesme Sissification sissy sista Sistah Sistante sister Sister? Sister’s Sisterhood Sister-in-law Sisterly sisters sister's Sisters Sister's SISTERS Sisters-In-Lust Sisterswap Sisterthis Sisy sit Sitdown site sites Sitfinger Sitfinger2 Sith Sitophilia sits sitter Sitters Sitter's Sittin sitting situation situations Sivia six SixBang Six-Foot Six-Man Six-On-One Six's Sixtantes Sixteen sixty sixty-nine Sixtynine Sixty-nine Sixtyniner Sixtyniners sixty-year-old Sixx Sixxx Siya Sizable size sized Sizes Sizi Sizis Sizi's sizzle Sizzler Sizzlers sizzles Sizzlin Sizzlin' sizzling Sizzzzzzzze Sk8ter Skafos Skank Skank? Skanks Skank's Skanky Skapandi Skat skate Skateboard Skateboarder Skateboarding Skatepark skater skates Skatewhoreding skating Skaylar Skeet Skeeter Skeeters skeeting Skeets SkeetSearch Skeleton Skeletons Skeletor SkeletorPart Skellington Skepazo Skeptical Sketch Sketching sketchy skewered ski Skibunny Skie Skies Skilful skill skilled Skillet skillful skills skillset Skillz Skim Skimmer Skimming Skimpy skin Skinflix Skinfluence Skinflute skinn skinned skinny Skinnydipping Skin-on-Skin Skinride skins Skin's Skinski Skint skintight Skin-tight Skin-Tillating skip Skipper Skippers Skipping Skips Skirmishers skirt Skirt? Skirt1 Skirt2 Skirtdance Skirtfinger Skirtfinger2 Skirtfingers Skirtfun Skirtgirl Skirtoy skirts Skirtstrip Skirttouch Skirtundies Skis Skool Skrilla skull Skullbikini skull-make loveed Skulls sky Skyar Skydiver Skydiving skye Skyes Skye's Skyhigh Sky-high Skyla skylar Skylar's Skylas Skyler Skylight Skyline Skyline's SkyLocal Skylor Skymm Skype Skyrim Skys Sky's SkyScared skyscraper skyscrapers Skyy Sl Slacker Slackin slacking Slade Slader Slade's Slag Slager slam Slam-Make love Slam-Make loveed Slamin Slammage slammed Slammer Slammin Slammin' slamming slams Slamwich Slang slangs Slant slap Slapped Slapper Slappin slapping Slaps Slate Slater Slather Slathered Slaughter slaughterhouse Slav Slava slave slave? slaveboy Slaveboys Slavehood Slave-Master Slavery slaves slave's Slaves Slave's Slaves100 SlavesDay Slavic Slavina Slaving Slay Slayed slayer Slayher Slayin Slaying slays Sleak Sleaze Sleazebag sleazy Sled Sledding Sledgehammer' sleek sleep Sleeper Sleeping Sleepless sleepover Sleep-Over sleeps sleepsack Sleepwalking Sleep-With-Me sleepy Sleeve Sleeves Sleezy slend slender Sleuth Slevie slice Slices slick Slick-Johnson Slicked Slicker Slickest slide Slide? Slider Sliders slides sliding Sligen Slightest slim Slime Slimed Slimmer Slims Slimy sling Slingin Slinging Slingshot Slink Slinks Slinky slip Slipe Sliping Slip'n Slippage slipped Slipper slippers slippery Slipperyoil slippin Slipping slips Slip-Up slit Slithering Slithers slits slit's Slits Sliver slo sloan Sloane slob Slobaknob slobbe slobber Slobberbone slobbering slobbers Slobber-Soaked Slobber-Swilling Slobbery slobbin slobbing Slobby slobs slogan Slo-Mo Slone Slop Slope Slopes Slop-Gagging Sloppier sloppiest sloppy slot slots Slovak Slovakia Slovakian Slovenian Slovokian slow slowly Slowness Slows slu SLUDS Slugger slumber Slumbering Slumberparty Slumdog Slumlord's Slumming slur slurp slurpee Slurpees slurpin slurping slurps Slurpy Slush slut Slut' SLUT Slut? slut3_isabelladior SlutA Slut-ber Slutcakes Sluteen Sluterday Sluticide Slutlove Slutmother Sluto Slutretary Slutry sluts slut's Sluts Slut's SLUTS Slutsformation Slut-Shaming slutsYou Slutt sluttie Sluttier Sluttiest Slutting sluttish slutty slutty_selvaggia slutty's Slutwalker sluty sly slyman SM sma smack smackaback smack-down Smackdown Smackdown's smacked Smacker Smackin Smacking smacks smacktacular small small-meloned smaller Smaller? smallest Smalls small-cans small-canted Smalltown Smanhoto smart Smart-booty Smarter Smartly smartphone Smarty smash smashed Smashin Smashing Smear smeared smears Smeeny smell Smells Smeraldi smile Smile? smiles smiley Smiley's Smilin smiling Smilla Smith Smiths Smith's smitten Smocking Smoke Smoke? Smoked Smoker Smokers smokes Smokeshow Smokestoy1 Smokestoy2 Smokey Smokie smokin Smokin' Smokin’ smoking Smoky Smoldering smoob Smooch Smoochers Smoochin Smoooth Smooshed smooth Smoothe Smoother smoothering smoothest Smoothie Smoothies Smoothing Smoothness smooths smooth-skinned Smooty Smore S'More S'mores Smorgasbord Smorjai smother smothered smothering smothers smoulders Smrhova SMS Smug smuggler Smuggling Smurf smut smutted Smutty Smyth sn snack Snacker Snackin snacking snacks Snacky Snag Snagged snail snake Snake-charmer snakecharms snakes Snake's Snakeskin snap Snapchat Snapped snapper Snappin snapping Snappy snaps Snapshot Snapshots Snare snatch Snatchbox snatchchat snatched snatcher Snatchers Snatches Snatching sneak Sneakaway Sneaker sneakers Sneakin Sneaking sneak--peek sneaks Sneakshot Sneaky sneaky_salon Snider Sniff Sniffer sniffers sniffing Sniffs Snip Sniper Snippity Snitch Snitches Snitch's Snobby Snogging snooker Snooky? Snoop Snooper Snooping Snooze Snoozing Snore Snoring Snorkel snow Snowball Snowballin' Snowballing snowballs snowbird Snowboard Snowboarder snowboarding Snowbooty Snowbound Snowbunny Snowed snowflake snowflake? Snowjob Snow's SnowShow Snow-white snowy snuck snug Snugg Snuggle Snuggles so Soak soaked Soaked' Soaker Soakers Soakher soaking Soakingwet soaks Soap Soaped Soapin soaping soaps Soapsuds Soapkitty soapy Soapybody Soapybum Soapyfingers Soapyfun Soapyshower Soapysituation Soapyteen Soares Soaring Soavitia SoBe Sober Sobre Sobriety Sobrina Soc SoCal soccer Soccerlove Soccertoy social SOCIAL-Abigail socialite society sock socked Socket Sock-Filled Sockies socking socks Sockstoy Sockstoy1 Sockstoy2 Soco Soda Sodom Sodomize Sodomized Sodomizes Sodomizing Sodomy Soers sofa Soffice Soffie Sofi Sofia Sofia's Sofie Sofie's Sofi's soft Softball softcore Softened Softening Softer Softest Softie Softlight Softlotionlove softly Softness Softcat software Sofy Sofya Soggy Sogni Sohley Soho So-Hoe soInnocent Soir soiree Sol Sola solace Solah Solamente Solana Solange Solania Solar Solari Solaris solarium Solaya Sold soldier soldiers soldier's Soldiers Soldier's Soldier-soldier sole Solecism Soleggiato Solei Soleil Soleil's Soleli Sole-ly Solemates soles Solhey Solicitor Solicits Solid solidify Solis solitaire Solitare Solitary Solitude Sollis solo Sologirl soloing solo-ing Soloing Soloist Solos Solstice solution Solutions solve solved solves Solving som Soma Sombrero some somebody SOMEBODY'S somehow someone someone?Enough someone's somethin something Something's someti Sometime sometimes Somewhat somewhere Somian Somiet Sommer Sommers Sommerz Somnific Somthing son Son? son’s Sona Sonata sonay Sonay's Sonechka song Songbird Songerie Songwriting Soni Sonia sonialopez Sonia's son-in-law Sonita Soniy Sonja Sonja's Sonny Sonorilo sons son's Sons Son's Sonya Sonya's Soo Soolin Soolyn soon Soon? Sooner sooo soooo Sooooo Sooth Soothe soothes Soothing Sop Sophei Sophi Sophia Sophia’ Sophias Sophia's sophie Sophie's Sophie-sticated sophisticated sophistication Sophomore Sophya soppin sopping Soppy Soprano Sorana Soraya sorbet sorceress Sorcery sordi sore Sorest Soroity sorority Sorority's Sorprese Sorrenti Sorrow sorry sort SOS Sosh SoThat Soto Sottile soul Soul?? Soul’s Soulful Soulmate Soulmates souls Soul-sucking sound Sounded sounding sounds soup Sour source Sourire Sous Sousa Sout south SouthBeach Southcentral Southeast southern Southwest souvenir Souvenirs Souza Sovereign Soverign Soviet Sowing Soxxx Soy Sp3cial spa space Spacenuts spaces Spaceship spaceships Spade spades Spade's Spaghetti Spain Spains Span Spandex Spandexxx Spangled Spanglish Spaniard Spaniard's spanish spanishand spank spanked Spankenmember's Spankers spanking spankings Spanko Spankomania spanks Spanksgiving Spankvibe spanky Spar spare Sparetime Spare-time Sparing spark Sparking sparkle Sparkled Sparklers Sparkles Sparkletoy Sparkling Sparkly Sparkplug Sparkplugs sparks Spark's SparksThe Sparkx sparky Sparkys Sparky's Sparkz Sparring Sparrow Sparrow's Sparta Spartan Spartica Spartica0-0 Sparx Sparx's Sparxx sparxxx Spa's Spasms Spatio Spattered Spatula Spaz Speads speak Speakeasy speaker Speaking speaks Speaky Spear speared spearm spears special special” specialist Specialists Specially Special-Orders Specials Specialty Specialty? Species Specific Specifications specimen Specs spectacle spectacles spectacular spectator Spectrum Specu-Fun speculum Specu-Yum Speech speechless speed Speedbumps Speeding Speedo Speedos Speedy spell Spell? Spellbinding Spellbound Spelled Spelling Spellpound spells Spelunking Spence Spencer Spencers spend spending spends spent Spera sperm Sperm? Sperma Sperm-addict Spermaid Spermatic Spermbanks spermcoktail spermed Sperm-Hungry Sperminate-Her Sperminator Sperm-Man sperm-nog sperms Sperm-Slathered Sperm-Slopped Spermsucker Sperm-Swallowing Spermy Spesa Spew Spews SPH Spheres Sphinchter Sphinctacular sphincter Sphincter-Smashing Sphinx Sphyncter spi Spiaggia spice spices Spicey Spicing Spick spicy Spider Spiderman Spiderwoman spied Spiegler Spielberg Spielen spies Spietato Spigot Spike Spikeytoy spill Spilled Spilling Spills Spilt spin Spin-Cycle spine Spinnder spinner Spinner’s SpinnerBTS Spinners Spinner's Spinning Spins Spiral Spiraldong1 Spiraldong2 Spiralpop Spirals spirit Spirited Spiritique Spirito spirits spiritual Spirituous Spirm spit Spit-Drenched spiteful spitmeister Spitroast spitroasted Spit-Roasted Spitroasting Spits Spit's SPITS Spitshine Spit-Smeared Spit-Soaked Spitters spitting Spittle Spitty Splainin Splak splash Splashdown splashed Splasher splashes Splashin Splashing splashy Splat Splatter splattered Splattering splayed Splays Splendes Splendid splendor Splish Splish-Splash split Split-End splits split-screen Splits-tacular splitsville Splitting Split-Tongued Splooge splooged Splooger Splooshed Splosh Sploshed Sploshing splurge splurges Splurging spoil spoiled Spoiler Spoilers spoiling spoils spoilt spoke Sponge Spongebath Spongeclit sponsor Spontanebooty Spontaneity spontaneous Spoof Spook spooked Spooking Spooktacular Spooky Spool spoon Spoon-Feeds Spoonful Spooning Spoons spor sport sportin Sporting sports Sportsgear Sportsmanship Sportsmen Sportstars Sportster Sports-Widow Sportswoman sporty spot Spotmember Spotless Spotlight Spotlights spots spotted Spotter spotting Spouse sprain sprained Sprawled spray sprayed spraying sprays Sprchac spread Spread? Spread-Eagle Spreader Spreaders Spreadin Spreadin' spreading spreadMade spreadPulled spreads Spreadtalks Spreadtwo spreadum Spreadz Sprechen spree spring Springbreak Springlare Springs Springtime Springvalley Springvalley's springy sprinkle Sprinkler Sprinkles Sprint Sprinting Sprite Sprites Spritz Sprocket Sproket Sprouting Sprouts Spruce Sprung Spryte Spun spunk spunked spunkmeister spunks Spunk-Slopped Spunk-Soaked Spunk-swallowing Spunky spur Spurs spurt Spurts SPX001 SPX002 SPX003 SPX004 SPX005 SPX006 spy spycam spy-cam-make loveed Spyce Spycey Spyder Spy-filmed Spyhole spying Spymaster spy's Spyware Sqeaky Squabble squad Square Squared squash Squashed squat squats Squatter Squatter's Squattin squatting Squaws Squeak Squeaks Squeaky squeal Squealer Squealers Squealing squeals Squeegee Squee-Jizz Squeeky squeeze squeezed Squeezer Squeezers squeezes Squeezin' Squeezing Squeezins Squeky Squiggles squiritng Squirm Squirming squirms squirmy Squirrel Squirrrrrt squirt Squirt' SQUIRT squirt? Squirtacular Squirtage Squirtarium Squirt-a-thon Squirtation Squirtbath Squirtboarding Squirtdown Squirted squirter Squirters Squirter's Squirtfest Squirtgasms Squirtgun Squirt-Gushing Squirticular Squirtin Squirtin' squirting squirting? SQUIRTING+FIST SquirtingDouble Squirting-Gaping-DP-Double-Vag Squirting's squirt-land Squirtmania Squirt-Off Squirt-O-thon Squirt-O-Vision SquirtR squirts Squirtsalot Squirtsational Squirt-Soaked Squirtus Squirt-Wet squirty Squiting Squrting Squrts Sra SS Ssindy sss Sssexy Ssy st Stab Stabber Stabbin Stabbing Stabby Stability Stabilna Stable Stable-Boy Stablehand's Stables Stace Stacee Stacey Stacey's Staci Stacie Staci's stack stacked Stacked' STACKED Stacked-Out Stacker Stackers Stackhouse Stackin stacking Stacks Stacky Stacy Stacys Stacy's staff Staffer Stag stage Staggering Stagliano Stagliano's Stagnant stain Stained Stains Stair staircase Staircaseorgasm Stairfun Stairmaster Stairplay Staircat stairs stairs? Stairs2 Stairvibrator stairway Stairwayfun Stairwaycat Stairways Stairwaytoy stairwell stake Stakeout Stakes Staks Stalk Stalked Stalked? stalker Stalker’s Stalkerland Stalkers StalkHER Stalking Stalkings stalks Stall Stalling stallion stallions Stallone Stallone's Stalls stamina Stamina? stamp Stamped stamping Stan Stana Stance stand Standard standards Standby Stand-In standing Standoff stands Stand-up Stanley Stanton Stanwick Stanza stappado Stappado'd star Star? Star’s Staranzano StarCurrent stardom Stardust Stare Stares Starfall starfish Starmake loveed staring starjuice Stark starl Starla starlet starlets starlet's Starlets Starlet's Starlett Starlette Starlettea Starletto Starlight StarLocal Starly Starmaker Starnger Starr Starr; Starr’s StarrAnal StarrHogtie Starri starring Starr's Starry stars Star's Stars? Star-Spangled Starstruck start Start? Startdom started Starter starters Startin starting Startled Startlet Startling starts Start-Up starved Starvin starving Starz Stas Stasey Stash Stasha Stasia Stasi's Stbootyi Stbootyia Stasy Stasy’s Stasya Stasy's Stat state Stately Static station Stationary Statue Statuesque Statuette stature status Stax Staxx Staxxx stay Stay? Stay-At-Home staycation Stay-cation stayed Stayin staying stays St-Clair St-Croixx stead Steady steak steal Stealer stealing steals Stealth Stealthy steam Steamed steaming steaming-hot Steamrolled Steamroom steams Steamworks steamy Steed steel Steele Steelers Steele's steeped steer Steering Stefan Stefana Stefani Stefania Stefania's Stefanie Stefanie's Stefano Stefanos Stefany Steffanie Steffany Steffie Stegal Steil Stein Steinkopf Steliana Stella Stella’s Stellar Stellas Stella's Stello Stemei Stems Stem-tastic Stena Stenciling Stendhall step Stepa Stepanska Step-Aunt Stepchick stepbro step-bro Stepbro Step-Bro Stepbro?? Stepbro’s Stepbros Stepbro's Step-Bros Step-Bro's stepbrother step-brother Stepbrother Step-brother StepBrother Step-Brother stepbrother’s Step-Brother’s Stepbrotherly Step-Brotherly stepbrother's step-brothers Stepbrothers Stepbrother's Step-Brother's Stepmember Stepcousin Step-cousin Step-Cousin's step-d stepdad step-dad Stepdad Step-dad StepDad Step-Dad Stepdad? stepdad’s Stepdaddy Step-Daddy step-daddy's Stepdaddy's Step-Daddy's stepdads stepdad's step-dad's Stepdads Stepdad's StepDads StepDad's Step-Dads Step-Dad's StepDadWith Stepdaugher stepdaughter Step-Daughter STEPDAUGHTER Stepdaughter’s stepdaughter's Stepdaughters Stepdaughter's Step-Daughters Step-Daughter's stepddad Stepjohnson Step-Douche Stepfamily Step-Family stepfather Step-father Stepfather’s Step-father’s Stepfathers Stepfather's Step-Fathers Step-Father's Stepford Stepgrandpa Step-Grandpa Steph Stephani Stephanie Stephanie's Stephani's Stephannie Stephany Stephen Stephens Stephie Step-In-Love Stepis Stepisters Stepkids Step-Lessons Stepmama StepMILF stepmom step-mom Stepmom Step-mom StepMom Step-Mom stepmom’s Step-Mom’s Stepmommy Step-Mommy stepmoms Stepmom's Step-mom's StepMom's Step-Mom's stepmother Step-mother STEPMOTHER Step-Mother-Daughter Stepmothers Stepmother's Step-Mother's step-neice step-niece Step-Nieces Step-Parents Step-Parent's stepped Stepping Steppy steps StepShower Stepsibiling Stepsibling Step-Sibling Stepsiblings Step-siblings StepSiblings Step-Siblings stepsibs stepsis step-sis Stepsis Step-sis StepSis Step-Sis Stepsis’ Stepsis's stepsister step-sister Stepsister Step-sister StepSister Step-Sister Stepsister’s Step-Sister’s stepsisters Stepsister's Step-sisters StepSisters Step-Sisters Step-Sister's STEP-Sisters Stepsisters’ stepson step-son Stepson Step-son StepSon Step-Son STEPSON stepson’s Stepsons Stepson's Step-Sons Step-Son's StepThief Step-Tradesies step-uncle Stepuncle Stepuncles Step-Walker Steren Sterling Sterling's Sterlyng Stern Stern's Steroid sterted stethoscope Steve Steven Stevens Steven's STEVENS Stevens's StevensThe StevensThis Stevenz Stevie Stevie's stevo Stew Steward stewardess Steward's Stewart Stewart?18 Sthefanny Sthefany stick sticked Sticker Stickers Stickin sticking sticks sticky Sticky1 Sticky2 Stickyfun Sticky-Sweet Stiel stiff Stiffany stiffen stiffener Stiffening stiffens stiffie stiffies Stiffly stiffy stiletto stilettos still Stillar Stills Stilts Stim Stimula Stimulant Stimulas stimulate Stimulated stimulates stimulating stimulation Stimulations Stimulators Stimulus sting Stinger Stinging Stingray Stingrey Stink stinkhole Stinkin Stinking Stinky stiptease stipteasing Stir Stirling stirred Stirring stirs Scanch Scanches StIves St-Ives StJames stoat stock stockade Stocked Stockholm stocking Stockinged stockings stockings? Stockings1 Stockings2 Stockingshoot Stockingshoot2 Stockingtoy stocks Stoke Stoked Stokely stokes Stokley stole stolen Stoli Stolidity Stolie Stom stomach Stomp Stompers Stomping Stomps stone Stone’s Stoned StoneGirl Stonell Stonem Stones Stone's Stonewall Stoney Stood stool Stools STOOPID stop StopI'm stopped Stopper Stoppers Stoppin' stopping stoppingORGASMS stops storage store Stored Storeroom StoreShe storewith stories storm StormDay Stormin Storming Storms Storm's Stormtrooper's stormy Stormy's Storri story Storyboard Storytime Stoune Stove Stovne Stow Stowaway Stowaway's Stoya StPatty's ST-PORNO'S Str8 Stracciatella Stracy Straddles Straddling straight straight? Straighten Straightened Straightening Straightforward straightjacket Straight-To-Anal Strain Strait Straitjackets stranded Strandling strange stranger strangers stranger's Strangers Stranger's STRANGERS Strangest strangulation strap Strap_On Strap-busting Strapdomme Strap-Mom strapon strap-on Strapon Strap-on Strapon; Strap-On'ed Strap-ons Strap-on's Strap-Ons Strap-On's strappado strappado'd strappadoIntense strapped strapping strappy Strategies Strategy Stratosphere Straw Strawberries strawberry Strawberry1 Straws stray streached Streak Streaker Streaking Streaks stream Streamen Streaming streams Strectched strecthed street StreetBound streets streetwalker Streetwalking Strelnice strength stres stress Stressed Stress-Free stressful Stress-handling Stressless stretch Stretch? Stretchdong stretched Stretcher Stretchers stretches Stretch-her Stretchin stretching Stretch-Sister Stretchy Stretton Strickly strict Strictly stride strike Striker strikes Striking String Stringers Stringing Stringkini strings Strings' strip Strip’n stripclub Strip-Club Stripe Stripease striped Stripedfingers Stripedpanties Stripecat striper Stripers stripes Stripes2 Stripeskirt Stripestockings Striplotion1 Striplotion2 Strip-O-Gram stripped Stripped-Down stripper Stripper’s strippers stripper's Strippers Stripper's Strippin stripping strips Stripshow Stripspread striptease stripteasedouble Stripteaser stripteases stripteasing strip-teasing Stripteasing Stripteasy striptesae striptise Striscio Strok Strokahontas stroke stroked Strokemoff Stroker Strokers strokes strokin Strokin' stroking stroll Strolls strong stronger strongest strongly Strong-Willed struck Structure Strudel struggle struggles struggling Strum Strummin Strumming Strumpet strung Strut struts Strutting Stryker stu Stuart Stub Stubborn Stuccoed stuck Stucked Stuck-Up stud Stud? Stud’s student Student? student’s StudentDisobedient student-first students student's Students Student's Student-teacher Studfinder studies studio studiogonzo studios Studiotoys Studious Studiovibe Studly studs stud's Studs Stud's Studs? Studway study Study-Abroad Study-Buddy studymake love studying stuff stuff? stuffed stuffening Stuffer Stuffers Stuff-Her Stuffin stuffing Stuffings Stuffins stuffs Stulback stumble stumbles Stun Stunna Stunnas stunned stunner Stunner’s stunners Stunner's stunni stunning Stunningly Stuns Stunt Stunts Stupendous stupid Sturdy Sturm Stuttering St-Valentine's Styes style Style' styled Styles Styles Super StylesDoing Styles's Stylez Stylez's Stylin Stylin' stylish Stylishly stylist Stylists Stylle su Suave Suaves sub Sub’s Subbing sub-boy’s subby Subculture SubDivideMe subdued subgriff Subhumanoid Subibaja Subil Subject Subjected Subjects Subjugated Sublet Subletter Sublime Subliminal submarine Submarines submerged Submi submission submissions submissive Submissived submissives Submissive's submit Submit? submits submitted submitThen Submitting subs Sub's Subscribed Subscription subscriptions Subservient subspace sub-space Subspace substantial subscanute subscanutes Subscanution Subtext Subcanle Subtle Subtly Subtracting Suburban Suburbia Suburbs Subversive subway Succeed success Successful successfully Succ-sex succubus Succubus0-0 Succulence succulent Succulento Succulents Suce such Suchell Sucia suck suck? Sucka Suckable Suckage Suck-cess SUCKcess sucked suckedso sucker suckered suckers Suckfest Suck-Her Suckie suckin sucking Sucking' SUCKING Suckjob Suckled Sucklers suck-loving Suck-n-Make love suckoff Suck-off Suckretary sucks Suckseed Sucksess Suck-Sex Suckstress Suck-That-Member SUCKtion sucky suction Suctionmember Suctiondong Suctioned sudden Suddenly SUDORA Suds Sudsing Sudsy Sudz Sue Sue’s suede Suedecouch Suelen Sueno Suerte Sue's suffer suffered suffering suffers suffersCategory suffersthough Sufficient Suffin suffocate Suffocated Suga Sugal sugar Sugar? Sugarbabe Sugarbaby Sugar-Coated sugardaddy sugardaddy's Sugarmommy Sugars Sugar's Suggested suggestion Suggestive suggests Sugian suicidal Suicide suit Suitcase Suite Suited Suiteheart suitor suits Suka Suki Sukis Sullivan sullying Sultan Sultan's Sultress sultry Sultry’s Sum Sumkit Summa summer summer? Summer’s Summerbreeze Summerlin Summers Summer's Summers's summertime 'Summertime Summery Summit Summon Summoned Summoning Summons sumptuous Sumthin Sumthin' sun Sunbaked Sunbath Sunbathbeauty Sunbather Sunbathers sunbathing Sunbeam Sunbreak Sunburned Sunburnt Suncatcher sunchair sundae sundae? Sundaes sunday Sundays Sundeck sundown Sun-drenched sundress Sundy Sunfired sunflower Sunflowers Sunflowers2 Sunflowertoy Sunglbooty Sunglbootyes sunk Sunkissed Sun-kissed sunlight Sunlit Sunn sunni Sunnie Sunning Sunnixa Sunnny sunny Sunnydale Sunnyday Sunnydeck Sunnys Sunny's Sunpool Sunrays sunrise Sunroom Suns Sun's Sunscreen sunset Sunset- SUNSET Sunsetaneous sunshine Sunshine's 'Sunshining' Sunshower Sunshyne suntan suntanned Suono supa Supa-Dupa super Súper Super-agile superb Superball Superbang Superbarbies Superbia superbly Supermelon Superbowl super-busty Supercalafraga-Lick super-charged Supercharged Super-Charged Supermember Supercute Superdanglers Super-Doc Super-Femme Superfetation Super-Freak Super-freaky Super-make love Supergirl Supergranny Superhero Superhot Super-hot SUPERHOT super-hottie Super-Hung super-hungry Superintendent Superior Superiority Superlatives Superman Supermarket Super-Minka supermod supermodel Supermonster Supernatural Super-natural Supernaturally Supernaturals Supernova Superpowers super-se supersedes Super-sexy Supershowers Supersize Supersized Super-sized Super-sizes superslut Super-slut Supersluts Supersoak Supersoaker SuperSophie Supersperm super-stacked superstar Superstars Superstar's Super-Stunner Supersucker Supersweet Super-Tight Super-cans Supervibe Supervillians Supervised supervision Supervisor Supervixen Superwand Super-wild Superwoman Suplex supper Supper's supple Supplies supply Support supported Supporting Supportive suppose supposed Suppository Suppresses Supravaginal Suprema supremacy supreme Supremely suprise Suprised Suprisingly Sur sure Surefire Surewood Surf Surface Surfaces Surfacing Surfboard surfer Surfer’s Surfer's surfin surfing Surfistinha Surfs Surf's Surge Surgeon surgeons surgery Surgical Suri Surnaturel surprise Surprise' surprised surprises Surprising Surprisingly Surreal surrealist Surrender Surrendering surrenders Surrender's Surrogate Surrounded Surta Surveillance Survey Surveying Survival Survivalist Survive Survived survives Surviving Survivor Sus susan Susan? Susana Susane Susane's Susanna Susannah Susanne Susan's Susceptible sushi Susi Susie suspect suspect's suspend suspended suspendedMake loveed suspenders Suspends Suspense suspension suspensionCaned SuspensionFirst suspensionHow suspensions SuspensionStripped SUSPENSIONTwo suspicious Suspiciously Susy Susy's Sutra Sutra's Sutton Suxxx Suz Suzan Suzana Suzanna Suzanne Suzanny Suze Suzi Suzie Suzie’s Suzie's Suziha Suzi's Suzuki Suzumi Suzumi's Suzy Suzy's Suzzie SV Sveiki svelte Sveta Svetik Svetlana Svitlana Svitlana's Svjat Svobodova Swaberry Swabery Swaddled Swag swagbunxious swagger Swallop swallow swallow? swallowed Swalloween swallower Swallowers SwallowHer swallowing swallows Swallow's SWALLOWs swamp Swan Swank Swanlike Swann Swans Swan's Swany swap Swap' SWAP Swaperoo Swapped Swappers Swappin swapping swaps Swarmed swarthy Swartz sway swayin Swaying Swayze Swayzo Swaziland swear Swears sweat Sweater Sweater2 Sweaters Sweatfest Sweatheart Sweathearts Sweatin sweating sweatingsquirting sweats Sweatshop sweaty Swed Swede Swede's swedish Sweeet Sweep Sweeper Sweepstakes sweet sweet_booty sweet_hole sweet_juice sweet_sweater_anal_angel sweetcake Sweeten Sweetened Sweetener Sweetening sweetens sweeter sweetest sweetheart Sweetheart’s sweethearts Sweetheart's Sweethole sweetie Sweetie? sweeties Sweet-looking sweetly Sweet'n Sweetness Sweet-ness sweets Sweet's Sweets's Sweetstorm Sweetsweet Sweety Sweetz Swell Swelling swells swelter Sweltering Swept swerve Swetie swift Swift's swim swim? swiming swimmer swimmers Swimmer's Swimmin' swimming Swimming? swimmingpool swims swimsuit Swimsuits Swimteam swimwear Swindled Swindling Swine swing swinger swingers swinger's Swingers Swinger's swingin swinging Swings Swingtime Swipe Swipes Swiping swirl Swirling swirls Swish Swiss switch Switchable Switchables Switch-aroo Switch-a-roo Switchblade Switcheroo switches Switching switchs Switch-Up Switzerland Swix Swole Swolemates Swoll swollen Swoon Swooning Swoop sword Swords Swtich Swurvy SX573 SX797 Sy Sybelle sybian Sybian1 Sybian2 SybianAny Sybianchat Sybianfun SybianHelpless sybianmade Sybianpleasure Sybianride Sybians Sybian's Sybiantwo Sybil Sybilissima Sybill Sybille Sybil's Syd Syde Sydnee Sydnee's Sydney Sydneys Sydney's Sydonia Sye Syllabus Sylvana Sylver Sylvi Sylvia Sylviana Sylvie Sylvi's Symbiotic Symbol Symon Symone Symone's sympathy symphony Sympli sync Synchronicity Synchronize Synchronized Syndee Syndrome Syndy Synergy Synesthesia Synful Synn Synolo synonymous Synthia Synz Syra Syre Syren Syrens Syren's Syre's Syrines Syrup Syrupy System Systems Sytnyy Sz Szabina Szalontai Szandi Szandi-Baby Szanto Szelina Szerepet Szilvia Szindy Szofy Szofya Szoke? Szombathely Szuza Szuzanne Szuzanne's Szuzie t T4K ta Tab tabatha Tabby Tabby's Tabela Tabitha table Tablefinger Tablefingers Tablefun Tablelove Tableplay Tablecat tables Tablespread tablet Tableteen Tabletop Table-top Tabletopfun Tabletopplay Tabletopcat Tabletopspread Tabletoptoy Tabletouch Tabletoy Tablekitty Tablevibe Tabloids taboo taboos Taboo's Tabouret Tac Tachmee tacking tackle tackles Tackling Taco Tacori Tacos Tactic tactical Tactically tactics Tactus Tad Tae Tae's Taft tag Taggart Taggart's tagged Taggin Tagging TAGS Tagteam Tag-Team Tag-Teamed Tag-teaming Tahiti Tahnee Tahoe Tai tail Tailbone tailed Tailgate Tailgating Tailing Taillon tailor Tailored Tailoring Tailpipe tails TAILSandTHIGH T'aime Taiming Tainah taint Tainted Tais Taisa Taisiya Taissia Taissia's Taisya Taj Taja tak take Take A take? take?Obviously take_it_all take-Down Takedown Takedowns taken TakeNoPrisoners Takeoff Take-Off take-off? takeon takeout Take-out takeover taker Takers takes take's Takes Takes A takes? takexs takin taking Takuo Tala Tala's Talbootyo tale talent talented talents tales Tali Talia Taliana Talia's Talina Talita talk talk? talked talker Talkers Talker's Talkin Talkin' talking talks Talkspread Talkspread1 Talkspread2 Talktome tall Tallahbootyee tallest Tallia Tallie tally tallywacker tally-whacker? Tallywhackers Talon Talore Talous Tam Tama Tamale Tamana Tamara Tamber Tambien Tambor tame tamed Tameka Tamer Tamers tames taming TamingTammie Tammi Tammie Tammy Tammy's Tampa Tampering Tamra tan Tana Tanaka Tana's Tanata Tandem Tandy tang Tangerine Tangerines Tangi Tangible tangle Tangled Tangling tango Tangy Tania Taniella Tanielle Tanika Tanita Tanitsu Tanja tank Tanka Tank-Top Tanline Tan-lined tanlines tanned Tannedbabe Tanner Tannerly Tanners Tanner's tanning Tanny Tans Tanskirt Tantalises Tantalising tantalize Tantalized tantalizer Tantalizers tantalizes tantalizing Tantalizingly Tantra tantric Tantrum Tanx Tanya Tanyaa Tanya's Tanza Tao tap Tapanga Tapas tape Tape? Taped tapeManhandled tapes tape-slave Taping Tapped Tapper Tappin Tapping taps Tara Tara’s Tara's tarde Tardy Tareas Tarey Target Targets Tariamvi Tarian Tarja Tarot tarp Tarps Tarra Tarra's Tarrasque tart Tartan Tartar tarts Tart's Taryn Tarzan tas Ta's tase Tased Tash Tasha Tasha's Tashiro Tasia task Taskforce Tasks Tbootyels taste Taste? taste-a-thon tastebuds Tasted Tastee Tastees Tasteful Taster Tasters tastes Tastey Tastic Tastier tastiest Tastiness tasting tastings tasty Tasty-Booty Tastyfingers tat Tata Tatalila Tatana's ta-tas ta-ta's Tatas Ta-tas Tata-Tastic Tate Tater taters tatersHeavy tatersSexual Taterz Tati Tatiana Tatiana's Tatiane Tatianna Tatiyana Tatjana Tatjana's Tatoo Tatra tats Tatt Tattas Tatta's Tatted Tatted-Up Tattle Tattletale tatto Tattoed tattoo Tattoo? tattoo_shop tattooed Tattoo'ed Tattooedpierced Tattooing tattoos Tatts Tatum Tatumn Tatum's Tatya Tatyana taught Taunt Taunted taunts Taurus Taut Tavalia Tavares Tavern tawdry Tawni Tawny Tax taxed Taxes taxi Taxing taxis Taxman Tay Taya Taybre Tayla Taylan Tayla's Taylee Tayler Tayler's taylor Taylored Taylors Taylor's Taytum Tazed Tazing T-Ball T-Beauty TBYB Tchanka Tchekan Tchernei tcherney TDD T-Diva's T-Doll te tea Teabag Teabaggers teach teacher teacher? teacher’s teachers teacher's Teachers Teacher's TEACHERS Teacher-Student Teacher-to-be teaches Teachin teaching teachings Teagan Teaganism Teagan's Teager Teajul Teal Teale team TeamBrutal teamed Teamed-Extreme teamedFinger Team-make love teamMake loveing Teamin teaming Teammate teammates TeamRain teams Team's TeamSkeet Teamster teamwork Teanna Teapot tear teardrop Tearing tears Tears? tease Tea-se TEASE Tease? teased Teasen teaser teaser? teasers Teasers’ teases Teasin Teasin' Teasin’ teasing Teasy Teatime Teatimetoys Teazy Tebow Tech techie Technic Technical technician Technicolor technique techniques Techno Technology Tecnician Ted Teddi teddy Teddybear Teddy's Tedesco TEDxxx tee Teegan Teeing Teem teen teen’s Teena teenage Teenagebedroom teenager teenagers Teenager's Teenah TEEN-aholics Teenbed Teenchat Teendong Teened Teenfidelity's Teenfinger Teenfingers Teenfondle Teengasm teengirl Teenhole Teenholes teenie Teeniechat Teenieholes Teenies Teenjugs Teenlicious Teenlove Teen-Lover's Teenorgasm Teenpink Teenpinkcat Teencat teens teen's Teens Teen's Teens4Sale Teenshower teen-slut Teentalk Teentie Teentime Teencans Teentoy Teentoys Teenkitty Teenvibe Teenworkout teeny teeny? Teenyblacked teeny's Teera Tees teeth Teets Teeya Teflon Tegan Tei Teighiana Teighjiana Tekiara Telefonando Telegram Telemarket Telenovela telepathy telephone Telephoning TelePoondo Telescope Telethon Television Teliko Teliulis tell Tell? teller telling tells Telltale Tellula Temenian Temp Tempe temper Temperament Temperances temperature Tempered Tempest Tempestuous Tempio Templar temple tempo Temporary Tempress Temps Temp's tempt temptation Temptations tempted temptin tempting temptress temptresses tempts Temtping ten Tenacious tenant Tenants Tend Tendance tendencies tender tenderes Tenderized tenderly tenderness Tenderoni Tending Tends Tenebrarum Tengo Ten-I-See Tenkias Tenna Tennessee Tennessees tennis tennis-court TennisSimple Tenniskitty Tenpin TENS Tense tension tensions Tenso tent Tentacle Tentant tents Tenue Teo tequila Tequila's Tequis tera Teradise Terapia Tera's Teratai Teraxe Tercera Tere Terell Terenza Teresa Teresa's Teresse Tereza Terezska Teri Teria Terka Term Terminates Terminating terminator Terminator's Termi-Nigga Terms Terra Terrace Terracotta terrain terrbooty Terrbootye Terrazza Terrazzo Terrestrials Terri terrible Terribly terrific Terri-fic TERRIFIC Terrifying Terriority Terri's Territorial Territory terror Terrorist's Terrorize terrorized Terrorizing Terry Terryn Terry's Tesia Tesla Tess Tessa Tessa's Tesse Tessy test test2 Test-Drives tested Tester Test-make loves Testicle testicles Testin testing testMade testosterone tests Testy Tetangas tetas Tete-A-Tete tethered Tetona Tetti Tetyana Tevan Tex Texan Texans Texas Texbooty Tex-Booty Texas-size Texas-Sized Texeira TexMex Tex's text textbook Texter Texting Texts Textual Teyra TF's TFSN tgif TGIFriday Tgirl T-Girl T-Girls T-Girl's th tha Thad Thai Thai? Thai-ed Thailand Thaina Thais Thai's Thaissa Thal Thalia Thalya than Thane Thang Thangs thank Thanked thankful Thanking thanks Thanksgiving Thanks-Giving Thankyou Thar that that? That?? That’s thats that's Thats That's Thaw Thawed Thay Thayer Thays the 'The THe The Colombian membersucking the_booty_bunch the_turkey_best_friend the2008 Thea Thea's theater theatre Theatro Thebes Thebunny1 Thebunny2 Thecla thee thee? theese theft Thegirlnextdoor Theia Theif their them them? Theme themed Themis themselves then Theo Theodora theory Theo's Thepair Therabbit Therabbit1 Therabbit2 Therapeutic Theraphsody therapist Therapist’s therapists therapist's therapy there there? there’s there…… there's Theres There's Theresa Therese Thereza Thermal thermometer these These? Thesis Thespian Thesybian TheThe Thetub TheUpperFloorcom Thevibrator they They’re Theyll they're Theyre They're they've thi Thia Thicc Thiccness Thich thick Thicken Thickens Thicker thicket thickicious Thickie Thickness Thick-shaped Thicky thief Thierry thieve Thievery thieves Thieving thigh Thigh-high thigh-highs Thighhighs thighs Thighs? thin thing Thing? things thing's Things think think? Thinker thinking Thinking? thinks Thinks? Thinner thir third thirst Thirsting Thirsts thirsty thirsty? Thirteen Thirty Thirty-Eight this This? This?? ThisGirlSucks Thiz Thoat Thoated Thom Thomas Thomas's Thompson thong Thongs Thor Thorn thorne thorns Thornton? Thornton's thorough Thoroughbred Thoroughly those Thot Thots Thottie Thotty Thou though thought thoughts thousan thousand thousands thousandth Thrashed Thrasher Thrashin Thrashing Thre Thread Threat three Threefifteen Threefifteen2 Threefinger Threefingergirl Three-Hole three-on-two Three-Ring Threes Three's THREEShannon ThreeSlave threesom threesome threesomes Threesome's threesone Three-Timing threeway three-way Threeway Three-way Threeways Threshold Thresholds thressome Thrice Thrift Thrifty thrill Thrilla Thrilled Thriller Thrillers Thrill-Her thrilling thrills throat throat? throated throat-filled ThroatFlogged Throatmake love throat-make loveed throat-make loveing Throatmake loveing Throat-make loveing Throathing throating throat-cat throats Throatskills Throatsluts Throaty throb throbber Throbbin throbbing throbs Throne Thrones throttle through Through A throw Throwback throwdown throwing thrown throws Thru thrust Thrusted Thrusters Thrusting thrusts Thug thugs Thugzilla thumb thumbs Thump Thumped Thumper Thumping Thunda thunder Thunderballs Thunderbolt Thundermelons Thunderclit Thunderstorm Thundy Thura Thurman Thurough Thursday thy Thyself ti Tia Tía Tiah Tian Tiana Tianas Tiana's Tianna Tiara Tias Tia's Tibby's Tibor Tic Tichuana Tick ticket tickets ticking tickle TICKLE? tickled Tickler ticklers tickles ticklin tickling ticklish Tickly Tick-Tick tidal Tiddie Tiddy tide Tideni Tides Tidus Tidy tie Tieand Tiebreaker Tie-Breaker tieBrutal tied Tied-up Tiempo Tiene Tierra Tiers ties tieSounds tieZippered Tifa Tifani Tifanny Tifany tifany's Tifereth Tifereth's Tiff Tiffani TiffaniRox Tiffanny tiffany Tiffanys Tiffany's Tiffian Tiffian's Tiffisa Tiffs Tifini Timake loveing Tig tiger Tigerr TigerrIf Tigerr's Tiger's Tiger-Sperm Tiger-Style Tiggest Tiggle tight Tightbooty tightbodied tight-bodied Tighten tightener tighter Tighter? tightest Tighthole tightly tightlyGets tightness Tightness? tight-cat tights tightster Tightteen Tighty Tiglians Tigra tigress Tii Tijuana Tik Tikar Tiki Tiki-Taka TikTokThots til 'Til Tila Tilda Tilden Tiled tiler Tiles Tiletoy Tilf till Tilli Tilly Tilt Tim Timber Timbers time Time? Timea Timea< Timea's TimeChick Timebomb's timed Timeless Timeline timeon-camera Timeout timer timers times Time's times’ timeshe Timetable TimeZZ Timi timid Timing Timmy Timo Timoax Timo's Timothy Timur Tin T'in tina Tina's Tinder Tindr Tindra tingle Tingled Tingler tingling Tingly Tini tinier Tiniest Tini's Tinka Tinker Tinkerbelle tinkle tinkled tinkler tinkling Tinley Tinos Tinsel Tinslee tint Tinted tiny Tiny4K Tinypink Tinycat Tinyskirt Tinyteen Tinytoy Tinykitty tip Tip? Tipfak Tipper Tippers Tippin Tipping tippy tips tipsy tip-toe Tiptoe Tiptoed Tiptoeing tiptoes tip-toes Tiptoes Tira tire tired Tired' tired? tireless Tires Tis 'Tis Tisa Tisdale tissue Tissy Tissy's can CANal Canamazed Canan cananic canans Canantic Canastic Can-a-thon Canball Canbuster Can-Chi can-fuc Canmake love Can-make love canmake loveed Can-make loveed Can-Make loveer's Can-make loveing CANMAKE LOVEING Canmake loves Can-make loves Cani Canies Canilated Canilating Can-ilations Canillate Canillated Canillating Canillation Caninterview canjob canle Canlicious Can-liscious Can-Mas Canmbootyage Canmasters Canness Cannic Cano Canorial can-prints Canrubinterview cans Can's Cans cans? cans?Then cansAbused Cans-A-Palooza cans-big cansDominates Cansgiving cansHair Cans-out cansSuffers cansTotally Cantalated Can-Tart Cantays canted Canter Cantes cantie cantied Cantie-Lick Cantierub canties Cantie's CANTIES canties? CANTIESS Cantilicious cants Cantsburgh canty canty_bar Canty-And-Anal cantymake love Canty-Make love CantyMake loveed Canty-Make loveed cantymake loveing canty-make loveing Cantymake loveing Canty-Make loveing Cantymake loves Canty-Make loves cantylicious Canty-opolis Cantypalooza canty's Cantytastic Cantywhombus Canual Canus canwank can-wank Canwank Canz Tiziana TJ tjhis TJ's TKO TLC T-Mac T-MILF's tmwVRnet T'nA TNA TNT T-N-T to To? toast Toasting Toasty Tobacco Tober Tober's Tobey Tobi Tobias Toby Toby's Toca Tocar Tochier Tock today today? Today’s today's Todays Today's Todd toe Toe-Curling Toed Toefetish toe-ful Toe-gather toeing toe-in-one Toeland toeless Toe-licking toenails toes toesExtreme toesocks toe-suck Toe-sucker toe-sucking toesYea Toe-tal Toe-tally Toey Toffee Tog toga Togas togeth together together? togetherBrutally Togetherness Togs toi toilet Toiletries Toilets Toilette Tok token Tokens toker Toki tokin Tokyo tol Tolcca told Toledo Tolerance Tolerances Tolerated Toliet Toll Tolls Tolls' tom Toma Tomahawked Tomas Tomas's Tomato Tomb Tombois tomboy Tomboy's Tomcat�s Tomcat's Tomei Tomi Tomiko Tommi Tommie Tommy Tommys Tommy's Tomo Tomoka's Tomorrow Tompson Toms Tom's ton Tone toned Tonen tones Toney tong Tongs Tongta tongue Tongued Tongue-Filled Tongue-Make loveed tongue-polishing Tonguers tongues Tonguing Toni Tonic tonight tonight? tonight's Toning Tonis Toni's Tonk Tonkaow Tonlew tons Tonsil tonsils Tony Tony? Tonya Tonya's Tony's Tonz too too? Toobig1 Toobig2 took tool Tooled Tooling tools Toon Tooshy tooter Tooters tooth Toothbrush Toothbrushfun tootsie tootsies Toot-Z-Roll top TOP_TAP Topanga Topaz top-floor top-heavy Topheavy Top-heavy Topher Topics topless top-model topped Topper Toppers Toppin topping tops Top's TOPS Top-shelf Toque Tora tore Toren Tori Tori’s torment tormented Tormenting torments torn Tornado Tornado0-0 torned Tornjeans1 Tornjeans2 Torn's Toro Toronto torpedo torpedoes torpedos Torpido Torque Torque's Torrent Torres Torrey Torrez Torrid Torrie Torri's Tort Tortoise torture torture?Short Tortureand tortured torturedCountdown torturedMade tortureextreme torture-rack Tortures torturing torturous Tory Tory Lane is back Toryn Tory's Tosh Tosha toSquirt Toss tossed tosses tossing Tot total Totaled totaleurosex totally Totalmente totals totem Tothe TOTM Toto Totti Totty Totum tou touch Touchable Touchdown Touche touched Toucher touches Touchin touching Touchingly Touchstrip Touchy Touchy-Feely tough Tough? Toughen tougher toughest Toughest? Toughgirl toughly toujours Touko's Touluk Tounging tour Tour-De-Madison Tourism tourist tourist's Tourists Tourist's tournament tourney Tours tow Towards towed towel Towels tower Towered Towering Towers Towing town town; Town? Towner Towners Townhouse Townsend Townsfolk Toxic toy toy? Toya Toybath Toybed Toybed2 Toybox toyboy Toycouch Toycouch2 Toycushion1 Toycushion2 Toy-Johnson toydoll toyed Toyer Toyers Toy-Filled Toyfinger Toyfingers Toy-For-Her Toymake love Toymake loveer Toymake loves Toyfun Toyfun1 Toyfun2 Toygasm Toyin toying Toyjoy Toyjuice Toyko toyland Toyless Toylotion Toylotion1 Toylove Toyplay toys Toy's TOYS Toys? Toys2 Toysex Toys'r'fun Toys-Tastic Toytease Toyteaser Toytub TP TPing TPMosterToys Tr Trabajando trabajas Trabajo Trace Traces Tracey Traci Tracion track Tracked Tracker Tracking tracks Tracksuit Traction Tracy trade traded Trade-Make love Trade-Off Trader Traders trades Trading Tradition traditional Traditions Traduci traffic Traga Tragándome Tragedy trail trailer Trails train trained trainee trainees trainer trainer’s Trainer-Aiden trainers Trainer's Trainess Train-Her TrainHER trainig training TrainingFeatured trains tram tramp Trampede Tramples trampling trampoline Tramp-oline Tramps Trance Trangible Traniing Traning Trannies tranny Tranquil Tranquility Tranquille Tranquilo trans Transaction TransAngels Transcendence Transcendent Transcendental Transcending Transcontinental Trans-Cop's Transexual Transfer Transferring Transfixed Transformation Transformed Transforms Transgender Transgenders Trans-Girl transgression Transgressions Transit Transition Translate translation Trans-lation Translator Trans-Lesbianism' Translicious Translucent Transmission transpa Transparency Transparent Transportation Transsexual Transsexuals Transvescane Trans-Visions Trans-Xperience trap trapped Trappers Trapping traps Trasero trash Trashcan Trashed Trashes Trashing Trash-Talking Trashy Trasks travel traveland Traveled traveler traveling Travelled traveller travelling travel-loving travels Travezz Traviesa Travis Travojago tray Tre Treachery Tread Treadmill treason treasure Treasured treasures treat treat? treated treatement Treater treating treatment treatments treats Treaty Treaza Trebati Trecking tree tree? Treehouse Treehouse? trees Treesome Treeswing Treetop Trejsi Trek Trekkie Trekking tremble trembles trembling tremendous tremendously tremors Trench Trend trendy Trent Trenton Tres Trespbootyer Trespbootyers Trespbootying Tresses Tresspaser Tresspbootying Trevor Trexxx Trey Trez Treza tri Tria Triage trial trials triangle triangles Tribootyathlon trib Tribal Tribbers tribbing Tribbmill tribs tribulations tribute Tricia trick trick? tricked Trickery Tricking Trickle trickles tricknology tricks Trickshots Trickster tricky Tri-color Tricsys Tricycle Trident tried tries Trifecta Trifero Trifling Trimake loveta trigger triggers Trillium Trillium's trilogy trim Trimble Trimly trimmed trimming trimmings Trims Trina Trina's Trinety Trinety's Triniti trinity Trinitys Trinity's Trinota Trinty trio Trío trip Trip' triple Triple-Anal Triple-D Triple-duty Triple-G TriplePen Triplet Triple-Teaming triplets Triple-X Triplicate Tripod Tripp Tripper Trippin Tripping Tripple Trippy trips Trish Trisha Trisha's Triska Triss Trist Trista Tristan Tristan's Tristen Tristyn triumphant Triumvirate Trivial Trix Trixi trixie Trixie's Trixxx Trixy Trochos Trogata trois Troll Trolley Trollin trolling Trollop Trolls Trolly trombone Tromboner trombones trooper Troops trophy trophy?Will tropic tropical Tropicana Tropicarium Tropics trot troub trouble Trouble? Troubled troublemaker Troublemakers troubles Troubleshoot Troubleshooting Troublesome Trounced Trouper Troupers trouser Trout Troy Troyka Trtikova Tru Truant Truce truck trucker Truckers Truckin Truckin' Truckloads true TrueAnal Truelove truly Truman Trump Trumped trumpet truncheon trunk trunks? trust Trustfund Trusting Trusts Trustworthy Trusty truth Truth? try Try? tryhard Tryin trying Tryme Tryme's Try-Ons tryout Try-Out tryouts Try-outs Tryp Try's tryst tryst? Ts TS+Stud TSA TSAnal Tshirt T-shirt T-shirts TSKelly TS-On-Female TS-On-Cat TS-on-TS TSPC TSPH TsCatHunters TsCatHunterscom TS's TSS TsSeduction TsSeductioncom TsSeductioncomNot Tsuma tsunami Tsunami1-1 Tsunami's Tsurara's Tsuyabi Tsuyayoku TT tu Tú tub Tub? Tubbin Tubbin' Tubbing tube Tubesocks tubesteak Tubfun Tubing Tubpleasure Tubrub Tubsational Tubshave Tubshave2 Tubtease Tubtoy Tubtoyfun Tubkitty Tubular Tucci Tucked Tucker Tuckered Tucker's TuckerThe Tuesdae Tuesday Tuesdays TUF Tuff tuft tug Tugboat Tugged tugger Tuggernaut Tuggie Tuggin tugging Tugjob Tug-o-Whore tugs tuition Tuitui Tulias tulip Tulips Tumble Tumbling tummy Tuna Tunde Tundella Tundia tune tuned Tunel Tunes Tune-up Tungboy Tuning tunnel tunnels Tunner Tupelo Tupperware Turbation Turbo Turbolenza Turchesi turf Turist Turista Turisti turkey Turkish turn Turnabout Turnah turnaround Turndown turned Turner Turner's TurnerThe Turnin turning turn-on Turnover turns Turnt turquoise Turtle Turtles Tus tush Tushie tushies Tushy tussies Tussle Tussles tutee tutor tutor’s tutored tutorial TutorialPart tutoring tutors tutor's Tutors Tutor's Tutti Tutu Tuxedo tv TW001 TW002 TW003 TW004 TW005 TW006 TW007 TW008 TW009 TW010 TW011 TW012 TW013 TW014 twacker twackin Twain T'was kitty Kittych Kittyie kitty-ress kittys kitty's Kittys Kittytable Kitty-tasters Kittyter Kittyvibe tweakin Tweaking Tweeks Tweet Tweety Twelve Twenty Twenty-toe Twenty-two twerk Twerk? Twerkalicious twerker Twerkin twerking Twerkout twerks Twerkshop Twerktastic twice Twiddle Twiddles twig Twiggs Twigs Twigs's twilight twin twinddle Twine twineBondage Twink twinkie twinkle twinklers twinkles Twinkling Twinks Twinky Twinning twins Twins-Video twirl Twirler Twirling twirls twist twisted twister twisters TWIST-HER Twisting Twists Twisty Twistyn Twistys Twisty's twitch Twitching Twitchxxx Twits Twix two two? Two-Boy two-member TwoDestroy Twofer Two-fer Twofinger Twofingermake love Twofingerfun Twofingers Two-guy TwoLesbians Two-Lips two-man Two-on-One two-on-two TwoReal twos Two's TwoSexual Twosie TwoSlave Twosome two-step Two-Stroke Two-tailed Two-Timer two-timing Twotoys1 Twotoys2 TwoTrust Two-way Txojkev Txt Ty Tya Tyann Tycoon Tyela Tyelar Tyher tying Tyla Tylan Tylar Tylee Tylene Tyler Tyler's Tylo Tylo's Tyna type Type?? Typecasting types typewriter typical Typika Typing Typist Typo Tyra Tyra's Tyrell tyres Tyrius Tyron Tysen Tyson Tyten tzarin u Uber uberbabe Uberchick Ubersex Ubus Udine UDP Uehara Uena UFC Uglies ugly Uh Uhl Uk Ukie Ukraine Ukraines Ukraine's Ukrainian Ukrainian's Ukranian Ukulele Ulia Uliana Ulrika Ulterior Ultima ültima ultimat ultimate UltimateSurrendercom Ultimatum ultra ultra-anal Ultra-Curvy Ultra-Intimate Ultra-Pink Uluvpunani Ulya Ulyana Um Uma Uma’s Umbrella Um-hmm un una Unabashed unable Unacademic Unacceptable Unadulterated Unaga Unauthorized Unbecoming unbelievable Unbelievably Un-Bliss Unbound Unboxing Unbreakable UnBREElievable unbridled Unbroken Unbuckles Unbumon Unbumoned Unbumoning un-caged Uncaged Uncanny Uncensored Unchain Unchained Un-Charm Uncharted Unchaste Unclasp uncle Uncles Uncle's Unclog Unclogging Unclogs Unclothing Uncollared Uncolors uncomfortable Uncommon Uncomplicated Unconciousness unconsciousness uncontrollable uncontrollably uncontrolled Unconventional Uncooperative Uncorked Uncoupling Uncover uncovered Uncovers uncut Unda Undecided undefeated undeniable undeniably under under_the_covers_agent Undermelon UnderCova undercover Under-Desk Underdog Underestimate underground underneath Underpaid underrated Undersecretary Underskirt Understall understand Understand? Understandable Understanding Understands understood Undertaker Underthesheets Under-The-Table underwater underwear Underwear1 Underwear2 Underwearfingers Underwearfingers2 Underwearplay Underwood Underworld Undiefun undies Undies1 Undies2 Undiesplay1 Undiesplay2 Undine Undisciplined Undiscovered Undisputed undoes Undone undoubtedly undress undressed Undresser undresses Un-dressher undressing Undriscriminating une Uneasy unemployed Unemployment Unending Uneven-aged unexpected unexpectedly Unfair unfaithful Unfaithfully Unfaithfuls unfamiliar Unfilled Unfiltered Unfinished Unfolding Unforeseen unforgettable unforgiving Unforseen Unfortunate Unfriendly unmake loveed unmake loveing Unmake loveingbelievable Unfulfilled UNGODLY Ungrateful Unhappily Unhappiness unhappy Unheard Unhidden Unhinged Unholy Unhooking Unhooks uni Unias Uniche Unicorn UnicornAmateur Unicorns uniform uniformed Uniforms Unik uninhibited Uninvited Union unique Unison unit unite United Units Unity Universal universe university unknowing Unknowingly unknown unlawful unleash unleashed unleashes Unleashing unless unlikely Unlimited unload Unloaded Unloading unloads unlocked Unlocking Unmasked Unmentionable unneeded Unnianis unnoticed Uno Unobtainable UnorthoDoc unorthodox Un-Orthodox Unpack Unpacked Unpacking Unpeeled Unplug Unplugged Unpolished unpredictable Unprofessional unprotected Unpure Unqualified unquestionably Unrated Unravel Unraveling Unreachable Unread Unreal Unregistered Unreliable Unrequited unreserved Unrestrained Unrestricted Unrivaled Unruly Uns UnSafe UN-SANCTIONED Unsatisfied Unsatisfied? unscripted un-scripted Unscripted Unsealed Unseen Unselfishly unSEXpected unshaved Unshaven Unsinkable Unspeakable Unspoiled Unspoken unstoppable UnstoppaBull Un-Straight Unsuccessful Unsucked Unsuit Unsullied Unsupervised Unsuspecting Untamable Untameable Untamed unthinkable Untie until Untill Uncanled Unto Untold Untouchable untouched Untraditional Untrainable Untranslated Untreatable Untwine unusual Unvarnished Unveil unveiled Unveiling unveils Unwanted Unwelcome unwilling unwind Unwinding Unwinds Unwittingly Unwrap Unwrapped Unwrapping Unwraps Unzip Unzippable unzipped Unzippity up up? Up’ UPBOOTY Upbeat UPCLAIR Up-close Upcoming update UPDATE*Webbed UPDATEBound updateDragon updateN updates UpdateThe Upgrade Upgrading Upholsterer upkeep Upload Uploading upon Upper UpRanked Uprising Ups UPSASHA upset upside Upsidedown Upside-Down upskirt Upskirts upstairs Upstate upThe Uptight Uptown UPYOUR upYou're ur Ura Uranus urban urge Urgencia Urgency Urgent Urgenzze urges urinal urinals Urinates urine Ursa Ursula Uruguayan us Us? Usa USC use Use? used UsedAbused usef Useful Useless Uselesss uses Usher using uss USSR usual usually USUn-Ex Utah Utensil Utensils Utility Utilizes utmost Utopia Utter utterly UV Uzbek v V20 Va Vaca Vacaciones Vacancy Vacant vacation vacationer Vacationing vacations Vacay Vaccuum VaChina Vachs Vacillate Vacillation vacuum vacuuming vacuums Vada Vadim Vaesen vag VagÄ­na Vag-A-Thon Vagazzled VAG-entine's Vag-etable Vaggy vagin vagina vaginal Vaginal-Penetration Vaginas Vaginavibe vagitarian Vag-itarian Vagitarians Vag-Oh vagrant Vagvibe Vahn Vahntastic Vai Vain VajayJay Vajenga Va-J-J Val Valami Valan Valarie Valda Valdi Vale ValeAs ValeJust Valenteen Valentein Valentien Valentien's Valentín Valentina Valentina’s Valentinas Valentina's valentine Valentine? Valentine’s valentines Valentine's Valentino Valentino's Valencans Valenttina Valentyne Valenzuela Valeri Valeria Valeria’s Valerie Valeries Valerie's Valeriya Valery Valerya Vale's Valeski valet Valet? Valeto Valetta Valhalla Valiant Validation Valizias Valkyrie Vallarie Valle Vallenssa Vallery Valles Valletta valley Valleys Valley's Valli Vally Valmont Valory Valour Val's Valtana valuable Value Values Valve Valya Valya's Vamos vamp Vampira Vampire Vampire? Vampirella Vampires Vampire's VAMPIRES vamps Vamp's van Vana vanbootyy Vance Vanda Vandal Vandalize Vandals Vanda's Vandella Vandella’s Vandella's Vander Vandeven Vandory Vanella Vanesa vanessa Vanessa’s Vanessa's Vanguard Vanguard's Vania Vaniity Vaniitys Vaniity's vanilla Vanilli vanishing Vanita Vanity Vanityfun Vanitymirror Vanitycat Vanitytoy Vanityvibe Vanna Vannah Vannah's Vanna's Vanoza Vans Vant Vanta Vanwan Vaper vapes Vapors Varchie Vargas Variable Variate Variations Variety Various Varsity Varvara Varya Vas vase vases Vasilina Vasilisa Vasilisa's Vasillisa Vasques Vasquez Vast Vastly Vaudeville Vaughn Vaughn's Vault Va-Va-Voom Va-Va-Vooms Vayana V-Card Vday V-day veal Veces Vecino Vecru Vedrana Vee Veeg veegee veegees Veg Vega Vega’s vegan Vegas Vega's VegBOOTY Vegeance Vegesexual vegetable vegetables Vegetable's Vegetarian Vegetarians Veggie Veggie-girl veggies Vehicle Veiki Veil Veils Vein Veintoy veiny Veinytoy Velada Velasques Velasquez Velez Velia Velicity Velicity's Vellonc Vellons Velma Velmont Velonka Velour velvet Velvetanus Velvetchair Velvet's Velvett velvety Vemiss Vena Venday Vendetta Vendetta7-0The Vendettas Vendetti Vending Vendula Vendula's Vend-Whore Vendy Vendy's Venera Venera's Veneration Veness Venessa Venezuela Venezuelan vengeance vengeance' Vengeance VengeanceSurprise VENGEANCEThe VENGEANCEVendetta13-3 Vengeful Vengo Venice Venida Venirse Venirte Vennue Venok Venom Venomous Vente Venter Venton ventrilo-clit Ventura Ventura's Venture Venturi VENU5 venus Venzelata ver Vera Veracruz Veranda Verano Veras Vera's Verbally Verbena Verco Verda Verde Verdes Verdi verdict Verdoyant Verdure Verene Verga vergota Verhooks Verified Veritas Verlant Vermillion Vermont vermouth Vern's Verona Verona's Veroni veronica Veronica' Veronica’s Veronicas Veronica's Veronik veronika Veronika's Veronique Verrattu Verronica Vers Versa Versatil Versatile version Verspanken Versu versus Verta vertical vertigo Veruca Veruca's Verunka very Veryica Vesco Vesela Vesna Vespoli Vespoli's Vessel Vessir Vessir's vest Vesta Vestale Vestibule vet veteran veterans Veto Vetro Vette Vette's Veure Vexx vez VHSeX Vi Vi0lation via viagra Vialenta Viana Viano Via's vib vibe Vibebed Vibeclit Vibecouch Vibecream Vibed Vibefun Vibelove Vibecat vibes Vibesocks Vibesquirt Vibeteen Vibing Vibraciones Vibrador Vibrance vibrant Vibras vibrated vibratedStretched Vibrates vibrating vibration Vibrational vibrations vibrator Vibrator1 Vibrator2 Vibrator-And-Switch vibratorEach Vibratorfun Vibratorplay vibrators Vibratortoy vibro vibroing vibros Vibtrate Vic Vica Vicarious Vicariously Vicca Vice Viceland vices Vicious viciously Vicki Vickie Vicki's Vicktoria Vicky Vickys Vicky's victim Victimized victim's victor Victorelly Victorely Victori victoria Victoria’s Victorian Victorias Victoria's Victorija Victoriya victory victoryLoser victoryNasty vid vida Vidal Vidas VIDE Videl Videl's video Vídeo VIDEO********** Video5007 Videochats Videogames videos Videoshoot Videotape Vidis Vidra vids vie Vienna Vienna’s Viennas Vienna's Vienta Viento Viera Viesdati Vietnam Vietnamese view View? Viewed viewer viewing views Vigaro Vigilante Vignette Vigor Vigorous Vigorously VII VIII Vik Vika Vika's Viki Viking Vikki Viktor Viktoria Viktoriah Viktorias Viktoria's Viktorica Viktorie Viktorija Viktorina Viktoriya Vila Vilhena Vilia Vilikias villa Village Villagia Villain Villainess Villainous Villains Ville Villianess Vilorean Vina Vina’s Vina's Vince Vincent Vindictive vine vines Vinestoy Vineyard Vinjera Vinna Vinnie Vinny Vino vintage Vinyasa Vinyl viola Violate violated violates Violation violence violent violently Violet Violet’s violetblue Violete Violets Violet's Violett Violetta Violette Violette's violi Violin Viona Viona's vip Viper Vipera Vipers Vips VICat Virag Virago viral Virgeus virgin virgin? virgin??? Virgina Virgina’s virginal virgindisgraced Virgine Virginee Virginee? virging Virginia Virginie virginity Virginity? virgins virgin's Virgins Virgin's VIRGINS Virginty Virginy Virgo Viri Virian Virile virtual Virtually virtue virtuoso virus Visa Visby Viscara Visconni Visconti visible vision visionaries visions visit Visita visitfor visiting visitor visitors visits vista vistisTickled Visual Visualizazi Vit vita Vitae Vital Vitalik vitality Vitaliy Vitalize vitals Vitamin Vitamine vitamins Vita's Vitio Vito Vitor Vitoria Vittoria Vitus Viv Viva vivacious Vivan vive Vivi Vivian Viviana Vivianas Vivianee Viviania Vivianna Vivianne Viviany Vivica Vivid Vivie Vivien Vivienne Vivienne’s Vivien's Vivinias Vivo vivre Viv's vixen Vixen’s Vixenation vixens Vixen's Vixens’ Vixon Vixon's Vixtor's Vixxen VixXxen Vixxxens Viya Vizacao Viziosa Vj Vlad Vlada Vladimira Vladlena Vlaska Vlasta Vlema Vlena vlog vlogger Vlogger's Vlogging Vocab Vocabulary vocal Vocalization vocals Vode Vodka Voeden Vog Vogel Vogue Voguel voice Voice'mouth Voices Voicing Void vol Vol1 Vol14 vol2 vol3 Vol4 Vol8 Volcanic volcano Voldok Volga Volkanik Volkova vollbracht Volley volleyball Volleyballin Volleyballs Volleying Volpetti Volt voltage volts Volt's volume Voluminous Volunteer Volunteers Volup Voluptous Voluptueux Voluptuos voluptuosa Voluptuosgirl voluptuous Von Vonage VonDoom VonDoom's Voneva Vonn Von's Voo voodoo Voom vor voracious Vore Vore-racious Vore's Voris Vosoni Voss Vosse Vostra vote voted Voter votes Voting Vouchers Vougue Voules Voulez vous Vouyerlove vouz Vov Vova Vow Vows Vox Voxx Voxxx Voxxx's Voy Voyage Voyager Voyagers Voyages Voyageur voyeur Voyeur’s Voyeurbath1 Voyeurdelight Voyeurism Voyeuristic Voyeurs Voyeur's vr Vrod Vroom vroom-vrooom vs V's VS vs DP vsAdrianna vsAmi vsAva vsClaire vsDa vsDarling vsFaye vsGwen vsJennifer vsKarrlie vsKiki vsKirra vsLorena vsMadison vsPenny VSRANKED vsRyan vsSamantha vsSarah vsSasha vsSmokie vsThe vsTia vsTrina vsVai vsWinter Vu Vue Vuelvete Vuiton Vuitton Vuk Vulgar Vulnerable vulnerableWe Vulture vuluptuous VULVA Vution VXN Vyona Vyvan Vyxen w W****App wa Wabbits wad Wade Waders Wads waffle Wag Wage Wager wagered Wagner WagnerThe Wagon Wags Wahine Waif Wailin Wailing Waine waist wait Wait? waited waiter Waiter’s waiter's WaitHow waiting Waitingroom Waitlist waitress Waitress's waits Waka wake Wake-N-Make love wakes wakeup wake-up Wakeup Wake-up Wakeup1 Wakeup2 Wakeupplay1 Wakeupplay2 Wakey Wakey-wakey waking Wales Waleska walk walk? Walkabout walked Walker walkers Walkin Walk-in walking Walkiria Walkout walks wall Wallace wallCareful Walled Wallet wallher Wallop Walloped wallowed Wall-painting walls Walner Walnut Waltz WAM wan wand Wanda Wandbeads Wander wandered Wanderer wandering Wanderlust Wanding Wands Wandtoy Wandtoys Wandvibe Wane Wanessa wang Waning wank wanker wanker's Wankin wanking Wanking? Wankmus wanks Wanksta Wankster Wanksy wanna Wanna-Bang-O wannabe Wanny want want? wanted wanting Wanton wants want's Wants war Warai ward Warden Warden's wardrobe warehouse wares warfield Warhol warm warmed Warmer Warmers warming Warms Warmth warmup warm-up Warmup Warm-up Warn Warner warning Warranty Warren warrior Warriors wars was Wasabi wash Washboard washed washer Washers Washerkitty washes washing Washington Washroom Washup wasn't Wasnt Wasn't WASP Waspy waste Wasted Wastelands Waster wastes Wasting watc watch watch? Watcha Watchdog watched Watcher Watchers watches Watchful Watchin watching watching? water waterboarded waterboarding Waterbondage Waterboy Watercolor Watercolors watercooler Watercouch Watercourse watered waterfall Waterfalls Waterflow Watergate watergirl Waterhole watering Waterlogged Watermelon watermelons Waterpark Watercat waters Water's waterside Watersplash watersport watersports water-sports Watersports Waterwall waterworks Waterworld Wating Watson Watson's Wattado wav wave waves Waving Wavy wax waxed waxes waxGIO279 waxin Waxing Waxy way WayAgain Wayfaring Way-Interview Wayne ways Wayward wc we We? we’re we’ve weâ??ve Weak weaken weakens Wealth wealthy Weapon Weapons wear wear? wearing wears Weasley Weather weathers Weaves Weavin' web Webb Webbed webcam Web-Cam Webcamer Webcammer Webcamming Webcams Webcams? Webinars website Wed We'd Wedd wedding Wedge Wedged Wedgie Wedgies Wednesday wee Weed week Week? Weekdays weekend week-end Weekend Weekly Weeknight weeks week's Weeks Weenie Weenies Weeny Weggie Weigel Weighs weight weighted Weightlifter weights Weiner WEINERschnitzel Weird Weirdo Weix welc Welcom welcome welcomed welcomes welcoming Weld welfare Welians well We'll well_versed Well-Built well-deserved Well-dressed Well-Endowed well-make loveed well-hung Wellin well-known Well-Needed Wellness Well-orchestrated Well-paid Well-prepared Well-red Wells Well-served wellSkin Well-spent Wells's Welsh Welter Welterweight Welther wench Wenches Wendee Wendi WENDUS Wendy Wendy's Wenessy Wenona Wenonaformer Wenona's WenonaThere went wer We'r were we're Were We're Weremilf Wes Wesley west Westbound Western westgate Weston West's Westsun wet wet? Wetdream Wetdreams Wetero Wetfingers Wethole Wetland Wetly Wetne wetness wet'n'wild Wetpleasure Wetcat wets Wet's Wetsx Wett Wet-T Wetted Wetteen Wetteencans wetter wettest Wettie Wetting Wetcans Wetkitty Wetty we've Weve We've we-we wh wha Wha? Whaaa WHAAAT whack Whacked whacking Whacks Whale Whaling Wham Whamies Whammy what what? What?? what?s what_is_in_your_luggage what’s Whatâ??s WHATCH Whatcha whatever what's Whats What's whatsoever whe Wheat Wheater Wheel Wheelbarre Wheelchair Wheeler Wheeler's wheels when Whence Whenever where Where's Where've wherever Whets which Whicker Whiff Whig while While? whille whilst Whim Whimpering Whimpers whims whimsical Whining Whinny Whinny's Whiny whip Whip? whipbooty Whipcream Whiped whipped WhippedBootycom Whippedcream whipping whips Whirl Whirlpool Whirlwind whisker whiskers Whisking Whisky Whisper whisperer Whispering Whispers whistle Whistles Whistling white White’s Whitebed Whitebanana Whitedress Whitegarder whitelace Whitelace1 Whitelace2 Whitelingerie Whiteneko Whitenighty Whiteout Whitepants Whiter WhiteRoom Whites White's Whiteshag Whitesheer Whitesilk Whiteskirt whitesocks Whitestocking Whitestockings Whitethong Whitetop Whitevibrator Whitey whith Whithney's Whitie Whitney Whitney’s Whitneys Whitney's Whiz who who? Who\'s Who’s whoa whoChloe Who'd Whodoyougot? Whoever who-ha's whole Wholesome Whom whom? Whome whomever Whoomp Whoopee whoops Whooties Whooty Whopper whoppers Whopping Whor Whorange Whorderly whore W-H-O-R-E Whore? whore5144 Whore-a-tied whorebound Whorecade whored Whoreder Whoreders Whore-ders WHOREders Whoredom Whoredrobe Whorehouse Whoreleans Whore-lem Whoremonger WhoreObics Whore-Off Whore-O-Scopes Whore-O-Scoping Whoreporate Whorerror whores whore's wHores Whore's WHORES Whoreschach Whoresome Whorever Whorey Whoreywood Whoriental Whorientation Whorin Whoring Whorish Whornitas Whoronation whorred Whorriors Whory who's Whos Who's whose why Whyte wi Wibeke Wibeke's Wiccan Wick wicked Wickedgirlcom Wickedly Wickedscenescom Wicker Wicker1 Wicker2 wicket Wickey wicks Wicky Wicky's Widdow wide wide-eyed wide-open Wider Widescreen widget widow Widower widows Widow's Wielding Wields Wien wiener Wieners Wienies Wienold Wiesenthal wife Wife? wife’s WifeFeels wife-make loveing Wife-I Wifely wife's Wifes Wife's Wifeswap Wife-Swap wifey Wifeys Wiffle Wifi Wi-fi Wi-Fi? Wifing wig Wiggle wiggles Wiggling Wiggly wih wild wild_teen_lets_loose Wildbootyholeslut wildcat Wildcats Wildchild wilde wilder Wildera wilderness Wilder's Wilde's wildest WildeThe wildfire Wildflowers Wild-hot Wilding Wildlife wildlife_photography wildly Wildness Wild-One Wilds Wild's wildwest Wildy Wildy? wiley Wiley's WILF Wilfred Wilfried WILFs will Willa Wille William Williams Williamsends Willie willing Willingness Willis Willl Willow Willows Willowy wills willy Wilma Wilson Wilt Wimbledon Wimp Wimpy win win? win?Which wind Winding Windmill Windmills window Window2 Windowcandy Windowfingers Windowlove Windowcat windows Window's Windowsha windowsill Windowstrip Windowtease Windowtoy Windowkitty winds windy wine Winecellarstrip Wined wineglbooty Wines Winetaster Wing winged Wingman Wingmen wings Winifred Winkers winkin winking winks Winn winner winners Winner's Winni Winnie Winnie's winning winnings winOne wins Win's wins? Winslett Winston winter winters Winter's Winters's wintertime Winter-time win-win Winwin Win-Win Wipe wiped Wiping Wipizzle Wire wired Wiredcat Wiredcatcom Wires Wiring Wisdom Wise wisely wish wishes Wishful Wishing wishlist wishmaster wiska Wiska's wit witch Witchcraft Witchery witches Witch's Witchy with With Cute With Female Withdrawals Withdrawn Witheneko Withers within Withney without Withstands Witness witnesses Wits Wive wives Wivien Wiyar Wizais Wizard Wizard's wizz WKRP Wlt wo Wobble Wobbly woes Wok woke woken Woking wolf Wolfe Wolff Wolfie Wolfox Wolf's Wolves wom woman Woman’s woman-handle Womanhood woman's Womans Woman's women Wo-Men WOMEN women's Womens Women's won Won’t wond wonder wonderful Wonder-ful Wonderfully wondergirl wondering Wonderjugs Wonder-Lana wonderland Wonderlust Wonderment wonders Wonderwoman Wondrous Wondrously Wong wont won't Wont Won't Wonton Woo wood Woodcutter's wooden Woodies woodland Woodman Woodroom Woodrow woods Wood's woodsman Woodward woody Woodymaker Woodz Wooly Woopee Woor Woos Wooty Word Word? Wordless words wore Worhship work work? workaday Workaholic workand Work-Boss workday worke worked worker workers worker's Workers Worker's Workforce Workin working Workinggirl workload Work-Load Workman Workman's workout Workout' Work-out WORKOUT workout? Workoutfun Workoutrubdown Workouts Workouttime Workouttoy Workoutwand Workover workplace works workshop Workspace world World? worldBrutal Worldcup worlds world's Worlds World's worldThis worldwide Worlwide worm worn Worries worry Worse worship worshiped Worshiper Worshipers Worshipful worshiping worshipped Worshipper Worshippers Worshipping worships worst worth worthless Worthshiping worthwhile worthy Worthy? Woship woul would wouldn't Would've Wound Wounded Wounds wow Wowie wows WOW's Wowcans Wowwi-Pop wowzers wra Wrangler Wrangles Wranglin Wrangling wrap wrapped Wrapper wrappin wrapping wraps Wrbootylin wrath Wreck Wreckage wrecked Wreckening Wrecker Wrecking Wrecking-Ball Wreckless Wrecks Wren wrench wrenches Wreslters Wrester wresting wrestingLoser wrestle Wrestlemake loveing wrestler wrestlers wrestlersCrushing wrestles wrestling wrestling1 wrestlingBlond wrestlingCrushing wrestlingLoser WrestlingRd2 wrestlingWinner Wriggles Wright wringer wrinkled Wrinkly wrist wrists write Writer Writers Writer's Writes writhing writing Written wrong Wronged Wrongs wrote ws WSG Wtf WTMake loveing wth Wudda Wunf WUNF-220 Wurlitzer Wuze Ww wwworgasmalleycom wwwRoccoFunClubcom Wyatt Wylde Wylder Wylde's Wynn Wynona Wynters Wyoming Wyson Wyte x X_Mas x2 X3 X69 Xana Xander Xander's Xandra Xandy Xania XArt X-Art Xasia Xaya XBEAUTIFIED XBox Xcite Xeena Xellix Xempra Xenia Xenija Xeniya Xenta Xes XFeatured X-Files X-Girlfriend XI Xiannas XII XIII Ximena XIV XIX XL XLGirl XLGirls XLGirlscom XLVII Xmarksthespot xmas X-mas XMas X-Mas XMAS Xmastoy X-Men xo Xotiko XoXo XPart Xperience X-perience Xposed X-Posure Xrated X-rated X-Ray X's XS X-Sensual Xspana XTC Xtra X-tra Xtreme X-treme XV XVI Xvideos XVII XVIII XvsBella XX XXHoliday XXI XXII XXIV XXIX XXL XXV XXVI XXVII XXVIII xxx XXXcersices XXXercise XXXL XXX-L xxxmas XXX-mas XXXMas XXX-Mas XXXMAS XXX-MBootyacre XXX-Men XXXpert XXXplorations XXX-POSED XXX-Ray XXXtra XXX-tra XXXtream XXXtreme XXXtremely XXXX XXXXAnal Xyla y ya Yabani yacht Yachts Yaeger Yago Yah Yahshua Yahshua's Yaiselys Yaisha Yakilia Yakuza' Yalena Yalinn Yammy Yams Yana yang Yanie Yanika yank Yanka Yankee Yanker Yankers Yanking Yanna Yara Yarbles yard Yari Yas Ya's Yasmeen Yasmi Yasmim Yasmin Yasmine Yasmin's Yasmyne Yay Yaya Yda Ye yea Yeaaaah-smine yeah year Year’s Yearbook Yearly yearn yearnin Yearning yearns years year's Years Year's yearsLet Yee YeeaaaH Yeehaw Yee-haw Yekaterina Yelena yellow Yellowbeads Yellowlove Yellowpanties Yellowpanty Yellowpigs Yellowroom Yellowskirt Yellowstripes Yellowtop Yellowtoy Yellowvibrator yells Yena Yenaikoto Yenka Yenna Yenona Yep yer yes Yes? Yesenia yesterday Yesterday's yet Yet? Yew Yfimertria Yggdrasil Ygrasia Yhivi Yhivi's Yi Yieva Yiki Yillie yin Ying Yippee Yippie y'know Ylane Ylena Ymare Ymnos Ynati Ynotradiocom yo Yoanna Yodeleh Yo-Face yoga Yoga' Yogas Yoga-tta Yoghurt Yogi Yogic Yogini Yogis yogurt Yogurt1 Yogurtwo Yoha Yoha's Yoked Yokuso Yola Yola's Yollanda Yollanta YOLO Yon Yonder Yong Yoni Yooouuuuuuuu York Yorker YORKPart YORKWiredcat Yo'Self you you? you?ve You´ll You’d you’ll you’re you’ve you… youAirtight you'd youDon't you'l you'll Youll You'll youn young younger youngest Youngling Youngman Youngs Young's youngster youngsters Young'un Young'uns your youre you're Youre You're your-face-of-fest yours yourself yourself? yourself?Why youth youthful you've Youve You've Yoyo Yo-Yo Yoyowitch Yperogi yr Ysana Yu Yudi Yuffie Yui Yui's Yuki Yukki Yukon Yul Yulan Yule Yuletide Yulia Yuliana Yulia's Yuliya Yull Yulya yum Yume Yumi yummie Yummies yummy Yumtastic Yung Yunno Yuno Yup Yura Yuri Yuri's Yurizan Yurizan's Yurjen Yu's Yuzu Yveta Yvett Yvetta Yvette Yvette's Yvy Z Zaawaadi Zac Zacarra Zach Zachary Zack Zack's Zaddy Zadie's Zadyn Zaffiro Zafinia Zafira Zafira's Zafiro Zaguna Zaisha Zak Zaltana Zan Zana Zane Zania Zanita Zanmai Zanna Zanni zapped Zappers zaps Zara Zara's Zarena Zaria Zarina Zario Zarrah Zatleskej Zavese Zavites Zaya Zaydyn Zazie Zazie's Zde Zdenka Zeba Zebedee zebra Zebrawood Zecchi Zedyna Zee Zee's Zeina Zeitgeist Zeke Zelda Zelda's Zelediz Zelina Zelinsk Zelma Zelsamina Zemskaya zen Zena Zenda Zengin Zenia Zen's Zenya Zenza Zephyr Zeppelin zero Zero'd Zeroes Zero-Cat Zero-Cat-Cat Zerva zest Zesty Zeta Zeth Zexy Zhanna Zhenia Zhenya Zhu Ziba Zidane Zieda Ziggy Zilian Zilla Zille Zima Zina Zinai Zingibro zinging Zinn Zintra Zip Zipline zipper zipperand zippered zippers zippers? Zippity Zippy zips Zita Zladka Zlata Zo Zocal Zodiac Zoe Zoel Zoe's Zoey Zoey's Zofia Zoi zoie Zoli Zoliboy Zoli-casting Zolty Zolva Zolvita Zolvyta zombie Zombies Zombooty zone Zoned Zones Zoning zoo Zoom zoom-ins Zora Zoraya's Zorra Zoryana Zova Zoya Z'S Zsabina Zsanett Zsofia Zsuja Zsuza Zsuzsa Zsuzsana Zu Zucchini Zucco Zucker Zuda Zufia Zuleima Zuma Zumba Zumbas Zur Zusen Zusie Zuzana Zuzana’s Zuzana's Zuzanna Zuzantasies Zuzu Zya Zylona Zyna ZZ ZZBA ZZInc's ZZ's Сhloe ================================================ FILE: scripts/test_db_generator/studio.txt ================================================ & 1 1000 18 18videoz 1By-Day 2 21 21Sextury 3 30 3D 3DX 40 40oz 4K 50 5K 60 8th a Abella Abigail About Abroad Abused Academie Acrobats Adriano Adult Adultery Adventures Affair After Age Agent Album Aletta All AllBlackX Allstars ALS Alysa Amateurs Amber America American Analyzed Anatomik and Anetta Angel Angels Aniston Anna Apartment Archives Arrest Art Asia Asian Asians ASMR Ass Assablanca Asses at Athletics ATMovs Attack Attackers Auditions Ava Awesome B Babes Baby Babysitters Back Backroom Bad BAEB Ball Ballerinas Balls Banana Bang Bang! Bangbros Banged BBC Be Bear Bears Beautiful Beauty Beauty4K Bed Bellesa Bells Best Besuch Better BF BFF BFFs BGG Bi BiEmpire Big Bikini Birds Bites Black Blacked Blockbuster Blondes Blow Blowpass Blows Blue Bondage Bonus Boob Boobs Bookworms Booty Bootylicious Boss Bottom Bounce Bound Bounty Box Boxxx Boys Brace Bratty Brazil Brazzers Break Britney Bromo Bros Brown Brutal Bubble Buchanon Buero Bully Bums Bunnies Burning Bus Bush Bushy Busted Busty Bustyz Butt Buttman Buttplays Butts By Cam Camel Camp CamSoda Can Captain Cardiogasm Casting Casual Caught CFNM Channel Charles Chaser Cheat Cheating Checkup Cherie Cherry Chicks Chix Choking Chongas Christoph Christoph's City Clark Classic Classics Classroom Clips Club Coach Cody Coeds Coin College Colombia Competition Confessions Conrad Content Control Cop Cops Core Corvus cosplay Couch Couch-HD Couch-X Cougar Cougars Country Couples Courtesans Crashers Crazy Cream Creampie Creampies Creep Crew Cruelty Cuckold Curry Curves Cuties CZ Czech D Dadcrush Daddies Daddys Daddy's Dad's Daily Dandy Dane Danger Dangerous Dani Daniels Dare Darkko DarkX Darlings Date Daughter Daughter's Dawn Day Daydreams DDF Debt Dee Deen Deep Deeper Defiled Deluxe Dera Desire Desiree Detention Deviant Device Deville Devil's Diary Digital Dirty Disgrace Disgraced Divine Do Doctor Doe Dog Dogfart Dollars Dominated Dongs Don't Door Dorm Dose Double DP Dreams-HD Dressing Drill Drilled Driver Driving Drone Dudes Dulce Dungeon DVD Ebony Edge Eighteen Electro Elegant Elfie Emily Empire Enema Enslaved Episodes Erito Erotic Erotica EroticaX Errotica Eternal Euro Eva Eve Everything Evil Ex Exotic Experience Experiment Exploited Extras Extreme Exxtra Exxxtra F Fab Faced Faces Facial Facials Factor Factory Fake Fakehub Fam Fame Families Family Fanatics Fantasies Fantasy Fast Fat Feature Features Feet Female Femdom Ferrara Fest Fever Fiesta Fight Files Film Films Filthy Finally First Fitness Five Flesh Flipside Flix Flixxx Floor Flora Flower Focus Foot Footsie for Forever Foster Foxes Freak Freaks Frenzy Freshman Fridays Friend Friend's Frisky From Full Fun Gag-n-Gape Galanti Galore Gals Gang Gape Gapeland Gaping Gay GF GFs GG Ghetto Gina Ginger Giorgio Giorgio's Girl Girlcore Girlfriend Girlfriends Girlfriend's Girls Girlsway Give Glamkore Glasses Glory Go Gods Goes Gone Gonna Gonzo Got Grandi Grandmas Grandpas Granny Graves Grind Gulpers Gum Gym Hairy Handson Happy Hard Hardcore HardX Have HD He Heart Hellcat Hentai Her Hero High Him Hoby Hogtied Hole Home Honeys Hook Horny Hospital Hostel Hot House Housewife How Hub Huge Humpers Hunter Hunters I Icon Idol Immoral in Inch Initiations Inlaws Innocent Intermixed Interviews Invasion is Isiah it Jake James Jane Jay Jizz Joanna Joey John Johnny JOI Jones Jonni Jordan Jordi Joy Jug Jul Jules Jurassic Kathia Keiran Kelly Kendra Kings Kink Kinky Kissing Know Knows Kombat LA Lab Labs Lacy Lady Land Landry Latin latina Latinas Layna Lee Leg Legal Legs Lennon lesb Lesbea Lesbian Lesbians LesbianX Leslie Lessons Lets Let's LetsDoeIt LeWood Lexington Lez Libertines Lick Life Like Lil Limit Little Live Living Load Loads Loca Lolly Look Love Lovely Lovers Loves Low Lubed Luna LunaXJames Lust Lusty Luxury Mac Machine Machines Made Madison Mag Magical Maid Make Male Malone Mandy Mania Manuel Massage Masseur Masterpiece Matthew Mature Matures Maxwell Me Mean Meat Media Mega Men MetArt Metro Mex Miami Mickey Micky Mighty Mike Mike's Mile milehigh milf Milfed MILFs Milk Milking Minutes Mistress MixedX Mod Model Models Mofos Mom Mom’s Mommies Mommy Mommy's Moms Money Monster Monsters Moone Mouth Mouthful Mouths Movs Mr Mr. Mrs. Munch My n Nacho Naked Nanny Nasty Natural Naturals Naughty Neighbor Nerdy Net Network New Newbie News Next Nicole Night Ninjas No Nobili Noir North Not Now Nubile Nubiles NubilesET Nude Nuru NVG NylonsX Nympho O Oasis Obsession Ocean of Office Official OG Old Older Omar on Online Only Open Oral Orgasm Orgasms Orgies Orgy Originals Out Overload Oye Pain Panty Pantyhose Paper Papi Parade Parlor Parodies Parody Parties Party Pass Passenger Passion Passions Patch Patrick Patrol Pawg Pee Penny Perfect Perv Pervert Pervs Peter Petite PI Pickups Pie Pierre Pies Pimp Pimps Pink Pinko Pix Play Playground Plays Plus Pop Pops Porn Pornfidelity Porno pornportalvod Pornstar Pornstars Portal POV POVD POVGod Power ppvod9901 Premium Premiun Pretty Prime Princess Private Pro Productions Project Projects Property Pros PSE Public Punished Pure Pussy Queen Quest Quickies Rachel Racks Rammed Rampage Ranger Rapid Raw Rawcut Real Reality RealityJunkies Reckless Reislin Relaxxxed Remastered Rest Revell Revenge Rich RK Roadside Roadtrip Rocco Rodgers Room Rooms Rose Round Rub RV Rylsky Saint Same Sandy Sandy's Sapphic Saturday SC Scam Scan Scarlett School Score Scoreland Scout Screampies Screw Sean seancody Secret Seduced Seduction Seductive See Seeking Selects Self Sell Sensations Sensual Series Service Sessions Sex SexArt Sextreme Sextury Sexy Shabby Shades Shady Shape Share She Shelf Shemale Shemales Shes She's Shoot Shoplyfter Show Siblings Sides Siffredi Silvera Silverstone Silvia Sin Sinemale Sineplex Sinner Sinners Sis Sister Sister's Sites Sitters Six Skeet Sleazy Small Smuts Sneaky Soapy Socal Sofia Soft Solo Something Sophie SOS Soup Spa Spank Speculum Sperm Spermantino Spice Spoiled Sports Spy Squad Squirt Squirtalicious Squirted Squirting Stabbin Staff Star Starr Stars Steele Step Stepdad stepfam Stepmom Sticks Sticky Stories Str8 Stranded Strangers Strap Strapon Street Strokes Studies Stunning Submission Submissived Sucks Sugar Summer Sun Super Surprise Surrender Swallowed Swallows Swap Sweet Sweethearts Sweeties T&A Table Taboo Take Tales Talks Tape Tapes Tarra Taxi Teach Teacher Teal Team Teamed TeamSkeet Teasers teen Teenfidelity Teens Teeny Tera Tgirls That the Thief This Thomas Throat Throats Thugs Tied Time Times Tiny to Toe Together Tonight's Top Torment Tour Tow Toy Training Trans Transfixed TransHarder Transsensual Transsexual Trick Trickery Tricky Truck True Try Tryouts TS Tube8Vip Tucci Tugs Turning Tushy Twatter twink Twistys UK Ultimate Under Undies Uniform University Unleashed UnrelatedX Unscripted Up Upper Ups Us Vacation Valley Vault Very Vidal Video Videos Vids View Vintage VIP Virgin Virgins Vision Viv Vixen Vote vr VRT Wake Wants Watch Watching Water Way We Web Weddings Wet When Whipped White Whore Why Wicked Wide Wife Wife's Wild Will Willis Wired With Wives Women Woodman Work Working Workout World Worldwide Worship WOW x Xander X-Angels X-Art xChimera XEmpire XL XXX Year Years YNGR Yoga Young YoungBusty Younger Your Youth Zebra Zoliboy ZZ ZZplus ================================================ FILE: scripts/test_db_generator/surname.txt ================================================ A A. Aaane AB Abada Abel Abott Abramov Abrego Abril AC Acacia Acberg Accel Ace Acecaria Achora Ackerman Acon Acosta Adair Adams Adamson Adan Adara addams Addamson Addis Addison Adel Adele Adelia Adelle Adin Adjani Adkins Adley Adorable Adore Adrolino AE Aeriend Aeryne AF Affair Afrodita Agave Aghora Aguchi Aguiar Aguilar Aguilara Aguilera Aguirre Ahud Aikawa Aikens Ailo Aima Aimes Aims Ainse Aire Aisha AJ Ajali Ajauro AK Akashova Akesson Akira Akizuki Akulova Alani Alanis Alarcón Alba Albarez Albina Albright Albrite Albuquerque Alcala Alcalá Alcantara Aldamen Alegra Aleigh Alena Alencer Alessandro Alexa alexander Alexandra Alexandre Alexia Alexis Alexus Alfano Ali Alice Alien Alighatti Alii Alika Alisa Alixus Aliyeva Alize Allbutt Allen Allens Alley Allison Allure Allwood Allyn Alma Almeida Aloha Alol Alpina Alser Alta Alton Alure Alvares Alvarez Alverson Alves Alyse Alysha Amada Amanda Amandi Amante Amari Amarillo Amateurs Amatista Amato Amber Ambrose Ambrosia Ambrosio Ambrus ames Ameto Amey Amillian Amillion Amilton Amira Amo Amor Amora Amoral Amore Amorel Amorim Amorina Amour Amoure Amsel Amy Amylee An Ana Anabell Anabelle Anal Analese Anasova Anbel Anca and Anders Anderson Anderssen Andersson Andis Andrade Andrea Andrews Anelise Ange angel Angeles Angeli Angelica Angelika Angelina Angeline Angelique Angelis Angelo Angels Angora Anguita Angy Anh Anime Anisova Aniston Aniton Anjali ann Anna Anne Annoga Anomia Ansell Antala Anthony Antistia Antoinette Antonia Anya Anzai Aorta Apont Appach Appelgate Apple Applebottom Applegate Apples April Aquinas Aquino Aragon Arana Araujo Arcand Arce Arch Archer Ardell Arden Ardolino Areana Arenas Argan Argant Argiles Aria Ariadna Arial Arian Arianna Arias Ariego Aries Arina Ariza Armand Armandi Armani Armour Arnal Arnaz Arowyn Arquez Arres Arroyo Arsh Art Arwen Arya Aryna Asagi Asano Asanty Ash Ashby Ashe Asher Ashlee Ashley Ashlyn Ashton Asian Asis Askani Askara Ason Aspen Ass Assange Asset Asstrophe Asti Aston Astona Astoria Astyn Athena Athena) Atkins Attison Atwell Au Aubrey Audley Audrey August Auguste Augustine Aura Aurelia Aurelli Aurora Ausin Austen Austin Austyn Avalon Avalos Avana Avano Avanti Avary Avatari Avery Avion avluv Avni Avril Awesome Axe Axel Axx Ayers Ayn Azar Azejedo Azul Azur Azure Azz Azzure B B* B. Baba Babe Babeurre Babi Baby Bacardi Baccchi Bacci Bach Badeau Badseed Bae Baez Bailey Baileys Bailey's Baja Baker Bakhtiari Bal Bala Balcano Baleay Balentyne Bales Balestra Balezi Balian Ballerina Ballerini Ballhaus Ballixxx Balls Balouga Bam Bambi Bambina Bambola Bamby Banana Bananas Bancroft Bandera Banderas Bandini Bandit Bang Bange Bangg Bangkok Bangles Bangs Banister Bank banks Banner Bannister Banx Banxx Banxxx Baracho Barb Barbela Barber Barbi Barbie Barby Barcelona Bardeaux Bardot Bardoux Bare Baren Bargo Barjeau Barjo Barker Barnes Barnett Baron Barra Barrett Barrington Barron Barrony Barroso Barry Bartelli Barthelemy Barts Baru Barz Base Basi Basinger Baster Bastos Bateman Bates Bathory Bauer Baum Bauxx Bavel Baweric Baxter bay Bayres Baz Bazin Bb Beach Beal Bean Bear Beasley Beast Beatty Beau Beaudy Beaulieu Beautiful Beautty Beauty Beaver Beaz Beck Bee Beil Bel Bela Belgium Beli Belize bell Bella Belladonna Bellamy Bellana Belle Belli Bellick Bellini Bellis Bellisima Bello Bells Bellucci Belluci Bellz Belmont Belov Belova Beltran Belucci Bender Bendz Benett Benjamins Benji Bennet Bennett Bennette Bensi Benson Bentho Bentley Benton Bentz benz Ber Berber Beretta Berg Berger Berk Berlin Berlusconi Bermudez Bernat Berne Berriman Berrimore Berry Berrymore Berti Berton Bertoni Berty Besk Best Beth Betsy Bexley Beyle Beyn Bhai Biaggi Bianca Bianchi Bianchy Bianco Bibi Bible Bie Bieber Bieder Biel Big Bigboobs Biggs Bijou Bilas Bilberry Biliss Billard Billberry Bina Bing Binge Binx Birch Bird Bit Bitch Bitencourt Bitencourty Bitencurt Bitoni Bittencourt Bitties Bittoni Biza Bizarre Bizart Bizzare Bizzarre Bl Blaar Blac Blacc Blach Black Blackberry Blackbirdy Blackburn Blackfox Blackie Blacks Blackwell Blade Blaine Blair Blake Blakhart Blakovich Blanc Blanca Blance Blanche Blanchett Blanchette Blanco Blank Blanks Blase Blau Blayne Blaze Bleins Blendova Blessed Bleu Bleue Bleur Blew Blewitt Blige Blighe Bliss Bloh Blond Blonde Blondi Blondie Blondinka Blondson Blondy Bloom Blooms Blossom Blossoms Blow Blows Blu Blue Blueberry Blume Blun Blunt Blush Bly Bocchi Bodeva Body Boggs Bold Bombom Bombon Bon Bonbon Bond Bonds Bone Bones Bonet Bong Bonkar Bonnet Bony Boo Boob Boobies Boobs Boomer Boop Booty Borav Bordas Bordeaux Borders Borges Borghese Borja Borrelli Borres Bose Boshe Boss Bottoms Bounce Bound Bounto Bounty Bourdain Bouye Bow Bowltree Bows Boyd Boyer Br Braces Brachto Bradburry Bradentine Bradley Bradshaw Bradshow Brady Bradyn Bragg Branch Branco Brand Brandao Brandy Branson Brasil Brass Bratislava Bratt Bratty Braun Bravo Brawen Bray Brazil Brazzle Bree Breelsen Breeze Brelle Brend Brendy Brent Brett Brian Briancon Briar Brice Bricks Bridge Brielle Briggs Bright Brighton Brigitta Brika Bril Briley Brill Brinx Brite British Britni Brito Britt Brittany Britton Brix Brixton Broadway Brock Brokelyn Bronstein Bronx Brook Brooke Brookes Brooks Brookshire Broox Brown Brugal Bruni Bryant Bryce Brymova Bryne Brynn Bsg Bubble Bubbles Bubblez Buccarelli Bucci Buck Bucxxx Budai Budds Bueno Bug Bugatti Bujoli Buks Bulgari Bulgaria Bull Bullock Bulovar Bumm Bunn Bunnell Bunnington Bunny Bunz Burd Burke Burma Burnett Burning Burns Burrow Burst Burton Bush Buss Buster Butland Butt Butterfly Butterz Button Butts Byasusky Bye Byens Bynes Byrd Byrne Bysmark Bytes Byttencourt c C. Ca Cabaeva Cade Cadilac Cadillac Cadsa Cage Caimanes Cain Caine Caitlin Cajth Cake Cakes Calabrase Calabre Calanthe Caley Calibre Calienta Caliente Calis Callaway Callegary Calliaro Callipygiah Callipygian Calloway Calogera Calvert Caly Calypso Cam Camacho Cambel Cambridge Camden Cameo Cameron Camerun Cami Camila Camile Camilla Camille Campbel Campbell Campol Campos Can Canada Canali Canalis Canape Canary Cancaster Cancellieri Cancino Candi Candy Cane Canela Cannibal Cannon Cano Canon Canyon Capella Capelli Capers Capone Cappelli Capri Caprice Capris Caraballo Caracciolo Caramel Carbon Cardenas Cardinale Cardo Cardona Cardoso Cardova Cardwell Care Carey Carina Carioca Carlene Carlisle Carlisto Carlton Carmell Carmen Carmichael Carmine Caro Carolin Caroline Carpenter Carr Carrera Carrere Carrie Carrington Carris Cars Carso Carson Cartel carter Cartier Cartwright Caruso Carvajal Carvalho Cas Casanova Case Casey Cash Cashley Cashmere Casio Casper Cass Cassidi Cassidy Castaneda Castellanos Castellari Castelli Castello Castillo Castle Castro Caszo Cat Catalina Cates Catherine Catori Cats Cattiva Catwoman Caulfield Cavali Cavalli Cavanni Cazso Ce Cee Celeste Celesti Celestine Celesto Celima Cerna Cerrano Cerrutti Chachanhsy Chain Chainz Chalizo Chambers Champayne Chan Chance Chandler Chanel Chanelle Channel Channing Channson Chanson Chaos Chaplin Char Charity Charles Charleston Charlize Charlon Charlotte Charm Charmelle Charming Charms Charmz Charnelle Chase Chaster Chavez Chaynes Che Chechick Chechik Cheeks Cheri Cherie Cherrie Cherry Chery Cheshire Chevalier Chevelle Chhavi chi Chiarari Chic Chief Chika Childs Chillz Chimera China Chloe Chocky Choco Choice Christ Christal Christian Christiansen Christie Christin Christine Christy Christyn Chrivtin Chrystall Chrystin Chu Chung Chups Churcher Ciccero Cielo Cienfuegos Cindee Cinderella Cindy Cinn Cinta Ciss Clair Claire Clairette Clara Clare Clarence Clarig Clarissa Clark Clarke Clarkson Clarrise Clary Class Classy Claudia Claus Clavier Clay Clayton Clear Cleavage Clegg Clementine Cleo Clif Cline Clinton Clit clitman Cloe Close Cloud Clouds Clove Clover Clutch Clyde Coal Coast Cobain Cobra Coburn Cockold Cocks Coco Cocoa Coda Codere Cohen Cohstly Colada Colby Cold Cole Colibri Coliun Collien Collins Colombara Colt Comet Comfort Compilation Confidential Conner Conners Conny Conrad Conroy Constance Conti Contrares Contreras Convince Cool Cooper Copafeel Copper Cora Corason Corazon Cordina Cordova Core Corly Cornejo Corona Corpo Corpse Correra Cors Cortes Cortez Costa Costello Costina Coton Cotton Cougar Count Courcelles Court Courtland Courtney Couto Couture Cova Covelli Covet cox Coxx Coxxx Coxz Coyne Craft Craig Crane Crash Craven Craves Crawford Crazy Cream Creams Creamy Creepshow Crewz Crimson Crist Crista Cristal Cristaldi Cristelli Cristine Cristoff Cristy Croft Cross Crouz Crow Crowley Crown Crowne Crox Cru Crue Cruise Crunch Crush Cruss Cruz Crysstal crystal Crystalis Crystall Cucci Cuckold Cudna Cuervo Culen Cullen Cum Cumings Cumming Cummings Cummins Cummore Cummz Cums Cumstar Cumz Cunt Cupcakes Cupps Cure Curiel Curly Curran Currie Curry Curtis Curvaceous Curve Curves Cute Cutie Cuty Cuvee Cyan Cyns Cypher Cyprus Cyrus D D' D. da Dadivoso Dae Dagger Dahl Dahlia Dahmer Dai Daikira Daikiri Daily Dain Daines Daire D'Aire Daisy Daize Dajmont Dake Dakota Daley Dalhart Dali Dallas Dalong Dalton Dalush Daly Dama Damage Dames Damn Damon Damone Damour Damzel Danae Danali Dance Dancy Dane Danells D'angelo Danger Daniellas Danielle Daniels Danielsova Danika Danleku Danseuse Dantas Dantes Dantric Danu Danvers Daor Darby Dare Dark Darkley Darko Darlin Darling Darmon Dash Dashwood Dasilva D'Ass Dast Datz Dava Davai Davida Davidson Davies Davin Davinci Davis Davor Dawkins Dawn Dawsome Dawson Dax Day Daye Dayer Dayida Dayne Daysie Daza Daze D'Bouar Dcamps de Dé Dea Dean DeAngelo Dearest Dearmond Deavoux Deb Debowe Debreaux Decarlo Decker dee Deelight Deen Deep Deer Defortuna DeFrancesco DeGarcia DeGarden DeGrey Deicide Dejour del Dela Delacroix Delage Delancey Delane Delani Delanie Delano Delatori Delatossa Delatosso Delaunay Delaure Delbene Delcroix Dele Deleon Delgado Delia Delice Delicious Delight Della Dellai DellaMorte Dellatossa Delmonico Delor Delovo Delphine Delray Delrio Deltore Deluca Deluna Delux Deluxe Deluxxx Deluxxxe Demarchi Demarco Demeanor Demellza Demelzza Demer Demiko Demoan Demon Demone DeMonia Demonte Demoore Demore DeMoro Dena Denae Denise Denvile Denville Denyle De'Nyle DeRay Derek Derriere Derusky Derza Desade Desado DeSaire Desanges Desantis Desilva Desire Desiree deSoura Desouza Despedida Desrey Detalosso Dettwiller Deuce Devant Deveaux DeVell Develle Devere Devereaux Devi Devil Deville Devin Devine Devis Devita Devlin Devoe Devon Devons DeVore Devour DeVries Devy Devyne Dew Dewinter Dey Deytrois di Dia Diablo Dial Diamant Diamanti Diamond Diamonde Diamonds Diamondz Diana Diane Diangelo Dias Diaz Diazz Dicapri Dicaprio DiCarlo Dicas Dice Dickens Dickson Diego Diem Diesel DiFeo DiGivanni Dii Dikarlo Dikki Dikky Dilapri Diletto Dillan Dillion Dillon Dimarco Dimez Dimitra Dimone Dimples Diniz Dinov Dior Diore Dire Dirty Dis Diva Divan Diver Divine Divis Dixon DJ D'leigh Dlux Dobrev Dodds Doe Doggystyle Dol Dolar Dolce Dolci Doll Dollar Dolls Dollxxx Dolly Dom Dominca Domingo Dominic Dominica Dominick Dominik Domore dona Donal Donaldson Donau Doneaway Donell Donna Donovan Doore Doran Dore Doren Dorev Dori Dos Doublei Douche Douglas Doux Dova Dove Downs Draagen Drabinova Drae Dragon Drake Dream Dreams Dreamxxx Dreamz Dreems Drew Drole Droppz Dropz Drozd Drum Dryli Du Duarte Dubai Dubois Dubrova Ducati Ducatti Duchess Duke Dukes Dulce Dumaire Dumb Duncan Dunes Dunkin Dunn Dupree Dupri Duran Durganova Duro DuRose Dust Dutch Dutra Duval Duvalle Duxe Duxes D'Vine Dwaine Dylan Dymes Dynamite Dynasty E E. Eare Easily Easter Easton Easy Ebony Echo Eden Edge Edible Edison Edita Edmonds Edward Edwards Ega Eggers Eilish Eince Eisley Ekina El Elekes Elektra Eleniak Elfie Elisa Elise Eliss Elizabeth Ella Elle Elleny Ellington Ellis Ellwood Elly Ellyson Elmer Elmeritta Elson Elvgren Elyse Embers Emerald Emerson Emilia Emily Emino Emma Emmerson Empera Enali Endi England English Entice Envy Epps Erickson Escobar Esm Essance Essence Essex Estefanía Estela Estella Estelle Estrada Estrada. Et Eternity Etoile Eubanks Evan Evangeline Evans Evans&Zoe Eve Evelin Evelina Everet Everhart Everheart Evermoore Evers Everson Eves Evins Evol Exclusiv Exe Exico Express Extreme Exx Eye Eyes Ezra F F. Fabel Fabiana Facella Fae Fair Fairbanks Fairchild Fairlane Fairy Faith Fakta Falco Falcon Falcone Falk Fall Fallon Falls Faltoyano Fancy Fantanelli Fantasia Fantasy Fantazy Fantini Fara Farce Fare Farel Faris Farley Farlow Farrah Farrell Fasterova Fatale Fatally Fate Faucett Faust Fauve Faux Fawn Fawndeli Fawx Fay Faye Fayez Fe Fears Feaver Federova Feel Feeling Feels Feirah Feist Felaktig Felicity Feline Felix Fellucci Felon Felucci Femme Fendi Fendii Fenix Fenn Fenox Fer Ferara Ferard Ferari Ferera Fererro Fernandes Fernandez Ferocious ferrara Ferraren Ferrari Ferraz Ferre Ferreira Ferrer Ferrera Ferreri Ferrero Ferretti Ferri Ferry Fesser Festiny Fetti Fetucci Fevari Fever Fey Feya FG Fice Fichner Fields Fierce Fierro Fiesta Figueroa Fillmore Filth Fina Fince Fine Finish Finn Finnish Fiona Fione Fiore Fiorentino Fiori Fire Fires First Fisher Fist Fitgerald Fitness Fitzgerald Fixx Flair Flame Flames Flamez Flash Flaxy Fleiss Fletcher Fleurette Flex Flexy Flight Flimes Flint Flirt Flirts Flor Flora Florentino Flores Flori Flow Flower Flowers Flux Flynn Fodor Foirce Folass Follass Folwer Fong Fontaine Fontana Fontanelli Fontes Fontez Fontinelly Ford Fore Foreplay Forero Forever Forrest Fortuna Fortune Forza Foster Fox Foxe Foxel Foxx Foxxe foxxx Foxxxe Foxy Fraga Francesca Francis Franco Frank Franklin Frankova Franks Fraunce Fray Frazier Freak Freaxxx Freedom Freeti Freire Freitas French Fresh Frey Frias Friday Frontaine Frost Fuckdoll Fuckingham Fuentes Fukme Fuli Funki Furious Fux Fuxx Fyre Fyres G G. Gabanna Gables Gabor Gaborova Gabriel Gadget Gaga Gain Gala Galatea Galen Gales Galkina Galla Gallardo Galvez Gamble Gandi Gang Gangiev Gap Gape Gapes Garcia García Gardell Garden Gardenia Gardner Garin Garmendia Garnet Gartner Garza Gasset Gates Gatsby Gattebb Gaucha Gaucho Gaugha Gaultier Gaviria Gayle Gaynor Gee Gehtr Gelato Gem Gemes Gemini Gemma Genocide Gensen Gently Gentry George Georgia Gerald Gere Gergo Gerson Getsit Getty Getz Ghettman Ghost Gia Giacomo Giana Giancarlo Gianino Gianna Giavonni Gibbons Gibson Giepky Gil Gilbert Gill Gimenez Gin Gina Ginger Gingersnaps Ginna Giovanna Giovanni Girl Givana Givanna Givemore givens Givonna Gizmo Glace Glam Glasford Glass Glaze Gleason Glee Glide Glock Glory Glover Glower Goddard Goddess Godess Godiva Gola gold Goldberg Golden Goldeu Goldfinger Goldi Goldis Goldnerova Golfinger Golubeva Gomes Gomez Gomory Gonzales Gonzalez Good Goody Gordon Gore Gorly Gortace Gotti Gottie Gozzi Grabbit Grace Gracen Gracie Graham Grahm Grain Gram Grand Grande Grandey Grandi Granger Grant Graves Gray Grays Grazzi Great Greco Green Greene Greenvelle Gregorio Gregory Grey Greys Gri Griffin Griffith Grillo Grim Grind Grindhouse Grinds Gross Grout Grove Gruda Gucci Guerlin Guerra Guerro Guess Guillen Guitierrez Gulobeva Gun Gunn Gunner Gunns Gutierrez Gutter Guzman Gyn Gyongy H H. Ha Hadid Hadjara Hail hair Haire Haize Halborg Hale Halili Hall Hally Halo Halston Hamilton Hammer Hampton Hamsel Hana Hanah Hanes Hank Hanna Hannah Hansen Hanson Harcourt Hard Hardcore Hardin Harding Hardon Hardt Hardy Hari Harley Harllow Harlow Harmon Harper Harrington Harris Harrison Hart Hartley Hartlova Hartman Hartz Haruhi Hase Hassana Hastar Hatano Hatter Hatzi Havel Haven Havli Hawian Hawke Hawkens Hawkins Hawthorne Hay Hayden Hayes Haynes Hays Haze Hazel Heart Heartazz Heartley Heartly Hearts Heat Heather Heaven Heavens Heidi Heidy Heiress Helen Hell Hellfire Hemingway Hempburn Hempburne Hendrix Henessy Henger Hengher Hennessy Hennesy Henrix Hermosa Hernandes Hernandez Herrara Herrera Hershey Hess Heveyn Hex Hexxx Heys Hicks Hidden Hide High Highlight Hill Hills Hillton Hilson Hilton Him Himera Hix Ho Hoboy Hoffner Hohan Hoiz Hola Hole Holiday Hollan Holland Hollander Holliday Hollie Hollish Hollister Holloway Holly Hollywood Holm Holmes Holo Holsten Holt Holz Honey Honeywell Hontas Hook Hooterz Hoove Hope Hopkins Hopper Hore Horn Horne Horny Horticu Hose Hot Hotcore Hotie Hott Hottie Hotwife Houston Hovorkova Howard Howe Howell Hoyos Hoyt Hsu Hudson Hughes Hulk Humes Hunt Huntel hunter Huntington Huntley Huntsman Hurley Hurlie Hurt Hush Hussy Hustle Huxlem Huxley Hyde Hydra Hymes Hype I I. Ianova Ibarra Ibge Ibiza Ice Ices Idol Idols II Iljimae Illarionova Iloua Ilova Ilsa Imelda In Incognita Indica Indie Indy Inge Ingretton Ink Inked Inky Innaki Ireland Irene Irie Iris Irish Iron Irons Irrova Isabel Isabella Isabelle Ishtar Isis Isizzu Island Isles Ito Ivana Ivanov Ivanova Ivanovich Ivans Ive Ives Ivey Ivory Ivy J J. Jackman Jackme Jackmon Jackson Jacme Jacobs Jacusy Jade Jadorlabit Jae Jager Jagger Jaguar Jai Jaimes Jain Jaine Jakal Jakarta Jalace Jale Jam Jambo James Jameson Jamison Jamma Jammer Jamsson Jamuel Jane Jane1 Janes Janine Jano Janson Jantzen Jarako Jardelli Jark Jarw Jasmine Jasper Jax Jaxin Jaxson Jaxxx Jay Jaydan Jayde Jayden Jaye Jayme Jaymes Jayn Jayne Jayy Jazz Jazzy Jean Jefferson jem Jen Jenay Jeneu Jenkens Jenkins Jenna Jenner Jennings Jennsen Jensen Jenson Jersey Jes Jess Jessi Jessie Jessop Jessy Jessyca Jet Jett Jevaux Jewel Jewell Jewells Jewels Jewelz Jey Jezel J'Honson Jifio Jill Jiminez Jinkcego Jizz Jo Joe Joel Johanson Johanssen Johansson Johnes Johnson Johnston Joice Joker Jolee Jolei Joleigh Jolene Joli Jolie Jollee Jolye Jolyn Jones Jons Joobiez Joones Joons Jordan Jordawn Jorden Jordin Jose Joy Jubalie Jude Judge Juggs Juggsy Juice Juicy Juja Juju Jul Jule Jules Julia Juliana Julianna Julie Juliet Juliett Juliette Julliett June Jung Jungev Jungle Juniper Just Justice Justin Justine Jynx K K. Ka Kaboom Kade Kae Kage Kahil Kahill Kahlua Kahsaklahwee Kai Kailey Kain Kaine Kait Kaitu Kakes Kakku Kala Kalani Kaleb Kalermen Kali Kaliana Kalifornia Kalisy Kallos Kally Kaltava Kalvetti Kalypso Kam Kamikatze Kamil Kandy Kane Kani Kano Kaos Kapri Kaptive Kara Karamb Kardia Karel Karela Karenina Karera Karerra Kari Karina Karinena Karins Karla Karma Karmel Karr Karrera Karrs Karson Kartel Karter Kartley Karyna Kas Kasady Kasanova Kase Kash Kaslo Kassandra Kassidy Kassin Kastle Kastro Kat Kataine Katava Katchings Kate Kates Kathrin Kathryn Katie Kato Kats Katsaros Katseye Katseyes katt Kattz Katz Katzerl Kauffman Kavalli Kavelli Kay Kayden Kaye Kayne Kays Kaytlin Kayy Kaz Keagan Keahola KeAloha Kean Keat Keaton Keelings Keely Keen Keepsake Keite Kelay Kelemen Keller Kelley Kellie Kelly Kelter Kemp Ken Kendall Kendra Kendrick Kendrixx Kennedy Kenner Kensley Kent Kenya Kenyon Kenzi Kenzie Kenzington Keogh Kerez Kerkove Kerr Kerstin Kert Kes Kessler Kester Kette Keutass Key Keyes Keylar Keys Keyz Khaide Khaleesi Khalessi Khalifa Khan Kiki Kilgore Kill Killer Killman Kim Kimber Kimberly Kimm Kincaid King Kingsley Kingsly Kingsnorth kingston Kink Kinkaid Kinky Kinkycat Kinski Kinskih Kinsley Kinz Kiraly Kiray Kirei Kirschner Kiss Kisser Kisses Kit Kita Kitaine Kite Kitsuen Kitt Kitten Kitti Kittin Kitty Kittyyy Kitz Kiu Klara Klarskov Klass Klassa Klay Klaymour Kleavage Kleevage Klein Klenot Klien Klimaxxx Kline Klonk Klout Klyde Knicks Knight Knightley Knightly Knights Knite Knock Knots Knox Knoxville Knoxx Knoxxx Ko Ko. Koal Kobain Koda Kohl Koi Koks Kole Kolege Kolt Komali Kombat Komori Konec Kong Konn Kooper Koos Korbin Kord Kori Kornikova Korrine Korrs Kors Kort Kortex Korti Kortny Koshi Koshka Kostner Kot Kougar Kova Kovac Kovacs Kovicova Kox Koxx Koxxx Koyote Krabbe Kraciva Kraft Kramer Kraven Kream Kreams Kreme Kressler Krey Kriguer Kris Kriss Kristal Kristar Krit Kroff Kroft Kros Kross Krowe Krown Krush Kruz Kryk Krystal Ku Kudanfer Kuess Kulani Kumar Kummin Kummings Kums Kupcakes Kurl Kurtis Kush Kushka Kwoi Ky Kye Kyle Kyler Kyra Kyrk L L. la Labarbara labeau Labelle Labrent Lacant Lace Lacey Lacourt Lacroft Lacroix Lady LaFemmeDC Lafferty Laflare Lafouine Lafuente Lago Lahay Lahren Lai Lain Laine Laird Laistner Lakai Lake Lakehurst Laken Lakes Lali LaMann Lamante Lamar Lamas Lamb Lamberti Lambertini Lambie Lambo LamLam Lamore LaMotta Lamour L'Amour Lamoure Lamun Lamy Lan Lana Lancaster Lancer Lanchester Lancome Lander Landers Landry Lane Lanette Lanewood Lang Langdon Lange Langer Langford Langston Lani Lanik Lanz Lanza Lapiedra Laput Laren Larimar Larios Lark Larker Larkson Larn Larnock Larocca Larocco Laroche Larson Larue Larynt Lasage Lasciva Lash Lashay Lashey Lashiene Lass Lata Latenight Latex Lathania Lati Latina Latine Latrisch Latte Latvia Laure Laurel Lauren Laurence Laurenn Laurent Laurenti Lauryn Lav Lava Lavay Laveaux Laveux Lavey Lavour Law Lawless Lawrence Lawrense Lawsom Lawson Laxmi Lay Laya Layke Layn Layne Layo laysia Lazure Le lea Leaf Leafe Leah Leal Leann Leathers Lebeau Lebelle Leblanc Lebon Lebrock Lecerf LeChance Lechter Leclair Leclaire Leda lee Leeane Leeanne Leen Leesa Lefleur Leflour Legend Legends Leggy lei Leiddi Leigh Leighton Leih Leila Leima Lelani Lemay Leme Lemeat Lemmore Lemon Lemore Lemos Lena Lenee Lenix Lennon Lennox LeNoir Lenore Lenvin Leon Leone Leoni Leopard Leota Lere Lerk Les Lesabre Lesante Leshay Lesley Leslie Less Lesta Letto Letty Leve Leveah Levi Levine Levon Lew Lewa Lewis Lex Lexa Lexi Lexing Lexington Lexis Lexus Lexx Lexxx Ley Leywood Lez Lezian L-fox Li Liana Licious Licioux Lick Licks Licx Licxxx Licz Liddell Liegant Ligaya Light Lighthouse Lightman Lightspeed Ligotage Lika Like Likit Lil Lilien Lillen Lilly Lily Lima Lin Lina Linares Linarez Lincoln Linda Lindemulder Linden Lindermann Lindsay Line Ling Link Links linn Lins Linsey Linx Linz Lion Lions Lionsmane Lipoldina Lipoldino Lipps Lips Liques Liqueur Lira Lisa Lisboa Lisbon Lish Lissa Lit Lita Lite Lito Litte Little Liu Lively Lives Livia Livingston Lix Lixx Lixxx Liz Liza Lizz Lloyd Lmonde Lo Loand Loarn Loba Lobo Lobos Lobov Locke Lockett Lockhart Locks Loco Logan Lohan Loilien Lok Loki Lokky Lola Lollypop Loma Lombana Lombard Lomeli London Lone Long Longleg Longoria Loo Loop Lopes Lopez Lor Lorain Lord Lords Lore Lorelei Lorell Loren Lorena Lorenn Lorens Lorenz Lorenza Lorenzini Lorenzza Lorian Lory Lost Lotharia Lothario Lott Lotts Lotus lou Louder Louie Louis Louise Louren Lousada Louvel Lova Lovato love Lovebug Lovecox Lovecraft Lovedream Lovee Lovehands Loveitt Lovelace Loveland Lovell Lovelle Lovelna Lovelock Lovely Loven Lovenz Lover Loves Lovett Lovette Lovia Lovit Lovitt Lovly Lovska Lowden Lowe Loxx Loy Lozano Lu Lua Luana Luanna Luanne Luau Luba Luberc Lubere Luberec Lubov Luca Lucci Lucero Luchik Lucia Luciano Lucille Lucky Lucy Lui Luicy Luis Luiza Luka Luke Lukics Lulov Luna Lune Lure Luscious Lusconi Luse Lush Lushes Lusila Lussy Lust Lustra Lustt Luuna Luv Luvana Luvbug Luvgood Luvv Luwis Lux Luxa Luxe Luxia Luxx Luxxx Luz Ly Lya Lyall Lykez Lyn Lyndon Lynn Lynn&Audrey Lynna Lynne Lynx Lynxxx Lyon Lyonn Lyons Lyra M M, M. Maax Mac Maca MacArthur Macc Mace Machado Mack Mackay Macy Maddison Maddisyn Maddox Maddron Maddux Madeline Maderas Madero Madina Madisin Madison Madness Madori Madrid Madron Maduire Madysinn Mae Maers Mafra Magalhoes Magenta Magic Magical Magma Magna Magne Magnum Magnusson Maguire Maho Mai Maia Maiden Main Major Mal Mala Malai Malani Malao malati Malcolm Maldonado Malen Maley Malibu Malice Malii Malina Malkova Malle Mallone Mallory Malo Malone Malvo Manarote Manche Mancini Mandala Mandlikova Mandorla Maneater Manelli Manga Mango Manhattan Mann Mannaken Manning Manole Manroe Mansfield Mansion Manson Mansur Maracas Marbra Marc Marcean Marceau Marcela Marcell Marcella Marcelle March Marchelli Marciano Marco Marcolini Marcolliny Marconi Marcus Mare Maree Marf Marfa Marga Margo Margot Mari Maria Mariah Marie Marika Marin Marín Marinetto Mariposa Mark Markova Marks Marlee Marleigh Marley Marlow Marlowe Marques Márquez Marquise Marr Mars Marshall Martell Marti Martin Martineli Martinelli Martinelly Martines martinez Martini Martins Martix Marton Marvellou Marx Marxxx Mary Maryy Masochist Masome Mason Mass Massaro Massey Masters Masterson Mastos Mastronelli Mata Matarazo Matarazzo Mathers Matheus Mathews Mathis Matos Matthews Mattos Matty Mau Maude Maui Mauri Mavali Maver Maverick Mavi Max Maxim Maxima Maxx Maxxine Maxxx may Maya Maybach Mayde Maye Mayer Mayers Mayes Mayfair Mayhem Maylee Maylen Maylene Mayne Mayo Mays Mayson Mayweather Maywood Maz Maze Mazz Mazza Mc Mcadams McArther Mcbrian Mcbride McCaine Mccarthy Mccay Mcclain Mccray McCullough McDonald McGraw McGregor Mcgwire Mcheaven Mckay McKenna Mckenzi Mckenzie Mckinley McKinnon Mcknight Mclain McLane Mclaren McPherson McQueen McQwire Mcrae McReese Me Meadors Meadow Meadows Mebarak Mechanique Meddison Medina Meeks Meer Meg Mei Meir Mekins Mekki Mel Melano Melatti Melba Melbourne Melchoto Melendez melhasova Melissa Mell Mellow Melo Melody Melon Melone Meloni Melrose Memphis Mena Menage Menaje Mendelson Mendes Mendexz Mendez Mendini Mendiny Mendosa Mendoza Menezes Mentoni Menza Meor Meow Mercedes Mercedez Mercer Merches Merchesi Merci Mercury Mercy Merino Merlot Mey Meyer Meyers Meys Mi Mia Miami Micca Michaels Michele Michelle Michova Mico Mihaylik Mijares Mike Mikita Mikulova Milan Milana Milani Milano Mild Miles Miley Milf Milian Miliani Milk Milka Milla Millan miller Million Mills Millz Milo Milos Milson Milstar Milton Minage Minaj Minardi Minarote Minax Minerva Mink Minks Minor Minsk Mint Minx Minxxx Miny Mirage Mirai Miraj Miranda Mirova Mishel Missina Missy Mist Mistical Misty Mitchell Mitchells Miteva Miyagi Moans Moda Model Models Moeller Mohir Mohr Moire Moist Mojado Mokhov Molass Moley Moll Molloy Molly Moloe Mom Momelli Momsen Mona Monaco Monae Monaee Monaghan Mone Monela Moneli Monelli Mones Monet Monett Monica Monique Monir Monro monroe Monroe. Monrow Monroy Monster Montada Montaine Montana Monte Monteiro Montenegro Montero Montes Montez Montgomery Montoya Monus Moody Moon Moone Moonlight Moonm Moor moore Moorhead Mor Mora Moraes Morales Moran Moranai Morano Morante Morbid More Morel Morena Moreno Moretti Morgan Mori Morich Morietti Morillo Moris Morison Morna Morningstar Moroe Moroso Morr Morre Morrgan Morris Morrison Mortenroe Moss Most Moth Mounds Moura Moure Mourning Mouth Moy Mrazkova Ms Mugler Muhari Mulatto Mulino Mullato Muller Mullet Mult Muniz Munoz Munroe Mur Murdock Murphy Murray Murzuk Musa Muse Muti Myah Myers Mylee Myles Myluv Mynah Myne Mynor Mynx Mynxx Myra N N. Naaz Nabakova Nacci Nacole Nadia Naghavi Nakai Nakamo Nala Nam Name Napoli nappi Narilove Nash Nasha Nataf Natasha Nats Naughty Nava Nava' Navaro Navarro Naveah Naylor Nazionale Nea Neal Neale Nee Neeme Neha Neidth Neight Neil Neill Nek Nelle Nelson Nemyo Neni Nepal Neri nero Nerri Nerville Nesso Netta Nevada Nevadah Nevaeh Nevaril Nevena Neves Nevez Next Nicce Nice Nichole Nichols Nicholson Nickels Nicki Nicky Nicol Nicolas nicole Nicoll Nicols Nielsen Nieto Nieves Nievez Night Nightly Nike Nikea Nikita Nikitina Nikki Nikol Nikola Nikole Nikova Nikulina Niky Nikyta Nil Nila Nile Niles Nils Nilsson Nimpho Nin Nina Nine Ninfo Ninja Nirvana Nisha Nite Nitro Nitzapanus Nix Nixon Nixx Nixxx N-Joy No Nobili Noble Noel Noelle Noir Noire Noiret Noirett Noja Noletty Nona Nord Nordstrum Norhman Norman North Northern Northman Norton Norwood Notty Nouvelle Nouwelle Nova Novack Novais Novak Novea Nover Nowak Nowell Nox Noxx Nuar Nunes Nunez Nuova Nute Nutter Nyce Nymphet Nymphette Nysm Nyson Nyte Nyx o O. O’Hara Oaks Oara Obrien O'Brilliant Obscure Ocean Ocelo Ochoa O'Connell Oconnor O'Connor October O'Dare O'Dell of Official Ohaire Ohara O'Hara Ohh Oi Oily Oishi Okita Oksana Ola Olar Ole Olgalit Oliveira Oliver Olove Olovely O'lovely Olsen Olson Olsoo Olsson Omidee On One O'Neal o'neil Oneil O'neil ONeil O'Neil Oneill Onnette Onyx Oolo Opal Ophelia OQuinn Ora Orchid O'Reilley Oreilly O'reilly OReilly O'Reilly Orger Orgy Oriley O'Riley Orion O'Rion Orlov Orlowsky Orozco Orsoia Orsolya Orsoya Ortega Orth Ortiz Orto O'Ryan Oscar Oshea O'Shine Oso Ossa Osuna Othila Otis Owens Ozaki P P. Pablo Pac Pacheco Pachino Pacific Pacino Padilha Padova Paes Pag Page Pai Paige Pain Paine Paint Paisley Paiva Paixao Pajón Pak Palace Palam Palanco Palma Palmer Paloma Palomino Paltrova Panda Panigale Pantera Panther Paola Paolo Paouk Paradice Paradis Paradise Parcker Paris Parisch Parish Pariss Park Parkee Parker Parks Parrish Partem Partia Parts Parvati Parys Pasion Passion Patal Pataski Patricia Patrick Patron Patti Paul Paula Pavlova Paws pax Paxson Payne Pea Peach Peachbloom Peache Peaches Peachez Peachy Peacock Peake Peaks Pearl Pearly Pears Pearson Pedals Pee Peida Pele Pena Peña Pendavis Pendragon Penn Pepper Peralta Perdue Perez Perfekta Peridot Perl Peron Perri Perry Peta Peters Peterson Petite Petra Petrasova Petrov Petrova Pettite Phair Phamous Phellasio Phelpz Pheonix Philippe Phillip Phillips Phire Phoenix Phoxxx Phucket Phuket Phukzalot Pi Pia Piaf Piaff Piccola Pie Pierce Pierceson Pierson Pigale Piggy Pills Pimienta Pinay Pinelli Pines Pink Pinkdot Pino Pinx Piper Piperfawn Piquet Pirelli Pires Pitanga Pixi Plant Platinum Play Plays Pleasant Pleasure Pleasures Pleezer Plugaru Plum Pochetino Poison Pol Polansky Polina Poll Polynesia Pomodoro Ponce Pons pop Popova Popp Poppens Popperz Poppos Porkman Porlote Porn Porna Pornero Porsche Porshe Port Porter Portland Portman Porto Porttioli Posa Posh Possa Post Potter Poulin Powder Powell Power Powers Poz Pozzi Prada Prado Praga Prat Praud Precious Preesleyy Prego Prensley Prentice Prescott Preslee Presley Presleyy Presova preston Pretel Prettyman Price Priego Prince Princess Priscilla Prodo Project Promisita Proud Provocateur Pryce Pucci Puller Pumpkin Pumpkins Punani Punzel Puppy Pure Pureheart Purr Pussan Pussy Putri Pync Pyr Q Q. Qartel Qinu Qorrel Queen Queens Queiros Querber Quest Quicero Quin Quinn Quinno Quintana Quintero Quinteros R R. Rabbit Rabina Rachel Rachelle Racquelle Rada Rader Radeva Radke Rae Raee Raegan Rafaelli Rafail Rage Rai Raily Rain Rainbow Raine Raines Rains Rainz Raise Raketa Rako Ramada Ramirez Ramon Ramondini Ramone Ramos Rampage Rampling Randee Randy Range Rangel Ranieri Rapace Raphael Raquel Rare Rasmussen Rav Ravaged Raven Raxxx Ray Rayann Raye Rayes Rayles Raymond Rayn Rayne Raynes Rayno Rayye Raz Re Read Reagan Reagen Reamz Reaper Rebeka Rebel Rebelde Rebell Rebka Recna Red Redbird Redd Redgrave Redhart Redheart Redmond Redwood Redz Reece Reed Reeder Reene Reese Reeves Regan Regar Regency Regin Rei Reid Reif Reign Reigns Reilly Rein Reina Reinas Reindeer Reines Reise Rel Remington Ren Renae Renay Rendall Rene Renea Renee Resa Ressen Restrepo Return Revamped Reve Revees Revere Rex Rey Reyes Reyez Reynolds Rhapsody Rhea Rhios Rhoades rhodes Rhound Rhyder Rhydes Rhymes Ria Ribeiro Ribera Riberio Rica Rican Ricci Rice Rich Richards Richardsen Richardson Riches Richey Richi Richie Richman Rico Rida Riddle Ride Rider Ried Riely Riesling Riggs Right Rights Riley Rima Rimers Rimes Rin Rina Rinaldi Ring Rio Rios Rise Risi Rising Risingstar Rispoli Rita Rite Ritz Rius Riv Rival Rivas River Rivera Riveras Rivers Riviera Rix Rixel Rizel Rizzi Rizzo Roads Robbie Robbin Robbins Roberts Robins Robinson Robles Roc Roca Roccaforte Rocher Rochester Rock Rocket Rockette Rocks Rockwell Rode Rodgers Rodhes Rodrigez Rodrigues Rodriguez Rogen Rogers Rogerz Rogue Rojas Roland Roll Rolland Roller Rollings Roma Romain Roman Romance Romani Romano Romanoff Romanova Rome Romee Romero Romin Rone Roper Rosa Rosae Rosar rose Rosebug Rosee Rosembush Rosen Rosenberg Roses Ross Rossa Rosses rossi Rossini Rosso Roswell Rosy Rosz Roth Ro'ti Rotten Rotti Rotts Rouge Rough roulette Round Roundell Rouso Rouss Rousseau Rousso Rovento Row Rowe Rox Roxx Roxxx Roxy Roy Royal Royalle Royce Roze Rozker Rrock Rubi Rubia Rubin Rubio Ruby Rucka Rud Ruiz Rumor Runaway Rush Ruso Russ Russa Russel Russell Russo Russof Rutska Rx Rya Ryad ryan Ryann Ryans Rydell Ryden Ryder Rydes Rye Rylee Ryu Ryun S S. S?imonova? Saander Saase Sabadra Sabatiny Sabbatini Sabelle Sable Sabrina Saddler Sadique Sadora Sag Sage Sahara Sahari Saige Saike Saint Saint-Amour Sainz Sakala Sakova Sakura Salazar Saldana Salieri Saliery Salinas Sallai Salles Salome Salt Salvatore Salzedo Samara Samira Samora Sampaio Sampson Samson Samsonit Samsonite Samuel Samuella Samuels san Sancha Sanches Sanchez Sand Sanders Sandimas Sandobar Sandorran Sandoval Sandra Sands Sandy Sandz Sanger Sanie Sanna Santamaria Santana Santanna Sante Santee Santez Santhiago Santi Santiago Santo Santoro Santos Sanz Saphire Sapphire Sarabria Saran Sartre Sashu Satin Sativa Satynge Sauerova Sault Saunders Sauvage Savage Savoy Sawamoto Sawyer Sax Saxo Scandal Scaris Scarlet Scarlett Schiffer Schimkova Schmidt Schmitt Schnaider Schon Schulten Schultz Schwartz Schwarz Schweider Scotland Scott Scout Scream Screw Sculptura Seacrast Seagrave Sean Seart Seashell Seback Sebring Secret Secrets Secura Sedona See Segal Seiber Seikola Sekova Selice Sephora Serandon Seraph Serbia Sereas Seron Serrano Setting Sev Seva Seven Severine Sevilla Sex Sexin Sexlove Sexon Sexston Sexton Sexwick Sexx Sexxx Sexxxton Sexy Sey Seymour Shadows Shae Shaft Shagwell Shai Shain Shaine Shakti Shan Shand Shane Shanelle Shannon Shanti Shanty Shanviya Shape Sharada Sharapova Shark Sharm Sharon Sharp Sharpe Shavon Shaw Shay Shayton Shea Shee Sheilds Shelby Shell Shelson Shelton Shepard Sheperd Sheppard Sheridan Sherwood Sheryl Shevon Sheyla Shi Shibari Shibuya Shidlerova Shieffer Shield Shields Shiffer Shiin Shine Shiner Shira Shiraz Shiva Shmidt Shore Shores Short Shortcake Shorte Show Showers Shpak Shreya Shy Shye Shyn Shyne Si Siam Sianna Siberia Sid Sidney Sieb Sienna Sierra Sights Sikos Silent Siline Silk Silky Silva Silveira Silver Silvermoon Silvers Silverstone Silvia Simari Simeone Simmers Simmons Simms Simon Simone Simons Simpson Sin Sinaloa Sincere Sinclair Sinclaire Sinderson Sindy Sinead Sinfox Sinful Singer Sinister Sinkati Sinn Sinner Sinns Sins Sinsacion Sintonni Sinz Sioux Sipos Siren Sirena Sirena69 Sires Sissy Sisters Six Sixth Sixty Sixx Sixxx Skarscard Skat Skie Skies Skills Skin Skinner Skinski sky skye Skyes Skyhigh Skylar Skyler Skylight Skyline Skymm Skyy Skyye Skyz Slade Slader Slag Slager Slam Slate Slave Slem Slick Sligen Slim Slimmer Slit Sliver Sloan Sloppy Sloss Slot Slow Slutson Sly SM Smack Small Smalls Smart Smeraldi Smile Smiles Smiley Smiss Smith Smoke Smokes Smolina Smooth Smooty Smorjai Smrhova Snake Snakeoil Snezna Snow Soares Socho Sofi Soft Sokol Sol Sola Solari Solaris Solei Soleil Solis Sollis Solo Somers Sommer Sommers Sommerville Son Sonay Song Sonic Sonja Sonnet Sophia Sophie Soprano Sosa Sosh Soto Sottile Soul Souls South Southe Souza Soxton Space Spade Spades Spain Spanks Spark Sparkle Sparkles Sparks Sparkx Sparkz Sparrow Sparx Sparxx Sparxxx Spazio Spears Speed Spencer Sphinx Spice Spicy Spider Spielberg Spilona Spindrift Spinks Spinner Spirit Spirits Spirm Spit Splash Split Spreadz Spring Springer Springfield Springlare Springvalley Sprouts Spyce Squaw Squirt Ssens Ssy st St. St.Claire St.James Staar Stabone Stacey Stacks Stacxxx Stacy Stafford Stair Staks Stallion Stallone Stalls Stanwick Stanza Star Staranzano Starbuck Starbux Starfall Stark Starks Starlet Starlett Starletto Starlight Starling Starlix Starr Starshine Starxxx Starz Starzi Stax Staxx Staxxx Staylon Stclair Stclaire Steal Steale Steel Steele Stefani Steffan Steffanie Steffano Stegal Steil Stein Stello Stena Stendhall Stephanie Stephens Sterling Sterlyng Stern Stetson stevens Stevenson Stevenz Stewart Stewert Stick Stiel Stiles Stillar Stills Sting Stingrey Stoke Stokely Stokes Stokley Stoli Stone Stoned Stonem Stones Storch Storm Stotch Stoune Stovne Stowaway Strack Strahan Straletto Strange Strauss Strawberry Streb Stretton Striker Strip Stroker Strokes Strom Strong Strutt Stuart Stunner Stunns Sturm Style Styles Stylez Stylle Su Suarez Submi Suck Sudhra Sue Suede Suga Sugar Sugarcube Suicide Suizi Suka Sullivan Sultisz Sultra Sultry Summer Summers Summerz Sun Sunderland Sunn Sunny Sunrace Sunset Sunshine Sunshyne Sure Surfer Surfistinha Susi Susterman Sutani Sutra Suz Suzie Suzuki Sveltnotska Swabery Swallow Swan Swank Swann Swartz Sway Swayze Swed Swede Sweeney Sweet Sweetheart Sweets Sweetstorm Sweety Sweetz Swen Swenson Swift Swing Swinger Swit Swix Swoon SX Sy Syde Sykes Sylver Sym Symon Symone Symz Synful Synn Synns Synz Syre Sytnyy Sz Sz. Szalontai Szoke T T. Tabitha Tae Taffe Taggart Tai Tailor Talerico Tali Taliana Talise Tality Talon Talonz Talore Tamayo Tame Tames Tan Tanaka Tanelli Taner Tango Tanner Tantra Tao Taormino Tarey Tassin Tate Tatoo Tattoo Tatu Tatum Tayler taylor Taylors Tchekan Tchernei Tcherney Tea Teagan Teasdale Tease Teddy Tedesco Teen Teena Teens Teilor Teles Templar Temple Temptations Temptem Ten Tender Tequila Terra Terrace Tess Texas Teyra Thai Thames the Theron Thick Thiest Thom Thomas thompson Thomson Thomsons Thoren Thorn Thorne Thornton Threeway Thumper Thunder Thurman Tia Ticha Tickler Tiffani Tiffany Tiffian Tiger Tight Tilashi Tilden Tilli Tilly Time Tina Tinelli Tink Tinley Tiny Tion Tip Tisdale Tissen Titty Tiziano TNT 'TNT' Toc Tolimb Tom Tomankova Tomas Tomassi Tommasi Too Toren Tores Torez Tormay Torn Toro Torrance Torres Torrez Tort Torvos Toryn Toscani Tosh Touch Touche Touched Tovar Toxic Toy Trailer Tralla Tran Trasks Traveler Travers Travis Treasure Treasures Trece Trejsi Trends Trent Trevino Trickle Trimble Trinity Trip Tripp Trix Trixie Trixxx Tropic Trouble Troy True Truelove Trump Truth Truu Tsunami Tucci Tucker Tuesday Tuft Tung Tunnels Turk Turnah Turner Twain Tweeks Twice Twigs Twins Ty Tye Tyelar Tyler Tylor Tyme Tyna Tyron U U. Ula Ulalai Ully Ultra Uluvpunani Unco Underhill Underwood Undine Uneek Unicorn Unik United Units Universe Unuke Uptop Uptown Ur Urages Uri Urugua V V. Vachs Vader Vaesen Vagine Vahn Vail Vain Vaine Valami Valdes Valdez Valdovino Vale Valencia Valente Valenti Valentien Valentin Valentina Valentine Valentino Valentyne Valenzuela VALERIA Valerie Valery Valkova Valkyrie Vallem Vallery Valletta Valley Valli Vallis Valmont Valor Valour Vamp van Vanaga Vanburen vandella Vandeven Vandory Vanessa Vanguard Vanickova Vanilie Vanilla Vanilli Vanity Vanoza Vardinski Varella Varga Vargas Vargaz Varney Varya Vasili Vasques Vasquez Vaughn Vazquez Vecru Vecsey Vedem Vee Vega Vegas Vegax Veils Vein Velasques Velasquez Velazquez Velez Veliz Vella Vellons Velour Velvet Velvett Vendetta Vendetti Venera Veness Venice Venom Venter Venton Ventura Venturi Venturini Venus Vera Veracruz Verde Verdi Verene Verhooks Verla Verlant Vermillion Veroni Verricci Verrone Versac Versace Versaci Vert Verwest Vesela Vespoli Vessir Vesta Vette Vex Vi Viano Vibe Vice Vicious Vicky Victoria Victorie Victory Victress Vidal Videl Vider Vidis Vie Vieira Viera Viexen Viksi Viktoria Villa Villain Villainess Villainous Villegas Vimax Vina Vincent Vine Vinson Vintage Vinton Violations Violet Violete Violetta Violette Viper Virago Virdi Virgin Virgo Visby Viscara Visconti Viskonti Vita Vital Vitale Vitality Vitus Viva Vivas Vivian Vivienne Vix Vixen Vixin Vixon Vixxen Viya Vog Voge Vogel Vogue Voice Vokova Voldok Volkova Volpetti Volt von Vonage VonDoom Voneva Vonn Voo Vood Vore Vortex Voss Vosse Vouge Vox Vox, Voxx Voxxx Voya Voyager Vrea Vs Vs. Vue Vuiton Vuitton Vution W W. Waay Wade Wagner Waine Wais Waist Walack Wales Walker Wallace Walner Wan Wander Wane Wank Want Ward Ware warner Warren Washington Wasse Waste Water Waterfall Waters Watson Wattana Wayne Ways Wayy Weasley Webb Weigel Weiss Weix Welch Well Welles Wellin Wellness Wellons Wells Wels Wenera West Westgate Westin Westley Weston Westsun Wet Wetsx Wett Weyron Whiley Whiskey Whisper Whispers White White-Kitten Whites Whorehall Whyte Wicky Widdow Widow Wienold Wiesenthal Wiggum Wil wild Wilde Wildee Wilder Wildman Wildwood Wiley Wilkes Will William Williams Willis Wilson Wimberly Wind Windsor Wine Winfe Wings Winks Winslett Winston Winter Winters Wipper Wire Wish Wisky Wissental Witch Withe Wolf Wolfe Wolff Wolfox Wolk Womba Wonder Wonderful Wonderland Wonders Woo Wood Woods Woodward Woodyhaven Woodz Woor Worder World Worth Worthington Worthy Woulfe Wow Wowgue Wren Wright Writes Wulam Wulf Wumps Wuze Wylde Wylder Wynn Wynne Wynter Wynters Wyte X X. Xandra Xcite Xexe Xiji Xinga Xo Xore XoXo XX XXL Xxx Y Y. Yamagucci Yamamoto Yande Yang Yani Yankovskaya Yasmin Yasmine Yayo Yen Yi Yin Yof Yohansson Yoko York Youn Young Youngs Yoyo Yoyowitch Yoyre Yu Yuki Yul Yulan Yummy Yung Z Z. Zaguna Zales Zalicova Zaltana Zan Zane Zara Zatch Zdrok Zecchi Zee Zemanova Zen Zero Zerva Zet Zeta Zeus Zex Zhora Ziana Zima Zinn Zo Zoe Zola Zolva Zoom Zora Zova Zur Zurich Zvezda Zya ================================================ FILE: tools.go ================================================ //go:build tools // +build tools package main import ( _ "github.com/99designs/gqlgen" _ "github.com/99designs/gqlgen/graphql/introspection" _ "github.com/Yamashou/gqlgenc" _ "github.com/vektah/dataloaden" _ "github.com/vektra/mockery/v2" ) ================================================ FILE: ui/login/login.css ================================================ /* try to reflect the default css as much as possible */ * { box-sizing: border-box; } html { font-size: 14px; } body { background-color: #202b33; color: #f5f8fa; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; margin: 0; padding: 0; overflow-y: hidden; } h6 { font-size: 1rem; margin-top: 0; margin-bottom: .5rem; font-weight: 500; line-height: 1.2; } button, input { margin: 0; font-family: inherit; font-size: inherit; line-height: inherit; } .card { background-color: #30404d; border-radius: 3px; box-shadow: 0 0 0 1px rgba(16,22,26,.4), 0 0 0 rgba(16,22,26,0), 0 0 0 rgba(16,22,26,0); padding: 20px; } .dialog { display: flex; align-items: center; justify-content: center; width: 100%; height: 100vh; padding-right: 15px; padding-left: 15px; margin-right: auto; margin-left: auto; } .form-group { margin-bottom: 1rem; } .form-control { display: block; width: 100%; height: calc(1.5em + .75rem + 2px); padding: .375rem .75rem; font-size: 1rem; font-weight: 400; line-height: 1.5; color: #495057; background-clip: padding-box; border: 1px solid #ced4da; border-radius: .25rem; -webkit-transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out; transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out; } .text-input { border: 0; box-shadow: 0 0 0 0 rgba(19,124,189,0), 0 0 0 0 rgba(19,124,189,0), 0 0 0 0 rgba(19,124,189,0), inset 0 0 0 1px rgba(16,22,26,.3), inset 0 1px 1px rgba(16,22,26,.4); color: #f5f8fa; } .text-input, .text-input:focus, .text-input[readonly] { background-color: rgba(16,22,26,.3); } .btn { display: inline-block; font-weight: 400; color: #212529; text-align: center; vertical-align: middle; cursor: pointer; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; background-color: initial; border: 1px solid transparent; padding: .375rem .75rem; font-size: 1rem; line-height: 1.5; border-radius: .25rem; -webkit-transition: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out; transition: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out; } .btn-primary { color: #fff; background-color: #137cbd; border-color: #137cbd; } .login-error { color: #db3737; font-size: 80%; font-weight: 500; padding-bottom: 1rem; } @media (max-width: 576px) { .card { width: 100%; } .dialog { height: auto; margin-top: 50%; } .btn-primary { width: 100%; } } ================================================ FILE: ui/login/login.html ================================================ Login
================================================ FILE: ui/ui.go ================================================ //go:generate go run -tags=dev ../scripts/generateLoginLocales.go package ui import ( "embed" "io/fs" "runtime" ) //go:embed v2.5/build var uiBox embed.FS var UIBox fs.FS //go:embed login var loginUIBox embed.FS var LoginUIBox fs.FS func init() { var err error UIBox, err = fs.Sub(uiBox, "v2.5/build") if err != nil { panic(err) } LoginUIBox, err = fs.Sub(loginUIBox, "login") if err != nil { panic(err) } } type faviconProvider struct{} var FaviconProvider = faviconProvider{} func (p *faviconProvider) GetFavicon() []byte { if runtime.GOOS == "windows" { ret, _ := fs.ReadFile(UIBox, "favicon.ico") return ret } return p.GetFaviconPng() } func (p *faviconProvider) GetFaviconPng() []byte { ret, _ := fs.ReadFile(UIBox, "favicon.png") return ret } ================================================ FILE: ui/v2.5/.editorconfig ================================================ root = true [*] charset = utf-8 end_of_line = lf indent_style = space indent_size = 2 insert_final_newline = true trim_trailing_whitespace = true [*.md] trim_trailing_whitespace = false ================================================ FILE: ui/v2.5/.eslintrc.json ================================================ { "env": { "browser": true }, "parser": "@typescript-eslint/parser", "parserOptions": { "project": "./tsconfig.json" }, "plugins": ["@typescript-eslint", "jsx-a11y"], "extends": [ "airbnb-typescript", "plugin:import/recommended", "plugin:react/recommended", "plugin:react/jsx-runtime", "airbnb/hooks", "prettier" ], "settings": { "react": { "version": "detect" } }, "ignorePatterns": [ "node_modules/", "src/core/generated-graphql.ts", "src/pluginApi.d.ts" ], "rules": { "@typescript-eslint/lines-between-class-members": "off", "@typescript-eslint/naming-convention": [ "error", { "selector": "interface", "format": ["PascalCase"], "custom": { "regex": "^I[A-Z]", "match": true } } ], "@typescript-eslint/no-explicit-any": 2, "@typescript-eslint/no-use-before-define": [ "error", { "functions": false, "classes": false } ], "import/extensions": [ "error", "ignorePackages", { "js": "never", "jsx": "never", "ts": "never", "tsx": "never" } ], "import/named": "off", "import/namespace": "off", "import/no-unresolved": "off", "lines-between-class-members": "off", "no-nested-ternary": "off", "prefer-destructuring": [ "error", { "VariableDeclarator": { "array": false, "object": true }, "AssignmentExpression": { "array": false, "object": false } } ], "react/display-name": "off", "react/prop-types": "off", "react/style-prop-object": ["error", { "allow": ["FormattedNumber"] }], "spaced-comment": ["error", "always", { "markers": ["/"] }] } } ================================================ FILE: ui/v2.5/.prettierignore ================================================ *.md # dependencies /node_modules pnpm-lock.yaml pnpm-workspace.yaml # locales src/locales/**/*.json # testing /coverage # production /build # generated src/core/generated-graphql.ts ================================================ FILE: ui/v2.5/.stylelintrc ================================================ { "plugins": ["stylelint-order"], "customSyntax": "postcss-scss", "rules": { "at-rule-empty-line-before": [ "always", { "except": ["after-same-name", "first-nested"], "ignore": ["after-comment"] } ], "at-rule-no-vendor-prefix": true, "selector-no-vendor-prefix": true, "block-no-empty": true, "color-hex-length": "short", "color-no-invalid-hex": true, "comment-empty-line-before": [ "always", { "except": ["first-nested"], "ignore": ["stylelint-commands"] } ], "comment-whitespace-inside": "always", "declaration-block-no-shorthand-property-overrides": true, "declaration-block-single-line-max-declarations": 1, "declaration-no-important": true, "font-family-name-quotes": "always-where-recommended", "function-calc-no-unspaced-operator": true, "function-linear-gradient-no-nonstandard-direction": true, "function-url-quotes": "always", "length-zero-no-unit": true, "max-nesting-depth": 4, "no-descending-specificity": null, "no-invalid-double-slash-comments": true, "number-max-precision": 3, "order/order": ["custom-properties", "declarations"], "order/properties-alphabetical-order": true, "rule-empty-line-before": [ "always-multi-line", { "except": ["after-single-line-comment", "first-nested"], "ignore": ["after-comment"] } ], "selector-max-id": 1, "selector-max-type": 2, "selector-class-pattern": "^(\\.*[A-Z]*[a-z]+)+(-[a-z0-9]+)*$", "selector-max-universal": 0, "selector-type-case": "lower", "selector-pseudo-element-colon-notation": "double", "string-no-newline": true, "time-min-milliseconds": 100 } } ================================================ FILE: ui/v2.5/README.md ================================================ This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). ## Available Scripts In the project directory, you can run: ### `npm run start` Runs the app in the development mode.
Open [http://localhost:3000](http://localhost:3000) to view it in the browser. The page will reload if you make edits.
You will also see any lint errors in the console. ### `npm run test` Launches the test runner in the interactive watch mode.
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. ### `npm run build` Builds the app for production to the `build` folder.
It correctly bundles React in production mode and optimizes the build for the best performance. The build is minified and the filenames include the hashes.
Your app is ready to be deployed! See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. ### `npm run format` Formats the whitespace of all typescript and scss code with prettier, to ease editing and ensure a common code style. Should ideally be run before all frontend PRs. ### `npm run eject` **Note: this is a one-way operation. Once you `eject`, you can’t go back!** If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. ## Learn More You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). To learn React, check out the [React documentation](https://reactjs.org/). ### Code Splitting This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting ### Analyzing the Bundle Size This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size ### Making a Progressive Web App This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app ### Advanced Configuration This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration ### Deployment This section has moved here: https://facebook.github.io/create-react-app/docs/deployment ### `npm run build` fails to minify This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify ================================================ FILE: ui/v2.5/codegen.ts ================================================ import type { CodegenConfig } from "@graphql-codegen/cli"; const config: CodegenConfig = { schema: [ "../../graphql/schema/**/*.graphql", "graphql/client-schema.graphql", ], config: { // makes conflicting fields override rather than error onFieldTypeConflict: (_existing: unknown, other: unknown) => other, }, documents: "graphql/**/*.graphql", generates: { "src/core/generated-graphql.ts": { plugins: [ "time", "typescript", "typescript-operations", "typescript-react-apollo", ], config: { strictScalars: true, scalars: { Time: "string", Timestamp: "string", Map: "{ [key: string]: unknown }", BoolMap: "{ [key: string]: boolean }", PluginConfigMap: "{ [id: string]: { [key: string]: unknown } }", Any: "unknown", Int64: "number", Upload: "File", UIConfig: "src/core/config#IUIConfig", SavedObjectFilter: "src/models/list-filter/types#SavedObjectFilter", SavedUIOptions: "src/models/list-filter/types#SavedUIOptions", }, withRefetchFn: true, }, }, }, }; export default config; ================================================ FILE: ui/v2.5/graphql/client-schema.graphql ================================================ scalar UIConfig scalar SavedObjectFilter scalar SavedUIOptions extend type ConfigResult { ui: UIConfig! } extend type SavedFilter { object_filter: SavedObjectFilter ui_options: SavedUIOptions } extend input SaveFilterInput { object_filter: SavedObjectFilter ui_options: SavedUIOptions } extend type Mutation { configureUI(input: Map, partial: Map): UIConfig! } ================================================ FILE: ui/v2.5/graphql/data/config.graphql ================================================ fragment ConfigGeneralData on ConfigGeneralResult { stashes { path excludeVideo excludeImage } databasePath backupDirectoryPath deleteTrashPath generatedPath metadataPath scrapersPath pluginsPath cachePath blobsPath blobsStorage ffmpegPath ffprobePath calculateMD5 videoFileNamingAlgorithm parallelTasks previewAudio previewSegments previewSegmentDuration previewExcludeStart previewExcludeEnd previewPreset transcodeHardwareAcceleration maxTranscodeSize maxStreamingTranscodeSize writeImageThumbnails createImageClipsFromVideos apiKey username password maxSessionAge logFile logOut logLevel logAccess logFileMaxSize useCustomSpriteInterval spriteInterval minimumSprites maximumSprites spriteScreenshotSize createGalleriesFromFolders galleryCoverRegex videoExtensions imageExtensions galleryExtensions excludes imageExcludes customPerformerImageLocation stashBoxes { name endpoint api_key max_requests_per_minute } pythonPath transcodeInputArgs transcodeOutputArgs liveTranscodeInputArgs liveTranscodeOutputArgs drawFunscriptHeatmapRange scraperPackageSources { name url local_path } pluginPackageSources { name url local_path } } fragment ConfigInterfaceData on ConfigInterfaceResult { sfwContentMode menuItems soundOnPreview wallShowTitle wallPlayback showScrubber maximumLoopDuration noBrowser notificationsEnabled autostartVideo autostartVideoOnPlaySelected continuePlaylistDefault showStudioAsText css cssEnabled javascript javascriptEnabled customLocales customLocalesEnabled disableCustomizations language imageLightbox { slideshowDelay displayMode scaleUp resetZoomOnNav scrollMode scrollAttemptsBeforeChange disableAnimation } disableDropdownCreate { performer tag studio movie gallery } handyKey funscriptOffset useStashHostedFunscript } fragment ConfigDLNAData on ConfigDLNAResult { serverName enabled port whitelistedIPs interfaces videoSortOrder } fragment ConfigScrapingData on ConfigScrapingResult { scraperUserAgent scraperCertCheck scraperCDPPath excludeTagPatterns } fragment IdentifyFieldOptionsData on IdentifyFieldOptions { field strategy createMissing } fragment IdentifyMetadataOptionsData on IdentifyMetadataOptions { fieldOptions { ...IdentifyFieldOptionsData } setCoverImage setOrganized performerGenders skipMultipleMatches skipMultipleMatchTag skipSingleNamePerformers skipSingleNamePerformerTag } fragment ScraperSourceData on ScraperSource { stash_box_index stash_box_endpoint scraper_id } fragment ConfigDefaultSettingsData on ConfigDefaultSettingsResult { scan { # don't get rescan - it should never be defaulted to true scanGenerateCovers scanGeneratePreviews scanGenerateImagePreviews scanGenerateSprites scanGeneratePhashes scanGenerateThumbnails scanGenerateClipPreviews } identify { sources { source { ...ScraperSourceData } options { ...IdentifyMetadataOptionsData } } options { ...IdentifyMetadataOptionsData } } autoTag { performers studios tags } generate { covers sprites previews imagePreviews previewOptions { previewSegments previewSegmentDuration previewExcludeStart previewExcludeEnd previewPreset } markers markerImagePreviews markerScreenshots transcodes phashes interactiveHeatmapsSpeeds clipPreviews imageThumbnails } deleteFile deleteGenerated } fragment ConfigData on ConfigResult { general { ...ConfigGeneralData } interface { ...ConfigInterfaceData } dlna { ...ConfigDLNAData } scraping { ...ConfigScrapingData } defaults { ...ConfigDefaultSettingsData } ui plugins } ================================================ FILE: ui/v2.5/graphql/data/file.graphql ================================================ fragment FolderData on Folder { id basename path } fragment VideoFileData on VideoFile { id path size mod_time duration video_codec audio_codec width height frame_rate bit_rate fingerprints { type value } } fragment ImageFileData on ImageFile { id path size mod_time width height fingerprints { type value } } fragment GalleryFileData on GalleryFile { id path size mod_time fingerprints { type value } } fragment VisualFileData on VisualFile { ... on BaseFile { id path size mod_time fingerprints { type value } } ... on ImageFile { id path size mod_time width height fingerprints { type value } } ... on VideoFile { id path size mod_time duration video_codec audio_codec width height frame_rate bit_rate fingerprints { type value } } } fragment SelectFolderData on Folder { id path basename } fragment RecursiveFolderData on Folder { ...SelectFolderData parent_folders { ...SelectFolderData } } ================================================ FILE: ui/v2.5/graphql/data/filter.graphql ================================================ fragment SavedFilterData on SavedFilter { id mode name find_filter { q page per_page sort direction } object_filter ui_options } ================================================ FILE: ui/v2.5/graphql/data/gallery-chapter.graphql ================================================ fragment GalleryChapterData on GalleryChapter { id title image_index gallery { id } } ================================================ FILE: ui/v2.5/graphql/data/gallery-slim.graphql ================================================ fragment SlimGalleryData on Gallery { id title code date urls details photographer rating100 organized files { ...GalleryFileData } folder { ...FolderData } image_count chapters { id title image_index } studio { id name image_path } tags { id name } performers { id name gender favorite image_path } scenes { ...SlimSceneData } paths { cover preview } } ================================================ FILE: ui/v2.5/graphql/data/gallery.graphql ================================================ fragment GalleryData on Gallery { id created_at updated_at title code date urls details photographer rating100 organized paths { cover preview } files { ...GalleryFileData } folder { ...FolderData } image_count chapters { ...GalleryChapterData } studio { ...SlimStudioData } tags { ...SlimTagData } performers { ...PerformerData } scenes { ...SlimSceneData } custom_fields } fragment SelectGalleryData on Gallery { id title date code studio { name } cover { paths { thumbnail } } paths { preview } files { path } folder { path } } ================================================ FILE: ui/v2.5/graphql/data/group-slim.graphql ================================================ fragment SlimGroupData on Group { id name front_image_path rating100 } fragment SelectGroupData on Group { id name aliases date studio { name } front_image_path } ================================================ FILE: ui/v2.5/graphql/data/group.graphql ================================================ # Full fragment for detail views - includes recursive counts fragment GroupData on Group { id name aliases duration date rating100 director studio { ...SlimStudioData } tags { ...SlimTagData } containing_groups { group { ...SlimGroupData } description } synopsis urls front_image_path back_image_path scene_count scene_count_all: scene_count(depth: -1) performer_count performer_count_all: performer_count(depth: -1) sub_group_count sub_group_count_all: sub_group_count(depth: -1) o_counter scenes { id title } custom_fields } # Lightweight fragment for list views - excludes expensive recursive counts # The _all fields (depth: -1) cause 10+ second queries on large databases fragment ListGroupData on Group { id name aliases duration date rating100 director studio { ...SlimStudioData } tags { ...SlimTagData } containing_groups { group { ...SlimGroupData } description } synopsis urls front_image_path back_image_path scene_count performer_count sub_group_count o_counter scenes { id title } } ================================================ FILE: ui/v2.5/graphql/data/image-slim.graphql ================================================ fragment SlimImageData on Image { id title code date urls details photographer rating100 organized o_counter paths { thumbnail preview image } galleries { id title files { path } folder { path } } studio { id name image_path } tags { id name } performers { id name gender favorite image_path } visual_files { ...VisualFileData } } ================================================ FILE: ui/v2.5/graphql/data/image.graphql ================================================ fragment ImageData on Image { id title code rating100 date urls details photographer organized o_counter created_at updated_at paths { thumbnail preview image } galleries { ...GalleryData } studio { ...SlimStudioData } tags { ...SlimTagData } performers { ...PerformerData } visual_files { ...VisualFileData } custom_fields } ================================================ FILE: ui/v2.5/graphql/data/job.graphql ================================================ fragment JobData on Job { id status subTasks description progress startTime endTime addTime error } ================================================ FILE: ui/v2.5/graphql/data/log.graphql ================================================ fragment LogEntryData on LogEntry { time level message } ================================================ FILE: ui/v2.5/graphql/data/package.graphql ================================================ fragment PackageData on Package { package_id name version date metadata sourceURL } ================================================ FILE: ui/v2.5/graphql/data/performer-slim.graphql ================================================ fragment SlimPerformerData on Performer { id name disambiguation gender urls image_path favorite ignore_auto_tag country birthdate ethnicity hair_color eye_color height_cm fake_tits penis_length circumcised career_start career_end tattoos piercings alias_list tags { id name } stash_ids { endpoint stash_id updated_at } rating100 death_date weight } fragment SelectPerformerData on Performer { id name disambiguation alias_list image_path birthdate death_date } ================================================ FILE: ui/v2.5/graphql/data/performer.graphql ================================================ fragment PerformerData on Performer { id name disambiguation urls gender birthdate ethnicity country eye_color height_cm measurements fake_tits penis_length circumcised career_start career_end tattoos piercings alias_list favorite ignore_auto_tag image_path scene_count image_count gallery_count group_count performer_count o_counter tags { ...SlimTagData } stash_ids { stash_id endpoint updated_at } rating100 details death_date hair_color weight custom_fields } ================================================ FILE: ui/v2.5/graphql/data/scene-marker.graphql ================================================ fragment SceneMarkerData on SceneMarker { id title seconds end_seconds stream preview screenshot scene { ...SceneMarkerSceneData } primary_tag { id name } tags { id name } } fragment SceneMarkerSceneData on Scene { id title files { width height path } performers { id name image_path } } ================================================ FILE: ui/v2.5/graphql/data/scene-slim.graphql ================================================ fragment SlimSceneData on Scene { id title code details director urls date rating100 o_counter organized interactive interactive_speed resume_time play_duration play_count files { ...VideoFileData } paths { screenshot preview stream webp vtt sprite funscript interactive_heatmap caption } scene_markers { id title seconds primary_tag { id name } } galleries { id files { path } folder { path } title } studio { id name image_path } groups { group { id name front_image_path } scene_index } tags { id name } performers { id name disambiguation gender favorite image_path } stash_ids { endpoint stash_id updated_at } } ================================================ FILE: ui/v2.5/graphql/data/scene.graphql ================================================ fragment SceneData on Scene { id title code details director urls date rating100 o_counter organized interactive interactive_speed captions { language_code caption_type } created_at updated_at resume_time last_played_at play_duration play_count play_history o_history files { ...VideoFileData } paths { screenshot preview stream webp vtt sprite funscript interactive_heatmap caption } scene_markers { ...SceneMarkerData } galleries { ...SlimGalleryData } studio { ...SlimStudioData } groups { group { ...GroupData } scene_index } tags { ...SlimTagData } performers { ...PerformerData } stash_ids { endpoint stash_id updated_at } sceneStreams { url mime_type label } custom_fields } fragment SelectSceneData on Scene { id title date code studio { name } files { path } paths { screenshot } } ================================================ FILE: ui/v2.5/graphql/data/scrapers.graphql ================================================ fragment ScrapedStudioData on ScrapedStudio { stored_id name urls parent { stored_id name urls image details aliases tags { ...ScrapedSceneTagData } remote_site_id } image details aliases tags { ...ScrapedSceneTagData } remote_site_id } fragment ScrapedPerformerData on ScrapedPerformer { stored_id name disambiguation gender urls birthdate ethnicity country eye_color height measurements fake_tits penis_length circumcised career_start career_end tattoos piercings aliases tags { ...ScrapedSceneTagData } images details death_date hair_color weight remote_site_id } fragment ScrapedScenePerformerData on ScrapedPerformer { stored_id name disambiguation gender urls birthdate ethnicity country eye_color height measurements fake_tits penis_length circumcised career_start career_end tattoos piercings aliases tags { ...ScrapedSceneTagData } remote_site_id images details death_date hair_color weight } fragment ScrapedGroupStudioData on ScrapedStudio { stored_id name urls } fragment ScrapedGroupData on ScrapedGroup { name aliases duration date rating director urls synopsis front_image back_image studio { ...ScrapedGroupStudioData } tags { ...ScrapedSceneTagData } } fragment ScrapedSceneGroupData on ScrapedGroup { stored_id name aliases duration date rating director urls synopsis front_image back_image studio { ...ScrapedGroupStudioData } tags { ...ScrapedSceneTagData } } fragment ScrapedSceneStudioData on ScrapedStudio { stored_id name urls parent { stored_id name urls image details aliases tags { ...ScrapedSceneTagData } remote_site_id } image details aliases tags { ...ScrapedSceneTagData } remote_site_id } fragment ScrapedSceneTagData on ScrapedTag { stored_id name description alias_list parent { stored_id name description } remote_site_id } fragment ScrapedSceneData on ScrapedScene { title code details director urls date image remote_site_id file { size duration video_codec audio_codec width height framerate bitrate } studio { ...ScrapedSceneStudioData } tags { ...ScrapedSceneTagData } performers { ...ScrapedScenePerformerData } groups { ...ScrapedSceneGroupData } fingerprints { hash algorithm duration } } fragment ScrapedGalleryData on ScrapedGallery { title code details urls photographer date studio { ...ScrapedSceneStudioData } tags { ...ScrapedSceneTagData } performers { ...ScrapedScenePerformerData } } fragment ScrapedImageData on ScrapedImage { title code details photographer urls date studio { ...ScrapedSceneStudioData } tags { ...ScrapedSceneTagData } performers { ...ScrapedScenePerformerData } } fragment ScrapedStashBoxSceneData on ScrapedScene { title code details director url date image remote_site_id duration file { size duration video_codec audio_codec width height framerate bitrate } fingerprints { hash algorithm duration } studio { ...ScrapedSceneStudioData } tags { ...ScrapedSceneTagData } performers { ...ScrapedScenePerformerData } groups { ...ScrapedSceneGroupData } } fragment ScrapedStashBoxPerformerData on StashBoxPerformerQueryResult { query results { ...ScrapedScenePerformerData } } ================================================ FILE: ui/v2.5/graphql/data/studio-slim.graphql ================================================ fragment SlimStudioData on Studio { id name image_path stash_ids { endpoint stash_id updated_at } parent_studio { id } details rating100 aliases tags { id name } favorite ignore_auto_tag organized o_counter } ================================================ FILE: ui/v2.5/graphql/data/studio.graphql ================================================ fragment StudioData on Studio { id name url urls parent_studio { id name url urls image_path } child_studios { id name image_path } ignore_auto_tag organized image_path scene_count scene_count_all: scene_count(depth: -1) image_count image_count_all: image_count(depth: -1) gallery_count gallery_count_all: gallery_count(depth: -1) performer_count performer_count_all: performer_count(depth: -1) group_count group_count_all: group_count(depth: -1) stash_ids { stash_id endpoint updated_at } details rating100 favorite aliases tags { ...SlimTagData } o_counter custom_fields } fragment SelectStudioData on Studio { id name aliases details image_path parent_studio { id name } } ================================================ FILE: ui/v2.5/graphql/data/tag-slim.graphql ================================================ fragment SlimTagData on Tag { id name sort_name aliases image_path parent_count child_count stash_ids { endpoint stash_id updated_at } } ================================================ FILE: ui/v2.5/graphql/data/tag.graphql ================================================ fragment TagData on Tag { id name sort_name description aliases ignore_auto_tag favorite stash_ids { endpoint stash_id updated_at } image_path scene_count scene_count_all: scene_count(depth: -1) scene_marker_count scene_marker_count_all: scene_marker_count(depth: -1) image_count image_count_all: image_count(depth: -1) gallery_count gallery_count_all: gallery_count(depth: -1) performer_count performer_count_all: performer_count(depth: -1) studio_count studio_count_all: studio_count(depth: -1) group_count group_count_all: group_count(depth: -1) parents { ...SlimTagData } children { ...SlimTagData } custom_fields } fragment SelectTagData on Tag { id name sort_name favorite description aliases image_path parents { id name sort_name } stash_ids { endpoint stash_id updated_at } } # Optimized fragment for tag list page - excludes expensive recursive *_count_all fields fragment TagListData on Tag { id name sort_name description aliases ignore_auto_tag favorite stash_ids { endpoint stash_id updated_at } image_path # Direct counts only - no recursive depth queries scene_count scene_marker_count image_count gallery_count performer_count studio_count group_count parents { ...SlimTagData } children { ...SlimTagData } } ================================================ FILE: ui/v2.5/graphql/mutations/config.graphql ================================================ mutation Setup($input: SetupInput!) { setup(input: $input) } mutation Migrate($input: MigrateInput!) { migrate(input: $input) } mutation DownloadFFMpeg { downloadFFMpeg } mutation ConfigureGeneral($input: ConfigGeneralInput!) { configureGeneral(input: $input) { ...ConfigGeneralData } } mutation ConfigureInterface($input: ConfigInterfaceInput!) { configureInterface(input: $input) { ...ConfigInterfaceData } } mutation ConfigureDLNA($input: ConfigDLNAInput!) { configureDLNA(input: $input) { ...ConfigDLNAData } } mutation ConfigureScraping($input: ConfigScrapingInput!) { configureScraping(input: $input) { ...ConfigScrapingData } } mutation ConfigureDefaults($input: ConfigDefaultSettingsInput!) { configureDefaults(input: $input) { ...ConfigDefaultSettingsData } } mutation ConfigureUI($input: Map, $partial: Map) { configureUI(input: $input, partial: $partial) } mutation ConfigureUISetting($key: String!, $value: Any) { configureUISetting(key: $key, value: $value) } mutation GenerateAPIKey($input: GenerateAPIKeyInput!) { generateAPIKey(input: $input) } ================================================ FILE: ui/v2.5/graphql/mutations/dlna.graphql ================================================ mutation EnableDLNA($input: EnableDLNAInput!) { enableDLNA(input: $input) } mutation DisableDLNA($input: DisableDLNAInput!) { disableDLNA(input: $input) } mutation AddTempDLNAIP($input: AddTempDLNAIPInput!) { addTempDLNAIP(input: $input) } mutation RemoveTempDLNAIP($input: RemoveTempDLNAIPInput!) { removeTempDLNAIP(input: $input) } ================================================ FILE: ui/v2.5/graphql/mutations/file.graphql ================================================ mutation DeleteFiles($ids: [ID!]!) { deleteFiles(ids: $ids) } mutation RevealFileInFileManager($id: ID!) { revealFileInFileManager(id: $id) } mutation RevealFolderInFileManager($id: ID!) { revealFolderInFileManager(id: $id) } ================================================ FILE: ui/v2.5/graphql/mutations/filter.graphql ================================================ mutation SaveFilter($input: SaveFilterInput!) { saveFilter(input: $input) { ...SavedFilterData } } mutation DestroySavedFilter($input: DestroyFilterInput!) { destroySavedFilter(input: $input) } ================================================ FILE: ui/v2.5/graphql/mutations/gallery-chapter.graphql ================================================ mutation GalleryChapterCreate( $title: String! $image_index: Int! $gallery_id: ID! ) { galleryChapterCreate( input: { title: $title, image_index: $image_index, gallery_id: $gallery_id } ) { ...GalleryChapterData } } mutation GalleryChapterUpdate( $id: ID! $title: String! $image_index: Int! $gallery_id: ID! ) { galleryChapterUpdate( input: { id: $id title: $title image_index: $image_index gallery_id: $gallery_id } ) { ...GalleryChapterData } } mutation GalleryChapterDestroy($id: ID!) { galleryChapterDestroy(id: $id) } ================================================ FILE: ui/v2.5/graphql/mutations/gallery.graphql ================================================ mutation GalleryCreate($input: GalleryCreateInput!) { galleryCreate(input: $input) { ...GalleryData } } mutation GalleryUpdate($input: GalleryUpdateInput!) { galleryUpdate(input: $input) { ...GalleryData } } mutation BulkGalleryUpdate($input: BulkGalleryUpdateInput!) { bulkGalleryUpdate(input: $input) { ...GalleryData } } mutation GalleriesUpdate($input: [GalleryUpdateInput!]!) { galleriesUpdate(input: $input) { ...GalleryData } } mutation GalleryDestroy( $ids: [ID!]! $delete_file: Boolean $delete_generated: Boolean ) { galleryDestroy( input: { ids: $ids delete_file: $delete_file delete_generated: $delete_generated } ) } mutation AddGalleryImages($gallery_id: ID!, $image_ids: [ID!]!) { addGalleryImages(input: { gallery_id: $gallery_id, image_ids: $image_ids }) } mutation RemoveGalleryImages($gallery_id: ID!, $image_ids: [ID!]!) { removeGalleryImages(input: { gallery_id: $gallery_id, image_ids: $image_ids }) } mutation SetGalleryCover($gallery_id: ID!, $cover_image_id: ID!) { setGalleryCover( input: { gallery_id: $gallery_id, cover_image_id: $cover_image_id } ) } mutation ResetGalleryCover($gallery_id: ID!) { resetGalleryCover(input: { gallery_id: $gallery_id }) } ================================================ FILE: ui/v2.5/graphql/mutations/group.graphql ================================================ mutation GroupCreate($input: GroupCreateInput!) { groupCreate(input: $input) { ...GroupData } } mutation GroupUpdate($input: GroupUpdateInput!) { groupUpdate(input: $input) { ...GroupData } } mutation BulkGroupUpdate($input: BulkGroupUpdateInput!) { bulkGroupUpdate(input: $input) { ...GroupData } } mutation GroupDestroy($id: ID!) { groupDestroy(input: { id: $id }) } mutation GroupsDestroy($ids: [ID!]!) { groupsDestroy(ids: $ids) } mutation AddGroupSubGroups($input: GroupSubGroupAddInput!) { addGroupSubGroups(input: $input) } mutation RemoveGroupSubGroups($input: GroupSubGroupRemoveInput!) { removeGroupSubGroups(input: $input) } mutation ReorderSubGroups($input: ReorderSubGroupsInput!) { reorderSubGroups(input: $input) } ================================================ FILE: ui/v2.5/graphql/mutations/image.graphql ================================================ mutation ImageUpdate($input: ImageUpdateInput!) { imageUpdate(input: $input) { ...SlimImageData } } mutation BulkImageUpdate($input: BulkImageUpdateInput!) { bulkImageUpdate(input: $input) { ...SlimImageData } } mutation ImagesUpdate($input: [ImageUpdateInput!]!) { imagesUpdate(input: $input) { ...SlimImageData } } mutation ImageIncrementO($id: ID!) { imageIncrementO(id: $id) } mutation ImageDecrementO($id: ID!) { imageDecrementO(id: $id) } mutation ImageResetO($id: ID!) { imageResetO(id: $id) } mutation ImageDestroy( $id: ID! $delete_file: Boolean $delete_generated: Boolean ) { imageDestroy( input: { id: $id delete_file: $delete_file delete_generated: $delete_generated } ) } mutation ImagesDestroy( $ids: [ID!]! $delete_file: Boolean $delete_generated: Boolean ) { imagesDestroy( input: { ids: $ids delete_file: $delete_file delete_generated: $delete_generated } ) } ================================================ FILE: ui/v2.5/graphql/mutations/job.graphql ================================================ mutation StopJob($job_id: ID!) { stopJob(job_id: $job_id) } mutation StopAllJobs { stopAllJobs } ================================================ FILE: ui/v2.5/graphql/mutations/metadata.graphql ================================================ mutation MetadataImport { metadataImport } mutation MetadataExport { metadataExport } mutation ExportObjects($input: ExportObjectsInput!) { exportObjects(input: $input) } mutation ImportObjects($input: ImportObjectsInput!) { importObjects(input: $input) } mutation MetadataScan($input: ScanMetadataInput!) { metadataScan(input: $input) } mutation MetadataGenerate($input: GenerateMetadataInput!) { metadataGenerate(input: $input) } mutation MetadataAutoTag($input: AutoTagMetadataInput!) { metadataAutoTag(input: $input) } mutation MetadataIdentify($input: IdentifyMetadataInput!) { metadataIdentify(input: $input) } mutation MetadataClean($input: CleanMetadataInput!) { metadataClean(input: $input) } mutation MetadataCleanGenerated($input: CleanGeneratedInput!) { metadataCleanGenerated(input: $input) } mutation MigrateHashNaming { migrateHashNaming } mutation BackupDatabase($input: BackupDatabaseInput!) { backupDatabase(input: $input) } mutation AnonymiseDatabase($input: AnonymiseDatabaseInput!) { anonymiseDatabase(input: $input) } mutation OptimiseDatabase { optimiseDatabase } ================================================ FILE: ui/v2.5/graphql/mutations/migration.graphql ================================================ mutation MigrateSceneScreenshots($input: MigrateSceneScreenshotsInput!) { migrateSceneScreenshots(input: $input) } mutation MigrateBlobs($input: MigrateBlobsInput!) { migrateBlobs(input: $input) } ================================================ FILE: ui/v2.5/graphql/mutations/performer.graphql ================================================ mutation PerformerCreate($input: PerformerCreateInput!) { performerCreate(input: $input) { ...PerformerData } } mutation PerformerUpdate($input: PerformerUpdateInput!) { performerUpdate(input: $input) { ...PerformerData } } mutation BulkPerformerUpdate($input: BulkPerformerUpdateInput!) { bulkPerformerUpdate(input: $input) { ...PerformerData } } mutation PerformerDestroy($id: ID!) { performerDestroy(input: { id: $id }) } mutation PerformersDestroy($ids: [ID!]!) { performersDestroy(ids: $ids) } mutation PerformerMerge($input: PerformerMergeInput!) { performerMerge(input: $input) { id } } ================================================ FILE: ui/v2.5/graphql/mutations/plugins.graphql ================================================ mutation ReloadPlugins { reloadPlugins } mutation RunPluginTask($plugin_id: ID!, $task_name: String!, $args_map: Map) { runPluginTask( plugin_id: $plugin_id task_name: $task_name args_map: $args_map ) } mutation ConfigurePlugin($plugin_id: ID!, $input: Map!) { configurePlugin(plugin_id: $plugin_id, input: $input) } mutation SetPluginsEnabled($enabledMap: BoolMap!) { setPluginsEnabled(enabledMap: $enabledMap) } mutation InstallPluginPackages($packages: [PackageSpecInput!]!) { installPackages(type: Plugin, packages: $packages) } mutation UpdatePluginPackages($packages: [PackageSpecInput!]!) { updatePackages(type: Plugin, packages: $packages) } mutation UninstallPluginPackages($packages: [PackageSpecInput!]!) { uninstallPackages(type: Plugin, packages: $packages) } ================================================ FILE: ui/v2.5/graphql/mutations/scene-marker.graphql ================================================ mutation SceneMarkerCreate( $title: String! $seconds: Float! $end_seconds: Float $scene_id: ID! $primary_tag_id: ID! $tag_ids: [ID!] = [] ) { sceneMarkerCreate( input: { title: $title seconds: $seconds end_seconds: $end_seconds scene_id: $scene_id primary_tag_id: $primary_tag_id tag_ids: $tag_ids } ) { ...SceneMarkerData } } mutation SceneMarkerUpdate( $id: ID! $title: String! $seconds: Float! $end_seconds: Float $scene_id: ID! $primary_tag_id: ID! $tag_ids: [ID!] = [] ) { sceneMarkerUpdate( input: { id: $id title: $title seconds: $seconds end_seconds: $end_seconds scene_id: $scene_id primary_tag_id: $primary_tag_id tag_ids: $tag_ids } ) { ...SceneMarkerData } } mutation BulkSceneMarkerUpdate($input: BulkSceneMarkerUpdateInput!) { bulkSceneMarkerUpdate(input: $input) { ...SceneMarkerData } } mutation SceneMarkerDestroy($id: ID!) { sceneMarkerDestroy(id: $id) } mutation SceneMarkersDestroy($ids: [ID!]!) { sceneMarkersDestroy(ids: $ids) } ================================================ FILE: ui/v2.5/graphql/mutations/scene.graphql ================================================ mutation SceneCreate($input: SceneCreateInput!) { sceneCreate(input: $input) { ...SceneData } } mutation SceneUpdate($input: SceneUpdateInput!) { sceneUpdate(input: $input) { ...SceneData } } mutation BulkSceneUpdate($input: BulkSceneUpdateInput!) { bulkSceneUpdate(input: $input) { ...SceneData } } mutation ScenesUpdate($input: [SceneUpdateInput!]!) { scenesUpdate(input: $input) { ...SceneData } } mutation SceneSaveActivity( $id: ID! $resume_time: Float $playDuration: Float ) { sceneSaveActivity( id: $id resume_time: $resume_time playDuration: $playDuration ) } mutation SceneResetActivity( $id: ID! $reset_resume: Boolean! $reset_duration: Boolean! ) { sceneResetActivity( id: $id reset_resume: $reset_resume reset_duration: $reset_duration ) } mutation SceneAddPlay($id: ID!, $times: [Timestamp!]) { sceneAddPlay(id: $id, times: $times) { count history } } mutation SceneDeletePlay($id: ID!, $times: [Timestamp!]) { sceneDeletePlay(id: $id, times: $times) { count history } } mutation SceneResetPlayCount($id: ID!) { sceneResetPlayCount(id: $id) } mutation SceneAddO($id: ID!, $times: [Timestamp!]) { sceneAddO(id: $id, times: $times) { count history } } mutation SceneDeleteO($id: ID!, $times: [Timestamp!]) { sceneDeleteO(id: $id, times: $times) { count history } } mutation SceneResetO($id: ID!) { sceneResetO(id: $id) } mutation SceneDestroy( $id: ID! $delete_file: Boolean $delete_generated: Boolean ) { sceneDestroy( input: { id: $id delete_file: $delete_file delete_generated: $delete_generated } ) } mutation ScenesDestroy( $ids: [ID!]! $delete_file: Boolean $delete_generated: Boolean ) { scenesDestroy( input: { ids: $ids delete_file: $delete_file delete_generated: $delete_generated } ) } mutation SceneGenerateScreenshot($id: ID!, $at: Float) { sceneGenerateScreenshot(id: $id, at: $at) } mutation SceneAssignFile($input: AssignSceneFileInput!) { sceneAssignFile(input: $input) } mutation SceneMerge($input: SceneMergeInput!) { sceneMerge(input: $input) { id } } ================================================ FILE: ui/v2.5/graphql/mutations/scrapers.graphql ================================================ mutation ReloadScrapers { reloadScrapers } mutation InstallScraperPackages($packages: [PackageSpecInput!]!) { installPackages(type: Scraper, packages: $packages) } mutation UpdateScraperPackages($packages: [PackageSpecInput!]!) { updatePackages(type: Scraper, packages: $packages) } mutation UninstallScraperPackages($packages: [PackageSpecInput!]!) { uninstallPackages(type: Scraper, packages: $packages) } ================================================ FILE: ui/v2.5/graphql/mutations/stash-box.graphql ================================================ mutation SubmitStashBoxFingerprints( $input: StashBoxFingerprintSubmissionInput! ) { submitStashBoxFingerprints(input: $input) } mutation StashBoxBatchPerformerTag($input: StashBoxBatchTagInput!) { stashBoxBatchPerformerTag(input: $input) } mutation StashBoxBatchStudioTag($input: StashBoxBatchTagInput!) { stashBoxBatchStudioTag(input: $input) } mutation StashBoxBatchTagTag($input: StashBoxBatchTagInput!) { stashBoxBatchTagTag(input: $input) } mutation SubmitStashBoxSceneDraft($input: StashBoxDraftSubmissionInput!) { submitStashBoxSceneDraft(input: $input) } mutation SubmitStashBoxPerformerDraft($input: StashBoxDraftSubmissionInput!) { submitStashBoxPerformerDraft(input: $input) } ================================================ FILE: ui/v2.5/graphql/mutations/studio.graphql ================================================ mutation StudioCreate($input: StudioCreateInput!) { studioCreate(input: $input) { ...StudioData } } mutation StudioUpdate($input: StudioUpdateInput!) { studioUpdate(input: $input) { ...StudioData } } mutation BulkStudioUpdate($input: BulkStudioUpdateInput!) { bulkStudioUpdate(input: $input) { ...StudioData } } mutation StudioDestroy($id: ID!) { studioDestroy(input: { id: $id }) } mutation StudiosDestroy($ids: [ID!]!) { studiosDestroy(ids: $ids) } ================================================ FILE: ui/v2.5/graphql/mutations/tag.graphql ================================================ mutation TagCreate($input: TagCreateInput!) { tagCreate(input: $input) { ...TagData } } mutation TagDestroy($id: ID!) { tagDestroy(input: { id: $id }) } mutation TagsDestroy($ids: [ID!]!) { tagsDestroy(ids: $ids) } mutation TagUpdate($input: TagUpdateInput!) { tagUpdate(input: $input) { ...TagData } } mutation BulkTagUpdate($input: BulkTagUpdateInput!) { bulkTagUpdate(input: $input) { ...TagData } } mutation TagsMerge( $source: [ID!]! $destination: ID! $values: TagUpdateInput ) { tagsMerge( input: { source: $source, destination: $destination, values: $values } ) { ...TagData } } ================================================ FILE: ui/v2.5/graphql/queries/dlna.graphql ================================================ query DLNAStatus { dlnaStatus { running until recentIPAddresses allowedIPAddresses { ipAddress until } } } ================================================ FILE: ui/v2.5/graphql/queries/filter.graphql ================================================ query FindSavedFilter($id: ID!) { findSavedFilter(id: $id) { ...SavedFilterData } } query FindSavedFilters($mode: FilterMode) { findSavedFilters(mode: $mode) { ...SavedFilterData } } ================================================ FILE: ui/v2.5/graphql/queries/folder.graphql ================================================ query FindRootFoldersForSelect { findFolders( filter: { per_page: -1, sort: "path", direction: ASC } folder_filter: { parent_folder: { modifier: IS_NULL } } ) { count folders { ...SelectFolderData } } } query FindFoldersForQuery( $filter: FindFilterType $folder_filter: FolderFilterType $ids: [ID!] ) { findFolders(filter: $filter, folder_filter: $folder_filter, ids: $ids) { count folders { ...RecursiveFolderData } } } ================================================ FILE: ui/v2.5/graphql/queries/gallery.graphql ================================================ query FindGalleries( $filter: FindFilterType $gallery_filter: GalleryFilterType ) { findGalleries(gallery_filter: $gallery_filter, filter: $filter) { count galleries { ...SlimGalleryData } } } query FindGallery($id: ID!) { findGallery(id: $id) { ...GalleryData } } query FindGalleriesForSelect( $filter: FindFilterType $gallery_filter: GalleryFilterType $ids: [ID!] ) { findGalleries(filter: $filter, gallery_filter: $gallery_filter, ids: $ids) { count galleries { ...SelectGalleryData } } } query FindGalleryImageID($id: ID!, $index: Int!) { findGallery(id: $id) { image(index: $index) { id } } } ================================================ FILE: ui/v2.5/graphql/queries/image.graphql ================================================ query FindImages( $filter: FindFilterType $image_filter: ImageFilterType $image_ids: [Int!] ) { findImages( filter: $filter image_filter: $image_filter image_ids: $image_ids ) { count images { ...SlimImageData } } } query FindImagesMetadata( $filter: FindFilterType $image_filter: ImageFilterType $image_ids: [Int!] ) { findImages( filter: $filter image_filter: $image_filter image_ids: $image_ids ) { megapixels filesize } } query FindImage($id: ID!, $checksum: String) { findImage(id: $id, checksum: $checksum) { ...ImageData } } ================================================ FILE: ui/v2.5/graphql/queries/job.graphql ================================================ query JobQueue { jobQueue { ...JobData } } query FindJob($input: FindJobInput!) { findJob(input: $input) { ...JobData } } ================================================ FILE: ui/v2.5/graphql/queries/legacy.graphql ================================================ query SceneWall($q: String) { sceneWall(q: $q) { ...SceneData } } query MarkerWall($q: String) { markerWall(q: $q) { ...SceneMarkerData } } ================================================ FILE: ui/v2.5/graphql/queries/misc.graphql ================================================ query MarkerStrings($q: String, $sort: String) { markerStrings(q: $q, sort: $sort) { id count title } } query Stats { stats { scene_count scenes_size scenes_duration image_count images_size gallery_count performer_count studio_count group_count tag_count total_o_count total_play_duration total_play_count scenes_played } } query Logs { logs { ...LogEntryData } } query Version { version { version hash build_time } } query LatestVersion { latestversion { version shorthash release_date url } } ================================================ FILE: ui/v2.5/graphql/queries/movie.graphql ================================================ query FindGroups($filter: FindFilterType, $group_filter: GroupFilterType) { findGroups(filter: $filter, group_filter: $group_filter) { count groups { ...ListGroupData } } } query FindGroup($id: ID!) { findGroup(id: $id) { ...GroupData } } query FindGroupsForSelect( $filter: FindFilterType $group_filter: GroupFilterType $ids: [ID!] ) { findGroups(filter: $filter, group_filter: $group_filter, ids: $ids) { count groups { ...SelectGroupData } } } ================================================ FILE: ui/v2.5/graphql/queries/performer.graphql ================================================ query FindPerformers( $filter: FindFilterType $performer_filter: PerformerFilterType $performer_ids: [Int!] ) { findPerformers( filter: $filter performer_filter: $performer_filter performer_ids: $performer_ids ) { count performers { ...PerformerData } } } query FindPerformer($id: ID!) { findPerformer(id: $id) { ...PerformerData } } query FindPerformersForSelect( $filter: FindFilterType $performer_filter: PerformerFilterType $ids: [ID!] ) { findPerformers( filter: $filter performer_filter: $performer_filter ids: $ids ) { count performers { ...SelectPerformerData } } } ================================================ FILE: ui/v2.5/graphql/queries/plugins.graphql ================================================ query Plugins { plugins { id name enabled description url version tasks { name description } hooks { name description hooks } settings { name display_name description type } requires paths { css javascript } } } query PluginTasks { pluginTasks { name description plugin { id name enabled } } } query InstalledPluginPackages { installedPackages(type: Plugin) { ...PackageData } } query InstalledPluginPackagesStatus { installedPackages(type: Plugin) { ...PackageData source_package { ...PackageData } } } query AvailablePluginPackages($source: String!) { availablePackages(source: $source, type: Plugin) { ...PackageData requires { package_id } } } ================================================ FILE: ui/v2.5/graphql/queries/scene-marker.graphql ================================================ query FindSceneMarkers( $filter: FindFilterType $scene_marker_filter: SceneMarkerFilterType ) { findSceneMarkers(filter: $filter, scene_marker_filter: $scene_marker_filter) { count scene_markers { ...SceneMarkerData } } } ================================================ FILE: ui/v2.5/graphql/queries/scene.graphql ================================================ query FindScenes( $filter: FindFilterType $scene_filter: SceneFilterType $scene_ids: [Int!] ) { findScenes( filter: $filter scene_filter: $scene_filter scene_ids: $scene_ids ) { count filesize duration scenes { ...SlimSceneData } } } query FindScenesByPathRegex($filter: FindFilterType) { findScenesByPathRegex(filter: $filter) { count filesize duration scenes { ...SlimSceneData } } } query FindDuplicateScenes($distance: Int, $duration_diff: Float) { findDuplicateScenes(distance: $distance, duration_diff: $duration_diff) { ...SlimSceneData } } query FindScene($id: ID!, $checksum: String) { findScene(id: $id, checksum: $checksum) { ...SceneData } } query FindFullScenes($ids: [Int!]) { findScenes(scene_ids: $ids) { scenes { ...SceneData } } } query FindSceneMarkerTags($id: ID!) { sceneMarkerTags(scene_id: $id) { tag { id name } scene_markers { ...SceneMarkerData } } } query ParseSceneFilenames( $filter: FindFilterType! $config: SceneParserInput! ) { parseSceneFilenames(filter: $filter, config: $config) { count results { scene { ...SlimSceneData } title code details director url date rating studio_id gallery_ids movies { movie_id } performer_ids tag_ids } } } query SceneStreams($id: ID!) { findScene(id: $id) { sceneStreams { url mime_type label } } } query FindScenesForSelect( $filter: FindFilterType $scene_filter: SceneFilterType $ids: [ID!] ) { findScenes(filter: $filter, scene_filter: $scene_filter, ids: $ids) { count scenes { ...SelectSceneData } } } ================================================ FILE: ui/v2.5/graphql/queries/scrapers/scrapers.graphql ================================================ query ListPerformerScrapers { listScrapers(types: [PERFORMER]) { id name performer { urls supported_scrapes } } } query ListSceneScrapers { listScrapers(types: [SCENE]) { id name scene { urls supported_scrapes } } } query ListGalleryScrapers { listScrapers(types: [GALLERY]) { id name gallery { urls supported_scrapes } } } query ListImageScrapers { listScrapers(types: [IMAGE]) { id name image { urls supported_scrapes } } } query ListGroupScrapers { listScrapers(types: [GROUP]) { id name group { urls supported_scrapes } } } query ScrapeSingleStudio( $source: ScraperSourceInput! $input: ScrapeSingleStudioInput! ) { scrapeSingleStudio(source: $source, input: $input) { ...ScrapedStudioData } } query ScrapeSingleTag( $source: ScraperSourceInput! $input: ScrapeSingleTagInput! ) { scrapeSingleTag(source: $source, input: $input) { ...ScrapedSceneTagData } } query ScrapeSinglePerformer( $source: ScraperSourceInput! $input: ScrapeSinglePerformerInput! ) { scrapeSinglePerformer(source: $source, input: $input) { ...ScrapedPerformerData } } query ScrapeMultiPerformers( $source: ScraperSourceInput! $input: ScrapeMultiPerformersInput! ) { scrapeMultiPerformers(source: $source, input: $input) { ...ScrapedPerformerData } } query ScrapePerformerURL($url: String!) { scrapePerformerURL(url: $url) { ...ScrapedPerformerData } } query ScrapeSingleScene( $source: ScraperSourceInput! $input: ScrapeSingleSceneInput! ) { scrapeSingleScene(source: $source, input: $input) { ...ScrapedSceneData } } query ScrapeMultiScenes( $source: ScraperSourceInput! $input: ScrapeMultiScenesInput! ) { scrapeMultiScenes(source: $source, input: $input) { ...ScrapedSceneData } } query ScrapeSceneURL($url: String!) { scrapeSceneURL(url: $url) { ...ScrapedSceneData } } query ScrapeSingleGallery( $source: ScraperSourceInput! $input: ScrapeSingleGalleryInput! ) { scrapeSingleGallery(source: $source, input: $input) { ...ScrapedGalleryData } } query ScrapeSingleImage( $source: ScraperSourceInput! $input: ScrapeSingleImageInput! ) { scrapeSingleImage(source: $source, input: $input) { ...ScrapedImageData } } query ScrapeGalleryURL($url: String!) { scrapeGalleryURL(url: $url) { ...ScrapedGalleryData } } query ScrapeImageURL($url: String!) { scrapeImageURL(url: $url) { ...ScrapedImageData } } query ScrapeGroupURL($url: String!) { scrapeGroupURL(url: $url) { ...ScrapedGroupData } } query InstalledScraperPackages { installedPackages(type: Scraper) { ...PackageData } } query InstalledScraperPackagesStatus { installedPackages(type: Scraper) { ...PackageData source_package { ...PackageData } } } query AvailableScraperPackages($source: String!) { availablePackages(source: $source, type: Scraper) { ...PackageData requires { package_id } } } ================================================ FILE: ui/v2.5/graphql/queries/settings/config.graphql ================================================ query Configuration { configuration { ...ConfigData } } query Directory($path: String) { directory(path: $path) { path parent directories } } query ValidateStashBox($input: StashBoxInput!) { validateStashBoxCredentials(input: $input) { valid status } } ================================================ FILE: ui/v2.5/graphql/queries/settings/metadata.graphql ================================================ query SystemStatus { systemStatus { databaseSchema databasePath appSchema status configPath os workingDir homeDir ffmpegPath ffprobePath } } ================================================ FILE: ui/v2.5/graphql/queries/studio.graphql ================================================ query FindStudios($filter: FindFilterType, $studio_filter: StudioFilterType) { findStudios(filter: $filter, studio_filter: $studio_filter) { count studios { ...StudioData } } } query FindStudio($id: ID!) { findStudio(id: $id) { ...StudioData } } query FindStudiosForSelect( $filter: FindFilterType $studio_filter: StudioFilterType $ids: [ID!] ) { findStudios(filter: $filter, studio_filter: $studio_filter, ids: $ids) { count studios { ...SelectStudioData } } } ================================================ FILE: ui/v2.5/graphql/queries/tag.graphql ================================================ query FindTags( $filter: FindFilterType $tag_filter: TagFilterType $ids: [ID!] ) { findTags(filter: $filter, tag_filter: $tag_filter, ids: $ids) { count tags { ...TagData } } } query FindTag($id: ID!) { findTag(id: $id) { ...TagData } } query FindTagsForSelect( $filter: FindFilterType $tag_filter: TagFilterType $ids: [ID!] ) { findTags(filter: $filter, tag_filter: $tag_filter, ids: $ids) { count tags { ...SelectTagData } } } # Optimized query for tag list page - uses TagListData fragment without recursive counts query FindTagsForList($filter: FindFilterType, $tag_filter: TagFilterType) { findTags(filter: $filter, tag_filter: $tag_filter) { count tags { ...TagListData } } } ================================================ FILE: ui/v2.5/graphql/subscriptions.graphql ================================================ subscription JobsSubscribe { jobsSubscribe { type job { id status subTasks description progress error startTime } } } subscription LoggingSubscribe { loggingSubscribe { ...LogEntryData } } subscription ScanCompleteSubscribe { scanCompleteSubscribe } ================================================ FILE: ui/v2.5/index.html ================================================ Stash
================================================ FILE: ui/v2.5/package.json ================================================ { "name": "stash", "private": true, "homepage": "./", "type": "module", "packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017", "scripts": { "start": "vite", "build": "vite build", "build-ci": "npm run validate && npm run build", "validate": "npm run lint && npm run check && npm run format-check", "lint": "npm run lint:js && npm run lint:css", "lint:css": "stylelint --cache \"src/**/*.scss\"", "lint:js": "eslint --cache src/", "check": "tsc --noEmit", "eslint": "eslint", "prettier": "prettier", "stylelint": "stylelint", "format": "prettier --write . ../../graphql", "format-check": "prettier --check . ../../graphql", "gqlgen": "gql-gen --config codegen.ts", "extract": "NODE_ENV=development extract-messages -l=en,de -o src/locale -d en --flat false 'src/**/!(*.test).tsx'" }, "dependencies": { "@ant-design/react-slick": "^1.0.0", "@apollo/client": "^3.8.10", "@formatjs/intl-getcanonicallocales": "^2.0.5", "@formatjs/intl-locale": "^3.0.11", "@formatjs/intl-numberformat": "^8.3.3", "@formatjs/intl-pluralrules": "^5.1.8", "@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/free-brands-svg-icons": "^7.1.0", "@fortawesome/free-regular-svg-icons": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/react-fontawesome": "^0.2.6", "@react-hook/resize-observer": "^1.2.6", "@silvermine/videojs-airplay": "^1.2.0", "@silvermine/videojs-chromecast": "^1.4.1", "@types/react-router-dom": "^5.3.3", "apollo-upload-client": "^18.0.1", "base64-blob": "^1.4.1", "bootstrap": "^4.6.2", "classnames": "^2.3.2", "crypto-js": "^4.2.0", "event-target-polyfill": "^0.0.4", "flag-icons": "^6.6.6", "flexbin": "^0.2.0", "formik": "^2.4.5", "graphql": "^16.8.1", "graphql-tag": "^2.12.6", "graphql-ws": "^5.14.3", "i18n-iso-countries": "^7.5.0", "localforage": "^1.10.0", "lodash-es": "^4.17.23", "moment": "^2.30.1", "mousetrap": "^1.6.5", "mousetrap-pause": "^1.0.0", "normalize-url": "^4.5.1", "react": "^17.0.2", "react-bootstrap": "^1.6.6", "react-datepicker": "^4.10.0", "react-dom": "^17.0.2", "react-helmet": "^6.1.0", "react-intl": "^6.2.8", "react-photo-gallery": "^8.0.0", "react-remark": "^2.1.0", "react-router-bootstrap": "^0.25.0", "react-router-dom": "^5.3.4", "react-router-hash-link": "^2.4.3", "react-select": "^5.7.0", "remark-gfm": "^1.0.0", "resize-observer-polyfill": "^1.5.1", "slick-carousel": "^1.8.1", "string.prototype.replaceall": "^1.0.7", "thehandy": "^1.0.3", "ua-parser-js": "^1.0.34", "universal-cookie": "^4.0.4", "video.js": "^7.21.3", "videojs-abloop": "^1.2.0", "videojs-contrib-dash": "^5.1.1", "videojs-mobile-ui": "^0.8.0", "videojs-seek-buttons": "^3.0.1", "videojs-vr": "1.8.0", "videojs-vtt.js": "^0.15.4", "yup": "^1.3.2" }, "devDependencies": { "@babel/core": "^7.20.12", "@graphql-codegen/cli": "^5.0.0", "@graphql-codegen/time": "^5.0.0", "@graphql-codegen/typescript": "^4.0.1", "@graphql-codegen/typescript-operations": "^4.0.1", "@graphql-codegen/typescript-react-apollo": "^4.1.0", "@types/apollo-upload-client": "^18.0.0", "@types/crypto-js": "^4.2.2", "@types/dom-screen-wake-lock": "^1.0.3", "@types/lodash-es": "^4.17.6", "@types/mousetrap": "^1.6.11", "@types/node": "^18.13.0", "@types/react": "^17.0.53", "@types/react-datepicker": "^4.10.0", "@types/react-dom": "^17.0.19", "@types/react-helmet": "^6.1.6", "@types/react-router-bootstrap": "^0.24.5", "@types/react-router-hash-link": "^2.4.5", "@types/three": "^0.154.0", "@types/ua-parser-js": "^0.7.36", "@types/video.js": "^7.3.51", "@types/videojs-mobile-ui": "^0.8.0", "@types/videojs-seek-buttons": "^2.1.0", "@typescript-eslint/eslint-plugin": "^5.52.0", "@typescript-eslint/parser": "^5.52.0", "@vitejs/plugin-legacy": "^5.4.3", "@vitejs/plugin-react": "^5.1.0", "eslint": "^8.34.0", "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-typescript": "^17.0.0", "eslint-config-prettier": "^8.6.0", "eslint-plugin-import": "^2.27.5", "eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", "extract-react-intl-messages": "^4.1.1", "postcss": "^8.4.31", "postcss-scss": "^4.0.6", "prettier": "^2.8.4", "sass": "^1.58.1", "stylelint": "^15.10.1", "stylelint-order": "^6.0.2", "terser": "^5.9.0", "ts-node": "^10.9.1", "typescript": "~4.8.4", "vite": "^5.4.21", "vite-plugin-compression": "^0.5.1", "vite-tsconfig-paths": "^4.0.5" } } ================================================ FILE: ui/v2.5/pnpm-workspace.yaml ================================================ onlyBuiltDependencies: - '@parcel/watcher' - core-js - esbuild ================================================ FILE: ui/v2.5/public/manifest.json ================================================ { "short_name": "Stash", "name": "Stash: Porn Organizer", "description": "Stash allows you to organize and view your own collection of adult video and image files. Think of it like a private PornHub site for your personal porn collection. ", "icons": [ { "src": "stash_icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "maskable any" }, { "src": "stash_icon.png", "sizes": "256x256 64x64 32x32 24x24 16x16", "type": "image/png", "purpose": "maskable any" }, { "src": "favicon.png", "sizes": "256x256 64x64 32x32 24x24 16x16", "type": "image/png", "purpose": "monochrome" } ], "start_url": "/", "scope": ".", "display": "standalone", "theme_color": "#394b59", "background_color": "#202b33" } ================================================ FILE: ui/v2.5/src/@types/mousetrap-pause.d.ts ================================================ /* eslint-disable @typescript-eslint/naming-convention */ declare module "mousetrap-pause" { import { MousetrapStatic } from "mousetrap"; function MousetrapPause(mousetrap: MousetrapStatic): MousetrapStatic; export default MousetrapPause; module "mousetrap" { interface MousetrapStatic { pause(): void; unpause(): void; pauseCombo(combo: string): void; unpauseCombo(combo: string): void; } interface MousetrapInstance { pause(): void; unpause(): void; pauseCombo(combo: string): void; unpauseCombo(combo: string): void; } } } ================================================ FILE: ui/v2.5/src/@types/string.prototype.replaceall.d.ts ================================================ declare module "string.prototype.replaceall" { function replaceAll( searchValue: string | RegExp, replaceValue: string ): string; function replaceAll( searchValue: string | RegExp, // eslint-disable-next-line @typescript-eslint/no-explicit-any replacer: (substring: string, ...args: any[]) => string ): string; namespace replaceAll { function getPolyfill(): typeof replaceAll; function implementation(): typeof replaceAll; function shim(): void; } export default replaceAll; } ================================================ FILE: ui/v2.5/src/@types/videojs-abloop.d.ts ================================================ /* eslint-disable @typescript-eslint/naming-convention */ declare module "videojs-abloop" { import videojs from "video.js"; declare function abLoopPlugin( window: Window & typeof globalThis, player: videojs ): abLoopPlugin.Plugin; declare namespace abLoopPlugin { interface Options { start: number | boolean; end: number | boolean; enabled: boolean; loopIfBeforeStart: boolean; loopIfAfterEnd: boolean; pauseBeforeLooping: boolean; pauseAfterLooping: boolean; } class Plugin extends videojs.Plugin { getOptions(): Options; setOptions(o: Options): void; } } export = abLoopPlugin; declare module "video.js" { interface VideoJsPlayer { abLoopPlugin: abLoopPlugin.Plugin; } } } ================================================ FILE: ui/v2.5/src/@types/videojs-contrib-dash.d.ts ================================================ /* eslint-disable @typescript-eslint/naming-convention */ declare module "videojs-contrib-dash" { class Html5DashJS { /** * Get a list of hooks for a specific lifecycle. * * @param type the lifecycle to get hooks from * @param hook optionally add a hook to the lifecycle * @return an array of hooks or empty if none */ static hooks(type: string, hook: Function | Function[]): Function[]; /** * Add a function hook to a specific dash lifecycle. * * @param type the lifecycle to hook the function to * @param hook the function or array of functions to attach */ static hook(type: string, hook: Function | Function[]): void; /** * Remove a hook from a specific dash lifecycle. * * @param type the lifecycle that the function hooked to * @param hook the hooked function to remove * @return true if the function was removed, false if not found */ static removeHook(type: string, hook: Function): boolean; } } ================================================ FILE: ui/v2.5/src/@types/videojs-vr.d.ts ================================================ /* eslint-disable @typescript-eslint/naming-convention */ declare module "videojs-vr" { import videojs from "video.js"; // we don't want to depend on THREE.js directly, these are just typedefs for videojs-vr // eslint-disable-next-line import/no-extraneous-dependencies import * as THREE from "three"; declare function videojsVR(options?: videojsVR.Options): videojsVR.Plugin; declare namespace videojsVR { const VERSION: typeof videojs.VERSION; type ProjectionType = // The video is half sphere and the user should not be able to look behind themselves | "180" // Used for side-by-side 180 videos The video is half sphere and the user should not be able to look behind themselves | "180_LR" // Used for monoscopic 180 videos The video is half sphere and the user should not be able to look behind themselves | "180_MONO" // The video is a sphere | "360" | "Sphere" | "equirectangular" // The video is a cube | "360_CUBE" | "Cube" // This video is not a 360 video | "NONE" // Check player.mediainfo.projection to see if the current video is a 360 video. | "AUTO" // Used for side-by-side 360 videos | "360_LR" // Used for top-to-bottom 360 videos | "360_TB" // Used for Equi-Angular Cubemap videos | "EAC" // Used for side-by-side Equi-Angular Cubemap videos | "EAC_LR"; interface Options { /** * Force the cardboard button to display on all devices even if we don't think they support it. * * @default false */ forceCardboard?: boolean; /** * Whether motion/gyro controls should be enabled. * * @default true on iOS and Android */ motionControls?: boolean; /** * Defines the projection type. * * @default "AUTO" */ projection?: ProjectionType; /** * This alters the number of segments in the spherical mesh onto which equirectangular videos are projected. * The default is 32 but in some circumstances you may notice artifacts and need to increase this number. * * @default 32 */ sphereDetail?: number; /** * Enable debug logging for this plugin * * @default false */ debug?: boolean; /** * Use this property to pass the Omnitone library object to the plugin. Please be aware of, the Omnitone library is not included in the build files. */ omnitone?: object; /** * Default options for the Omnitone library. Please check available options on https://github.com/GoogleChrome/omnitone */ omnitoneOptions?: object; /** * Feature to disable the togglePlay manually. This functionality is useful in live events so that users cannot stop the live, but still have a controlBar available. * * @default false */ disableTogglePlay?: boolean; } interface PlayerMediaInfo { /** * This should be set on a source-by-source basis to turn 360 videos on an off depending upon the video. * Note that AUTO is the same as NONE for player.mediainfo.projection. */ projection?: ProjectionType; } class Plugin extends videojs.Plugin { setProjection(projection: ProjectionType): void; init(): void; reset(): void; cameraVector: THREE.Vector3; camera: THREE.Camera; scene: THREE.Scene; renderer: THREE.Renderer; } } export = videojsVR; declare module "video.js" { interface VideoJsPlayer { vr: typeof videojsVR; mediainfo?: videojsVR.PlayerMediaInfo; } } } ================================================ FILE: ui/v2.5/src/@types/videojs-vtt.d.ts ================================================ /* eslint-disable @typescript-eslint/naming-convention */ declare module "videojs-vtt.js" { /** * A custom JS error object that is reported through the parser's `onparsingerror` callback. * It has a name, code, and message property, along with all the regular properties that come with a JavaScript error object. * * There are two error codes that can be reported back currently: * * 0 BadSignature * * 1 BadTimeStamp * * Note: Exceptions other then ParsingError will be thrown and not reported. */ class ParsingError extends Error { readonly name: string; readonly code: number; readonly message: string; } export namespace WebVTT { /** * A parser for the WebVTT spec in JavaScript. */ class Parser { /** * The Parser constructor is passed a window object with which it will create new `VTTCues` and `VTTRegions` * as well as an optional `StringDecoder` object which it will use to decode the data that the `parse()` function receives. * For ease of use, a `StringDecoder` is provided via `WebVTT.StringDecoder()`. * If a custom `StringDecoder` object is passed in it must support the API specified by the #whatwg string encoding spec. * * @param window the window object to use * @param vttjs the vtt.js module * @param decoder the decoder to decode `parse()` data with */ constructor(window: Window); constructor(window: Window, decoder: TextDecoder); constructor( window: Window, vttjs: typeof import("videojs-vtt.js"), decoder: TextDecoder ); /** * Callback that is invoked for every region that is correctly parsed. Is passed a `VTTRegion` object. */ onregion?: (cue: VTTRegion) => void; /** * Callback that is invoked for every cue that is fully parsed. In case of streaming parsing, * `oncue` is delayed until the cue has been completely received. Is passed a `VTTCue` object. */ oncue?: (cue: VTTCue) => void; /** * Is invoked in response to `flush()` and after the content was parsed completely. */ onflush?: () => void; /** * Is invoked when a parsing error has occurred. This means that some part of the WebVTT file markup is badly formed. * Is passed a `ParsingError` object. */ onparsingerror?: (e: ParsingError) => void; /** * Hands data in some format to the parser for parsing. The passed data format is expected to be decodable by the * StringDecoder object that it has. The parser decodes the data and reassembles partial data (streaming), even across line breaks. * * @param data data to be parsed */ parse(data: string): this; /** * Indicates that no more data is expected and will force the parser to parse any unparsed data that it may have. * Will also trigger `onflush`. */ flush(): this; } /** * Helper to allow strings to be decoded instead of the default binary utf8 data. */ function StringDecoder(): TextDecoder; /** * Parses the cue text handed to it into a tree of DOM nodes that mirrors the internal WebVTT node structure of the cue text. * It uses the window object handed to it to construct new HTMLElements and returns a tree of DOM nodes attached to a top level div. * * @param window window object to use * @param cuetext cue text to parse */ function convertCueToDOMTree( window: Window, cuetext: string ): HTMLDivElement | null; /** * Converts the cuetext of the cues passed to it to DOM trees - by calling convertCueToDOMTree - and then runs the * processing model steps of the WebVTT specification on the divs. The processing model applies the necessary CSS styles * to the cue divs to prepare them for display on the web page. During this process the cue divs get added to a block level element (overlay). * The overlay should be a part of the live DOM as the algorithm will use the computed styles (only of the divs) to do overlap avoidance. * * @param overlay A block level element (usually a div) that the computed cues and regions will be placed into. */ function processCues( window: Window, cues: VTTCue[], overlay: Element ): void; } } ================================================ FILE: ui/v2.5/src/App.tsx ================================================ import React, { Suspense, useEffect, useState } from "react"; import { Route, Switch, useHistory, useLocation, useRouteMatch, } from "react-router-dom"; import { IntlProvider, CustomFormats, FormattedMessage } from "react-intl"; import { Helmet } from "react-helmet"; import cloneDeep from "lodash-es/cloneDeep"; import mergeWith from "lodash-es/mergeWith"; import { ToastProvider } from "src/hooks/Toast"; import { LightboxProvider } from "src/hooks/Lightbox/context"; import { initPolyfills } from "src/polyfills"; import locales, { registerCountry } from "src/locales"; import { useConfiguration, useConfigureUI, useSystemStatus, } from "src/core/StashService"; import flattenMessages from "./utils/flattenMessages"; import * as yup from "yup"; import Mousetrap from "mousetrap"; import MousetrapPause from "mousetrap-pause"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { MainNavbar } from "./components/MainNavbar"; import { PageNotFound } from "./components/PageNotFound"; import * as GQL from "./core/generated-graphql"; import { makeTitleProps } from "./hooks/title"; import { LoadingIndicator } from "./components/Shared/LoadingIndicator"; import { ConfigurationProvider, useConfigurationContextOptional, } from "./hooks/Config"; import { ManualProvider } from "./components/Help/context"; import { InteractiveProvider } from "./hooks/Interactive/context"; import { ReleaseNotesDialog } from "./components/Dialogs/ReleaseNotesDialog"; import { releaseNotes } from "./docs/en/ReleaseNotes"; import { getPlatformURL } from "./core/createClient"; import { lazyComponent } from "./utils/lazyComponent"; import { isPlatformUniquelyRenderedByApple } from "./utils/apple"; import Event from "./hooks/event"; import { PluginRoutes, PluginsLoader } from "./plugins"; // import plugin_api to run code import "./pluginApi"; import { ConnectionMonitor } from "./ConnectionMonitor"; import { TroubleshootingModeOverlay } from "./components/TroubleshootingMode/TroubleshootingModeOverlay"; import { PatchFunction } from "./patch"; import moment from "moment/min/moment-with-locales"; import { ErrorMessage } from "./components/Shared/ErrorMessage"; import cx from "classnames"; const Performers = lazyComponent( () => import("./components/Performers/Performers") ); const FrontPage = lazyComponent( () => import("./components/FrontPage/FrontPage") ); const Scenes = lazyComponent(() => import("./components/Scenes/Scenes")); const Settings = lazyComponent(() => import("./components/Settings/Settings")); const Stats = lazyComponent(() => import("./components/Stats")); const Studios = lazyComponent(() => import("./components/Studios/Studios")); const Galleries = lazyComponent( () => import("./components/Galleries/Galleries") ); const Groups = lazyComponent(() => import("./components/Groups/Groups")); const Tags = lazyComponent(() => import("./components/Tags/Tags")); const Images = lazyComponent(() => import("./components/Images/Images")); const Setup = lazyComponent(() => import("./components/Setup/Setup")); const Migrate = lazyComponent(() => import("./components/Setup/Migrate")); const SceneFilenameParser = lazyComponent( () => import("./components/SceneFilenameParser/SceneFilenameParser") ); const SceneDuplicateChecker = lazyComponent( () => import("./components/SceneDuplicateChecker/SceneDuplicateChecker") ); const appleRendering = isPlatformUniquelyRenderedByApple(); initPolyfills(); MousetrapPause(Mousetrap); const intlFormats: CustomFormats = { date: { long: { year: "numeric", month: "long", day: "numeric" }, }, }; const defaultLocale = "en-GB"; function languageMessageString(language: string) { return language.replace(/-/, ""); } const AppContainer: React.FC> = PatchFunction( "App", (props: React.PropsWithChildren<{}>) => { return <>{props.children}; } ) as React.FC; const MainContainer: React.FC = ({ children }) => { // use optional here because the configuration may have be loading or errored const { configuration } = useConfigurationContextOptional() || {}; const { sfwContentMode } = configuration?.interface || {}; return (
{children}
); }; function translateLanguageLocale(l: string) { // intl doesn't support all locales, so we need to map some to supported ones switch (l) { case "nn-NO": // use other Norwegian locale for intl return "nb-NO"; default: return l; } } export const App: React.FC = () => { const config = useConfiguration(); const [saveUI] = useConfigureUI(); const { data: systemStatusData } = useSystemStatus(); const language = config.data?.configuration?.interface?.language ?? defaultLocale; const intlLanguage = translateLanguageLocale(language); // use en-GB as default messages if any messages aren't found in the chosen language const [messages, setMessages] = useState<{}>(); const [customMessages, setCustomMessages] = useState<{}>(); useEffect(() => { (async () => { try { const res = await fetch(getPlatformURL("customlocales")); if (res.ok) { setCustomMessages(await res.json()); } } catch (err) { console.log(err); } })(); }, []); useEffect(() => { const setLocale = async () => { const defaultMessageLanguage = languageMessageString(defaultLocale); const messageLanguage = languageMessageString(language); // register countries for the chosen language await registerCountry(language); const defaultMessages = (await locales[defaultMessageLanguage]()).default; const mergedMessages = cloneDeep(Object.assign({}, defaultMessages)); const chosenMessages = (await locales[messageLanguage]()).default; mergeWith( mergedMessages, chosenMessages, customMessages, (objVal, srcVal) => { if (srcVal === "") { return objVal; } } ); const newMessages = flattenMessages(mergedMessages); yup.setLocale({ mixed: { required: newMessages["validation.required"], }, }); setMessages(newMessages); moment.locale([language, defaultLocale]); }; setLocale(); }, [customMessages, language]); const location = useLocation(); const history = useHistory(); const setupMatch = useRouteMatch(["/setup", "/migrate"]); // dispatch event when location changes useEffect(() => { Event.dispatch("location", "", { location }); }, [location]); // redirect to setup or migrate as needed useEffect(() => { if (!systemStatusData) { return; } const { status } = systemStatusData.systemStatus; if ( location.pathname !== "/setup" && status === GQL.SystemStatusEnum.Setup ) { // redirect to setup page history.push("/setup"); } if ( location.pathname !== "/migrate" && status === GQL.SystemStatusEnum.NeedsMigration ) { // redirect to migrate page history.replace("/migrate"); } }, [systemStatusData, setupMatch, history, location]); function maybeRenderNavbar() { // don't render navbar for setup views if (!setupMatch) { return ; } } function renderContent() { if (!systemStatusData) { return ; } return ( }> ); } function maybeRenderReleaseNotes() { if (setupMatch || !systemStatusData || config.loading || config.error) { return; } const lastNoteSeen = config.data?.configuration.ui.lastNoteSeen; const notes = releaseNotes.filter((n) => { return !lastNoteSeen || n.date > lastNoteSeen; }); if (notes.length === 0) return; return ( { saveUI({ variables: { input: { ...config.data?.configuration.ui, lastNoteSeen: notes[0].date, }, }, }); }} /> ); } const title = config.data?.configuration.ui.title || "Stash"; const titleProps = makeTitleProps(title); if (!messages) { return null; } function renderSimple(content: React.ReactNode) { return ( {content} ); } if (config.loading) { return renderSimple(); } if (config.error) { return renderSimple( } error={config.error.message} /> ); } return ( {maybeRenderReleaseNotes()} }> {maybeRenderNavbar()} {renderContent()} ); }; ================================================ FILE: ui/v2.5/src/ConnectionMonitor.tsx ================================================ import { useEffect, useState } from "react"; import { getWSClient, useWSState } from "./core/StashService"; import { useToast } from "./hooks/Toast"; import { useIntl } from "react-intl"; export const ConnectionMonitor: React.FC = () => { const Toast = useToast(); const intl = useIntl(); const { state } = useWSState(getWSClient()); const [cachedState, setCacheState] = useState(state); useEffect(() => { if (cachedState === "connecting" && state === "error") { Toast.error( intl.formatMessage({ id: "connection_monitor.websocket_connection_failed", }) ); } if (state === "connected" && cachedState === "error") { Toast.success( intl.formatMessage({ id: "connection_monitor.websocket_connection_reestablished", }) ); } setCacheState(state); }, [state, cachedState, Toast, intl]); return null; }; ================================================ FILE: ui/v2.5/src/components/Changelog/Changelog.tsx ================================================ import React from "react"; import { useChangelogStorage } from "src/hooks/LocalForage"; import Version from "./Version"; import V010 from "src/docs/en/Changelog/v010.md"; import V011 from "src/docs/en/Changelog/v011.md"; import V020 from "src/docs/en/Changelog/v020.md"; import V021 from "src/docs/en/Changelog/v021.md"; import V030 from "src/docs/en/Changelog/v030.md"; import V040 from "src/docs/en/Changelog/v040.md"; import V050 from "src/docs/en/Changelog/v050.md"; import V060 from "src/docs/en/Changelog/v060.md"; import V070 from "src/docs/en/Changelog/v070.md"; import V080 from "src/docs/en/Changelog/v080.md"; import V090 from "src/docs/en/Changelog/v090.md"; import V0100 from "src/docs/en/Changelog/v0100.md"; import V0110 from "src/docs/en/Changelog/v0110.md"; import V0120 from "src/docs/en/Changelog/v0120.md"; import V0130 from "src/docs/en/Changelog/v0130.md"; import V0131 from "src/docs/en/Changelog/v0131.md"; import V0140 from "src/docs/en/Changelog/v0140.md"; import V0150 from "src/docs/en/Changelog/v0150.md"; import V0160 from "src/docs/en/Changelog/v0160.md"; import V0161 from "src/docs/en/Changelog/v0161.md"; import V0170 from "src/docs/en/Changelog/v0170.md"; import V0180 from "src/docs/en/Changelog/v0180.md"; import V0190 from "src/docs/en/Changelog/v0190.md"; import V0200 from "src/docs/en/Changelog/v0200.md"; import V0210 from "src/docs/en/Changelog/v0210.md"; import V0220 from "src/docs/en/Changelog/v0220.md"; import V0230 from "src/docs/en/Changelog/v0230.md"; import V0240 from "src/docs/en/Changelog/v0240.md"; import V0250 from "src/docs/en/Changelog/v0250.md"; import V0260 from "src/docs/en/Changelog/v0260.md"; import V0270 from "src/docs/en/Changelog/v0270.md"; import V0280 from "src/docs/en/Changelog/v0280.md"; import V0290 from "src/docs/en/Changelog/v0290.md"; import V0300 from "src/docs/en/Changelog/v0300.md"; import V0310 from "src/docs/en/Changelog/v0310.md"; import V0290ReleaseNotes from "src/docs/en/ReleaseNotes/v0290.md"; import { MarkdownPage } from "../Shared/MarkdownPage"; import { FormattedMessage } from "react-intl"; const Changelog: React.FC = () => { const [{ data, loading }, setOpenState] = useChangelogStorage(); const stashVersion = import.meta.env.VITE_APP_STASH_VERSION; const buildTime = import.meta.env.VITE_APP_DATE; let buildDate; if (buildTime) { buildDate = buildTime.substring(0, buildTime.indexOf(" ")); } if (loading) return <>; const openState = data?.versions ?? {}; const setVersionOpenState = (key: string, state: boolean) => setOpenState({ versions: { ...openState, [key]: state, }, }); interface IStashRelease { version: string; date?: string; page: string; defaultOpen?: boolean; releaseNotes?: string; } // after new release: // add entry to releases, using the current* fields // then update the current fields. const currentVersion = stashVersion || "v0.31.0"; const currentDate = buildDate; const currentPage = V0310; const releases: IStashRelease[] = [ { version: currentVersion, date: currentDate, page: currentPage, defaultOpen: true, }, { version: "v0.30.1", date: "2025-12-18", page: V0300, releaseNotes: V0290ReleaseNotes, }, { version: "v0.29.3", date: "2025-11-06", page: V0290, releaseNotes: V0290ReleaseNotes, }, { version: "v0.28.1", date: "2025-03-20", page: V0280, }, { version: "v0.27.2", date: "2024-10-16", page: V0270, }, { version: "v0.26.2", date: "2024-06-27", page: V0260, }, { version: "v0.25.1", date: "2024-03-13", page: V0250, }, { version: "v0.24.3", date: "2024-01-15", page: V0240, }, { version: "v0.23.1", date: "2023-10-14", page: V0230, }, { version: "v0.22.1", date: "2023-08-21", page: V0220, }, { version: "v0.21.0", date: "2023-06-13", page: V0210, }, { version: "v0.20.2", date: "2023-04-08", page: V0200, }, { version: "v0.19.1", date: "2023-02-21", page: V0190, }, { version: "v0.18.0", date: "2022-11-30", page: V0180, }, { version: "v0.17.2", date: "2022-10-25", page: V0170, }, { version: "v0.16.1", date: "2022-07-26", page: V0161, }, { version: "v0.16.0", date: "2022-07-05", page: V0160, }, { version: "v0.15.0", date: "2022-05-18", page: V0150, }, { version: "v0.14.0", date: "2022-04-11", page: V0140, }, { version: "v0.13.1", date: "2022-03-16", page: V0131, }, { version: "v0.13.0", date: "2022-03-08", page: V0130, }, { version: "v0.12.0", date: "2021-12-29", page: V0120, }, { version: "v0.11.0", date: "2021-11-16", page: V0110, }, { version: "v0.10.0", date: "2021-10-11", page: V0100, }, { version: "v0.9.0", date: "2021-09-06", page: V090, }, { version: "v0.8.0", date: "2021-07-02", page: V080, }, { version: "v0.7.0", date: "2021-05-15", page: V070, }, { version: "v0.6.0", date: "2021-03-29", page: V060, }, { version: "v0.5.0", date: "2021-02-23", page: V050, }, { version: "v0.4.0", date: "2020-11-24", page: V040, }, { version: "v0.3.0", date: "2020-09-02", page: V030, }, { version: "v0.2.1", date: "2020-06-10", page: V021, }, { version: "v0.2.0", date: "2020-06-06", page: V020, }, { version: "v0.1.1", date: "2020-02-25", page: V011, }, { version: "v0.1.0", date: "2020-02-24", page: V010, }, ]; return (

{releases.map((r) => ( {r.releaseNotes && (


)}
))}
); }; export default Changelog; ================================================ FILE: ui/v2.5/src/components/Changelog/Version.tsx ================================================ import { faAngleDown, faAngleUp } from "@fortawesome/free-solid-svg-icons"; import React, { useState } from "react"; import { Button, Card, Collapse } from "react-bootstrap"; import { FormattedDate, FormattedMessage } from "react-intl"; import { Icon } from "src/components/Shared/Icon"; interface IVersionProps { version: string; date?: string; defaultOpen?: boolean; setOpenState: (key: string, state: boolean) => void; openState: Record; } const Version: React.FC = ({ version, date, defaultOpen, openState, setOpenState, children, }) => { const [open, setOpen] = useState( defaultOpen ?? openState[version + date] ?? false ); const updateState = () => { setOpenState(version + date, !open); setOpen(!open); }; return (

{children}
); }; export default Version; ================================================ FILE: ui/v2.5/src/components/Changelog/styles.scss ================================================ .changelog { margin-bottom: 4rem; .btn { color: inherit; font-size: inherit; font-weight: inherit; &:focus { text-decoration: unset; } &:hover { text-decoration: underline; } } .card, .card-body { padding: 0; } &-version { &-body { padding: 1rem 2rem; } &-header { color: $text-color; } } } ================================================ FILE: ui/v2.5/src/components/Dialogs/GenerateDialog.tsx ================================================ import React, { useState, useEffect, useMemo } from "react"; import { Form, Button } from "react-bootstrap"; import { mutateMetadataGenerate } from "src/core/StashService"; import { ModalComponent } from "../Shared/Modal"; import { Icon } from "src/components/Shared/Icon"; import { useToast } from "src/hooks/Toast"; import * as GQL from "src/core/generated-graphql"; import { FormattedMessage, useIntl } from "react-intl"; import { useConfigurationContext } from "src/hooks/Config"; import { Manual } from "../Help/Manual"; import { withoutTypename } from "src/utils/data"; import { GenerateOptions } from "../Settings/Tasks/GenerateOptions"; import { SettingSection } from "../Settings/SettingSection"; import { faCogs, faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { SettingsContext } from "../Settings/context"; interface IGenerateDialog { selectedIds?: string[]; onClose: () => void; type: "scene" | "image" | "gallery"; } export const GenerateDialog: React.FC = ({ selectedIds, onClose, type, }) => { const sceneIDs = type === "scene" ? selectedIds : undefined; const imageIDs = type === "image" ? selectedIds : undefined; const galleryIDs = type === "gallery" ? selectedIds : undefined; const { configuration } = useConfigurationContext(); function getDefaultOptions(): GQL.GenerateMetadataInput { return { sprites: true, phashes: true, previews: true, markers: true, previewOptions: { previewSegments: 0, previewSegmentDuration: 0, previewPreset: GQL.PreviewPreset.Slow, }, }; } const [options, setOptions] = useState( getDefaultOptions() ); const [configRead, setConfigRead] = useState(false); const [showManual, setShowManual] = useState(false); const [animation, setAnimation] = useState(true); const intl = useIntl(); const Toast = useToast(); useEffect(() => { if (configRead) { return; } // combine the defaults with the system preview generation settings if (configuration?.defaults.generate) { const { generate } = configuration.defaults; setOptions(withoutTypename(generate)); setConfigRead(true); } if (configuration?.general) { const { general } = configuration; setOptions((existing) => ({ ...existing, previewOptions: { ...existing.previewOptions, previewSegments: general.previewSegments ?? existing.previewOptions?.previewSegments, previewSegmentDuration: general.previewSegmentDuration ?? existing.previewOptions?.previewSegmentDuration, previewExcludeStart: general.previewExcludeStart ?? existing.previewOptions?.previewExcludeStart, previewExcludeEnd: general.previewExcludeEnd ?? existing.previewOptions?.previewExcludeEnd, previewPreset: general.previewPreset ?? existing.previewOptions?.previewPreset, }, })); setConfigRead(true); } }, [configuration, configRead]); const selectionStatus = useMemo(() => { const countableIds: Record = { scene: "countables.scenes", image: "countables.images", gallery: "countables.galleries", }; const countableId = countableIds[type]; if (selectedIds) { return ( . ); } const message = ( . ); return (
{message}
); }, [selectedIds, intl, type]); async function onGenerate() { try { await mutateMetadataGenerate({ ...options, sceneIDs, imageIDs, galleryIDs, }); Toast.success( intl.formatMessage( { id: "config.tasks.added_job_to_queue" }, { operation_name: intl.formatMessage({ id: "actions.generate" }) } ) ); } catch (e) { Toast.error(e); } finally { onClose(); } } function onShowManual() { setAnimation(false); setShowManual(true); } if (showManual) { return ( setShowManual(false)} defaultActiveTab="Tasks.md" /> ); } return ( onClose(), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} leftFooterButtons={ } >
{selectionStatus}
); }; export default GenerateDialog; ================================================ FILE: ui/v2.5/src/components/Dialogs/IdentifyDialog/FieldOptions.tsx ================================================ import React, { useState, useEffect, useCallback } from "react"; import { Form, Button, Table } from "react-bootstrap"; import { Icon } from "src/components/Shared/Icon"; import * as GQL from "src/core/generated-graphql"; import { FormattedMessage, useIntl } from "react-intl"; import { multiValueSceneFields, SceneField, sceneFieldMessageID, sceneFields, } from "./constants"; import { ThreeStateBoolean } from "./ThreeStateBoolean"; import { faCheck, faPencilAlt, faTimes, } from "@fortawesome/free-solid-svg-icons"; interface IFieldOptionsEditor { options: GQL.IdentifyFieldOptions | undefined; field: SceneField; editField: () => void; editOptions: (o?: GQL.IdentifyFieldOptions | null) => void; editing: boolean; allowSetDefault: boolean; defaultOptions?: GQL.IdentifyMetadataOptionsInput; } interface IFieldOptions { field: string; strategy: GQL.IdentifyFieldStrategy | undefined; createMissing?: GQL.Maybe | undefined; } const FieldOptionsEditor: React.FC = ({ options, field, editField, editOptions, editing, allowSetDefault, defaultOptions, }) => { const intl = useIntl(); const [localOptions, setLocalOptions] = useState(); const resetOptions = useCallback(() => { let toSet: IFieldOptions; if (!options) { // unset - use default values toSet = { field, strategy: undefined, createMissing: undefined, }; } else { toSet = { field, strategy: options.strategy, createMissing: options.createMissing, }; } setLocalOptions(toSet); }, [options, field]); useEffect(() => { resetOptions(); }, [resetOptions]); function renderField() { return intl.formatMessage({ id: sceneFieldMessageID(field) }); } function renderStrategy() { if (!localOptions) { return; } const strategies = Object.entries(GQL.IdentifyFieldStrategy); let { strategy } = localOptions; if (strategy === undefined) { if (!allowSetDefault) { strategy = GQL.IdentifyFieldStrategy.Merge; } } if (!editing) { if (strategy === undefined) { return intl.formatMessage({ id: "actions.use_default" }); } const f = strategies.find((s) => s[1] === strategy); return intl.formatMessage({ id: `actions.${f![0].toLowerCase()}`, }); } return ( {allowSetDefault ? ( setLocalOptions({ ...localOptions, strategy: undefined, }) } disabled={!editing} label={intl.formatMessage({ id: "actions.use_default" })} /> ) : undefined} {strategies.map((f) => ( setLocalOptions({ ...localOptions, strategy: f[1], }) } disabled={!editing} label={intl.formatMessage({ id: `actions.${f[0].toLowerCase()}`, })} /> ))} ); } function maybeRenderCreateMissing() { if (!localOptions) { return; } if ( multiValueSceneFields.includes(localOptions.field as SceneField) && localOptions.strategy !== GQL.IdentifyFieldStrategy.Ignore ) { const value = localOptions.createMissing === null ? undefined : localOptions.createMissing; if (!editing) { if (value === undefined && allowSetDefault) { return intl.formatMessage({ id: "actions.use_default" }); } if (value) { return ; } return ; } const defaultVal = defaultOptions?.fieldOptions?.find( (f) => f.field === localOptions.field )?.createMissing; // if allowSetDefault is false, then strategy is considered merge // if its true, then its using the default value and should not be shown here if (localOptions.strategy === undefined && allowSetDefault) { return; } return ( setLocalOptions({ ...localOptions, createMissing: v }) } defaultValue={defaultVal ?? undefined} /> ); } } function onEditOptions() { if (!localOptions) { return; } const localOptionsCopy = { ...localOptions }; if (localOptionsCopy.strategy === undefined && !allowSetDefault) { localOptionsCopy.strategy = GQL.IdentifyFieldStrategy.Merge; } // send null if strategy is undefined if (localOptionsCopy.strategy === undefined) { editOptions(null); resetOptions(); } else { let { createMissing } = localOptionsCopy; if (createMissing === undefined && !allowSetDefault) { createMissing = false; } editOptions({ ...localOptionsCopy, strategy: localOptionsCopy.strategy, createMissing, }); } } return (
); }; interface IFieldOptionsList { fieldOptions?: GQL.IdentifyFieldOptions[]; setFieldOptions: (o: GQL.IdentifyFieldOptions[]) => void; setEditingField: (v: boolean) => void; allowSetDefault?: boolean; defaultOptions?: GQL.IdentifyMetadataOptionsInput; } export const FieldOptionsList: React.FC = ({ fieldOptions, setFieldOptions, setEditingField, allowSetDefault = true, defaultOptions, }) => { const [localFieldOptions, setLocalFieldOptions] = useState(); const [editField, setEditField] = useState(); useEffect(() => { if (fieldOptions) { setLocalFieldOptions([...fieldOptions]); } else { setLocalFieldOptions([]); } }, [fieldOptions]); function handleEditOptions(o?: GQL.IdentifyFieldOptions | null) { if (!localFieldOptions) { return; } if (o !== undefined) { const newOptions = [...localFieldOptions]; const index = newOptions.findIndex( (option) => option.field === editField ); if (index !== -1) { // if null, then we're removing if (o === null) { newOptions.splice(index, 1); } else { // replace in list newOptions.splice(index, 1, o); } } else if (o !== null) { // don't add if null newOptions.push(o); } setFieldOptions(newOptions); } setEditField(undefined); setEditingField(false); } function onEditField(field: string) { setEditField(field); setEditingField(true); } if (!localFieldOptions) { return <>; } return (
{renderField()} {renderStrategy()} {maybeRenderCreateMissing()} {editing ? ( <> ) : ( <> )}
{/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} {sceneFields.map((f) => ( o.field === f)} editField={() => onEditField(f)} editOptions={handleEditOptions} editing={f === editField} defaultOptions={defaultOptions} /> ))}
); }; ================================================ FILE: ui/v2.5/src/components/Dialogs/IdentifyDialog/IdentifyDialog.tsx ================================================ import React, { useState, useEffect, useMemo } from "react"; import { Button, Form } from "react-bootstrap"; import { mutateMetadataIdentify, useConfiguration, useConfigureDefaults, useListSceneScrapers, } from "src/core/StashService"; import { Icon } from "src/components/Shared/Icon"; import { ModalComponent } from "src/components/Shared/Modal"; import { OperationButton } from "src/components/Shared/OperationButton"; import { useToast } from "src/hooks/Toast"; import * as GQL from "src/core/generated-graphql"; import { FormattedMessage, useIntl } from "react-intl"; import { withoutTypename } from "src/utils/data"; import { SCRAPER_PREFIX, STASH_BOX_PREFIX, } from "src/components/Tagger/constants"; import { DirectorySelectionDialog } from "src/components/Settings/Tasks/DirectorySelectionDialog"; import { Manual } from "src/components/Help/Manual"; import { IScraperSource } from "./constants"; import { OptionsEditor } from "./Options"; import { SourcesEditor, SourcesList } from "./Sources"; import { faCogs, faFolderOpen, faQuestionCircle, } from "@fortawesome/free-solid-svg-icons"; const autoTagScraperID = "builtin_autotag"; interface IIdentifyDialogProps { selectedIds?: string[]; onClose: () => void; } export const IdentifyDialog: React.FC = ({ selectedIds, onClose, }) => { function getDefaultOptions(): GQL.IdentifyMetadataOptionsInput { return { fieldOptions: [ { field: "title", strategy: GQL.IdentifyFieldStrategy.Overwrite, }, { field: "studio", strategy: GQL.IdentifyFieldStrategy.Merge, createMissing: true, }, { field: "performers", strategy: GQL.IdentifyFieldStrategy.Merge, createMissing: true, }, { field: "tags", strategy: GQL.IdentifyFieldStrategy.Merge, createMissing: true, }, ], performerGenders: undefined, setCoverImage: true, setOrganized: false, skipMultipleMatches: true, skipMultipleMatchTag: undefined, skipSingleNamePerformers: true, skipSingleNamePerformerTag: undefined, }; } const [configureDefaults] = useConfigureDefaults(); const [options, setOptions] = useState( getDefaultOptions() ); const [sources, setSources] = useState([]); const [editingSource, setEditingSource] = useState< IScraperSource | undefined >(); const [paths, setPaths] = useState([]); const [showManual, setShowManual] = useState(false); const [settingPaths, setSettingPaths] = useState(false); const [animation, setAnimation] = useState(true); const [editingField, setEditingField] = useState(false); const [savingDefaults, setSavingDefaults] = useState(false); const intl = useIntl(); const Toast = useToast(); const { data: configData, error: configError } = useConfiguration(); const { data: scraperData, error: scraperError } = useListSceneScrapers(); const allSources = useMemo(() => { if (!configData || !scraperData) return; const ret: IScraperSource[] = []; ret.push( ...configData.configuration.general.stashBoxes.map((b, i) => { return { id: `${STASH_BOX_PREFIX}${i}`, displayName: `stash-box: ${b.name}`, stash_box_endpoint: b.endpoint, }; }) ); const scrapers = scraperData.listScrapers; const fragmentScrapers = scrapers.filter((s) => s.scene?.supported_scrapes.includes(GQL.ScrapeType.Fragment) ); ret.push( ...fragmentScrapers.map((s) => { return { id: `${SCRAPER_PREFIX}${s.id}`, displayName: s.name, scraper_id: s.id, }; }) ); return ret; }, [configData, scraperData]); const selectionStatus = useMemo(() => { if (selectedIds) { return ( . ); } const message = paths.length ? (
:
    {paths.map((p) => (
  • {p}
  • ))}
) : ( . ); function onClick() { setAnimation(false); setSettingPaths(true); } return (
{message}
); }, [selectedIds, intl, paths]); useEffect(() => { if (!configData || !allSources) return; const { identify: identifyDefaults } = configData.configuration.defaults; if (identifyDefaults) { const mappedSources = identifyDefaults.sources .map((s) => { const found = allSources.find( (ss) => ss.scraper_id === s.source.scraper_id || ss.stash_box_endpoint === s.source.stash_box_endpoint ); if (!found) return; const ret: IScraperSource = { ...found, }; if (s.options) { const sourceOptions = withoutTypename(s.options); sourceOptions.fieldOptions = sourceOptions.fieldOptions?.map(withoutTypename); ret.options = sourceOptions; } return ret; }) .filter((s) => s) as IScraperSource[]; setSources(mappedSources); if (identifyDefaults.options) { const defaultOptions = withoutTypename(identifyDefaults.options); defaultOptions.fieldOptions = defaultOptions.fieldOptions?.map(withoutTypename); setOptions(defaultOptions); } } else { // default to first stash-box instance only const stashBox = allSources.find((s) => s.stash_box_endpoint); // add auto-tag as well const autoTag = allSources.find( (s) => s.id === `${SCRAPER_PREFIX}${autoTagScraperID}` ); const newSources: IScraperSource[] = []; if (stashBox) { newSources.push(stashBox); } // sanity check - this should always be true if (autoTag) { // don't set organised by default const autoTagCopy = { ...autoTag }; autoTagCopy.options = { setOrganized: false, skipMultipleMatches: true, skipSingleNamePerformers: true, }; newSources.push(autoTagCopy); } setSources(newSources); } }, [allSources, configData]); if (configError || scraperError) return
{configError ?? scraperError}
; if (!allSources || !configData) return
; function makeIdentifyInput(): GQL.IdentifyMetadataInput { return { sources: sources.map((s) => { return { source: { scraper_id: s.scraper_id, stash_box_endpoint: s.stash_box_endpoint, }, options: s.options, }; }), options, sceneIDs: selectedIds, paths, }; } function makeDefaultIdentifyInput() { const ret = makeIdentifyInput(); const { sceneIDs, paths: _paths, ...withoutSpecifics } = ret; return withoutSpecifics; } async function onIdentify() { try { await mutateMetadataIdentify(makeIdentifyInput()); Toast.success( intl.formatMessage( { id: "config.tasks.added_job_to_queue" }, { operation_name: intl.formatMessage({ id: "actions.identify" }) } ) ); } catch (e) { Toast.error(e); } finally { onClose(); } } function getAvailableSources() { // only include scrapers not already present return !editingSource?.id === undefined ? [] : allSources?.filter((s) => { return !sources.some((ss) => ss.id === s.id); }) ?? []; } function onEditSource(s?: IScraperSource) { setAnimation(false); // if undefined, then set a dummy source to create a new one if (!s) { setEditingSource(getAvailableSources()[0]); } else { setEditingSource(s); } } function onShowManual() { setAnimation(false); setShowManual(true); } function isNewSource() { return !!editingSource && !sources.includes(editingSource); } function onSaveSource(s?: IScraperSource) { if (s) { let found = false; const newSources = sources.map((ss) => { if (ss.id === s.id) { found = true; return s; } return ss; }); if (!found) { newSources.push(s); } setSources(newSources); } setEditingSource(undefined); } async function setAsDefault() { try { setSavingDefaults(true); await configureDefaults({ variables: { input: { identify: makeDefaultIdentifyInput(), }, }, }); Toast.success( intl.formatMessage( { id: "config.tasks.defaults_set" }, { action: intl.formatMessage({ id: "actions.identify" }) } ) ); } catch (e) { Toast.error(e); } finally { setSavingDefaults(false); } } if (editingSource) { return ( ); } if (settingPaths) { return ( { if (p) { setPaths(p); } setSettingPaths(false); }} /> ); } if (showManual) { return ( setShowManual(false)} defaultActiveTab="Identify.md" /> ); } return ( onClose(), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} disabled={editingField || savingDefaults || sources.length === 0} footerButtons={ } leftFooterButtons={ } >
{selectionStatus} setSources(s)} editSource={onEditSource} canAdd={sources.length < allSources.length} /> setOptions(o)} setEditingField={(v) => setEditingField(v)} />
); }; export default IdentifyDialog; ================================================ FILE: ui/v2.5/src/components/Dialogs/IdentifyDialog/Options.tsx ================================================ import React from "react"; import { Col, Form, Row } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import { FormattedMessage, useIntl } from "react-intl"; import { IScraperSource } from "./constants"; import { FieldOptionsList } from "./FieldOptions"; import { ThreeStateBoolean } from "./ThreeStateBoolean"; import { TagSelect } from "src/components/Shared/Select"; import { genderList } from "src/utils/gender"; interface IOptionsEditor { options: GQL.IdentifyMetadataOptionsInput; setOptions: (s: GQL.IdentifyMetadataOptionsInput) => void; source?: IScraperSource; defaultOptions?: GQL.IdentifyMetadataOptionsInput; setEditingField: (v: boolean) => void; } export const OptionsEditor: React.FC = ({ options, setOptions: setOptionsState, source, setEditingField, defaultOptions, }) => { const intl = useIntl(); function setOptions(v: Partial) { setOptionsState({ ...options, ...v }); } const headingID = !source ? "config.tasks.identify.default_options" : "config.tasks.identify.source_options"; const checkboxProps = { allowUndefined: !!source, indeterminateClassname: "text-muted", }; function maybeRenderMultipleMatchesTag() { if (!options.skipMultipleMatches) { return; } return ( setOptions({ skipMultipleMatchTag: tags[0]?.id, }) } ids={ options.skipMultipleMatchTag ? [options.skipMultipleMatchTag] : [] } noSelectionString="Select/create tag..." menuPortalTarget={document.body} /> ); } function maybeRenderPerformersTag() { if (!options.skipSingleNamePerformers) { return; } return ( setOptions({ skipSingleNamePerformerTag: tags[0]?.id, }) } ids={ options.skipSingleNamePerformerTag ? [options.skipSingleNamePerformerTag] : [] } noSelectionString="Select/create tag..." menuPortalTarget={document.body} /> ); } return (
{!source && ( {intl.formatMessage({ id: "config.tasks.identify.explicit_set_description", })} )}
{source && ( ) => { if (e.currentTarget.checked) { setOptions({ performerGenders: undefined }); } else { setOptions({ performerGenders: defaultOptions?.performerGenders ?? genderList.slice(), }); } }} /> )} {(options.performerGenders != null || !source) && genderList.map((gender) => { const performerGenders = options.performerGenders ?? genderList.slice(); return ( } checked={performerGenders.includes(gender)} onChange={(e: React.ChangeEvent) => { const isChecked = e.currentTarget.checked; setOptions({ performerGenders: isChecked ? [...performerGenders, gender] : performerGenders.filter((g) => g !== gender), }); }} /> ); })} setOptions({ setCoverImage: v, }) } label={intl.formatMessage({ id: "config.tasks.identify.set_cover_images", })} defaultValue={defaultOptions?.setCoverImage ?? undefined} {...checkboxProps} /> setOptions({ setOrganized: v, }) } label={intl.formatMessage({ id: "config.tasks.identify.set_organized", })} defaultValue={defaultOptions?.setOrganized ?? undefined} {...checkboxProps} /> setOptions({ skipMultipleMatches: v, }) } label={intl.formatMessage({ id: "config.tasks.identify.skip_multiple_matches", })} defaultValue={defaultOptions?.skipMultipleMatches ?? undefined} tooltip={intl.formatMessage({ id: "config.tasks.identify.skip_multiple_matches_tooltip", })} {...checkboxProps} /> {maybeRenderMultipleMatchesTag()} setOptions({ skipSingleNamePerformers: v, }) } label={intl.formatMessage({ id: "config.tasks.identify.skip_single_name_performers", })} defaultValue={defaultOptions?.skipSingleNamePerformers ?? undefined} tooltip={intl.formatMessage({ id: "config.tasks.identify.skip_single_name_performers_tooltip", })} {...checkboxProps} /> {maybeRenderPerformersTag()} setOptions({ fieldOptions: o })} setEditingField={setEditingField} allowSetDefault={!!source} defaultOptions={defaultOptions} />
); }; ================================================ FILE: ui/v2.5/src/components/Dialogs/IdentifyDialog/Sources.tsx ================================================ import React, { useState, useEffect } from "react"; import { Form, Button, ListGroup } from "react-bootstrap"; import { ModalComponent } from "src/components/Shared/Modal"; import { Icon } from "src/components/Shared/Icon"; import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { IScraperSource } from "./constants"; import { OptionsEditor } from "./Options"; import { faCog, faGripVertical, faMinus, faPencilAlt, faPlus, } from "@fortawesome/free-solid-svg-icons"; interface ISourceEditor { isNew: boolean; availableSources: IScraperSource[]; source: IScraperSource; saveSource: (s?: IScraperSource) => void; defaultOptions: GQL.IdentifyMetadataOptionsInput; } export const SourcesEditor: React.FC = ({ isNew, availableSources, source: initialSource, saveSource, defaultOptions, }) => { const [source, setSource] = useState(initialSource); const [editingField, setEditingField] = useState(false); const intl = useIntl(); // if id is empty, then we are adding a new source const headerMsgId = isNew ? "actions.add" : "dialogs.edit_entity_title"; const acceptMsgId = isNew ? "actions.add" : "actions.confirm"; function handleSourceSelect(e: React.ChangeEvent) { const selectedSource = availableSources.find( (s) => s.id === e.currentTarget.value ); if (!selectedSource) return; setSource({ ...source, id: selectedSource.id, displayName: selectedSource.displayName, scraper_id: selectedSource.scraper_id, stash_box_endpoint: selectedSource.stash_box_endpoint, }); } return ( saveSource(source), text: intl.formatMessage({ id: acceptMsgId }), }} cancel={{ onClick: () => saveSource(), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} disabled={ (!source.scraper_id && !source.stash_box_endpoint) || editingField } >
{isNew && (
{availableSources.map((i) => ( ))}
)} setSource({ ...source, options: o })} source={source} setEditingField={(v) => setEditingField(v)} defaultOptions={defaultOptions} />
); }; interface ISourcesList { sources: IScraperSource[]; setSources: (s: IScraperSource[]) => void; editSource: (s?: IScraperSource) => void; canAdd: boolean; } export const SourcesList: React.FC = ({ sources, setSources, editSource, canAdd, }) => { const [tempSources, setTempSources] = useState(sources); const [dragIndex, setDragIndex] = useState(); const [mouseOverIndex, setMouseOverIndex] = useState(); useEffect(() => { setTempSources([...sources]); }, [sources]); function removeSource(index: number) { const newSources = [...sources]; newSources.splice(index, 1); setSources(newSources); } function onDragStart(event: React.DragEvent, index: number) { event.dataTransfer.effectAllowed = "move"; setDragIndex(index); } function onDragOver(event: React.DragEvent, index?: number) { if (dragIndex !== undefined && index !== undefined && index !== dragIndex) { const newSources = [...tempSources]; const moved = newSources.splice(dragIndex, 1); newSources.splice(index, 0, moved[0]); setTempSources(newSources); setDragIndex(index); } event.dataTransfer.dropEffect = "move"; event.preventDefault(); } function onDragOverDefault(event: React.DragEvent) { event.dataTransfer.dropEffect = "move"; event.preventDefault(); } function onDrop() { // assume we've already set the temp source list // feed it up setSources(tempSources); setDragIndex(undefined); setMouseOverIndex(undefined); } return (
{tempSources.map((s, index) => ( onDragStart(e, index)} onDragEnter={(e) => onDragOver(e, index)} onDrop={() => onDrop()} >
setMouseOverIndex(index)} onMouseLeave={() => setMouseOverIndex(undefined)} >
{s.displayName}
))}
{canAdd && (
)}
); }; ================================================ FILE: ui/v2.5/src/components/Dialogs/IdentifyDialog/ThreeStateBoolean.tsx ================================================ import React from "react"; import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; interface IThreeStateBoolean { id: string; value: boolean | undefined; setValue: (v: boolean | undefined) => void; allowUndefined?: boolean; label?: React.ReactNode; disabled?: boolean; defaultValue?: boolean; tooltip?: string | undefined; } export const ThreeStateBoolean: React.FC = ({ id, value, setValue, allowUndefined = true, label, disabled, defaultValue, tooltip, }) => { const intl = useIntl(); if (!allowUndefined) { return ( setValue(!value)} title={tooltip} /> ); } function getBooleanText(v: boolean) { if (v) { return intl.formatMessage({ id: "true" }); } return intl.formatMessage({ id: "false" }); } function getButtonText(v: boolean | undefined) { if (v === undefined) { const defaultVal = defaultValue !== undefined ? ( {" "} ({getBooleanText(defaultValue)}) ) : ( "" ); return ( {intl.formatMessage({ id: "actions.use_default" })} {defaultVal} ); } return getBooleanText(v); } function renderModeButton(v: boolean | undefined) { return ( setValue(v)} disabled={disabled} label={getButtonText(v)} /> ); } return (
{label}
{renderModeButton(undefined)} {renderModeButton(false)} {renderModeButton(true)}
); }; ================================================ FILE: ui/v2.5/src/components/Dialogs/IdentifyDialog/constants.ts ================================================ import * as GQL from "src/core/generated-graphql"; export interface IScraperSource { id: string; displayName: string; stash_box_endpoint?: string; scraper_id?: string; options?: GQL.IdentifyMetadataOptionsInput; } export const sceneFields = [ "title", "code", "date", "director", "details", "url", "studio", "performers", "tags", "stash_ids", ] as const; export type SceneField = (typeof sceneFields)[number]; export const multiValueSceneFields: SceneField[] = [ "studio", "performers", "tags", ]; export function sceneFieldMessageID(field: SceneField) { if (field === "code") { return "scene_code"; } else if (field === "studio") { return "studio_and_parent"; } return field; } ================================================ FILE: ui/v2.5/src/components/Dialogs/IdentifyDialog/styles.scss ================================================ .identify-source-editor { .default-value { color: #bfccd6; } } .scraper-source-list { .list-group-item { background-color: $textfield-bg; padding: 0.25em; .drag-handle { cursor: move; display: inline-block; margin: -0.25em 0.25em -0.25em -0.25em; padding: 0.25em 0.5em 0.25em; } .drag-handle:hover, .drag-handle:active, .drag-handle:focus, .drag-handle:focus:active { background-color: initial; border-color: initial; box-shadow: initial; } } } .scraper-sources { .add-scraper-source-button { margin-right: 0.25em; } } ================================================ FILE: ui/v2.5/src/components/Dialogs/ReleaseNotesDialog.tsx ================================================ import React from "react"; import { ModalComponent } from "../Shared/Modal"; import { faCogs } from "@fortawesome/free-solid-svg-icons"; import { useIntl } from "react-intl"; import { MarkdownPage } from "../Shared/MarkdownPage"; import { IReleaseNotes } from "src/docs/en/ReleaseNotes"; interface IReleaseNotesDialog { notes: IReleaseNotes[]; onClose: () => void; } export const ReleaseNotesDialog: React.FC = ({ notes, onClose, }) => { const intl = useIntl(); return (
{notes .map((n, i) => (

{n.version}

)) .reduce((accu, curr) => ( <> {accu}
{curr} ))}
); }; export default ReleaseNotesDialog; ================================================ FILE: ui/v2.5/src/components/Dialogs/SubmitDraft.tsx ================================================ import React, { useEffect, useState } from "react"; import { Form } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import { mutateSubmitStashBoxPerformerDraft, mutateSubmitStashBoxSceneDraft, } from "src/core/StashService"; import { ModalComponent } from "src/components/Shared/Modal"; import { getStashboxBase } from "src/utils/stashbox"; import { FormattedMessage, useIntl } from "react-intl"; import { faPaperPlane } from "@fortawesome/free-solid-svg-icons"; import { ExternalLink } from "../Shared/ExternalLink"; interface IProps { type: "scene" | "performer"; entity: Pick< GQL.SceneDataFragment | GQL.PerformerDataFragment, "id" | "stash_ids" >; boxes: Pick[]; show: boolean; onHide: () => void; } export const SubmitStashBoxDraft: React.FC = ({ type, boxes, entity, show, onHide, }) => { const intl = useIntl(); const [selectedBoxIndex, setSelectedBoxIndex] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [reviewUrl, setReviewUrl] = useState(); // this can be undefined, if e.g. boxes is empty // since we aren't using noUncheckedIndexedAccess, add undefined explicitly const selectedBox: (typeof boxes)[number] | undefined = boxes[selectedBoxIndex]; // #4354: reset state when shown, or if any props change useEffect(() => { if (show) { setSelectedBoxIndex(0); setLoading(false); setError(undefined); setReviewUrl(undefined); } }, [show, type, boxes, entity]); async function doSubmit() { if (!selectedBox) return; const input = { id: entity.id, stash_box_endpoint: selectedBox.endpoint, }; if (type === "scene") { const r = await mutateSubmitStashBoxSceneDraft(input); return r.data?.submitStashBoxSceneDraft; } else if (type === "performer") { const r = await mutateSubmitStashBoxPerformerDraft(input); return r.data?.submitStashBoxPerformerDraft; } } async function onSubmit() { if (!selectedBox) return; try { setLoading(true); const responseId = await doSubmit(); const stashboxBase = getStashboxBase(selectedBox.endpoint); if (responseId) { setReviewUrl(`${stashboxBase}drafts/${responseId}`); } else { // if the mutation returned a null id but didn't error, then just link to the drafts page setReviewUrl(`${stashboxBase}drafts`); } } catch (e) { if (e instanceof Error && e.message) { setError(e.message); } else { setError(String(e)); } } finally { setLoading(false); } } function renderContents() { if (error !== undefined) { return ( <>
{error}
); } else if (reviewUrl !== undefined) { return ( <>
); } else { return ( : setSelectedBoxIndex(Number(e.currentTarget.value))} value={selectedBoxIndex} className="col-6 input-control" > {boxes.map((box, i) => ( ))} ); } } function getFooterProps() { if (error !== undefined || reviewUrl !== undefined) { return { accept: { onClick: () => onHide(), }, }; } // If the scene has an attached stash_id from that endpoint, the operation will be an update const isUpdate = entity.stash_ids.find((id) => id.endpoint === selectedBox?.endpoint) !== undefined; return { footerButtons: isUpdate && !loading && ( ), accept: { onClick: () => onSubmit(), text: intl.formatMessage({ id: isUpdate ? "actions.submit_update" : "actions.submit", }), variant: isUpdate ? "primary" : "success", }, cancel: { onClick: () => onHide(), variant: "secondary", }, }; } return ( {renderContents()} ); }; export default SubmitStashBoxDraft; ================================================ FILE: ui/v2.5/src/components/Dialogs/styles.scss ================================================ @import "IdentifyDialog/styles.scss"; .dialog-selected-folders { & > div { display: flex; justify-content: space-between; } } .form-group { h6, label { &[title]:not([title=""]) { cursor: help; text-decoration: underline dotted; } } } ================================================ FILE: ui/v2.5/src/components/ErrorBoundary.tsx ================================================ import React from "react"; import { FormattedMessage } from "react-intl"; import { isLazyComponentError } from "src/utils/lazyComponent"; interface IErrorBoundaryProps { children?: React.ReactNode; } type ErrorInfo = { componentStack: string; }; interface IErrorBoundaryState { error?: Error; errorHelpId?: string; errorInfo?: ErrorInfo; } export class ErrorBoundary extends React.Component< IErrorBoundaryProps, IErrorBoundaryState > { constructor(props: IErrorBoundaryProps) { super(props); this.state = {}; } public componentDidCatch(error: Error, errorInfo: ErrorInfo) { let errorHelpId: string | undefined; if (isLazyComponentError(error)) { errorHelpId = "errors.lazy_component_error_help"; } this.setState({ error, errorHelpId, errorInfo, }); } public render() { const { error, errorHelpId, errorInfo } = this.state; if (errorInfo) { // Error path return (

{errorHelpId && (
)}
{error?.toString()}
{errorInfo.componentStack.trim().replaceAll(/^\s*/gm, " ")}
); } // Normally, just render children return this.props.children; } } ================================================ FILE: ui/v2.5/src/components/FrontPage/Control.tsx ================================================ import React, { useMemo } from "react"; import { useIntl } from "react-intl"; import { FrontPageContent, ICustomFilter } from "src/core/config"; import * as GQL from "src/core/generated-graphql"; import { useFindSavedFilter } from "src/core/StashService"; import { useConfigurationContext } from "src/hooks/Config"; import { ListFilterModel } from "src/models/list-filter/filter"; import { GalleryRecommendationRow } from "../Galleries/GalleryRecommendationRow"; import { ImageRecommendationRow } from "../Images/ImageRecommendationRow"; import { GroupRecommendationRow } from "../Groups/GroupRecommendationRow"; import { PerformerRecommendationRow } from "../Performers/PerformerRecommendationRow"; import { SceneRecommendationRow } from "../Scenes/SceneRecommendationRow"; import { StudioRecommendationRow } from "../Studios/StudioRecommendationRow"; import { TagRecommendationRow } from "../Tags/TagRecommendationRow"; import { SceneMarkerRecommendationRow } from "../Scenes/SceneMarkerRecommendationRow"; interface IFilter { mode: GQL.FilterMode; filter: ListFilterModel; header: string; } const RecommendationRow: React.FC = ({ mode, filter, header }) => { function isTouchEnabled() { return "ontouchstart" in window || navigator.maxTouchPoints > 0; } const isTouch = isTouchEnabled(); switch (mode) { case GQL.FilterMode.Scenes: return ( ); case GQL.FilterMode.Studios: return ( ); case GQL.FilterMode.Movies: case GQL.FilterMode.Groups: return ( ); case GQL.FilterMode.Performers: return ( ); case GQL.FilterMode.Galleries: return ( ); case GQL.FilterMode.Images: return ( ); case GQL.FilterMode.Tags: return ( ); case GQL.FilterMode.SceneMarkers: return ( ); default: return <>; } }; interface ISavedFilterResults { savedFilterID: string; } const SavedFilterResults: React.FC = ({ savedFilterID, }) => { const { configuration: config } = useConfigurationContext(); const { loading, data } = useFindSavedFilter(savedFilterID.toString()); const filter = useMemo(() => { if (!data?.findSavedFilter) return; const { mode } = data.findSavedFilter; const ret = new ListFilterModel(mode, config); ret.currentPage = 1; ret.configureFromSavedFilter(data.findSavedFilter); ret.randomSeed = -1; return ret; }, [data?.findSavedFilter, config]); if (loading || !data?.findSavedFilter || !filter) { return <>; } const { name, mode } = data.findSavedFilter; return ; }; interface ICustomFilterProps { customFilter: ICustomFilter; } const CustomFilterResults: React.FC = ({ customFilter, }) => { const { configuration: config } = useConfigurationContext(); const intl = useIntl(); const filter = useMemo(() => { const itemsPerPage = 25; const ret = new ListFilterModel(customFilter.mode, config); ret.sortBy = customFilter.sortBy; ret.sortDirection = customFilter.direction; ret.itemsPerPage = itemsPerPage; ret.currentPage = 1; ret.randomSeed = -1; return ret; }, [customFilter, config]); const header = customFilter.message ? intl.formatMessage( { id: customFilter.message.id }, customFilter.message.values ) : customFilter.title ?? ""; return ( ); }; interface IProps { content: FrontPageContent; } export const Control: React.FC = ({ content }) => { switch (content.__typename) { case "SavedFilter": if (!content.savedFilterId) { return
Error: missing savedFilterId
; } return ( ); case "CustomFilter": return ; default: return <>; } }; ================================================ FILE: ui/v2.5/src/components/FrontPage/FilteredRecommendationRow.tsx ================================================ import React from "react"; import { Link } from "react-router-dom"; import Slider from "@ant-design/react-slick"; import { ListFilterModel } from "src/models/list-filter/filter"; import { getSlickSliderSettings } from "src/core/recommendations"; import { RecommendationRow } from "../FrontPage/RecommendationRow"; import { FormattedMessage } from "react-intl"; import { PatchComponent } from "src/patch"; import { UnsupportedCriterion } from "src/models/list-filter/criteria/criterion"; import { PopoverCard, WarningHoverPopover } from "../Shared/HoverPopover"; interface IProps { className?: string; isTouch: boolean; filter: ListFilterModel; heading: string; count: number; loading: boolean; url: string; } export const FilteredRecommendationRow: React.FC = PatchComponent( "FilteredRecommendationRow", (props) => { const cardCount = props.count; const unsupportedCriteria = props.filter.criteria.filter( (criterion) => criterion instanceof UnsupportedCriterion ); const header = unsupportedCriteria.length ? (
{props.heading} c.criterionOption.type) .join(", "), }} /> } />
) : ( props.heading ); if (!props.loading && !cardCount) { return null; } return ( } > {props.children} ); } ); ================================================ FILE: ui/v2.5/src/components/FrontPage/FrontPage.tsx ================================================ import React, { useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { useConfigureUI } from "src/core/StashService"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { Button } from "react-bootstrap"; import { FrontPageConfig } from "./FrontPageConfig"; import { useToast } from "src/hooks/Toast"; import { Control } from "./Control"; import { useConfigurationContext } from "src/hooks/Config"; import { FrontPageContent, generateDefaultFrontPageContent, getFrontPageContent, } from "src/core/config"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { PatchComponent } from "src/patch"; const FrontPage: React.FC = PatchComponent("FrontPage", () => { const intl = useIntl(); const Toast = useToast(); const [isEditing, setIsEditing] = useState(false); const [saving, setSaving] = useState(false); const [saveUI] = useConfigureUI(); const { configuration } = useConfigurationContext(); useScrollToTopOnMount(); async function onUpdateConfig(content?: FrontPageContent[]) { setIsEditing(false); if (!content) { return; } setSaving(true); try { await saveUI({ variables: { input: { ...configuration?.ui, frontPageContent: content, }, }, }); } catch (e) { Toast.error(e); } setSaving(false); } if (saving) { return ; } if (isEditing) { return onUpdateConfig(content)} />; } const ui = configuration?.ui ?? {}; if (!ui.frontPageContent) { const defaultContent = generateDefaultFrontPageContent(intl); onUpdateConfig(defaultContent); } const frontPageContent = getFrontPageContent(ui); return (
{frontPageContent?.map((content, i) => ( ))}
); }); export default FrontPage; ================================================ FILE: ui/v2.5/src/components/FrontPage/FrontPageConfig.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { FormattedMessage, IntlShape, useIntl } from "react-intl"; import { useFindSavedFilters } from "src/core/StashService"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { Button, Form, Modal } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import { useConfigurationContext } from "src/hooks/Config"; import { ISavedFilterRow, ICustomFilter, FrontPageContent, generatePremadeFrontPageContent, getFrontPageContent, } from "src/core/config"; interface IAddSavedFilterModalProps { onClose: (content?: FrontPageContent) => void; existingSavedFilterIDs: string[]; candidates: GQL.FindSavedFiltersQuery; } const FilterModeToMessageID = { [GQL.FilterMode.Galleries]: "galleries", [GQL.FilterMode.Images]: "images", [GQL.FilterMode.Movies]: "groups", [GQL.FilterMode.Groups]: "groups", [GQL.FilterMode.Performers]: "performers", [GQL.FilterMode.SceneMarkers]: "markers", [GQL.FilterMode.Scenes]: "scenes", [GQL.FilterMode.Studios]: "studios", [GQL.FilterMode.Tags]: "tags", }; type SavedFilter = Pick; function filterTitle(intl: IntlShape, f: SavedFilter) { const typeMessage = intl.formatMessage({ id: FilterModeToMessageID[f.mode] }); return `${typeMessage}: ${f.name}`; } const AddContentModal: React.FC = ({ onClose, existingSavedFilterIDs, candidates, }) => { const intl = useIntl(); const premadeFilterOptions = useMemo( () => generatePremadeFrontPageContent(intl), [intl] ); const [contentType, setContentType] = useState( "front_page.types.premade_filter" ); const [premadeFilterIndex, setPremadeFilterIndex] = useState< number | undefined >(0); const [savedFilter, setSavedFilter] = useState(); function onTypeSelected(t: string) { setContentType(t); switch (t) { case "front_page.types.premade_filter": setPremadeFilterIndex(0); setSavedFilter(undefined); break; case "front_page.types.saved_filter": setPremadeFilterIndex(undefined); setSavedFilter(undefined); break; } } function isValid() { switch (contentType) { case "front_page.types.premade_filter": return premadeFilterIndex !== undefined; case "front_page.types.saved_filter": return savedFilter !== undefined; } return false; } const savedFilterOptions = useMemo(() => { const ret = [ { value: "", text: "", }, ].concat( candidates.findSavedFilters .filter((f) => { return !existingSavedFilterIDs.includes(f.id); }) .map((f) => { return { value: f.id, text: filterTitle(intl, f), }; }) ); ret.sort((a, b) => { return a.text.localeCompare(b.text); }); return ret; }, [candidates, existingSavedFilterIDs, intl]); function renderTypeSelect() { const options = [ "front_page.types.premade_filter", "front_page.types.saved_filter", ]; return ( onTypeSelected(e.target.value)} className="btn-secondary" > {options.map((c) => ( ))} ); } function maybeRenderPremadeFiltersSelect() { if (contentType !== "front_page.types.premade_filter") return; return ( setPremadeFilterIndex(parseInt(e.target.value))} className="btn-secondary" > {premadeFilterOptions.map((c, i) => ( ))} ); } function maybeRenderSavedFiltersSelect() { if (contentType !== "front_page.types.saved_filter") return; return ( setSavedFilter(e.target.value)} className="btn-secondary" > {savedFilterOptions.map((c) => ( ))} ); } function doAdd() { switch (contentType) { case "front_page.types.premade_filter": onClose(premadeFilterOptions[premadeFilterIndex!]); return; case "front_page.types.saved_filter": onClose({ __typename: "SavedFilter", savedFilterId: parseInt(savedFilter!), }); return; } onClose(); } return ( onClose()}>
{renderTypeSelect()} {maybeRenderSavedFiltersSelect()} {maybeRenderPremadeFiltersSelect()}
); }; interface IFilterRowProps { content: FrontPageContent; allSavedFilters: SavedFilter[]; onDelete: () => void; } const ContentRow: React.FC = (props: IFilterRowProps) => { const intl = useIntl(); function title() { switch (props.content.__typename) { case "SavedFilter": const savedFilterId = String(props.content.savedFilterId); const savedFilter = props.allSavedFilters.find( (f) => f.id === savedFilterId ); if (!savedFilter) return ""; return filterTitle(intl, savedFilter); case "CustomFilter": const asCustomFilter = props.content as ICustomFilter; if (asCustomFilter.message) return intl.formatMessage( { id: asCustomFilter.message.id }, asCustomFilter.message.values ); return asCustomFilter.title ?? ""; } } return (

{title()}

); }; interface IFrontPageConfigProps { onClose: (content?: FrontPageContent[]) => void; } export const FrontPageConfig: React.FC = ({ onClose, }) => { const { configuration } = useConfigurationContext(); const ui = configuration?.ui; const { data: allFilters, loading } = useFindSavedFilters(); const [isAdd, setIsAdd] = useState(false); const [currentContent, setCurrentContent] = useState([]); const [dragIndex, setDragIndex] = useState(); useEffect(() => { if (!allFilters?.findSavedFilters) { return; } const frontPageContent = getFrontPageContent(ui); if (frontPageContent) { setCurrentContent( // filter out rows where the saved filter no longer exists frontPageContent.filter((r) => { if (r.__typename === "SavedFilter") { const savedFilterId = String(r.savedFilterId); return allFilters.findSavedFilters.some( (f) => f.id === savedFilterId ); } return true; }) ); } }, [allFilters, ui]); function onDragStart(event: React.DragEvent, index: number) { event.dataTransfer.effectAllowed = "move"; setDragIndex(index); } function onDragOver(event: React.DragEvent, index?: number) { if (dragIndex !== undefined && index !== undefined && index !== dragIndex) { const newFilters = [...currentContent]; const moved = newFilters.splice(dragIndex, 1); newFilters.splice(index, 0, moved[0]); setCurrentContent(newFilters); setDragIndex(index); } event.dataTransfer.dropEffect = "move"; event.preventDefault(); } function onDragOverDefault(event: React.DragEvent) { event.dataTransfer.dropEffect = "move"; event.preventDefault(); } function onDrop() { // assume we've already set the temp filter list // feed it up setDragIndex(undefined); } if (loading) { return ; } const existingSavedFilterIDs = currentContent .filter( (f) => f.__typename === "SavedFilter" && (f as ISavedFilterRow).savedFilterId ) .map((f) => (f as ISavedFilterRow).savedFilterId.toString()); function addSavedFilter(content?: FrontPageContent) { setIsAdd(false); if (!content) { return; } setCurrentContent([...currentContent, content]); } function deleteSavedFilter(index: number) { setCurrentContent(currentContent.filter((f, i) => i !== index)); } return ( <> {isAdd && allFilters && ( )}
{currentContent.map((content, index) => (
onDragStart(e, index)} onDragEnter={(e) => onDragOver(e, index)} onDrop={() => onDrop()} > deleteSavedFilter(index)} />
))}
); }; ================================================ FILE: ui/v2.5/src/components/FrontPage/RecommendationRow.tsx ================================================ import React, { PropsWithChildren } from "react"; import { PatchComponent } from "src/patch"; interface IProps { className?: string; header: React.ReactNode; link: JSX.Element; } export const RecommendationRow: React.FC> = PatchComponent( "RecommendationRow", ({ className, header, link, children }) => (

{header}

{link}
{children}
) ); ================================================ FILE: ui/v2.5/src/components/FrontPage/styles.scss ================================================ .recommendations-container { padding-left: 20px; padding-right: 20px; @media (max-width: 576px) { padding-left: 0; padding-right: 0; } .recommendations-footer { display: flex; justify-content: right; margin-bottom: 1em; margin-top: 1em; button:not(:last-child) { margin-right: 10px; } } } .no-recommendations { font-size: 1.5rem; padding-top: 2rem; text-align: center; } .recommendation-row-head { align-items: center; border-radius: 0; -webkit-box-align: center; -webkit-box-pack: justify; display: flex; justify-content: space-between; padding: 15px 0; } .recommendations-container-edit { .recommendation-row { background-color: $secondary; margin-bottom: 10px; &:not(.recommendation-row-add) { cursor: grab; } } .recommendation-row-add .recommendation-row-head { justify-content: center; } .recommendation-row-head { padding: 15px 10px; } } .recommendation-row-head h2 { display: inline-flex; font-size: 1.25rem; font-weight: 600; margin-bottom: 0; text-transform: uppercase; white-space: normal; } .recommendation-row-head a { display: inline-flex; font-size: 1.2rem; white-space: normal; } .card hr { margin-top: auto; } /* skeletons */ .skeleton-card { -webkit-animation: cardLoadingAnimation 2s infinite ease-in-out; -moz-animation: cardLoadingAnimation 2s infinite ease-in-out; -o-animation: cardLoadingAnimation 2s infinite ease-in-out; animation: cardLoadingAnimation 2s infinite ease-in-out; background-clip: border-box; background-color: #30404d; border: 1px solid rgba(0, 0, 0, 0.13); border-radius: 3px; box-shadow: 0 0 0 1px #10161a66, 0 0 #10161a00, 0 0 #10161a00; display: flex; flex-direction: column; margin: 5px; overflow: hidden; padding: 0; position: relative; word-wrap: break-word; } @keyframes cardLoadingAnimation { 50% { opacity: 0.5; } } .scene-skeleton { max-width: 320px; min-height: 365px; min-width: 320px; @media (max-width: 576px) { max-width: 20rem; min-height: 25.2rem; min-width: 20rem; } } .group-skeleton { max-width: 240px; min-height: 540px; min-width: 240px; @media (max-width: 576px) { max-width: 16rem; min-height: 34rem; min-width: 16rem; } } .performer-skeleton { max-width: 20rem; min-height: 39.1rem; min-width: 20rem; @media (max-width: 576px) { max-width: 16rem; min-height: 33.1rem; min-width: 16rem; } } .image-skeleton, .gallery-skeleton { max-width: 320px; min-height: 403.5px; min-width: 320px; @media (max-width: 576px) { max-width: 20rem; min-height: 38.5rem; min-width: 20rem; } } .studio-skeleton { max-width: 360px; min-height: 278px; min-width: 360px; @media (max-width: 576px) { max-width: 20rem; min-height: 19.8rem; min-width: 20rem; } } .tag-skeleton { max-width: 240px; min-height: 365px; min-width: 240px; @media (max-width: 576px) { max-width: 16rem; min-height: 26rem; min-width: 16rem; } } /* Slider */ .slick-slider { box-sizing: border-box; display: block; position: relative; -webkit-tap-highlight-color: transparent; -ms-touch-action: pan-y; touch-action: pan-y; -webkit-touch-callout: none; -khtml-user-select: none; -ms-user-select: none; -moz-user-select: none; -webkit-user-select: none; user-select: none; } .slick-list { display: block; margin: 0; overflow: hidden; padding: 0; position: relative; } .slick-list:focus { outline: none; } .slick-list.dragging { cursor: pointer; cursor: hand; } .slick-slider .slick-track, .slick-slider .slick-list { -moz-transform: translate3d(0, 0, 0); -ms-transform: translate3d(0, 0, 0); -o-transform: translate3d(0, 0, 0); -webkit-transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0); } .slick-track { display: block; left: 0; margin-left: auto; margin-right: auto; position: relative; top: 0; } .slick-track::before, .slick-track::after { content: ""; display: table; } .slick-track::after { clear: both; } .slick-loading .slick-track { visibility: hidden; } .slick-slide { display: none; float: left; height: 100%; min-height: 1px; } [dir="rtl"] .slick-slide { float: right; } .slick-slide img { display: block; } .slick-slide.slick-loading img { display: none; } .slick-slide.dragging img { pointer-events: none; } .slick-initialized .slick-slide { display: block; } .slick-loading .slick-slide { visibility: hidden; } .slick-vertical .slick-slide { border: 1px solid transparent; display: block; height: auto; } .slick-arrow.slick-hidden { display: none; } .slick-loading .slick-list { background: #fff url("slick-carousel/slick/ajax-loader.gif") center center no-repeat; } .slick-list .card-check { display: none; } .container-fluid .slick-track { display: flex; } .container-fluid .slick-slide { display: flex; height: auto; } .slick-slide .card { height: 98%; } .slick-slide .studio-card-image { height: 150px; } @media (max-width: 576px) { .slick-list .scene-card.card, .slick-list .studio-card.card, .slick-list .gallery-card.card, .slick-list .image-card.card { width: 20rem; } .slick-list .group-card.card { width: 16rem; } .slick-list .performer-card.card { width: 16rem; } } /* Icons */ @font-face { font-family: slick; font-style: normal; font-weight: normal; src: url("slick-carousel/slick/fonts/slick.eot"); src: url("slick-carousel/slick/fonts/slick.eot?#iefix") format("embedded-opentype"), url("slick-carousel/slick/fonts/slick.woff") format("woff"), url("slick-carousel/slick/fonts/slick.ttf") format("truetype"), url("slick-carousel/slick/fonts/slick.svg#slick") format("svg"); } /* Arrows */ .slick-prev, .slick-next { background: transparent; border: none; color: transparent; cursor: pointer; display: block; font-size: 0; height: 100%; line-height: 0; outline: none; padding: 0; position: absolute; top: 50%; -webkit-transform: translate(0, -50%); -ms-transform: translate(0, -50%); transform: translate(0, -50%); width: 20px; } .slick-prev:hover, .slick-prev:focus, .slick-next:hover, .slick-next:focus { background: transparent; color: transparent; outline: none; } .slick-prev:hover::before, .slick-prev:focus::before, .slick-next:hover::before, .slick-next:focus::before { opacity: 1; } .slick-prev.slick-disabled::before, .slick-next.slick-disabled::before { opacity: 0.25; } .slick-prev::before, .slick-next::before { color: white; font-family: slick; font-size: 20px; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; line-height: 1; opacity: 0.75; } .slick-prev { left: -20px; } [dir="rtl"] .slick-prev { left: auto; right: -20px; } .slick-prev::before { content: "←"; } [dir="rtl"] .slick-prev::before { content: "→"; } .slick-next { right: -25px; } [dir="rtl"] .slick-next { left: -25px; right: auto; } .slick-next::before { content: "→"; } [dir="rtl"] .slick-next::before { content: "←"; } /* Dots */ .slick-dotted.slick-slider { margin-bottom: 30px; } .slick-dots { bottom: -25px; display: block; list-style: none; margin: 0; padding: 0; text-align: center; width: 100%; } .slick-dots li { cursor: pointer; display: inline-block; height: 20px; margin: 0 5px; padding: 0; position: relative; width: 20px; } .slick-dots li button { background: transparent; border: 0; color: transparent; cursor: pointer; display: block; font-size: 0; height: 20px; line-height: 0; outline: none; padding: 5px; width: 20px; } .slick-dots li button:hover, .slick-dots li button:focus { outline: none; } .slick-dots li button:hover::before, .slick-dots li button:focus::before { opacity: 1; } .slick-dots li button::before { color: white; content: "-"; font-family: slick; font-size: 50px; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; height: 20px; left: 0; opacity: 0.25; position: absolute; text-align: center; top: 0; width: 20px; } .slick-dots li.slick-active button::before { color: white; opacity: 0.75; } // HACK: compatibility with existing behaviour after removed width from zoom-1 class // this should really be changed to use the specific card types instead of a generic zoom-1 class, // but this is a quick fix to prevent breaking existing styles .recommendation-row .card.zoom-1 { width: 320px; } ================================================ FILE: ui/v2.5/src/components/Galleries/DeleteGalleriesDialog.tsx ================================================ import React, { useState } from "react"; import { Form } from "react-bootstrap"; import { useGalleryDestroy } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "../Shared/Modal"; import { useToast } from "src/hooks/Toast"; import { useConfigurationContext } from "src/hooks/Config"; import { FormattedMessage, useIntl } from "react-intl"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; interface IDeleteGalleryDialogProps { selected: GQL.SlimGalleryDataFragment[]; onClose: (confirmed: boolean) => void; } export const DeleteGalleriesDialog: React.FC = ( props: IDeleteGalleryDialogProps ) => { const intl = useIntl(); const singularEntity = intl.formatMessage({ id: "gallery" }); const pluralEntity = intl.formatMessage({ id: "galleries" }); const header = intl.formatMessage( { id: "dialogs.delete_entity_title" }, { count: props.selected.length, singularEntity, pluralEntity } ); const toastMessage = intl.formatMessage( { id: "toast.delete_past_tense" }, { count: props.selected.length, singularEntity, pluralEntity } ); const message = intl.formatMessage( { id: "dialogs.delete_entity_desc" }, { count: props.selected.length, singularEntity, pluralEntity } ); const { configuration: config } = useConfigurationContext(); const [deleteFile, setDeleteFile] = useState( config?.defaults.deleteFile ?? false ); const [deleteGenerated, setDeleteGenerated] = useState( config?.defaults.deleteGenerated ?? true ); const Toast = useToast(); const [deleteGallery] = useGalleryDestroy(getGalleriesDeleteInput()); // Network state const [isDeleting, setIsDeleting] = useState(false); function getGalleriesDeleteInput(): GQL.GalleryDestroyInput { return { ids: props.selected.map((gallery) => gallery.id!), delete_file: deleteFile, delete_generated: deleteGenerated, }; } async function onDelete() { setIsDeleting(true); try { await deleteGallery(); Toast.success(toastMessage); } catch (e) { Toast.error(e); } setIsDeleting(false); props.onClose(true); } function maybeRenderDeleteFileAlert() { if (!deleteFile) { return; } const deletedFiles: string[] = []; props.selected.forEach((s) => { const paths = s.files.map((f) => f.path); deletedFiles.push(...paths); }); if (deletedFiles.length === 0) { return; } const deleteTrashPath = config?.general.deleteTrashPath; const deleteAlertId = deleteTrashPath ? "dialogs.delete_alert_to_trash" : "dialogs.delete_alert"; return (

    {deletedFiles.slice(0, 5).map((s) => (
  • {s}
  • ))} {deletedFiles.length > 5 && ( )}
); } return ( props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} isRunning={isDeleting} >

{message}

{maybeRenderDeleteFileAlert()}
setDeleteFile(!deleteFile)} /> setDeleteGenerated(!deleteGenerated)} />
); }; ================================================ FILE: ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import { useBulkGalleryUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { StudioSelect } from "../Shared/Select"; import { ModalComponent } from "../Shared/Modal"; import { MultiSet } from "../Shared/MultiSet"; import { useToast } from "src/hooks/Toast"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { getAggregateInputValue, getAggregatePerformerIds, getAggregateStateObject, getAggregateTagIds, getAggregateStudioId, getAggregateSceneIds, } from "src/utils/bulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; import { BulkUpdateDateInput } from "../Shared/DateInput"; import { getDateError } from "src/utils/yup"; interface IListOperationProps { selected: GQL.SlimGalleryDataFragment[]; onClose: (applied: boolean) => void; } const galleryFields = [ "code", "rating100", "details", "organized", "photographer", "date", ]; export const EditGalleriesDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); const [updateInput, setUpdateInput] = useState({ ids: props.selected.map((gallery) => { return gallery.id; }), }); const [performerIds, setPerformerIds] = useState({ mode: GQL.BulkUpdateIdMode.Add, }); const [tagIds, setTagIds] = useState({ mode: GQL.BulkUpdateIdMode.Add, }); const [sceneIds, setSceneIds] = useState({ mode: GQL.BulkUpdateIdMode.Add, }); const unsetDisabled = props.selected.length < 2; const [dateError, setDateError] = useState(); const [updateGalleries] = useBulkGalleryUpdate(); // Network state const [isUpdating, setIsUpdating] = useState(false); const aggregateState = useMemo(() => { const updateState: Partial = {}; const state = props.selected; updateState.studio_id = getAggregateStudioId(props.selected); const updateTagIds = getAggregateTagIds(props.selected); const updatePerformerIds = getAggregatePerformerIds(props.selected); const updateSceneIds = getAggregateSceneIds(props.selected); let first = true; state.forEach((gallery: GQL.SlimGalleryDataFragment) => { getAggregateStateObject(updateState, gallery, galleryFields, first); first = false; }); return { state: updateState, tagIds: updateTagIds, performerIds: updatePerformerIds, sceneIds: updateSceneIds, }; }, [props.selected]); // update initial state from aggregate useEffect(() => { setUpdateInput((current) => ({ ...current, ...aggregateState.state })); }, [aggregateState]); useEffect(() => { setDateError(getDateError(updateInput.date ?? "", intl)); }, [updateInput.date, intl]); function setUpdateField(input: Partial) { setUpdateInput((current) => ({ ...current, ...input })); } function getGalleryInput(): GQL.BulkGalleryUpdateInput { const galleryInput: GQL.BulkGalleryUpdateInput = { ...updateInput, tag_ids: tagIds, performer_ids: performerIds, scene_ids: sceneIds, }; // we don't have unset functionality for the rating star control // so need to determine if we are setting a rating or not galleryInput.rating100 = getAggregateInputValue( updateInput.rating100, aggregateState.state.rating100 ); return galleryInput; } async function onSave() { setIsUpdating(true); try { await updateGalleries({ variables: { input: getGalleryInput() } }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl.formatMessage({ id: "galleries" }).toLocaleLowerCase(), } ) ); props.onClose(true); } catch (e) { Toast.error(e); } setIsUpdating(false); } function render() { return ( props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} isRunning={isUpdating} >
setUpdateField({ rating100: value ?? undefined }) } disabled={isUpdating} /> setUpdateField({ code: newValue })} unsetDisabled={unsetDisabled} /> setUpdateField({ date: newValue })} unsetDisabled={unsetDisabled} error={dateError} /> setUpdateField({ photographer: newValue }) } unsetDisabled={unsetDisabled} /> setUpdateField({ studio_id: items.length > 0 ? items[0]?.id : undefined, }) } ids={updateInput.studio_id ? [updateInput.studio_id] : []} isDisabled={isUpdating} menuPortalTarget={document.body} /> { setPerformerIds((c) => ({ ...c, ids: itemIDs })); }} onSetMode={(newMode) => { setPerformerIds((c) => ({ ...c, mode: newMode })); }} ids={performerIds.ids ?? []} existingIds={aggregateState.performerIds} mode={performerIds.mode} menuPortalTarget={document.body} /> { setSceneIds((c) => ({ ...c, ids: itemIDs })); }} onSetMode={(newMode) => { setSceneIds((c) => ({ ...c, mode: newMode })); }} ids={sceneIds.ids ?? []} existingIds={aggregateState.sceneIds} mode={sceneIds.mode} menuPortalTarget={document.body} /> { setTagIds((c) => ({ ...c, ids: itemIDs })); }} onSetMode={(newMode) => { setTagIds((c) => ({ ...c, mode: newMode })); }} ids={tagIds.ids ?? []} existingIds={aggregateState.tagIds} mode={tagIds.mode} menuPortalTarget={document.body} /> setUpdateField({ details: newValue })} unsetDisabled={unsetDisabled} as="textarea" /> setUpdateField({ organized: checked })} checked={updateInput.organized ?? undefined} />
); } return render(); }; ================================================ FILE: ui/v2.5/src/components/Galleries/Galleries.tsx ================================================ import React from "react"; import { Redirect, Route, RouteComponentProps, Switch } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import Gallery from "./GalleryDetails/Gallery"; import GalleryCreate from "./GalleryDetails/GalleryCreate"; import { FilteredGalleryList } from "./GalleryList"; import { View } from "../List/views"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { ErrorMessage } from "../Shared/ErrorMessage"; import { useFindGalleryImageID } from "src/core/StashService"; interface IGalleryImageParams { id: string; index: string; } const GalleryImage: React.FC> = ({ match, }) => { const { id, index: indexStr } = match.params; let index = parseInt(indexStr); if (isNaN(index)) { index = 0; } const { data, loading, error } = useFindGalleryImageID(id, index); if (isNaN(index)) { return ; } if (loading) return ; if (error) return ; if (!data?.findGallery) return ; return ; }; const Galleries: React.FC = () => { return ; }; const GalleryRoutes: React.FC = () => { const titleProps = useTitleProps({ id: "galleries" }); return ( <> ); }; export default GalleryRoutes; ================================================ FILE: ui/v2.5/src/components/Galleries/GalleryCard.tsx ================================================ import { Button, ButtonGroup, OverlayTrigger, Tooltip } from "react-bootstrap"; import React, { useMemo, useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { GridCard } from "../Shared/GridCard/GridCard"; import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; import { SceneLink, TagLink } from "../Shared/TagLink"; import { TruncatedText } from "../Shared/TruncatedText"; import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton"; import { PopoverCountButton } from "../Shared/PopoverCountButton"; import NavUtils from "src/utils/navigation"; import { RatingBanner } from "../Shared/RatingBanner"; import { faBox, faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons"; import { galleryTitle } from "src/core/galleries"; import { StudioOverlay } from "../Shared/GridCard/StudioOverlay"; import { GalleryPreviewScrubber } from "./GalleryPreviewScrubber"; import cx from "classnames"; import { useHistory } from "react-router-dom"; import { PatchComponent } from "src/patch"; interface IGalleryPreviewProps { gallery: GQL.SlimGalleryDataFragment; onScrubberClick?: (index: number) => void; disabled?: boolean; } export const GalleryPreview: React.FC = ({ gallery, onScrubberClick, disabled, }) => { const [imgSrc, setImgSrc] = useState( gallery.paths.cover ?? undefined ); return (
{!!imgSrc && ( {gallery.title )} {gallery.image_count > 0 && ( )}
); }; interface IGalleryCardProps { gallery: GQL.SlimGalleryDataFragment; cardWidth?: number; selecting?: boolean; selected?: boolean | undefined; zoomIndex?: number; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; } const GalleryCardPopovers = PatchComponent( "GalleryCard.Popovers", (props: IGalleryCardProps) => { function maybeRenderScenePopoverButton() { if (props.gallery.scenes.length === 0) return; const popoverContent = props.gallery.scenes.map((scene) => ( )); return ( ); } function maybeRenderTagPopoverButton() { if (props.gallery.tags.length <= 0) return; const popoverContent = props.gallery.tags.map((tag) => ( )); return ( ); } function maybeRenderPerformerPopoverButton() { if (props.gallery.performers.length <= 0) return; return ( ); } function maybeRenderImagesPopoverButton() { if (!props.gallery.image_count) return; return ( ); } function maybeRenderOrganized() { if (props.gallery.organized) { return ( {"Organized"}} placement="bottom" >
); } } function maybeRenderPopoverButtonGroup() { if ( props.gallery.scenes.length > 0 || props.gallery.performers.length > 0 || props.gallery.tags.length > 0 || props.gallery.organized || props.gallery.image_count > 0 ) { return ( <>
{maybeRenderImagesPopoverButton()} {maybeRenderTagPopoverButton()} {maybeRenderPerformerPopoverButton()} {maybeRenderScenePopoverButton()} {maybeRenderOrganized()} ); } } return <>{maybeRenderPopoverButtonGroup()}; } ); const GalleryCardDetails = PatchComponent( "GalleryCard.Details", (props: IGalleryCardProps) => { return (
{props.gallery.date}
); } ); const GalleryCardOverlays = PatchComponent( "GalleryCard.Overlays", (props: IGalleryCardProps) => { const ret = useMemo(() => { return ( ); }, [props.gallery.studio, props.selecting]); return ret; } ); const GalleryCardImage = PatchComponent( "GalleryCard.Image", (props: IGalleryCardProps) => { const history = useHistory(); return ( <> { history.push(`/galleries/${props.gallery.id}/images/${i}`); }} disabled={props.selecting} /> ); } ); export const GalleryCard = PatchComponent( "GalleryCard", (props: IGalleryCardProps) => { return ( } overlays={} details={} popovers={} selected={props.selected} selecting={props.selecting} onSelectedChanged={props.onSelectedChanged} /> ); } ); ================================================ FILE: ui/v2.5/src/components/Galleries/GalleryCardGrid.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { GalleryCard } from "./GalleryCard"; import { useCardWidth, useContainerDimensions, } from "../Shared/GridCard/GridCard"; import { PatchComponent } from "src/patch"; interface IGalleryCardGrid { galleries: GQL.SlimGalleryDataFragment[]; selectedIds: Set; zoomIndex: number; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; } const zoomWidths = [280, 340, 480, 640]; export const GalleryCardGrid: React.FC = PatchComponent( "GalleryCardGrid", ({ galleries, selectedIds, zoomIndex, onSelectChange }) => { const [componentRef, { width: containerWidth }] = useContainerDimensions(); const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); return (
{galleries.map((gallery) => ( 0} selected={selectedIds.has(gallery.id)} onSelectedChanged={(selected: boolean, shiftKey: boolean) => onSelectChange(gallery.id, selected, shiftKey) } /> ))}
); } ); ================================================ FILE: ui/v2.5/src/components/Galleries/GalleryDetails/ChapterEntry.tsx ================================================ import React from "react"; import { FormattedMessage } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { Button } from "react-bootstrap"; interface IChapterEntries { galleryChapters: GQL.GalleryChapterDataFragment[]; onClickChapter: (image_index: number) => void; onEdit: (chapter: GQL.GalleryChapterDataFragment) => void; } export const ChapterEntries: React.FC = ({ galleryChapters, onClickChapter, onEdit, }) => { if (!galleryChapters?.length) return
; const chapterCards = galleryChapters.map((chapter) => { return (

); }); return
{chapterCards}
; }; ================================================ FILE: ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx ================================================ import { Button, Tab, Nav, Dropdown } from "react-bootstrap"; import React, { useEffect, useMemo, useState } from "react"; import { useHistory, Link, RouteComponentProps, Redirect, } from "react-router-dom"; import { FormattedMessage, useIntl } from "react-intl"; import { Helmet } from "react-helmet"; import * as GQL from "src/core/generated-graphql"; import { mutateMetadataScan, mutateResetGalleryCover, useFindGallery, useGalleryUpdate, } from "src/core/StashService"; import { lazyComponent } from "src/utils/lazyComponent"; const GenerateDialog = lazyComponent( () => import("../../Dialogs/GenerateDialog") ); import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { Icon } from "src/components/Shared/Icon"; import { Counter } from "src/components/Shared/Counter"; import Mousetrap from "mousetrap"; import { useGalleryLightbox } from "src/hooks/Lightbox/hooks"; import { useToast } from "src/hooks/Toast"; import { OrganizedButton } from "src/components/Scenes/SceneDetails/OrganizedButton"; import { GalleryEditPanel } from "./GalleryEditPanel"; import { GalleryDetailPanel } from "./GalleryDetailPanel"; import { DeleteGalleriesDialog } from "../DeleteGalleriesDialog"; import { GalleryImagesPanel } from "./GalleryImagesPanel"; import { GalleryAddPanel } from "./GalleryAddPanel"; import { GalleryFileInfoPanel } from "./GalleryFileInfoPanel"; import { GalleryScenesPanel } from "./GalleryScenesPanel"; import { faEllipsisV, faChevronRight, faChevronLeft, } from "@fortawesome/free-solid-svg-icons"; import { galleryPath, galleryTitle } from "src/core/galleries"; import { GalleryChapterPanel } from "./GalleryChaptersPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import cx from "classnames"; import { useRatingKeybinds } from "src/hooks/keybinds"; import { useConfigurationContext } from "src/hooks/Config"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { goBackOrReplace } from "src/utils/history"; import { FormattedDate } from "src/components/Shared/Date"; interface IProps { gallery: GQL.GalleryDataFragment; add?: boolean; } interface IGalleryParams { id: string; tab?: string; } export const GalleryPage: React.FC = ({ gallery, add }) => { const history = useHistory(); const Toast = useToast(); const intl = useIntl(); const { configuration } = useConfigurationContext(); const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters); const [collapsed, setCollapsed] = useState(false); const [activeTabKey, setActiveTabKey] = useState("gallery-details-panel"); const setMainTabKey = (newTabKey: string | null) => { if (newTabKey === "add") { history.replace(`/galleries/${gallery.id}/add`); } else { history.replace(`/galleries/${gallery.id}`); } }; const path = useMemo(() => galleryPath(gallery), [gallery]); const [updateGallery] = useGalleryUpdate(); const [organizedLoading, setOrganizedLoading] = useState(false); async function onSave(input: GQL.GalleryCreateInput) { await updateGallery({ variables: { input: { id: gallery.id, ...input, }, }, }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl.formatMessage({ id: "gallery" }).toLocaleLowerCase() } ) ); } const onOrganizedClick = async () => { try { setOrganizedLoading(true); await updateGallery({ variables: { input: { id: gallery.id, organized: !gallery.organized, }, }, }); } catch (e) { Toast.error(e); } finally { setOrganizedLoading(false); } }; function getCollapseButtonIcon() { return collapsed ? faChevronRight : faChevronLeft; } async function onRescan() { if (!gallery || !path) { return; } await mutateMetadataScan({ paths: [path], rescan: true, }); Toast.success( intl.formatMessage( { id: "toast.rescanning_entity" }, { count: 1, singularEntity: intl.formatMessage({ id: "gallery" }), } ) ); } async function onResetCover() { try { await mutateResetGalleryCover({ gallery_id: gallery.id!, }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl.formatMessage({ id: "gallery" }).toLocaleLowerCase(), } ) ); } catch (e) { Toast.error(e); } } async function onClickChapter(imageindex: number) { showLightbox(imageindex - 1); } const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false); function onDeleteDialogClosed(deleted: boolean) { setIsDeleteAlertOpen(false); if (deleted) { goBackOrReplace(history, "/galleries"); } } function maybeRenderDeleteDialog() { if (isDeleteAlertOpen && gallery) { return ( ); } } function maybeRenderGenerateDialog() { if (isGenerateDialogOpen) { return ( setIsGenerateDialogOpen(false)} type="gallery" /> ); } } function renderOperations() { return ( {path ? ( onRescan()} > ) : undefined} onResetCover()} > setIsGenerateDialogOpen(true)} > {`${intl.formatMessage({ id: "actions.generate" })}…`} setIsDeleteAlertOpen(true)} > ); } function renderTabs() { if (!gallery) { return; } return ( k && setActiveTabKey(k)} >
setIsDeleteAlertOpen(true)} /> {gallery.scenes.length > 0 && ( )}
); } function renderRightTabs() { if (!gallery) { return; } return (
); } function setRating(v: number | null) { updateGallery({ variables: { input: { id: gallery.id, rating100: v, }, }, }); } useRatingKeybinds( true, configuration?.ui.ratingSystemOptions?.type, setRating ); // set up hotkeys useEffect(() => { Mousetrap.bind("a", () => setActiveTabKey("gallery-details-panel")); Mousetrap.bind("c", () => setActiveTabKey("gallery-chapter-panel")); Mousetrap.bind("e", () => setActiveTabKey("gallery-edit-panel")); Mousetrap.bind("f", () => setActiveTabKey("gallery-file-info-panel")); Mousetrap.bind(",", () => setCollapsed(!collapsed)); return () => { Mousetrap.unbind("a"); Mousetrap.unbind("c"); Mousetrap.unbind("e"); Mousetrap.unbind("f"); Mousetrap.unbind(","); }; }); const title = galleryTitle(gallery); return (
{title} {maybeRenderDeleteDialog()} {maybeRenderGenerateDialog()}
{gallery.studio && (

{`${gallery.studio.name}

)}

{!!gallery.date && ( )}
{renderOperations()}
{renderTabs()}
{renderRightTabs()}
); }; const GalleryLoader: React.FC> = ({ location, match, }) => { const { id, tab } = match.params; const { data, loading, error } = useFindGallery(id); useScrollToTopOnMount(); if (loading) return ; if (error) return ; if (!data?.findGallery) return ; if (tab === "add") { return ; } if (tab) { return ( ); } return ; }; export default GalleryLoader; ================================================ FILE: ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx ================================================ import React, { useCallback } from "react"; import * as GQL from "src/core/generated-graphql"; import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries"; import { ListFilterModel } from "src/models/list-filter/filter"; import { FilteredImageList } from "src/components/Images/ImageList"; import { showWhenSelected } from "src/components/List/ItemList"; import { mutateAddGalleryImages } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import { useIntl } from "react-intl"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { galleryTitle } from "src/core/galleries"; import { IItemListOperation } from "src/components/List/FilteredListToolbar"; import { PatchComponent } from "src/patch"; interface IGalleryAddProps { active: boolean; gallery: GQL.GalleryDataFragment; extraOperations?: IItemListOperation[]; } export const GalleryAddPanel: React.FC = PatchComponent( "GalleryAddPanel", ({ active, gallery, extraOperations = [] }) => { const Toast = useToast(); const intl = useIntl(); const filterHook = useCallback( (filter: ListFilterModel) => { const galleryValue = { id: gallery.id, label: galleryTitle(gallery), }; // if galleries is already present, then we modify it, otherwise add let galleryCriterion = filter.criteria.find((c) => { return c.criterionOption.type === "galleries"; }) as GalleriesCriterion | undefined; if ( galleryCriterion && galleryCriterion.modifier === GQL.CriterionModifier.Excludes ) { // add the gallery if not present if ( !galleryCriterion.value.find((p) => { return p.id === gallery.id; }) ) { galleryCriterion.value.push(galleryValue); } galleryCriterion.modifier = GQL.CriterionModifier.Excludes; } else { // overwrite galleryCriterion = new GalleriesCriterion(); galleryCriterion.modifier = GQL.CriterionModifier.Excludes; galleryCriterion.value = [galleryValue]; filter.criteria.push(galleryCriterion); } return filter; }, [gallery] ); async function addImages( result: GQL.FindImagesQueryResult, filter: ListFilterModel, selectedIds: Set ) { try { await mutateAddGalleryImages({ gallery_id: gallery.id!, image_ids: Array.from(selectedIds.values()), }); const imageCount = selectedIds.size; Toast.success( intl.formatMessage( { id: "toast.added_entity" }, { count: imageCount, singularEntity: intl.formatMessage({ id: "image" }), pluralEntity: intl.formatMessage({ id: "images" }), } ) ); } catch (e) { Toast.error(e); } } const otherOperations = [ ...extraOperations, { text: intl.formatMessage( { id: "actions.add_to_entity" }, { entityType: intl.formatMessage({ id: "gallery" }) } ), onClick: addImages, isDisplayed: showWhenSelected, postRefetch: true, icon: faPlus, }, ]; return ( ); } ); ================================================ FILE: ui/v2.5/src/components/Galleries/GalleryDetails/GalleryChapterForm.tsx ================================================ import React from "react"; import { Button, Form } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { useFormik } from "formik"; import * as yup from "yup"; import * as GQL from "src/core/generated-graphql"; import { useGalleryChapterCreate, useGalleryChapterUpdate, useGalleryChapterDestroy, } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import isEqual from "lodash-es/isEqual"; import { formikUtils } from "src/utils/form"; import { yupFormikValidate, yupInputNumber } from "src/utils/yup"; interface IGalleryChapterForm { galleryID: string; chapter?: GQL.GalleryChapterDataFragment; onClose: () => void; } export const GalleryChapterForm: React.FC = ({ galleryID, chapter, onClose, }) => { const intl = useIntl(); const [galleryChapterCreate] = useGalleryChapterCreate(); const [galleryChapterUpdate] = useGalleryChapterUpdate(); const [galleryChapterDestroy] = useGalleryChapterDestroy(); const Toast = useToast(); const isNew = chapter === undefined; const schema = yup.object({ title: yup.string().ensure(), image_index: yupInputNumber() .integer() .moreThan(0) .required() .label(intl.formatMessage({ id: "image_index" })), }); const initialValues = { title: chapter?.title ?? "", image_index: chapter?.image_index ?? 1, }; type InputValues = yup.InferType; const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), onSubmit: (values) => onSave(schema.cast(values)), }); async function onSave(input: InputValues) { try { if (isNew) { await galleryChapterCreate({ variables: { gallery_id: galleryID, ...input, }, }); } else { await galleryChapterUpdate({ variables: { id: chapter.id, gallery_id: galleryID, ...input, }, }); } } catch (e) { Toast.error(e); } finally { onClose(); } } async function onDelete() { if (isNew) return; try { await galleryChapterDestroy({ variables: { id: chapter.id } }); } catch (e) { Toast.error(e); } finally { onClose(); } } const splitProps = { labelProps: { column: true, sm: 3, }, fieldProps: { sm: 9, }, }; const { renderInputField } = formikUtils(intl, formik, splitProps); return (
{renderInputField("title")} {renderInputField("image_index", "number")}
{!isNew && ( )}
); }; ================================================ FILE: ui/v2.5/src/components/Galleries/GalleryDetails/GalleryChaptersPanel.tsx ================================================ import React, { useState, useEffect } from "react"; import { Button } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { ChapterEntries } from "./ChapterEntry"; import { GalleryChapterForm } from "./GalleryChapterForm"; interface IGalleryChapterPanelProps { gallery: GQL.GalleryDataFragment; isVisible: boolean; onClickChapter: (index: number) => void; } export const GalleryChapterPanel: React.FC = ({ gallery, isVisible, onClickChapter, }) => { const [isEditorOpen, setIsEditorOpen] = useState(false); const [editingChapter, setEditingChapter] = useState(); // set up hotkeys useEffect(() => { if (!isVisible) return; Mousetrap.bind("n", () => onOpenEditor()); return () => { Mousetrap.unbind("n"); }; }); function onOpenEditor(chapter?: GQL.GalleryChapterDataFragment) { setIsEditorOpen(true); setEditingChapter(chapter ?? undefined); } const closeEditor = () => { setEditingChapter(undefined); setIsEditorOpen(false); }; if (isEditorOpen) return ( ); return (
); }; export default GalleryChapterPanel; ================================================ FILE: ui/v2.5/src/components/Galleries/GalleryDetails/GalleryCreate.tsx ================================================ import React, { useMemo } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { useHistory, useLocation } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import { useGalleryCreate } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import { GalleryEditPanel } from "./GalleryEditPanel"; const GalleryCreate: React.FC = () => { const history = useHistory(); const intl = useIntl(); const Toast = useToast(); const location = useLocation(); const query = useMemo(() => new URLSearchParams(location.search), [location]); const gallery = { title: query.get("q") ?? undefined, }; const [createGallery] = useGalleryCreate(); async function onSave(input: GQL.GalleryCreateInput, andNew?: boolean) { const result = await createGallery({ variables: { input }, }); if (result.data?.galleryCreate) { if (!andNew) { history.push(`/galleries/${result.data.galleryCreate.id}`); } Toast.success( intl.formatMessage( { id: "toast.created_entity" }, { entity: intl.formatMessage({ id: "gallery" }).toLocaleLowerCase() } ) ); } } return (

{}} />
); }; export default GalleryCreate; ================================================ FILE: ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx ================================================ import React from "react"; import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; import { TagLink } from "src/components/Shared/TagLink"; import { PerformerCard } from "src/components/Performers/PerformerCard"; import { sortPerformers } from "src/core/performers"; import { PhotographerLink } from "src/components/Shared/Link"; import { CustomFields } from "src/components/Shared/CustomFields"; interface IGalleryDetailProps { gallery: GQL.GalleryDataFragment; } export const GalleryDetailPanel: React.FC = ({ gallery, }) => { const intl = useIntl(); function renderDetails() { if (!gallery.details) return; return ( <>
:{" "}

{gallery.details}

); } function renderTags() { if (gallery.tags.length === 0) return; const tags = gallery.tags.map((tag) => ( )); return ( <>
{tags} ); } function renderPerformers() { if (gallery.performers.length === 0) return; const performers = sortPerformers(gallery.performers); const cards = performers.map((performer) => ( )); return ( <>
{cards}
); } // filename should use entire row if there is no studio const galleryDetailsWidth = gallery.studio ? "col-9" : "col-12"; return ( <>
:{" "} {TextUtils.formatDateTime(intl, gallery.created_at)}{" "}
:{" "} {TextUtils.formatDateTime(intl, gallery.updated_at)}{" "}
{gallery.code && (
: {gallery.code}{" "}
)} {gallery.photographer && (
:{" "}
)}
{renderDetails()} {renderTags()} {renderPerformers()}
); }; ================================================ FILE: ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { Prompt } from "react-router-dom"; import { Button, Dropdown, Form, Col, Row, SplitButton } from "react-bootstrap"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; import { queryScrapeGallery, queryScrapeGalleryURL, useListGalleryScrapers, mutateReloadScrapers, } from "src/core/StashService"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useToast } from "src/hooks/Toast"; import { useFormik } from "formik"; import { GalleryScrapeDialog } from "./GalleryScrapeDialog"; import isEqual from "lodash-es/isEqual"; import { handleUnsavedChanges } from "src/utils/navigation"; import { Performer, PerformerSelect, } from "src/components/Performers/PerformerSelect"; import { yupDateString, yupFormikValidate, yupUniqueStringList, } from "src/utils/yup"; import { formikUtils } from "src/utils/form"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; import { CustomFieldsInput, formatCustomFieldInput, } from "src/components/Shared/CustomFields"; import { cloneDeep } from "@apollo/client/utilities"; interface IProps { gallery: Partial; isVisible: boolean; onSubmit: (input: GQL.GalleryCreateInput, andNew?: boolean) => Promise; onDelete: () => void; } export const GalleryEditPanel: React.FC = ({ gallery, isVisible, onSubmit, onDelete, }) => { const intl = useIntl(); const Toast = useToast(); const [scenes, setScenes] = useState([]); const [performers, setPerformers] = useState([]); const [studio, setStudio] = useState(null); const isNew = gallery.id === undefined; const scrapers = useListGalleryScrapers(); const [scrapedGallery, setScrapedGallery] = useState(); // Network state const [isLoading, setIsLoading] = useState(false); const titleRequired = isNew || (gallery?.files?.length === 0 && !gallery?.folder); const schema = yup.object({ title: titleRequired ? yup.string().required() : yup.string().ensure(), code: yup.string().ensure(), urls: yupUniqueStringList(intl), date: yupDateString(intl), photographer: yup.string().ensure(), studio_id: yup.string().required().nullable(), performer_ids: yup.array(yup.string().required()).defined(), tag_ids: yup.array(yup.string().required()).defined(), scene_ids: yup.array(yup.string().required()).defined(), details: yup.string().ensure(), custom_fields: yup.object().required().defined(), }); const initialValues = { title: gallery?.title ?? "", code: gallery?.code ?? "", urls: gallery?.urls ?? [], date: gallery?.date ?? "", photographer: gallery?.photographer ?? "", studio_id: gallery?.studio?.id ?? null, performer_ids: (gallery?.performers ?? []).map((p) => p.id), tag_ids: (gallery?.tags ?? []).map((t) => t.id), scene_ids: (gallery?.scenes ?? []).map((s) => s.id), details: gallery?.details ?? "", custom_fields: cloneDeep(gallery?.custom_fields ?? {}), }; type InputValues = yup.InferType; const [customFieldsError, setCustomFieldsError] = useState(); function submit(values: InputValues) { const input = { ...schema.cast(values), custom_fields: formatCustomFieldInput(isNew, values.custom_fields), }; onSave(input); } const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), onSubmit: submit, }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( gallery.tags, (ids) => formik.setFieldValue("tag_ids", ids) ); function onSetScenes(items: Scene[]) { setScenes(items); formik.setFieldValue( "scene_ids", items.map((i) => i.id) ); } function onSetPerformers(items: Performer[]) { setPerformers(items); formik.setFieldValue( "performer_ids", items.map((item) => item.id) ); } function onSetStudio(item: Studio | null) { setStudio(item); formik.setFieldValue("studio_id", item ? item.id : null); } useEffect(() => { setPerformers(gallery.performers ?? []); }, [gallery.performers]); useEffect(() => { setStudio(gallery.studio ?? null); }, [gallery.studio]); useEffect(() => { setScenes(gallery.scenes ?? []); }, [gallery.scenes]); useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { if (formik.dirty) { formik.submitForm(); } }); Mousetrap.bind("d d", () => { onDelete(); }); return () => { Mousetrap.unbind("s s"); Mousetrap.unbind("d d"); }; } }); const fragmentScrapers = useMemo(() => { return (scrapers?.data?.listScrapers ?? []).filter((s) => s.gallery?.supported_scrapes.includes(GQL.ScrapeType.Fragment) ); }, [scrapers]); const cover = useMemo(() => { if (gallery?.paths?.cover) { return (
{intl.formatMessage({
); } return
; }, [gallery?.paths?.cover, intl]); async function onSave(input: InputValues, andNew?: boolean) { setIsLoading(true); try { await onSubmit(input, andNew); formik.resetForm(); } catch (e) { Toast.error(e); } setIsLoading(false); } async function onSaveAndNewClick() { const input = { ...schema.cast(formik.values), custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), }; onSave(input, true); } async function onScrapeClicked(s: GQL.ScraperSourceInput) { if (!gallery || !gallery.id) return; setIsLoading(true); try { const result = await queryScrapeGallery(s.scraper_id!, gallery.id); if (!result.data || !result.data.scrapeSingleGallery?.length) { Toast.success("No galleries found"); return; } setScrapedGallery(result.data.scrapeSingleGallery[0]); } catch (e) { Toast.error(e); } finally { setIsLoading(false); } } async function onReloadScrapers() { setIsLoading(true); try { await mutateReloadScrapers(); } catch (e) { Toast.error(e); } finally { setIsLoading(false); } } function onScrapeDialogClosed(data?: GQL.ScrapedGalleryDataFragment) { if (data) { updateGalleryFromScrapedGallery(data); } setScrapedGallery(undefined); } function maybeRenderScrapeDialog() { if (!scrapedGallery) { return; } const currentGallery = { id: gallery.id!, ...formik.values, }; return ( { onScrapeDialogClosed(data); }} /> ); } function urlScrapable(scrapedUrl: string): boolean { return (scrapers?.data?.listScrapers ?? []).some((s) => (s?.gallery?.urls ?? []).some((u) => scrapedUrl.includes(u)) ); } function updateGalleryFromScrapedGallery( galleryData: GQL.ScrapedGalleryDataFragment ) { if (galleryData.title) { formik.setFieldValue("title", galleryData.title); } if (galleryData.code) { formik.setFieldValue("code", galleryData.code); } if (galleryData.details) { formik.setFieldValue("details", galleryData.details); } if (galleryData.photographer) { formik.setFieldValue("photographer", galleryData.photographer); } if (galleryData.date) { formik.setFieldValue("date", galleryData.date); } if (galleryData.urls) { formik.setFieldValue("urls", galleryData.urls); } if (galleryData.studio?.stored_id) { onSetStudio({ id: galleryData.studio.stored_id, name: galleryData.studio.name ?? "", aliases: [], }); } if (galleryData.performers?.length) { const idPerfs = galleryData.performers.filter((p) => { return p.stored_id !== undefined && p.stored_id !== null; }); if (idPerfs.length > 0) { onSetPerformers( idPerfs.map((p) => { return { id: p.stored_id!, name: p.name ?? "", alias_list: [], }; }) ); } } updateTagsStateFromScraper(galleryData.tags ?? undefined); } async function onScrapeGalleryURL(url: string) { if (!url) { return; } setIsLoading(true); try { const result = await queryScrapeGalleryURL(url); if (!result || !result.data || !result.data.scrapeGalleryURL) { return; } setScrapedGallery(result.data.scrapeGalleryURL); } catch (e) { Toast.error(e); } finally { setIsLoading(false); } } if (isLoading) return ; const splitProps = { labelProps: { column: true, sm: 3, }, fieldProps: { sm: 9, }, }; const fullWidthProps = { labelProps: { column: true, sm: 3, xl: 12, }, fieldProps: { sm: 9, xl: 12, }, }; const urlProps = isNew ? splitProps : { labelProps: { column: true, md: 3, lg: 12, }, fieldProps: { md: 9, lg: 12, }, }; const { renderField, renderInputField, renderDateField, renderURLListField } = formikUtils(intl, formik, splitProps); function renderScenesField() { const title = intl.formatMessage({ id: "scenes" }); const control = ( onSetScenes(items)} isMulti /> ); return renderField("scene_ids", title, control); } function renderStudioField() { const title = intl.formatMessage({ id: "studio" }); const control = ( onSetStudio(items.length > 0 ? items[0] : null)} values={studio ? [studio] : []} /> ); return renderField("studio_id", title, control); } function renderPerformersField() { const date = (() => { try { return schema.validateSyncAt("date", formik.values); } catch (e) { return undefined; } })(); const title = intl.formatMessage({ id: "performers" }); const control = ( ); return renderField("performer_ids", title, control, fullWidthProps); } function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); return renderField("tag_ids", title, tagsControl(), fullWidthProps); } function renderDetailsField() { const props = { labelProps: { column: true, sm: 3, lg: 12, }, fieldProps: { sm: 9, lg: 12, }, }; return renderInputField("details", "textarea", "details", props); } return ( ); }; ================================================ FILE: ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx ================================================ import React, { useMemo, useState } from "react"; import { Accordion, Button, Card } from "react-bootstrap"; import { FormattedMessage, FormattedTime } from "react-intl"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { DeleteFilesDialog } from "src/components/Shared/DeleteFilesDialog"; import { RevealInFilesystemButton } from "src/components/Shared/RevealInFilesystemButton"; import * as GQL from "src/core/generated-graphql"; import { mutateGallerySetPrimaryFile } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import TextUtils from "src/utils/text"; import { TextField, URLsField } from "src/utils/field"; interface IFileInfoPanelProps { folder?: Pick; file?: GQL.GalleryFileDataFragment; primary?: boolean; ofMany?: boolean; onSetPrimaryFile?: () => void; onDeleteFile?: () => void; loading?: boolean; } const FileInfoPanel: React.FC = ( props: IFileInfoPanelProps ) => { const checksum = props.file?.fingerprints.find((f) => f.type === "md5"); const path = props.folder ? props.folder.path : props.file?.path ?? ""; const id = props.folder ? "folder" : "path"; return (
{props.primary && ( <>
)} {props.file && ( )}
{props.ofMany && props.onSetPrimaryFile && !props.primary && (
)}
); }; interface IGalleryFileInfoPanelProps { gallery: GQL.GalleryDataFragment; } export const GalleryFileInfoPanel: React.FC = ( props: IGalleryFileInfoPanelProps ) => { const Toast = useToast(); const [loading, setLoading] = useState(false); const [deletingFile, setDeletingFile] = useState< GQL.GalleryFileDataFragment | undefined >(); const filesPanel = useMemo(() => { if (props.gallery.folder) { return ; } if (props.gallery.files.length === 0) { return <>; } if (props.gallery.files.length === 1) { return ; } async function onSetPrimaryFile(fileID: string) { try { setLoading(true); await mutateGallerySetPrimaryFile(props.gallery.id, fileID); } catch (e) { Toast.error(e); } finally { setLoading(false); } } return ( {deletingFile && ( setDeletingFile(undefined)} selected={[deletingFile]} /> )} {props.gallery.files.map((file, index) => ( onSetPrimaryFile(file.id)} loading={loading} onDeleteFile={() => setDeletingFile(file)} /> ))} ); }, [props.gallery, loading, Toast, deletingFile]); return ( <>
{filesPanel} ); }; ================================================ FILE: ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx ================================================ import React, { useCallback } from "react"; import * as GQL from "src/core/generated-graphql"; import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries"; import { ListFilterModel } from "src/models/list-filter/filter"; import { FilteredImageList } from "src/components/Images/ImageList"; import { mutateRemoveGalleryImages, mutateSetGalleryCover, } from "src/core/StashService"; import { showWhenSelected, showWhenSingleSelection, } from "src/components/List/ItemList"; import { useToast } from "src/hooks/Toast"; import { useIntl } from "react-intl"; import { faMinus } from "@fortawesome/free-solid-svg-icons"; import { galleryTitle } from "src/core/galleries"; import { View } from "src/components/List/views"; import { PatchComponent } from "src/patch"; import { IItemListOperation } from "src/components/List/FilteredListToolbar"; interface IGalleryDetailsProps { active: boolean; gallery: GQL.GalleryDataFragment; extraOperations?: IItemListOperation[]; } export const GalleryImagesPanel: React.FC = PatchComponent( "GalleryImagesPanel", ({ active, gallery, extraOperations = [] }) => { const intl = useIntl(); const Toast = useToast(); const filterHook = useCallback( (filter: ListFilterModel) => { const galleryValue = { id: gallery.id!, label: galleryTitle(gallery), }; // if galleries is already present, then we modify it, otherwise add let galleryCriterion = filter.criteria.find((c) => { return c.criterionOption.type === "galleries"; }) as GalleriesCriterion | undefined; if ( galleryCriterion && (galleryCriterion.modifier === GQL.CriterionModifier.IncludesAll || galleryCriterion.modifier === GQL.CriterionModifier.Includes) ) { // add the gallery if not present if ( !galleryCriterion.value.find((p) => { return p.id === gallery.id; }) ) { galleryCriterion.value.push(galleryValue); } galleryCriterion.modifier = GQL.CriterionModifier.IncludesAll; } else { // overwrite galleryCriterion = new GalleriesCriterion(); galleryCriterion.value = [galleryValue]; filter.criteria.push(galleryCriterion); } return filter; }, [gallery] ); async function setCover( result: GQL.FindImagesQueryResult, filter: ListFilterModel, selectedIds: Set ) { const coverImageID = selectedIds.values().next(); if (coverImageID.done) { // operation should only be displayed when exactly one image is selected return; } try { await mutateSetGalleryCover({ gallery_id: gallery.id!, cover_image_id: coverImageID.value, }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl .formatMessage({ id: "gallery" }) .toLocaleLowerCase(), } ) ); } catch (e) { Toast.error(e); } } async function removeImages( result: GQL.FindImagesQueryResult, filter: ListFilterModel, selectedIds: Set ) { try { await mutateRemoveGalleryImages({ gallery_id: gallery.id!, image_ids: Array.from(selectedIds.values()), }); Toast.success( intl.formatMessage( { id: "toast.removed_entity" }, { count: selectedIds.size, singularEntity: intl.formatMessage({ id: "image" }), pluralEntity: intl.formatMessage({ id: "images" }), } ) ); } catch (e) { Toast.error(e); } } const otherOperations = [ ...extraOperations, { text: intl.formatMessage({ id: "actions.set_cover" }), onClick: setCover, isDisplayed: showWhenSingleSelection, }, { text: intl.formatMessage({ id: "actions.remove_from_gallery" }), onClick: removeImages, isDisplayed: showWhenSelected, postRefetch: true, icon: faMinus, buttonVariant: "danger", }, ]; return ( ); } ); ================================================ FILE: ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScenesPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { SceneCard } from "src/components/Scenes/SceneCard"; interface IGalleryScenesPanelProps { scenes: GQL.SlimSceneDataFragment[]; } export const GalleryScenesPanel: React.FC = ({ scenes, }) => (
{scenes.map((scene) => ( ))}
); ================================================ FILE: ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx ================================================ import React, { useState } from "react"; import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { ScrapedInputGroupRow, ScrapedStringListRow, ScrapedTextAreaRow, } from "src/components/Shared/ScrapeDialog/ScrapeDialogRow"; import { ScrapeDialog } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; import { ObjectListScrapeResult, ObjectScrapeResult, ScrapeResult, } from "src/components/Shared/ScrapeDialog/scrapeResult"; import { ScrapedPerformersRow, ScrapedStudioRow, } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow"; import { sortStoredIdObjects } from "src/utils/data"; import { Performer } from "src/components/Performers/PerformerSelect"; import { useCreateScrapedPerformer, useCreateScrapedStudio, } from "src/components/Shared/ScrapeDialog/createObjects"; import { uniq } from "lodash-es"; import { Tag } from "src/components/Tags/TagSelect"; import { Studio } from "src/components/Studios/StudioSelect"; import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags"; interface IGalleryScrapeDialogProps { gallery: Partial; galleryStudio: Studio | null; galleryTags: Tag[]; galleryPerformers: Performer[]; scraped: GQL.ScrapedGallery; onClose: (scrapedGallery?: GQL.ScrapedGallery) => void; } export const GalleryScrapeDialog: React.FC = ({ gallery, galleryStudio, galleryTags, galleryPerformers, scraped, onClose, }) => { const intl = useIntl(); const [title, setTitle] = useState>( new ScrapeResult(gallery.title, scraped.title) ); const [code, setCode] = useState>( new ScrapeResult(gallery.code, scraped.code) ); const [urls, setURLs] = useState>( new ScrapeResult( gallery.urls, scraped.urls ? uniq((gallery.urls ?? []).concat(scraped.urls ?? [])) : undefined ) ); const [date, setDate] = useState>( new ScrapeResult(gallery.date, scraped.date) ); const [photographer, setPhotographer] = useState>( new ScrapeResult(gallery.photographer, scraped.photographer) ); const [studio, setStudio] = useState>( new ObjectScrapeResult( galleryStudio ? { stored_id: galleryStudio.id, name: galleryStudio.name, } : undefined, scraped.studio ) ); const [newStudio, setNewStudio] = useState( scraped.studio && !scraped.studio.stored_id ? scraped.studio : undefined ); const [performers, setPerformers] = useState< ObjectListScrapeResult >( new ObjectListScrapeResult( sortStoredIdObjects( galleryPerformers.map((p) => ({ stored_id: p.id, name: p.name, })) ), sortStoredIdObjects(scraped.performers ?? undefined) ) ); const [newPerformers, setNewPerformers] = useState( scraped.performers?.filter((t) => !t.stored_id) ?? [] ); const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags( galleryTags, scraped.tags ); const [details, setDetails] = useState>( new ScrapeResult(gallery.details, scraped.details) ); const createNewStudio = useCreateScrapedStudio({ scrapeResult: studio, setScrapeResult: setStudio, setNewObject: setNewStudio, }); const createNewPerformer = useCreateScrapedPerformer({ scrapeResult: performers, setScrapeResult: setPerformers, newObjects: newPerformers, setNewObjects: setNewPerformers, }); // don't show the dialog if nothing was scraped if ( [ title, code, urls, date, photographer, studio, performers, tags, details, ].every((r) => !r.scraped) && !newStudio && newPerformers.length === 0 && newTags.length === 0 ) { onClose(); return <>; } function makeNewScrapedItem(): GQL.ScrapedGalleryDataFragment { const newStudioValue = studio.getNewValue(); return { title: title.getNewValue(), code: code.getNewValue(), urls: urls.getNewValue(), date: date.getNewValue(), photographer: photographer.getNewValue(), studio: newStudioValue, performers: performers.getNewValue(), tags: tags.getNewValue(), details: details.getNewValue(), }; } function renderScrapeRows() { return ( <> setTitle(value)} /> setCode(value)} /> setURLs(value)} /> setDate(value)} /> setPhotographer(value)} /> setStudio(value)} newStudio={newStudio} onCreateNew={createNewStudio} /> setPerformers(value)} newObjects={newPerformers} onCreateNew={createNewPerformer} ageFromDate={date.useNewValue ? date.newValue : date.originalValue} /> {scrapedTagsRow} setDetails(value)} /> ); } if (linkDialog) { return linkDialog; } return ( { onClose(apply ? makeNewScrapedItem() : undefined); }} > {renderScrapeRows()} ); }; ================================================ FILE: ui/v2.5/src/components/Galleries/GalleryList.tsx ================================================ import React, { useCallback, useEffect } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import cloneDeep from "lodash-es/cloneDeep"; import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { useFilteredItemList } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { queryFindGalleries, useFindGalleries } from "src/core/StashService"; import GalleryWallCard from "./GalleryWallCard"; import { EditGalleriesDialog } from "./EditGalleriesDialog"; import { DeleteGalleriesDialog } from "./DeleteGalleriesDialog"; import { ExportDialog } from "../Shared/ExportDialog"; import { GenerateDialog } from "../Dialogs/GenerateDialog"; import { GalleryListTable } from "./GalleryListTable"; import { GalleryCardGrid } from "./GalleryCardGrid"; import { View } from "../List/views"; import useFocus from "src/utils/focus"; import { Sidebar, SidebarPane, SidebarPaneContent, SidebarStateContext, useSidebarState, } from "../Shared/Sidebar"; import { useCloseEditDelete, useFilterOperations } from "../List/util"; import { FilteredSidebarHeader, useFilteredSidebarKeybinds, } from "../List/Filters/FilterSidebar"; import cx from "classnames"; import { LoadedContent } from "../List/PagedList"; import { Pagination, PaginationIndex } from "../List/Pagination"; import { PatchComponent, PatchContainerComponent } from "src/patch"; import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter"; import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter"; import { SidebarTagsFilter } from "../List/Filters/TagsFilter"; import { SidebarRatingFilter } from "../List/Filters/RatingFilter"; import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized"; import { Button } from "react-bootstrap"; import { IListFilterOperation, ListOperations, } from "../List/ListOperationButtons"; import { FilteredListToolbar, IItemListOperation, } from "../List/FilteredListToolbar"; import { FilterTags } from "../List/FilterTags"; import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter"; import { PerformerAgeCriterionOption } from "src/models/list-filter/galleries"; import { SidebarFolderFilter } from "../List/Filters/FolderFilter"; import { ParentFolderCriterionOption } from "src/models/list-filter/criteria/folder"; const GalleryList: React.FC<{ galleries: GQL.SlimGalleryDataFragment[]; filter: ListFilterModel; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; }> = PatchComponent( "GalleryList", ({ galleries, filter, selectedIds, onSelectChange }) => { if (galleries.length === 0) { return null; } if (filter.displayMode === DisplayMode.Grid) { return ( ); } if (filter.displayMode === DisplayMode.List) { return ( ); } if (filter.displayMode === DisplayMode.Wall) { return (
{galleries.map((gallery) => ( onSelectChange(gallery.id, selected, shiftKey) } selecting={selectedIds.size > 0} /> ))}
); } return null; } ); const GalleryFilterSidebarSections = PatchContainerComponent( "FilteredGalleryList.SidebarSections" ); const SidebarContent: React.FC<{ filter: ListFilterModel; setFilter: (filter: ListFilterModel) => void; filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; sidebarOpen: boolean; onClose?: () => void; showEditFilter: (editingCriterion?: string) => void; count?: number; focus?: ReturnType; }> = ({ filter, setFilter, filterHook, view, showEditFilter, sidebarOpen, onClose, count, focus, }) => { const showResultsId = count !== undefined ? "actions.show_count_results" : "actions.show_results"; const hideStudios = view === View.StudioScenes; return ( <> {!hideStudios && ( )} } criterionOption={ParentFolderCriterionOption} filter={filter} setFilter={setFilter} sectionID="parent_folder" /> } data-type={OrganizedCriterionOption.type} option={OrganizedCriterionOption} filter={filter} setFilter={setFilter} sectionID="organized" /> } option={PerformerAgeCriterionOption} filter={filter} setFilter={setFilter} sectionID="performer_age" />
); }; interface IGalleryList { filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; alterQuery?: boolean; extraOperations?: IItemListOperation[]; } function useViewRandom(filter: ListFilterModel, count: number) { const history = useHistory(); const viewRandom = useCallback(async () => { // query for a random scene if (count === 0) { return; } const index = Math.floor(Math.random() * count); const filterCopy = cloneDeep(filter); filterCopy.itemsPerPage = 1; filterCopy.currentPage = index + 1; const singleResult = await queryFindGalleries(filterCopy); if (singleResult.data.findGalleries.galleries.length === 1) { const { id } = singleResult.data.findGalleries.galleries[0]; // navigate to the image player page history.push(`/galleries/${id}`); } }, [history, filter, count]); return viewRandom; } function useAddKeybinds(filter: ListFilterModel, count: number) { const viewRandom = useViewRandom(filter, count); useEffect(() => { Mousetrap.bind("p r", () => { viewRandom(); }); return () => { Mousetrap.unbind("p r"); }; }, [viewRandom]); } export const FilteredGalleryList = PatchComponent( "FilteredGalleryList", (props: IGalleryList) => { const intl = useIntl(); const searchFocus = useFocus(); const { filterHook, view, alterQuery, extraOperations = [] } = props; // States const { showSidebar, setShowSidebar, sectionOpen, setSectionOpen, loading: sidebarStateLoading, } = useSidebarState(view); const { filterState, queryResult, modalState, listSelect, showEditFilter } = useFilteredItemList({ filterStateProps: { filterMode: GQL.FilterMode.Galleries, view, useURL: alterQuery, }, queryResultProps: { useResult: useFindGalleries, getCount: (r) => r.data?.findGalleries.count ?? 0, getItems: (r) => r.data?.findGalleries.galleries ?? [], filterHook, }, }); const { filter, setFilter } = filterState; const { effectiveFilter, result, cachedResult, items, totalCount } = queryResult; const { selectedIds, selectedItems, onSelectChange, onSelectAll, onSelectNone, onInvertSelection, hasSelection, } = listSelect; const { modal, showModal, closeModal } = modalState; // Utility hooks const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ filter, setFilter, }); useAddKeybinds(effectiveFilter, totalCount); useFilteredSidebarKeybinds({ showSidebar, setShowSidebar, }); useEffect(() => { Mousetrap.bind("e", () => { if (hasSelection) { onEdit?.(); } }); Mousetrap.bind("d d", () => { if (hasSelection) { onDelete?.(); } }); return () => { Mousetrap.unbind("e"); Mousetrap.unbind("d d"); }; }); const onCloseEditDelete = useCloseEditDelete({ closeModal, onSelectNone, result, }); const viewRandom = useViewRandom(effectiveFilter, totalCount); function onExport(all: boolean) { showModal( closeModal()} /> ); } function onEdit() { showModal( ); } function onDelete() { showModal( ); } function onGenerate() { showModal( closeModal()} /> ); } const convertedExtraOperations: IListFilterOperation[] = extraOperations.map((o) => ({ ...o, isDisplayed: o.isDisplayed ? () => o.isDisplayed!(result, filter, selectedIds) : undefined, onClick: () => { o.onClick(result, filter, selectedIds); }, })); const otherOperations = [ ...convertedExtraOperations, { text: intl.formatMessage({ id: "actions.select_all" }), onClick: () => onSelectAll(), isDisplayed: () => totalCount > 0, }, { text: intl.formatMessage({ id: "actions.select_none" }), onClick: () => onSelectNone(), isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.invert_selection" }), onClick: () => onInvertSelection(), isDisplayed: () => totalCount > 0, }, { text: intl.formatMessage({ id: "actions.view_random" }), onClick: viewRandom, }, { text: `${intl.formatMessage({ id: "actions.generate" })}…`, onClick: onGenerate, isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export" }), onClick: () => onExport(false), isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export_all" }), onClick: () => onExport(true), }, ]; // render if (sidebarStateLoading) return null; const operations = ( ); return (
{modal} setShowSidebar(false)}> setShowSidebar(false)} count={cachedResult.loading ? undefined : totalCount} focus={searchFocus} /> setShowSidebar(!showSidebar)} > showEditFilter(c.criterionOption.type)} onRemoveCriterion={removeCriterion} onRemoveAll={clearAllCriteria} />
setFilter(filter.changePage(page))} />
{totalCount > filter.itemsPerPage && (
)}
); } ); ================================================ FILE: ui/v2.5/src/components/Galleries/GalleryListTable.tsx ================================================ import React from "react"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import NavUtils from "src/utils/navigation"; import { useIntl } from "react-intl"; import { objectTitle } from "src/core/files"; import { galleryTitle } from "src/core/galleries"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { useGalleryUpdate } from "src/core/StashService"; import { IColumn, ListTable } from "../List/ListTable"; import { useTableColumns } from "src/hooks/useTableColumns"; interface IGalleryListTableProps { galleries: GQL.SlimGalleryDataFragment[]; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; } const TABLE_NAME = "galleries"; export const GalleryListTable: React.FC = ( props: IGalleryListTableProps ) => { const intl = useIntl(); const [updateGallery] = useGalleryUpdate(); function setRating(v: number | null, galleryId: string) { if (galleryId) { updateGallery({ variables: { input: { id: galleryId, rating100: v, }, }, }); } } const CoverImageCell = (gallery: GQL.SlimGalleryDataFragment) => { const title = galleryTitle(gallery); return ( {title} ); }; const TitleCell = (gallery: GQL.SlimGalleryDataFragment) => { const title = galleryTitle(gallery); return ( {title} ); }; const DateCell = (gallery: GQL.SlimGalleryDataFragment) => ( <>{gallery.date} ); const RatingCell = (gallery: GQL.SlimGalleryDataFragment) => ( setRating(value, gallery.id)} clickToRate /> ); const ImagesCell = (gallery: GQL.SlimGalleryDataFragment) => { return ( {gallery.image_count} ); }; const TagCell = (gallery: GQL.SlimGalleryDataFragment) => (
    {gallery.tags.map((tag) => (
  • {tag.name}
  • ))}
); const PerformersCell = (gallery: GQL.SlimGalleryDataFragment) => (
    {gallery.performers.map((performer) => (
  • {performer.name}
  • ))}
); const StudioCell = (gallery: GQL.SlimGalleryDataFragment) => { if (gallery.studio) { return ( {gallery.studio.name} ); } }; const SceneCell = (gallery: GQL.SlimGalleryDataFragment) => (
    {gallery.scenes.map((galleryScene) => (
  • {objectTitle(galleryScene)}
  • ))}
); const PathCell = (scene: GQL.SlimGalleryDataFragment) => (
    {scene.files.map((file) => (
  • {file.path}
  • ))}
); interface IColumnSpec { value: string; label: string; defaultShow?: boolean; mandatory?: boolean; render?: ( gallery: GQL.SlimGalleryDataFragment, index: number ) => React.ReactNode; } const allColumns: IColumnSpec[] = [ { value: "cover_image", label: intl.formatMessage({ id: "cover_image" }), defaultShow: true, render: CoverImageCell, }, { value: "title", label: intl.formatMessage({ id: "title" }), defaultShow: true, mandatory: true, render: TitleCell, }, { value: "date", label: intl.formatMessage({ id: "date" }), defaultShow: true, render: DateCell, }, { value: "rating", label: intl.formatMessage({ id: "rating" }), defaultShow: true, render: RatingCell, }, { value: "code", label: intl.formatMessage({ id: "scene_code" }), render: (s) => <>{s.code}, }, { value: "images", label: intl.formatMessage({ id: "images" }), defaultShow: true, render: ImagesCell, }, { value: "tags", label: intl.formatMessage({ id: "tags" }), defaultShow: true, render: TagCell, }, { value: "performers", label: intl.formatMessage({ id: "performers" }), defaultShow: true, render: PerformersCell, }, { value: "studio", label: intl.formatMessage({ id: "studio" }), defaultShow: true, render: StudioCell, }, { value: "scenes", label: intl.formatMessage({ id: "scenes" }), defaultShow: true, render: SceneCell, }, { value: "photographer", label: intl.formatMessage({ id: "photographer" }), render: (s) => <>{s.photographer}, }, { value: "path", label: intl.formatMessage({ id: "path" }), render: PathCell, }, ]; const defaultColumns = allColumns .filter((col) => col.defaultShow) .map((col) => col.value); const { selectedColumns, saveColumns } = useTableColumns( TABLE_NAME, defaultColumns ); const columnRenderFuncs: Record< string, (gallery: GQL.SlimGalleryDataFragment, index: number) => React.ReactNode > = {}; allColumns.forEach((col) => { if (col.render) { columnRenderFuncs[col.value] = col.render; } }); function renderCell( column: IColumn, gallery: GQL.SlimGalleryDataFragment, index: number ) { const render = columnRenderFuncs[column.value]; if (render) return render(gallery, index); } return ( saveColumns(c)} selectedIds={props.selectedIds} onSelectChange={props.onSelectChange} renderCell={renderCell} /> ); }; ================================================ FILE: ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx ================================================ import React, { useEffect, useState } from "react"; import { useThrottle } from "src/hooks/throttle"; import { HoverScrubber } from "../Shared/HoverScrubber"; import cx from "classnames"; export const GalleryPreviewScrubber: React.FC<{ className?: string; previewPath: string; defaultPath: string; imageCount: number; onClick?: (imageIndex: number) => void; onPathChanged: React.Dispatch>; disabled?: boolean; }> = ({ className, previewPath, defaultPath, imageCount, onClick, onPathChanged, disabled, }) => { const [activeIndex, setActiveIndex] = useState(); const debounceSetActiveIndex = useThrottle(setActiveIndex, 50); function onScrubberClick(index: number) { if (!onClick) { return; } onClick(index); } useEffect(() => { function getPath() { if (activeIndex === undefined) { return defaultPath; } return `${previewPath}/${activeIndex}`; } onPathChanged(getPath()); }, [activeIndex, defaultPath, previewPath, onPathChanged]); return (
debounceSetActiveIndex(i)} onClick={onScrubberClick} disabled={disabled} />
); }; ================================================ FILE: ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx ================================================ import React from "react"; import { useFindGalleries } from "src/core/StashService"; import { GalleryCard } from "./GalleryCard"; import { ListFilterModel } from "src/models/list-filter/filter"; import { PatchComponent } from "src/patch"; import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow"; interface IProps { isTouch: boolean; filter: ListFilterModel; header: string; } export const GalleryRecommendationRow: React.FC = PatchComponent( "GalleryRecommendationRow", (props) => { const result = useFindGalleries(props.filter); const count = result.data?.findGalleries.count ?? 0; return ( {result.loading ? [...Array(props.filter.itemsPerPage)].map((i) => (
)) : result.data?.findGalleries.galleries.map((g) => ( ))}
); } ); ================================================ FILE: ui/v2.5/src/components/Galleries/GallerySelect.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { OptionProps, components as reactSelectComponents, MultiValueGenericProps, SingleValueProps, } from "react-select"; import cx from "classnames"; import * as GQL from "src/core/generated-graphql"; import { queryFindGalleriesForSelect, queryFindGalleriesByIDForSelect, useGalleryCreate, } from "src/core/StashService"; import { useConfigurationContext } from "src/hooks/Config"; import { useIntl } from "react-intl"; import { defaultMaxOptionsShown } from "src/core/config"; import { ListFilterModel } from "src/models/list-filter/filter"; import { FilterSelectComponent, IFilterIDProps, IFilterProps, IFilterValueProps, Option as SelectOption, } from "../Shared/FilterSelect"; import { useCompare } from "src/hooks/state"; import { Placement } from "react-bootstrap/esm/Overlay"; import { sortByRelevance } from "src/utils/query"; import { galleryTitle } from "src/core/galleries"; import { PatchComponent, PatchFunction } from "src/patch"; import { ModifierCriterion, CriterionValue, } from "src/models/list-filter/criteria/criterion"; import { PathCriterion } from "src/models/list-filter/criteria/path"; import { TruncatedText } from "../Shared/TruncatedText"; export type Gallery = Pick & { studio?: Pick | null; files: Pick[]; folder?: Pick | null; cover?: Pick | null; }; type Option = SelectOption; type ExtraGalleryProps = { hoverPlacement?: Placement; excludeIds?: string[]; extraCriteria?: Array>; }; type FindGalleriesResult = Awaited< ReturnType >["data"]["findGalleries"]["galleries"]; function sortGalleriesByRelevance( input: string, galleries: FindGalleriesResult ) { return sortByRelevance(input, galleries, galleryTitle, (g) => { return g.files.map((f) => f.path).concat(g.folder?.path ?? []); }); } const gallerySelectSort = PatchFunction( "GallerySelect.sort", sortGalleriesByRelevance ); const _GallerySelect: React.FC< IFilterProps & IFilterValueProps & ExtraGalleryProps > = (props) => { const [createGallery] = useGalleryCreate(); const { configuration } = useConfigurationContext(); const intl = useIntl(); const maxOptionsShown = configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown; const defaultCreatable = !configuration?.interface.disableDropdownCreate.gallery; const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); async function loadGalleries(input: string): Promise { const filter = new ListFilterModel(GQL.FilterMode.Galleries); filter.searchTerm = input; filter.currentPage = 1; filter.itemsPerPage = maxOptionsShown; filter.sortBy = "title"; filter.sortDirection = GQL.SortDirectionEnum.Asc; if (props.extraCriteria) { filter.criteria = [...props.extraCriteria]; } const query = await queryFindGalleriesForSelect(filter); let ret = query.data.findGalleries.galleries.filter((gallery) => { // HACK - we should probably exclude these in the backend query, but // this will do in the short-term return !exclude.includes(gallery.id.toString()); }); return gallerySelectSort(input, ret).map((gallery) => ({ value: gallery.id, object: gallery, })); } const GalleryOption: React.FC> = ( optionProps ) => { let thisOptionProps = optionProps; const { object } = optionProps.data; const title = galleryTitle(object); // if title does not match the input value but the path does, show the path const { inputValue } = optionProps.selectProps; let matchedPath: string | undefined = ""; if (!title.toLowerCase().includes(inputValue.toLowerCase())) { matchedPath = object.files?.find((a) => a.path.toLowerCase().includes(inputValue.toLowerCase()) )?.path; if ( !matchedPath && object.folder?.path.toLowerCase().includes(inputValue.toLowerCase()) ) { matchedPath = object.folder?.path; } } thisOptionProps = { ...optionProps, children: ( {object.cover?.paths?.thumbnail && ( )} {object.studio?.name && ( {object.studio?.name} )} {object.date && ( {object.date} )} {object.code && ( {object.code} )} {matchedPath && ( {`(${matchedPath})`} )} ), }; return ; }; const GalleryMultiValueLabel: React.FC< MultiValueGenericProps > = (optionProps) => { let thisOptionProps = optionProps; const { object } = optionProps.data; thisOptionProps = { ...optionProps, children: galleryTitle(object), }; return ; }; const GalleryValueLabel: React.FC> = ( optionProps ) => { let thisOptionProps = optionProps; const { object } = optionProps.data; thisOptionProps = { ...optionProps, children: <>{galleryTitle(object)}, }; return ; }; const onCreate = async (name: string) => { const result = await createGallery({ variables: { input: { title: name } }, }); return { value: result.data!.galleryCreate!.id, item: result.data!.galleryCreate!, message: "Created gallery", }; }; const getNamedObject = (id: string, name: string): Gallery => { return { id, title: name, files: [], folder: null, }; }; const isValidNewOption = (inputValue: string, options: Gallery[]) => { if (!inputValue) { return false; } if ( options.some((o) => { return galleryTitle(o).toLowerCase() === inputValue.toLowerCase(); }) ) { return false; } return true; }; return ( {...props} className={cx( "gallery-select", { "gallery-select-active": props.active, }, props.className )} loadOptions={loadGalleries} getNamedObject={getNamedObject} isValidNewOption={isValidNewOption} components={{ Option: GalleryOption, MultiValueLabel: GalleryMultiValueLabel, SingleValue: GalleryValueLabel, }} isMulti={props.isMulti ?? false} creatable={props.creatable ?? defaultCreatable} onCreate={onCreate} placeholder={ props.noSelectionString ?? intl.formatMessage( { id: "actions.select_entity" }, { entityType: intl.formatMessage({ id: props.isMulti ? "galleries" : "gallery", }), } ) } closeMenuOnSelect={!props.isMulti} /> ); }; export const GallerySelect = PatchComponent("GallerySelect", _GallerySelect); const _GalleryIDSelect: React.FC< IFilterProps & IFilterIDProps & ExtraGalleryProps > = (props) => { const { ids, onSelect: onSelectValues } = props; const [values, setValues] = useState([]); const idsChanged = useCompare(ids); function onSelect(items: Gallery[]) { setValues(items); onSelectValues?.(items); } async function loadObjectsByID(idsToLoad: string[]): Promise { const query = await queryFindGalleriesByIDForSelect(idsToLoad); const { galleries: loadedGalleries } = query.data.findGalleries; return loadedGalleries; } useEffect(() => { if (!idsChanged) { return; } if (!ids || ids?.length === 0) { setValues([]); return; } // load the values if we have ids and they haven't been loaded yet const filteredValues = values.filter((v) => ids.includes(v.id.toString())); if (filteredValues.length === ids.length) { return; } const load = async () => { const items = await loadObjectsByID(ids); setValues(items); }; load(); }, [ids, idsChanged, values]); return ; }; export const GalleryIDSelect = PatchComponent( "GalleryIDSelect", _GalleryIDSelect ); function getExcludeFilebaseGalleriesFilter() { const ret = new PathCriterion(); ret.modifier = GQL.CriterionModifier.IsNull; return ret; } export const excludeFileBasedGalleries = [getExcludeFilebaseGalleriesFilter()]; ================================================ FILE: ui/v2.5/src/components/Galleries/GalleryViewer.tsx ================================================ import React, { useCallback, useMemo } from "react"; import { useLightbox } from "src/hooks/Lightbox/hooks"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import Gallery, { PhotoClickHandler } from "react-photo-gallery"; import "flexbin/flexbin.css"; import { CriterionModifier, useFindImagesQuery, } from "src/core/generated-graphql"; interface IProps { galleryId: string; } export const GalleryViewer: React.FC = ({ galleryId }) => { // TODO - add paging - don't load all images at once const pageSize = -1; const currentFilter = useMemo(() => { return { per_page: pageSize, sort: "path", }; }, [pageSize]); const { data, loading } = useFindImagesQuery({ variables: { filter: currentFilter, image_filter: { galleries: { modifier: CriterionModifier.Includes, value: [galleryId], }, }, }, }); const images = useMemo(() => data?.findImages?.images ?? [], [data]); const lightboxState = useMemo(() => { return { images, showNavigation: false, }; }, [images]); const showLightbox = useLightbox(lightboxState); const showLightboxOnClick: PhotoClickHandler = useCallback( (event, { index }) => { showLightbox({ initialIndex: index }); }, [showLightbox] ); if (loading) return ; let photos: { src: string; srcSet?: string | string[] | undefined; sizes?: string | string[] | undefined; width: number; height: number; alt?: string | undefined; key?: string | undefined; }[] = []; images.forEach((image, index) => { let imageData = { src: image.paths.thumbnail!, width: image.visual_files[0]?.width ?? 0, height: image.visual_files[0]?.height ?? 0, tabIndex: index, key: image.id ?? index, loading: "lazy", className: "gallery-image", alt: image.title ?? index.toString(), }; photos.push(imageData); }); return (
); }; export default GalleryViewer; ================================================ FILE: ui/v2.5/src/components/Galleries/GalleryWallCard.tsx ================================================ import React, { useState } from "react"; import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import TextUtils from "src/utils/text"; import { useGalleryLightbox } from "src/hooks/Lightbox/hooks"; import { galleryTitle } from "src/core/galleries"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { GalleryPreviewScrubber } from "./GalleryPreviewScrubber"; import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect"; import cx from "classnames"; const CLASSNAME = "GalleryWallCard"; const CLASSNAME_FOOTER = `${CLASSNAME}-footer`; const CLASSNAME_IMG = `${CLASSNAME}-img`; const CLASSNAME_TITLE = `${CLASSNAME}-title`; const CLASSNAME_IMG_CONTAIN = `${CLASSNAME}-img-contain`; interface IProps { gallery: GQL.SlimGalleryDataFragment; selected?: boolean; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; selecting?: boolean; } type Orientation = "landscape" | "portrait"; function getOrientation(width: number, height: number): Orientation { return width > height ? "landscape" : "portrait"; } const GalleryWallCard: React.FC = ({ gallery, selected, onSelectedChanged, selecting, }) => { const intl = useIntl(); const [coverOrientation, setCoverOrientation] = React.useState("landscape"); const [imageOrientation, setImageOrientation] = React.useState("landscape"); const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters); const { dragProps } = useDragMoveSelect({ selecting: selecting || false, selected: selected || false, onSelectedChanged: onSelectedChanged, }); const cover = gallery?.paths.cover; function onCoverLoad(e: React.SyntheticEvent) { const target = e.target as HTMLImageElement; setCoverOrientation( getOrientation(target.naturalWidth, target.naturalHeight) ); } function onNonCoverLoad(e: React.SyntheticEvent) { const target = e.target as HTMLImageElement; setImageOrientation( getOrientation(target.naturalWidth, target.naturalHeight) ); } const [imgSrc, setImgSrc] = useState(cover ?? undefined); const title = galleryTitle(gallery); const performerNames = gallery.performers.map((p) => p.name); const performers = performerNames.length >= 2 ? [...performerNames.slice(0, -2), performerNames.slice(-2).join(" & ")] : performerNames; function handleCardClick(event: React.MouseEvent) { if (selecting && onSelectedChanged) { onSelectedChanged(!selected, event.shiftKey); return; } showLightboxStart(); } async function showLightboxStart() { if (gallery.image_count === 0) { return; } showLightbox(0); } const imgClassname = imageOrientation !== coverOrientation ? CLASSNAME_IMG_CONTAIN : ""; let shiftKey = false; return ( <>
showLightboxStart()} role="button" tabIndex={0} {...dragProps} > {onSelectedChanged && ( onSelectedChanged(!selected, shiftKey)} onClick={( event: React.MouseEvent ) => { shiftKey = event.shiftKey; event.stopPropagation(); }} /> )}
{ if (selecting) { e.preventDefault(); handleCardClick(e); } e.stopPropagation(); }} > {title && ( )}
{gallery.date && TextUtils.formatFuzzyDate(intl, gallery.date)}
{ showLightbox(i); }} onPathChanged={setImgSrc} />
); }; export default GalleryWallCard; ================================================ FILE: ui/v2.5/src/components/Galleries/styles.scss ================================================ @use "sass:math"; .gallery-image { &:hover { cursor: pointer; } } @include media-breakpoint-only(lg) { .gallery-header-container { align-items: center; display: flex; justify-content: space-between; .gallery-header { flex: 0 0 75%; order: 1; } .gallery-studio-image { flex: 0 0 25%; order: 2; } } } .gallery-header { flex-basis: auto; font-size: 1.5rem; margin-top: 30px; @include media-breakpoint-down(xl) { font-size: 1.75rem; } } .gallery-subheader { display: flex; justify-content: space-between; margin-top: 0.5rem; .date { color: $text-muted; } .resolution { font-weight: bold; } } .gallery-toolbar { align-items: center; display: flex; justify-content: space-between; margin-bottom: 0.25rem; margin-top: 0.5rem; padding-bottom: 0.25rem; width: 100%; .gallery-toolbar-group { align-items: center; column-gap: 0.25rem; display: flex; width: 100%; &:last-child { justify-content: flex-end; } } } #gallery-details-container { .tab-content { min-height: 15rem; } } .gallery-card { &.card { overflow: hidden; padding: 0; padding-bottom: 1rem; @media (max-width: 576px) { width: 100%; } } .card-section { margin-top: auto; a:hover { text-decoration: none; } } .card-popovers { margin-bottom: 0; } .card-section-title { color: $text-color; } &-cover { position: relative; } .preview-scrubber { top: 0; } &-image { object-fit: contain; } } .gallery-tabs { max-height: calc(100vh - 4rem); overflow: auto; overflow-wrap: break-word; word-wrap: break-word; } $galleryTabWidth: 450px; @media (min-width: 1200px) { .gallery-tabs { flex: 0 0 $galleryTabWidth; max-width: $galleryTabWidth; &.collapsed { display: none; } } .gallery-divider { flex: 0 0 15px; max-width: 15px; button { background-color: transparent; border: 0; color: $link-color; cursor: pointer; font-size: 10px; font-weight: 800; height: 100%; line-height: 100%; padding: 0; text-align: center; width: 100%; &:active:not(:hover), &:focus:not(:hover) { background-color: transparent; border: 0; box-shadow: none; } } } .gallery-container { flex: 0 0 calc(100% - #{$galleryTabWidth} - 15px); height: calc(100vh - 4rem); max-width: calc(100% - #{$galleryTabWidth} - 15px); overflow: auto; &.expanded { flex: 0 0 calc(100% - 15px); max-width: calc(100% - 15px); } } } .gallery-tabs, .gallery-container { padding-left: 15px; padding-right: 15px; position: relative; width: 100%; } @media (min-width: 1200px) { .gallery-container .image-list .filtered-list-toolbar.has-selection { top: 0; } } @media (min-width: 1200px), (max-width: 575px) { .gallery-performers { .performer-card { width: 15rem; &-gallery { height: 22.5rem; } &-image { height: 22.5rem; width: 15rem; } } } } #gallery-edit-details { .rating-stars { font-size: 1.3em; height: calc(1.5em + 0.75rem + 2px); } .form-group[data-field="urls"] .string-list-input input.form-control { font-size: 0.85em; } @include media-breakpoint-up(xl) { .custom-fields-input { .custom-fields-field { flex: 0 0 25%; max-width: 25%; } .custom-fields-value { flex: 0 0 75%; max-width: 75%; } } } } .gallery-cover { aspect-ratio: 4 / 3; display: block; height: auto; width: 100%; } .gallery-cover img { height: auto; max-height: 100%; max-width: 100%; object-fit: contain; width: auto; } div.GalleryWall { display: flex; flex-wrap: wrap; margin: 0 auto; /* Prevents last row from consuming all space and stretching images to oblivion */ &::after { content: ""; flex: auto; flex-grow: 9999; } } .GalleryWallCard { height: auto; padding: 2px; position: relative; $width: 96vw; &-landscape { flex-grow: 2; width: 96vw; } &-portrait { flex-grow: 1; width: 96vw; } .lineargradient { background-image: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.3)); bottom: 100px; height: 100px; position: relative; } .preview-scrubber { top: 0; z-index: 1; } &-img { height: 100%; object-fit: cover; object-position: center 20%; width: 100%; &.GalleryWallCard-img-contain { object-fit: contain; object-position: initial; } } &-title { font-weight: bold; } &-footer { bottom: 20px; padding: 1rem; position: absolute; text-shadow: 1px 1px 3px black; transition: 0s opacity; width: 100%; z-index: 2; @media (min-width: 768px) { opacity: 0; } &:hover { .GalleryWallCard-title { text-decoration: underline; } } a { color: white; } } &:hover &-footer { opacity: 1; transition: 1s opacity; transition-delay: 500ms; a { text-decoration: none; } } .rating-stars, .rating-number { position: absolute; right: 1rem; text-shadow: 1px 1px 3px black; top: 1rem; z-index: 2; } .rating-stars { .star-fill-0 .unfilled-star { display: none; } .star-fill-10 .unfilled-star, .star-fill-20 .unfilled-star, .star-fill-25 .unfilled-star, .star-fill-30 .unfilled-star, .star-fill-40 .unfilled-star, .star-fill-50 .unfilled-star, .star-fill-60 .unfilled-star, .star-fill-70 .unfilled-star, .star-fill-75 .unfilled-star, .star-fill-80 .unfilled-star, .star-fill-90 .unfilled-star { visibility: hidden; } .filled-star { filter: drop-shadow(1px 1px 1px #222); } } } div.GalleryWall { @mixin galleryWidth($width) { height: math.div($width, 3) * 2; &-landscape { width: $width; } &-portrait { width: math.div($width, 2); } } .GalleryWallCard { @media (min-width: 576px) { @include galleryWidth(96vw); } } &.zoom-0 .GalleryWallCard { @media (min-width: 768px) { @include galleryWidth(16vw); } @media (min-width: 1200px) { @include galleryWidth(10vw); } } &.zoom-1 .GalleryWallCard { @media (min-width: 768px) { @include galleryWidth(24vw); } @media (min-width: 1200px) { @include galleryWidth(16vw); } } &.zoom-2 .GalleryWallCard { @media (min-width: 768px) { @include galleryWidth(32vw); } @media (min-width: 1200px) { @include galleryWidth(24vw); } } &.zoom-3 .GalleryWallCard { @media (min-width: 768px) { @include galleryWidth(48vw); } @media (min-width: 1200px) { @include galleryWidth(32vw); } } } .gallery-file-card.card { margin: 0; padding: 0; .card-header { cursor: pointer; } dl { margin-bottom: 0; } } .col-form-label { padding-right: 2px; } .gallery-select-option { .gallery-select-row { align-items: center; display: flex; width: 100%; .gallery-select-image { background-color: $body-bg; margin-right: 0.4em; max-height: 50px; max-width: 89px; object-fit: contain; object-position: center; } .gallery-select-details { display: flex; flex-direction: column; justify-content: flex-start; max-height: 4.1rem; overflow: hidden; .gallery-select-title { flex-shrink: 0; white-space: pre-wrap; word-break: break-all; } .gallery-select-date, .gallery-select-studio, .gallery-select-code { color: $text-muted; flex-shrink: 0; font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } } } .gallery-select-alias { font-size: 0.8rem; font-weight: bold; width: 100%; word-break: break-all; } } ================================================ FILE: ui/v2.5/src/components/Groups/ContainingGroupsMultiSet.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { MultiSetModeButtons } from "../Shared/MultiSet"; import { IRelatedGroupEntry, RelatedGroupTable, } from "./GroupDetails/RelatedGroupTable"; import { Group, GroupSelect } from "./GroupSelect"; export const ContainingGroupsMultiSet: React.FC<{ existingValue?: IRelatedGroupEntry[]; value: IRelatedGroupEntry[]; mode: GQL.BulkUpdateIdMode; disabled?: boolean; onUpdate: (value: IRelatedGroupEntry[]) => void; onSetMode: (mode: GQL.BulkUpdateIdMode) => void; menuPortalTarget?: HTMLElement | null; }> = (props) => { const { mode, onUpdate, existingValue } = props; function onSetMode(m: GQL.BulkUpdateIdMode) { if (m === mode) { return; } // if going to Set, set the existing ids if (m === GQL.BulkUpdateIdMode.Set && existingValue) { onUpdate(existingValue); // if going from Set, wipe the ids } else if ( m !== GQL.BulkUpdateIdMode.Set && mode === GQL.BulkUpdateIdMode.Set ) { onUpdate([]); } props.onSetMode(m); } function onRemoveSet(items: Group[]) { onUpdate(items.map((group) => ({ group }))); } return (
{mode !== GQL.BulkUpdateIdMode.Remove ? ( ) : ( onRemoveSet(items)} values={[]} isDisabled={props.disabled} menuPortalTarget={props.menuPortalTarget} /> )}
); }; ================================================ FILE: ui/v2.5/src/components/Groups/EditGroupsDialog.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import { useBulkGroupUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { StudioSelect } from "../Shared/Select"; import { ModalComponent } from "../Shared/Modal"; import { MultiSet } from "../Shared/MultiSet"; import { useToast } from "src/hooks/Toast"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { getAggregateInputValue, getAggregateStateObject, getAggregateTagIds, getAggregateStudioId, getAggregateIds, } from "src/utils/bulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; import { BulkUpdateDateInput } from "../Shared/DateInput"; import { IRelatedGroupEntry } from "./GroupDetails/RelatedGroupTable"; import { ContainingGroupsMultiSet } from "./ContainingGroupsMultiSet"; import { getDateError } from "src/utils/yup"; interface IListOperationProps { selected: GQL.ListGroupDataFragment[]; onClose: (applied: boolean) => void; } export function getAggregateContainingGroups( state: Pick[] ) { const sortedLists: IRelatedGroupEntry[][] = state.map((o) => o.containing_groups .map((oo) => ({ group: oo.group, description: oo.description, })) .sort((a, b) => a.group.id.localeCompare(b.group.id)) ); return getAggregateIds(sortedLists); } function getAggregateContainingGroupInput( mode: GQL.BulkUpdateIdMode, input: IRelatedGroupEntry[] | undefined, aggregateValues: IRelatedGroupEntry[] ): GQL.BulkUpdateGroupDescriptionsInput | undefined { if (mode === GQL.BulkUpdateIdMode.Set && (!input || input.length === 0)) { // and all scenes have the same ids, if (aggregateValues.length > 0) { // then unset, otherwise ignore return { mode, groups: [] }; } } else { // if input non-empty, then we are setting them return { mode, groups: input?.map((e) => { return { group_id: e.group.id, description: e.description }; }) || [], }; } return undefined; } const groupFields = ["rating100", "synopsis", "director", "date"]; export const EditGroupsDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); const [updateInput, setUpdateInput] = useState({ ids: props.selected.map((group) => { return group.id; }), }); const [tagIds, setTagIds] = useState({ mode: GQL.BulkUpdateIdMode.Add, }); const [containingGroupsMode, setGroupMode] = React.useState(GQL.BulkUpdateIdMode.Add); const [containingGroups, setGroups] = useState(); const unsetDisabled = props.selected.length < 2; const [updateGroups] = useBulkGroupUpdate(); const [dateError, setDateError] = useState(); // Network state const [isUpdating, setIsUpdating] = useState(false); const aggregateState = useMemo(() => { const updateState: Partial = {}; const state = props.selected; updateState.studio_id = getAggregateStudioId(props.selected); const updateTagIds = getAggregateTagIds(props.selected); const aggregateGroups = getAggregateContainingGroups(props.selected); let first = true; state.forEach((group: GQL.ListGroupDataFragment) => { getAggregateStateObject(updateState, group, groupFields, first); first = false; }); return { state: updateState, tagIds: updateTagIds, containingGroups: aggregateGroups, }; }, [props.selected]); // update initial state from aggregate useEffect(() => { setUpdateInput((current) => ({ ...current, ...aggregateState.state })); }, [aggregateState]); useEffect(() => { setDateError(getDateError(updateInput.date ?? "", intl)); }, [updateInput.date, intl]); function setUpdateField(input: Partial) { setUpdateInput((current) => ({ ...current, ...input })); } function getGroupInput(): GQL.BulkGroupUpdateInput { const groupInput: GQL.BulkGroupUpdateInput = { ...updateInput, tag_ids: tagIds, }; // we don't have unset functionality for the rating star control // so need to determine if we are setting a rating or not groupInput.rating100 = getAggregateInputValue( updateInput.rating100, aggregateState.state.rating100 ); groupInput.containing_groups = getAggregateContainingGroupInput( containingGroupsMode, containingGroups, aggregateState.containingGroups ); return groupInput; } async function onSave() { setIsUpdating(true); try { await updateGroups({ variables: { input: getGroupInput() } }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl.formatMessage({ id: "groups" }).toLocaleLowerCase() } ) ); props.onClose(true); } catch (e) { Toast.error(e); } setIsUpdating(false); } function render() { return ( props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} isRunning={isUpdating} >
setUpdateField({ rating100: value ?? undefined }) } disabled={isUpdating} /> setUpdateField({ date: newValue })} unsetDisabled={unsetDisabled} error={dateError} /> setUpdateField({ director: newValue }) } unsetDisabled={unsetDisabled} /> setUpdateField({ studio_id: items.length > 0 ? items[0]?.id : undefined, }) } ids={updateInput.studio_id ? [updateInput.studio_id] : []} isDisabled={isUpdating} menuPortalTarget={document.body} /> setGroups(v)} onSetMode={(newMode) => setGroupMode(newMode)} existingValue={aggregateState.containingGroups ?? []} value={containingGroups ?? []} mode={containingGroupsMode} menuPortalTarget={document.body} /> { setTagIds((c) => ({ ...c, ids: itemIDs })); }} onSetMode={(newMode) => { setTagIds((c) => ({ ...c, mode: newMode })); }} ids={tagIds.ids ?? []} existingIds={aggregateState.tagIds} mode={tagIds.mode} menuPortalTarget={document.body} /> setUpdateField({ synopsis: newValue }) } unsetDisabled={unsetDisabled} as="textarea" />
); } return render(); }; ================================================ FILE: ui/v2.5/src/components/Groups/GroupCard.tsx ================================================ import React, { useMemo } from "react"; import { Button, ButtonGroup } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import { PatchComponent } from "src/patch"; import { GridCard } from "../Shared/GridCard/GridCard"; import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; import { SceneLink, TagLink } from "../Shared/TagLink"; import { TruncatedText } from "../Shared/TruncatedText"; import { FormattedMessage } from "react-intl"; import { RatingBanner } from "../Shared/RatingBanner"; import { faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons"; import { RelatedGroupPopoverButton } from "./RelatedGroupPopover"; import { OCounterButton } from "../Shared/CountButton"; const Description: React.FC<{ sceneNumber?: number; description?: string; }> = ({ sceneNumber, description }) => { if (!sceneNumber && !description) return null; return ( <>
{sceneNumber !== undefined && ( #{sceneNumber} )} {description !== undefined && ( {description} )} ); }; interface IProps { group: GQL.ListGroupDataFragment; cardWidth?: number; sceneNumber?: number; selecting?: boolean; selected?: boolean; zoomIndex?: number; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; fromGroupId?: string; onMove?: (srcIds: string[], targetId: string, after: boolean) => void; } export const GroupCard: React.FC = PatchComponent( "GroupCard", ({ group, sceneNumber, cardWidth, selecting, selected, zoomIndex, onSelectedChanged, fromGroupId, onMove, }) => { const groupDescription = useMemo(() => { if (!fromGroupId) { return undefined; } const containingGroup = group.containing_groups.find( (cg) => cg.group.id === fromGroupId ); return containingGroup?.description ?? undefined; }, [fromGroupId, group.containing_groups]); function maybeRenderScenesPopoverButton() { if (group.scenes.length === 0) return; const popoverContent = group.scenes.map((scene) => ( )); return ( ); } function maybeRenderTagPopoverButton() { if (group.tags.length <= 0) return; const popoverContent = group.tags.map((tag) => ( )); return ( ); } function maybeRenderOCounter() { if (!group.o_counter) return; return ; } function maybeRenderPopoverButtonGroup() { if ( sceneNumber || groupDescription || group.scenes.length > 0 || group.tags.length > 0 || group.containing_groups.length > 0 || group.sub_group_count > 0 ) { return ( <>
{maybeRenderScenesPopoverButton()} {maybeRenderTagPopoverButton()} {(group.sub_group_count > 0 || group.containing_groups.length > 0) && ( )} {maybeRenderOCounter()} ); } } return ( {group.name } details={
{group.date}
} selected={selected} selecting={selecting} onSelectedChanged={onSelectedChanged} popovers={maybeRenderPopoverButtonGroup()} /> ); } ); ================================================ FILE: ui/v2.5/src/components/Groups/GroupCardGrid.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { GroupCard } from "./GroupCard"; import { useCardWidth, useContainerDimensions, } from "../Shared/GridCard/GridCard"; import { PatchComponent } from "src/patch"; interface IGroupCardGrid { groups: GQL.ListGroupDataFragment[]; selectedIds: Set; zoomIndex: number; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; fromGroupId?: string; onMove?: (srcIds: string[], targetId: string, after: boolean) => void; } const zoomWidths = [210, 250, 300, 375]; export const GroupCardGrid: React.FC = PatchComponent( "GroupCardGrid", ({ groups, selectedIds, zoomIndex, onSelectChange, fromGroupId, onMove }) => { const [componentRef, { width: containerWidth }] = useContainerDimensions(); const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); return (
{groups.map((p) => ( 0} selected={selectedIds.has(p.id)} onSelectedChanged={(selected: boolean, shiftKey: boolean) => onSelectChange(p.id, selected, shiftKey) } fromGroupId={fromGroupId} onMove={onMove} /> ))}
); } ); ================================================ FILE: ui/v2.5/src/components/Groups/GroupDetails/AddGroupsDialog.tsx ================================================ import React, { useCallback, useMemo, useState } from "react"; import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { useToast } from "src/hooks/Toast"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { RelatedGroupTable, IRelatedGroupEntry } from "./RelatedGroupTable"; import { ModalComponent } from "src/components/Shared/Modal"; import { useAddSubGroups } from "src/core/StashService"; import { ListFilterModel } from "src/models/list-filter/filter"; import { ContainingGroupsCriterionOption, GroupsCriterion, } from "src/models/list-filter/criteria/groups"; interface IListOperationProps { containingGroup: GQL.GroupDataFragment; onClose: (applied: boolean) => void; } export const AddSubGroupsDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); const [isUpdating, setIsUpdating] = useState(false); const addSubGroups = useAddSubGroups(); const Toast = useToast(); const [entries, setEntries] = useState([]); const excludeIDs = useMemo( () => [ ...props.containingGroup.containing_groups.map((m) => m.group.id), props.containingGroup.id, ], [props.containingGroup] ); const filterHook = useCallback( (f: ListFilterModel) => { const groupValue = { id: props.containingGroup.id, label: props.containingGroup.name, }; // filter out sub groups that are already in the containing group const criterion = new GroupsCriterion(ContainingGroupsCriterionOption); criterion.value = { items: [groupValue], depth: 1, excluded: [], }; criterion.modifier = GQL.CriterionModifier.Excludes; f.criteria.push(criterion); return f; }, [props.containingGroup] ); const onSave = async () => { setIsUpdating(true); try { // add the sub groups await addSubGroups( props.containingGroup.id, entries.map((m) => ({ group_id: m.group.id, description: m.description, })) ); const imageCount = entries.length; Toast.success( intl.formatMessage( { id: "toast.added_entity" }, { count: imageCount, singularEntity: intl.formatMessage({ id: "group" }), pluralEntity: intl.formatMessage({ id: "groups" }), } ) ); props.onClose(true); } catch (err) { Toast.error(err); } finally { setIsUpdating(false); } }; return ( props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} isRunning={isUpdating} >
setEntries(input)} excludeIDs={excludeIDs} filterHook={filterHook} menuPortalTarget={document.body} />
); }; ================================================ FILE: ui/v2.5/src/components/Groups/GroupDetails/Group.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { Helmet } from "react-helmet"; import cx from "classnames"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { useFindGroup, useGroupUpdate, useGroupDestroy, } from "src/core/StashService"; import { useHistory, RouteComponentProps, Redirect } from "react-router-dom"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { ModalComponent } from "src/components/Shared/Modal"; import { useToast } from "src/hooks/Toast"; import { GroupScenesPanel } from "./GroupScenesPanel"; import { CompressedGroupDetailsPanel, GroupDetailsPanel, } from "./GroupDetailsPanel"; import { GroupEditPanel } from "./GroupEditPanel"; import { faRefresh, faTrashAlt } from "@fortawesome/free-solid-svg-icons"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { useConfigurationContext } from "src/hooks/Config"; import { DetailImage } from "src/components/Shared/DetailImage"; import { useRatingKeybinds } from "src/hooks/keybinds"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { ExternalLinkButtons } from "src/components/Shared/ExternalLinksButton"; import { BackgroundImage } from "src/components/Shared/DetailsPage/BackgroundImage"; import { DetailTitle } from "src/components/Shared/DetailsPage/DetailTitle"; import { ExpandCollapseButton } from "src/components/Shared/CollapseButton"; import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; import { LightboxLink } from "src/hooks/Lightbox/LightboxLink"; import { TabTitleCounter, useTabKey, } from "src/components/Shared/DetailsPage/Tabs"; import { Button, Tab, Tabs } from "react-bootstrap"; import { GroupSubGroupsPanel } from "./GroupSubGroupsPanel"; import { GroupPerformersPanel } from "./GroupPerformersPanel"; import { Icon } from "src/components/Shared/Icon"; import { goBackOrReplace } from "src/utils/history"; const validTabs = ["default", "scenes", "performers", "subgroups"] as const; type TabKey = (typeof validTabs)[number]; function isTabKey(tab: string): tab is TabKey { return validTabs.includes(tab as TabKey); } const GroupTabs: React.FC<{ tabKey?: TabKey; group: GQL.GroupDataFragment; abbreviateCounter: boolean; }> = ({ tabKey, group, abbreviateCounter }) => { const { scene_count: sceneCount, performer_count: performerCount, sub_group_count: groupCount, } = group; const populatedDefaultTab = useMemo(() => { if (sceneCount == 0) { if (performerCount != 0) { return "performers"; } else if (groupCount !== 0) { return "subgroups"; } } return "scenes"; }, [sceneCount, performerCount, groupCount]); const { setTabKey } = useTabKey({ tabKey, validTabs, defaultTabKey: populatedDefaultTab, baseURL: `/groups/${group.id}`, }); return ( } > } > } > ); }; interface IProps { group: GQL.GroupDataFragment; tabKey?: TabKey; } interface IGroupParams { id: string; tab?: string; } const GroupPage: React.FC = ({ group, tabKey }) => { const intl = useIntl(); const history = useHistory(); const Toast = useToast(); // Configuration settings const { configuration } = useConfigurationContext(); const uiConfig = configuration?.ui; const enableBackgroundImage = uiConfig?.enableMovieBackgroundImage ?? false; const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false; const showAllDetails = uiConfig?.showAllDetails ?? true; const abbreviateCounter = uiConfig?.abbreviateCounters ?? false; const [focusedOnFront, setFocusedOnFront] = useState(true); const [collapsed, setCollapsed] = useState(!showAllDetails); const loadStickyHeader = useLoadStickyHeader(); // Editing state const [isEditing, setIsEditing] = useState(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); // Editing group state const [frontImage, setFrontImage] = useState(); const [backImage, setBackImage] = useState(); const [encodingImage, setEncodingImage] = useState(false); const aliases = useMemo( () => (group.aliases ? [group.aliases] : []), [group.aliases] ); const isDefaultImage = group.front_image_path && group.front_image_path.includes("default=true"); const lightboxImages = useMemo(() => { const covers = []; if (group.front_image_path && !isDefaultImage) { covers.push({ paths: { thumbnail: group.front_image_path, image: group.front_image_path, }, }); } if (group.back_image_path) { covers.push({ paths: { thumbnail: group.back_image_path, image: group.back_image_path, }, }); } return covers; }, [group.front_image_path, group.back_image_path, isDefaultImage]); const activeFrontImage = useMemo(() => { let existingImage = group.front_image_path; if (isEditing) { if (frontImage === null && existingImage) { const imageURL = new URL(existingImage); imageURL.searchParams.set("default", "true"); return imageURL.toString(); } else if (frontImage) { return frontImage; } } return existingImage; }, [isEditing, group.front_image_path, frontImage]); const activeBackImage = useMemo(() => { let existingImage = group.back_image_path; if (isEditing) { if (backImage === null) { return undefined; } else if (backImage) { return backImage; } } return existingImage; }, [isEditing, group.back_image_path, backImage]); const [updateGroup, { loading: updating }] = useGroupUpdate(); const [deleteGroup, { loading: deleting }] = useGroupDestroy({ id: group.id, }); // set up hotkeys useEffect(() => { Mousetrap.bind("e", () => toggleEditing()); Mousetrap.bind("d d", () => { setIsDeleteAlertOpen(true); }); Mousetrap.bind(",", () => setCollapsed(!collapsed)); return () => { Mousetrap.unbind("e"); Mousetrap.unbind("d d"); }; }); useRatingKeybinds( true, configuration?.ui.ratingSystemOptions?.type, setRating ); async function onSave(input: GQL.GroupCreateInput) { await updateGroup({ variables: { input: { id: group.id, ...input, }, }, }); toggleEditing(false); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl.formatMessage({ id: "group" }).toLocaleLowerCase() } ) ); } async function onDelete() { try { await deleteGroup(); } catch (e) { Toast.error(e); return; } goBackOrReplace(history, "/groups"); } function toggleEditing(value?: boolean) { if (value !== undefined) { setIsEditing(value); } else { setIsEditing((e) => !e); } setFrontImage(undefined); setBackImage(undefined); } function renderDeleteAlert() { return ( setIsDeleteAlertOpen(false) }} >

); } function setRating(v: number | null) { if (group.id) { updateGroup({ variables: { input: { id: group.id, rating100: v, }, }, }); } } if (updating || deleting) return ; const headerClassName = cx("detail-header", { edit: isEditing, collapsed, "full-width": !collapsed && !compactExpandedDetails, }); return (
{group?.name}
{!!activeFrontImage && ( )} {!!activeBackImage && ( )} {!!(activeFrontImage && activeBackImage) && ( )}
{!isEditing && ( setCollapsed(v)} /> )} setRating(value)} clickToRate withoutContext /> {!isEditing && ( )} {isEditing ? ( toggleEditing()} onDelete={onDelete} setFrontImage={setFrontImage} setBackImage={setBackImage} setEncodingImage={setEncodingImage} /> ) : ( toggleEditing()} onSave={() => {}} onImageChange={() => {}} onDelete={onDelete} /> )}
{!isEditing && loadStickyHeader && ( )}
{!isEditing && ( )}
{renderDeleteAlert()}
); }; const GroupLoader: React.FC> = ({ location, match, }) => { const { id, tab } = match.params; const { data, loading, error } = useFindGroup(id); useScrollToTopOnMount(); if (tab && !isTabKey(tab)) { return ( ); } if (loading) return ; if (error) return ; if (!data?.findGroup) return ; return ( ); }; export default GroupLoader; ================================================ FILE: ui/v2.5/src/components/Groups/GroupDetails/GroupCreate.tsx ================================================ import React, { useMemo, useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { useGroupCreate } from "src/core/StashService"; import { useHistory, useLocation } from "react-router-dom"; import { useIntl } from "react-intl"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useToast } from "src/hooks/Toast"; import { GroupEditPanel } from "./GroupEditPanel"; const GroupCreate: React.FC = () => { const history = useHistory(); const intl = useIntl(); const Toast = useToast(); const location = useLocation(); const query = useMemo(() => new URLSearchParams(location.search), [location]); const group = { name: query.get("q") ?? undefined, }; // Editing group state const [frontImage, setFrontImage] = useState(); const [backImage, setBackImage] = useState(); const [encodingImage, setEncodingImage] = useState(false); const [createGroup] = useGroupCreate(); async function onSave(input: GQL.GroupCreateInput, andNew?: boolean) { const result = await createGroup({ variables: { input }, }); if (result.data?.groupCreate?.id) { if (!andNew) { history.push(`/groups/${result.data.groupCreate.id}`); } Toast.success( intl.formatMessage( { id: "toast.created_entity" }, { entity: intl.formatMessage({ id: "group" }).toLocaleLowerCase() } ) ); } } function renderFrontImage() { if (frontImage) { return (
Front Cover
); } } function renderBackImage() { if (backImage) { return (
Back Cover
); } } // TODO: CSS class return (
{encodingImage ? ( ) : (
{renderFrontImage()} {renderBackImage()}
)}
history.push("/groups")} onDelete={() => {}} setFrontImage={setFrontImage} setBackImage={setBackImage} setEncodingImage={setEncodingImage} />
); }; export default GroupCreate; ================================================ FILE: ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx ================================================ import React from "react"; import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; import { DetailItem } from "src/components/Shared/DetailItem"; import { Link } from "react-router-dom"; import { DirectorLink } from "src/components/Shared/Link"; import { GroupLink, TagLink } from "src/components/Shared/TagLink"; import { CustomFields } from "src/components/Shared/CustomFields"; interface IGroupDescription { group: GQL.SlimGroupDataFragment; description?: string | null; } const GroupsList: React.FC<{ groups: IGroupDescription[] }> = ({ groups }) => { if (!groups.length) { return null; } return (
    {groups.map((entry) => (
  • ))}
); }; interface IGroupDetailsPanel { group: GQL.GroupDataFragment; collapsed?: boolean; fullWidth?: boolean; } export const GroupDetailsPanel: React.FC = ({ group, fullWidth, }) => { // Network state const intl = useIntl(); function renderTagsField() { if (!group.tags.length) { return; } return (
    {(group.tags ?? []).map((tag) => ( ))}
); } return (
{group.studio?.name} ) : ( "" ) } fullWidth={fullWidth} /> ) : ( "" ) } fullWidth={fullWidth} /> {group.containing_groups.length > 0 && ( } fullWidth={fullWidth} /> )}
); }; export const CompressedGroupDetailsPanel: React.FC = ({ group, }) => { function scrollToTop() { window.scrollTo({ top: 0, behavior: "smooth" }); } return (
scrollToTop()}> {group.name} {group?.studio?.name ? ( <> / {group?.studio?.name} ) : ( "" )}
); }; ================================================ FILE: ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; import Mousetrap from "mousetrap"; import { queryScrapeGroupURL, useListGroupScrapers, } from "src/core/StashService"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { useToast } from "src/hooks/Toast"; import { Modal as BSModal, Form, Button } from "react-bootstrap"; import TextUtils from "src/utils/text"; import ImageUtils from "src/utils/image"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; import { GroupScrapeDialog } from "./GroupScrapeDialog"; import isEqual from "lodash-es/isEqual"; import { handleUnsavedChanges } from "src/utils/navigation"; import { formikUtils } from "src/utils/form"; import { yupDateString, yupFormikValidate, yupUniqueStringList, } from "src/utils/yup"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { Group } from "src/components/Groups/GroupSelect"; import { RelatedGroupTable, IRelatedGroupEntry } from "./RelatedGroupTable"; import { CustomFieldsInput, formatCustomFieldInput, } from "src/components/Shared/CustomFields"; import { cloneDeep } from "@apollo/client/utilities"; interface IGroupEditPanel { group: Partial; onSubmit: (group: GQL.GroupCreateInput, andNew?: boolean) => Promise; onCancel: () => void; onDelete: () => void; setFrontImage: (image?: string | null) => void; setBackImage: (image?: string | null) => void; setEncodingImage: (loading: boolean) => void; } export const GroupEditPanel: React.FC = ({ group, onSubmit, onCancel, onDelete, setFrontImage, setBackImage, setEncodingImage, }) => { const intl = useIntl(); const Toast = useToast(); const isNew = group.id === undefined; const [isLoading, setIsLoading] = useState(false); const [isImageAlertOpen, setIsImageAlertOpen] = useState(false); const [imageClipboard, setImageClipboard] = useState(); const Scrapers = useListGroupScrapers(); const [scrapedGroup, setScrapedGroup] = useState(); const [studio, setStudio] = useState(null); const [containingGroups, setContainingGroups] = useState([]); const schema = yup.object({ name: yup.string().required(), aliases: yup.string().ensure(), duration: yup.number().integer().min(0).nullable().defined(), date: yupDateString(intl), studio_id: yup.string().required().nullable(), tag_ids: yup.array(yup.string().required()).defined(), containing_groups: yup .array( yup.object({ group_id: yup.string().required(), description: yup.string().nullable().ensure(), }) ) .defined(), director: yup.string().ensure(), urls: yupUniqueStringList(intl), synopsis: yup.string().ensure(), front_image: yup.string().nullable().optional(), back_image: yup.string().nullable().optional(), custom_fields: yup.object().required().defined(), }); const initialValues = { name: group?.name ?? "", aliases: group?.aliases ?? "", duration: group?.duration ?? null, date: group?.date ?? "", studio_id: group?.studio?.id ?? null, tag_ids: (group?.tags ?? []).map((t) => t.id), containing_groups: (group?.containing_groups ?? []).map((m) => { return { group_id: m.group.id, description: m.description ?? "" }; }), director: group?.director ?? "", urls: group?.urls ?? [], synopsis: group?.synopsis ?? "", custom_fields: cloneDeep(group?.custom_fields ?? {}), }; type InputValues = yup.InferType; const [customFieldsError, setCustomFieldsError] = useState(); function submit(values: InputValues) { const input = { ...schema.cast(values), custom_fields: formatCustomFieldInput(isNew, values.custom_fields), }; onSave(input); } const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), onSubmit: submit, }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( group.tags, (ids) => formik.setFieldValue("tag_ids", ids) ); const containingGroupEntries = useMemo(() => { return formik.values.containing_groups .map((m) => { return { group: containingGroups.find((mm) => mm.id === m.group_id), description: m.description, }; }) .filter((m) => m.group !== undefined) as IRelatedGroupEntry[]; }, [formik.values.containing_groups, containingGroups]); function onSetStudio(item: Studio | null) { setStudio(item); formik.setFieldValue("studio_id", item ? item.id : null); } useEffect(() => { setStudio(group.studio ?? null); }, [group.studio]); useEffect(() => { setContainingGroups(group.containing_groups?.map((m) => m.group) ?? []); }, [group.containing_groups]); // set up hotkeys useEffect(() => { // Mousetrap.bind("u", (e) => { // setStudioFocus() // e.preventDefault(); // }); Mousetrap.bind("s s", () => { if (formik.dirty) { formik.submitForm(); } }); return () => { // Mousetrap.unbind("u"); Mousetrap.unbind("s s"); }; }); function updateGroupEditStateFromScraper( state: Partial ) { if (state.name) { formik.setFieldValue("name", state.name); } if (state.aliases) { formik.setFieldValue("aliases", state.aliases); } if (state.duration) { const seconds = TextUtils.timestampToSeconds(state.duration); if (seconds) { formik.setFieldValue("duration", seconds); } } if (state.date) { formik.setFieldValue("date", state.date); } if (state.studio && state.studio.stored_id) { onSetStudio({ id: state.studio.stored_id, name: state.studio.name ?? "", aliases: [], }); } if (state.director) { formik.setFieldValue("director", state.director); } if (state.synopsis) { formik.setFieldValue("synopsis", state.synopsis); } if (state.urls) { formik.setFieldValue("urls", state.urls); } updateTagsStateFromScraper(state.tags ?? undefined); if (state.front_image) { // image is a base64 string formik.setFieldValue("front_image", state.front_image); } if (state.back_image) { // image is a base64 string formik.setFieldValue("back_image", state.back_image); } } async function onSave(input: InputValues, andNew?: boolean) { setIsLoading(true); try { await onSubmit(input, andNew); formik.resetForm(); } catch (e) { Toast.error(e); } setIsLoading(false); } async function onSaveAndNewClick() { const input = { ...schema.cast(formik.values), custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), }; onSave(input, true); } async function onScrapeGroupURL(url: string) { if (!url) return; setIsLoading(true); try { const result = await queryScrapeGroupURL(url); if (!result.data || !result.data.scrapeGroupURL) { return; } // if this is a new group, just dump the data if (isNew) { updateGroupEditStateFromScraper(result.data.scrapeGroupURL); } else { setScrapedGroup(result.data.scrapeGroupURL); } } catch (e) { Toast.error(e); } finally { setIsLoading(false); } } function urlScrapable(scrapedUrl: string) { return ( !!scrapedUrl && (Scrapers?.data?.listScrapers ?? []).some((s) => (s?.group?.urls ?? []).some((u) => scrapedUrl.includes(u)) ) ); } function maybeRenderScrapeDialog() { if (!scrapedGroup) { return; } const currentGroup = { id: group.id!, ...formik.values, }; // Get image paths for scrape gui currentGroup.front_image = group?.front_image_path; currentGroup.back_image = group?.back_image_path; return ( { onScrapeDialogClosed(m); }} /> ); } function onScrapeDialogClosed(p?: GQL.ScrapedGroupDataFragment) { if (p) { updateGroupEditStateFromScraper(p); } setScrapedGroup(undefined); } const encodingImage = ImageUtils.usePasteImage(showImageAlert); useEffect(() => { setFrontImage(formik.values.front_image); }, [formik.values.front_image, setFrontImage]); useEffect(() => { setBackImage(formik.values.back_image); }, [formik.values.back_image, setBackImage]); useEffect(() => { setEncodingImage(encodingImage); }, [setEncodingImage, encodingImage]); function onFrontImageLoad(imageData: string | null) { formik.setFieldValue("front_image", imageData); } function onFrontImageChange(event: React.FormEvent) { ImageUtils.onImageChange(event, onFrontImageLoad); } function onBackImageLoad(imageData: string | null) { formik.setFieldValue("back_image", imageData); } function onBackImageChange(event: React.FormEvent) { ImageUtils.onImageChange(event, onBackImageLoad); } function showImageAlert(imageData: string) { setImageClipboard(imageData); setIsImageAlertOpen(true); } function setImageFromClipboard(isFrontImage: boolean) { if (isFrontImage) { formik.setFieldValue("front_image", imageClipboard); } else { formik.setFieldValue("back_image", imageClipboard); } setImageClipboard(undefined); setIsImageAlertOpen(false); } function renderImageAlert() { return ( setIsImageAlertOpen(false)} >

Select image to set

); } if (isLoading) return ; const { renderField, renderInputField, renderDateField, renderDurationField, renderURLListField, } = formikUtils(intl, formik); function renderStudioField() { const title = intl.formatMessage({ id: "studio" }); const control = ( onSetStudio(items.length > 0 ? items[0] : null)} values={studio ? [studio] : []} /> ); return renderField("studio_id", title, control); } function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); return renderField("tag_ids", title, tagsControl()); } function onSetContainingGroupEntries(input: IRelatedGroupEntry[]) { setContainingGroups(input.map((m) => m.group)); const newGroups = input.map((m) => ({ group_id: m.group.id, description: m.description, })); formik.setFieldValue("containing_groups", newGroups); } function renderContainingGroupsField() { const title = intl.formatMessage({ id: "containing_groups" }); const control = ( ); return renderField("containing_groups", title, control); } // TODO: CSS class return (
{isNew && (

{intl.formatMessage( { id: "actions.add_entity" }, { entityType: intl.formatMessage({ id: "group" }) } )}

)} { // Check if it's a redirect after group creation if (action === "PUSH" && location.pathname.startsWith("/groups/")) return true; return handleUnsavedChanges(intl, "groups", group.id)(location); }} />
{renderInputField("name")} {renderInputField("aliases")} {renderDurationField("duration")} {renderDateField("date")} {renderContainingGroupsField()} {renderStudioField()} {renderInputField("director")} {renderURLListField("urls", onScrapeGroupURL, urlScrapable)} {renderInputField("synopsis", "textarea")} {renderTagsField()} formik.setFieldValue("custom_fields", v)} error={customFieldsError} setError={(e) => setCustomFieldsError(e)} /> onFrontImageLoad(null)} onBackImageChange={onBackImageChange} onBackImageChangeURL={onBackImageLoad} onClearBackImage={() => onBackImageLoad(null)} onDelete={onDelete} /> {maybeRenderScrapeDialog()} {renderImageAlert()}
); }; ================================================ FILE: ui/v2.5/src/components/Groups/GroupDetails/GroupPerformersPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useGroupFilterHook } from "src/core/groups"; import { FilteredPerformerList } from "src/components/Performers/PerformerList"; import { View } from "src/components/List/views"; interface IGroupPerformersPanel { active: boolean; group: GQL.GroupDataFragment; showChildGroupContent?: boolean; } export const GroupPerformersPanel: React.FC = ({ active, group, showChildGroupContent, }) => { const filterHook = useGroupFilterHook(group, showChildGroupContent); return ( ); }; ================================================ FILE: ui/v2.5/src/components/Groups/GroupDetails/GroupScenesPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { GroupsCriterion, GroupsCriterionOption, } from "src/models/list-filter/criteria/groups"; import { ListFilterModel } from "src/models/list-filter/filter"; import { FilteredSceneList } from "src/components/Scenes/SceneList"; import { View } from "src/components/List/views"; interface IGroupScenesPanel { active: boolean; group: GQL.GroupDataFragment; showSubGroupContent?: boolean; } function useFilterHook( group: Pick, showSubGroupContent?: boolean ) { return (filter: ListFilterModel) => { const groupValue = { id: group.id, label: group.name }; // if group is already present, then we modify it, otherwise add let groupCriterion = filter.criteria.find((c) => { return c.criterionOption.type === "groups"; }) as GroupsCriterion | undefined; if ( groupCriterion && (groupCriterion.modifier === GQL.CriterionModifier.IncludesAll || groupCriterion.modifier === GQL.CriterionModifier.Includes) ) { // add the group if not present if ( !groupCriterion.value.items.find((p) => { return p.id === group.id; }) ) { groupCriterion.value.items.push(groupValue); } groupCriterion.modifier = GQL.CriterionModifier.IncludesAll; } else { // overwrite groupCriterion = new GroupsCriterion(GroupsCriterionOption); groupCriterion.value = { items: [groupValue], depth: showSubGroupContent ? -1 : 0, excluded: [], }; filter.criteria.push(groupCriterion); } return filter; }; } export const GroupScenesPanel: React.FC = ({ active, group, showSubGroupContent, }) => { const filterHook = useFilterHook(group, showSubGroupContent); if (group && group.id) { return ( ); } return <>; }; ================================================ FILE: ui/v2.5/src/components/Groups/GroupDetails/GroupScrapeDialog.tsx ================================================ import React, { useState } from "react"; import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { ScrapedInputGroupRow, ScrapedImageRow, ScrapedTextAreaRow, ScrapedStringListRow, } from "src/components/Shared/ScrapeDialog/ScrapeDialogRow"; import { ScrapeDialog } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; import TextUtils from "src/utils/text"; import { ObjectScrapeResult, ScrapeResult, } from "src/components/Shared/ScrapeDialog/scrapeResult"; import { Studio } from "src/components/Studios/StudioSelect"; import { useCreateScrapedStudio } from "src/components/Shared/ScrapeDialog/createObjects"; import { ScrapedStudioRow } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow"; import { uniq } from "lodash-es"; import { Tag } from "src/components/Tags/TagSelect"; import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags"; interface IGroupScrapeDialogProps { group: Partial; groupStudio: Studio | null; groupTags: Tag[]; scraped: GQL.ScrapedGroup; onClose: (scrapedGroup?: GQL.ScrapedGroup) => void; } export const GroupScrapeDialog: React.FC = ({ group, groupStudio: groupStudio, groupTags: groupTags, scraped, onClose, }) => { const intl = useIntl(); const [name, setName] = useState>( new ScrapeResult(group.name, scraped.name) ); const [aliases, setAliases] = useState>( new ScrapeResult(group.aliases, scraped.aliases) ); const [duration, setDuration] = useState>( new ScrapeResult( TextUtils.secondsToTimestamp(group.duration || 0), // convert seconds to string if it's a number scraped.duration && !isNaN(+scraped.duration) ? TextUtils.secondsToTimestamp(parseInt(scraped.duration, 10)) : scraped.duration ) ); const [date, setDate] = useState>( new ScrapeResult(group.date, scraped.date) ); const [director, setDirector] = useState>( new ScrapeResult(group.director, scraped.director) ); const [synopsis, setSynopsis] = useState>( new ScrapeResult(group.synopsis, scraped.synopsis) ); const [studio, setStudio] = useState>( new ObjectScrapeResult( groupStudio ? { stored_id: groupStudio.id, name: groupStudio.name, } : undefined, scraped.studio?.stored_id ? scraped.studio : undefined ) ); const [urls, setURLs] = useState>( new ScrapeResult( group.urls, scraped.urls ? uniq((group.urls ?? []).concat(scraped.urls ?? [])) : undefined ) ); const [frontImage, setFrontImage] = useState>( new ScrapeResult(group.front_image, scraped.front_image) ); const [backImage, setBackImage] = useState>( new ScrapeResult(group.back_image, scraped.back_image) ); const [newStudio, setNewStudio] = useState( scraped.studio && !scraped.studio.stored_id ? scraped.studio : undefined ); const createNewStudio = useCreateScrapedStudio({ scrapeResult: studio, setScrapeResult: setStudio, setNewObject: setNewStudio, }); const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags( groupTags, scraped.tags ); const allFields = [ name, aliases, duration, date, director, synopsis, studio, tags, urls, frontImage, backImage, ]; // don't show the dialog if nothing was scraped if ( allFields.every((r) => !r.scraped) && !newStudio && newTags.length === 0 ) { onClose(); return <>; } function makeNewScrapedItem(): GQL.ScrapedGroup { const newStudioValue = studio.getNewValue(); const durationString = duration.getNewValue(); return { name: name.getNewValue() ?? "", aliases: aliases.getNewValue(), duration: durationString, date: date.getNewValue(), director: director.getNewValue(), synopsis: synopsis.getNewValue(), studio: newStudioValue, tags: tags.getNewValue(), urls: urls.getNewValue(), front_image: frontImage.getNewValue(), back_image: backImage.getNewValue(), }; } function renderScrapeRows() { return ( <> setName(value)} /> setAliases(value)} /> setDuration(value)} /> setDate(value)} /> setDirector(value)} /> setSynopsis(value)} /> setStudio(value)} newStudio={newStudio} onCreateNew={createNewStudio} /> setURLs(value)} /> {scrapedTagsRow} setFrontImage(value)} /> setBackImage(value)} /> ); } if (linkDialog) { return linkDialog; } return ( { onClose(apply ? makeNewScrapedItem() : undefined); }} > {renderScrapeRows()} ); }; ================================================ FILE: ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { FilteredGroupList } from "../GroupList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { ContainingGroupsCriterionOption, GroupsCriterion, } from "src/models/list-filter/criteria/groups"; import { useRemoveSubGroups, useReorderSubGroupsMutation, } from "src/core/StashService"; import { IItemListOperation } from "src/components/List/FilteredListToolbar"; import { showWhenNoneSelected, showWhenSelected, } from "src/components/List/ItemList"; import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons"; import { useIntl } from "react-intl"; import { useToast } from "src/hooks/Toast"; import { useModal } from "src/hooks/modal"; import { AddSubGroupsDialog } from "./AddGroupsDialog"; import { PatchComponent } from "src/patch"; import { View } from "src/components/List/views"; const useContainingGroupFilterHook = ( group: Pick, showSubGroupContent?: boolean ) => { return (filter: ListFilterModel) => { const groupValue = { id: group.id, label: group.name }; // if studio is already present, then we modify it, otherwise add let groupCriterion = filter.criteria.find((c) => { return c.criterionOption.type === "containing_groups"; }) as GroupsCriterion | undefined; if (groupCriterion) { // add the group if not present if ( !groupCriterion.value.items.find((p) => { return p.id === group.id; }) ) { groupCriterion.value.items.push(groupValue); } } else { groupCriterion = new GroupsCriterion(ContainingGroupsCriterionOption); groupCriterion.value = { items: [groupValue], excluded: [], depth: showSubGroupContent ? -1 : 0, }; groupCriterion.modifier = GQL.CriterionModifier.Includes; filter.criteria.push(groupCriterion); } filter.sortBy = "sub_group_order"; filter.sortDirection = GQL.SortDirectionEnum.Asc; return filter; }; }; interface IGroupSubGroupsPanel { active: boolean; group: GQL.GroupDataFragment; extraOperations?: IItemListOperation[]; } const defaultFilter = (() => { const sortBy = "sub_group_order"; const ret = new ListFilterModel(GQL.FilterMode.Groups, undefined, { defaultSortBy: sortBy, }); // unset the sort by so that its not included in the URL ret.sortBy = undefined; return ret; })(); export const GroupSubGroupsPanel: React.FC = PatchComponent( "GroupSubGroupsPanel", ({ active, group, extraOperations = [] }) => { const intl = useIntl(); const Toast = useToast(); const { modal, showModal, closeModal } = useModal(); const [reorderSubGroups] = useReorderSubGroupsMutation(); const mutateRemoveSubGroups = useRemoveSubGroups(); const filterHook = useContainingGroupFilterHook(group); async function removeSubGroups( result: GQL.FindGroupsQueryResult, filter: ListFilterModel, selectedIds: Set ) { try { await mutateRemoveSubGroups( group.id, Array.from(selectedIds.values()) ); Toast.success( intl.formatMessage( { id: "toast.removed_entity" }, { count: selectedIds.size, singularEntity: intl.formatMessage({ id: "group" }), pluralEntity: intl.formatMessage({ id: "groups" }), } ) ); } catch (e) { Toast.error(e); } } async function onAddSubGroups() { showModal( ); } const otherOperations = [ ...extraOperations, { text: intl.formatMessage({ id: "actions.add_sub_groups" }), onClick: onAddSubGroups, isDisplayed: showWhenNoneSelected, postRefetch: true, icon: faPlus, buttonVariant: "secondary", }, { text: intl.formatMessage({ id: "actions.remove_from_containing_group", }), onClick: removeSubGroups, isDisplayed: showWhenSelected, postRefetch: true, icon: faMinus, buttonVariant: "danger", }, ]; function onMove(srcIds: string[], targetId: string, after: boolean) { reorderSubGroups({ variables: { input: { group_id: group.id, sub_group_ids: srcIds, insert_at_id: targetId, insert_after: after, }, }, }); } return ( <> {modal} ); } ); ================================================ FILE: ui/v2.5/src/components/Groups/GroupDetails/RelatedGroupTable.tsx ================================================ import React, { useMemo } from "react"; import { FormattedMessage } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { Form, Row, Col } from "react-bootstrap"; import { Group, GroupSelect } from "src/components/Groups/GroupSelect"; import cx from "classnames"; import { ListFilterModel } from "src/models/list-filter/filter"; export type GroupSceneIndexMap = Map; export interface IRelatedGroupEntry { group: Group; description?: GQL.InputMaybe | undefined; } export const RelatedGroupTable: React.FC<{ value: IRelatedGroupEntry[]; onUpdate: (input: IRelatedGroupEntry[]) => void; excludeIDs?: string[]; filterHook?: (f: ListFilterModel) => ListFilterModel; disabled?: boolean; menuPortalTarget?: HTMLElement | null; }> = (props) => { const { value, onUpdate } = props; const groupIDs = useMemo(() => value.map((m) => m.group.id), [value]); const excludeIDs = useMemo( () => [...groupIDs, ...(props.excludeIDs ?? [])], [props.excludeIDs, groupIDs] ); const updateFieldChanged = (index: number, description: string | null) => { const newValues = value.map((existing, i) => { if (i === index) { return { ...existing, description, }; } return existing; }); onUpdate(newValues); }; function onGroupSet(index: number, groups: Group[]) { if (!groups.length) { // remove this entry const newValues = value.filter((_, i) => i !== index); onUpdate(newValues); return; } const group = groups[0]; const newValues = value.map((existing, i) => { if (i === index) { return { ...existing, group: group, }; } return existing; }); onUpdate(newValues); } function onNewGroupSet(groups: Group[]) { if (!groups.length) { return; } const group = groups[0]; const newValues = [ ...value, { group: group, scene_index: null, }, ]; onUpdate(newValues); } return (
{value.map((m, i) => ( onGroupSet(i, items)} values={[m.group!]} excludeIds={excludeIDs} filterHook={props.filterHook} isDisabled={props.disabled} menuPortalTarget={props.menuPortalTarget} /> ) => { updateFieldChanged( i, e.currentTarget.value === "" ? null : e.currentTarget.value ); }} disabled={props.disabled} /> ))} onNewGroupSet(items)} values={[]} excludeIds={excludeIDs} filterHook={props.filterHook} isDisabled={props.disabled} menuPortalTarget={props.menuPortalTarget} />
); }; ================================================ FILE: ui/v2.5/src/components/Groups/GroupList.tsx ================================================ import React, { useCallback, useEffect } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import cloneDeep from "lodash-es/cloneDeep"; import Mousetrap from "mousetrap"; import { useHistory } from "react-router-dom"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import * as GQL from "src/core/generated-graphql"; import { queryFindGroups, useFindGroups, useGroupsDestroy, } from "src/core/StashService"; import { useFilteredItemList } from "../List/ItemList"; import { ExportDialog } from "../Shared/ExportDialog"; import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; import { GroupCardGrid } from "./GroupCardGrid"; import { EditGroupsDialog } from "./EditGroupsDialog"; import { View } from "../List/views"; import { FilteredListToolbar, IItemListOperation, } from "../List/FilteredListToolbar"; import { PatchComponent, PatchContainerComponent } from "src/patch"; import useFocus from "src/utils/focus"; import { Sidebar, SidebarPane, SidebarPaneContent, SidebarStateContext, useSidebarState, } from "../Shared/Sidebar"; import { useCloseEditDelete, useFilterOperations } from "../List/util"; import { FilteredSidebarHeader, useFilteredSidebarKeybinds, } from "../List/Filters/FilterSidebar"; import { IListFilterOperation, ListOperations, } from "../List/ListOperationButtons"; import cx from "classnames"; import { FilterTags } from "../List/FilterTags"; import { Pagination, PaginationIndex } from "../List/Pagination"; import { LoadedContent } from "../List/PagedList"; import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter"; import { SidebarTagsFilter } from "../List/Filters/TagsFilter"; import { SidebarRatingFilter } from "../List/Filters/RatingFilter"; import { Button } from "react-bootstrap"; const GroupList: React.FC<{ groups: GQL.ListGroupDataFragment[]; filter: ListFilterModel; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; fromGroupId?: string; onMove?: (srcIds: string[], targetId: string, after: boolean) => void; }> = PatchComponent( "GroupList", ({ groups, filter, selectedIds, onSelectChange, fromGroupId, onMove }) => { if (groups.length === 0) { return null; } if (filter.displayMode === DisplayMode.Grid) { return ( ); } return null; } ); const GroupFilterSidebarSections = PatchContainerComponent( "FilteredGroupList.SidebarSections" ); const SidebarContent: React.FC<{ filter: ListFilterModel; setFilter: (filter: ListFilterModel) => void; filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; sidebarOpen: boolean; onClose?: () => void; showEditFilter: (editingCriterion?: string) => void; count?: number; focus?: ReturnType; }> = ({ filter, setFilter, filterHook, view, showEditFilter, sidebarOpen, onClose, count, focus, }) => { const showResultsId = count !== undefined ? "actions.show_count_results" : "actions.show_results"; const hideStudios = view === View.StudioScenes; return ( <> {!hideStudios && ( )}
); }; interface IGroupListContext { filterHook?: (filter: ListFilterModel) => ListFilterModel; defaultFilter?: ListFilterModel; view?: View; alterQuery?: boolean; } interface IGroupList extends IGroupListContext { fromGroupId?: string; onMove?: (srcIds: string[], targetId: string, after: boolean) => void; otherOperations?: IItemListOperation[]; } function useViewRandom(filter: ListFilterModel, count: number) { const history = useHistory(); const viewRandom = useCallback(async () => { // query for a random scene if (count === 0) { return; } const index = Math.floor(Math.random() * count); const filterCopy = cloneDeep(filter); filterCopy.itemsPerPage = 1; filterCopy.currentPage = index + 1; const singleResult = await queryFindGroups(filterCopy); if (singleResult.data.findGroups.groups.length === 1) { const { id } = singleResult.data.findGroups.groups[0]; // navigate to the image player page history.push(`/groups/${id}`); } }, [history, filter, count]); return viewRandom; } function useAddKeybinds(filter: ListFilterModel, count: number) { const viewRandom = useViewRandom(filter, count); useEffect(() => { Mousetrap.bind("p r", () => { viewRandom(); }); return () => { Mousetrap.unbind("p r"); }; }, [viewRandom]); } export const FilteredGroupList = PatchComponent( "FilteredGroupList", (props: IGroupList) => { const intl = useIntl(); const searchFocus = useFocus(); const { filterHook, view, alterQuery, onMove, fromGroupId, otherOperations: providedOperations = [], defaultFilter, } = props; const withSidebar = view !== View.GroupSubGroups; const filterable = view !== View.GroupSubGroups; const sortable = view !== View.GroupSubGroups; // States const { showSidebar, setShowSidebar, sectionOpen, setSectionOpen, loading: sidebarStateLoading, } = useSidebarState(view); const { filterState, queryResult, modalState, listSelect, showEditFilter } = useFilteredItemList({ filterStateProps: { filterMode: GQL.FilterMode.Groups, defaultFilter, view, useURL: alterQuery, }, queryResultProps: { useResult: useFindGroups, getCount: (r) => r.data?.findGroups.count ?? 0, getItems: (r) => r.data?.findGroups.groups ?? [], filterHook, }, }); const { filter, setFilter } = filterState; const { effectiveFilter, result, cachedResult, items, totalCount } = queryResult; const { selectedIds, selectedItems, onSelectChange, onSelectAll, onSelectNone, onInvertSelection, hasSelection, } = listSelect; const { modal, showModal, closeModal } = modalState; // Utility hooks const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ filter, setFilter, }); useAddKeybinds(effectiveFilter, totalCount); useFilteredSidebarKeybinds({ showSidebar, setShowSidebar, }); useEffect(() => { Mousetrap.bind("e", () => { if (hasSelection) { onEdit?.(); } }); Mousetrap.bind("d d", () => { if (hasSelection) { onDelete?.(); } }); return () => { Mousetrap.unbind("e"); Mousetrap.unbind("d d"); }; }); const onCloseEditDelete = useCloseEditDelete({ closeModal, onSelectNone, result, }); const viewRandom = useViewRandom(effectiveFilter, totalCount); function onExport(all: boolean) { showModal( closeModal()} /> ); } function onEdit() { showModal( ); } function onDelete() { showModal( ); } const convertedExtraOperations: IListFilterOperation[] = providedOperations.map((o) => ({ ...o, isDisplayed: o.isDisplayed ? () => o.isDisplayed!(result, filter, selectedIds) : undefined, onClick: () => { o.onClick(result, filter, selectedIds); }, })); const otherOperations = [ ...convertedExtraOperations, { text: intl.formatMessage({ id: "actions.select_all" }), onClick: () => onSelectAll(), isDisplayed: () => totalCount > 0, }, { text: intl.formatMessage({ id: "actions.select_none" }), onClick: () => onSelectNone(), isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.invert_selection" }), onClick: () => onInvertSelection(), isDisplayed: () => totalCount > 0, }, { text: intl.formatMessage({ id: "actions.view_random" }), onClick: viewRandom, }, { text: intl.formatMessage({ id: "actions.export" }), onClick: () => onExport(false), isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export_all" }), onClick: () => onExport(true), }, ]; // render if (sidebarStateLoading) return null; const operations = ( ); const content = ( <> showEditFilter(c.criterionOption.type)} onRemoveCriterion={removeCriterion} onRemoveAll={clearAllCriteria} />
setFilter(filter.changePage(page))} />
{totalCount > filter.itemsPerPage && (
)} ); if (!withSidebar) { return content; } return (
{modal} setShowSidebar(false)}> setShowSidebar(false)} count={cachedResult.loading ? undefined : totalCount} focus={searchFocus} /> setShowSidebar(!showSidebar)} > {content}
); } ); ================================================ FILE: ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx ================================================ import React from "react"; import { useFindGroups } from "src/core/StashService"; import { GroupCard } from "./GroupCard"; import { ListFilterModel } from "src/models/list-filter/filter"; import { PatchComponent } from "src/patch"; import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow"; interface IProps { isTouch: boolean; filter: ListFilterModel; header: string; } export const GroupRecommendationRow: React.FC = PatchComponent( "GroupRecommendationRow", (props: IProps) => { const result = useFindGroups(props.filter); const count = result.data?.findGroups.count ?? 0; return ( {result.loading ? [...Array(props.filter.itemsPerPage)].map((i) => (
)) : result.data?.findGroups.groups.map((g) => ( ))}
); } ); ================================================ FILE: ui/v2.5/src/components/Groups/GroupSelect.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { OptionProps, components as reactSelectComponents, MultiValueGenericProps, SingleValueProps, } from "react-select"; import cx from "classnames"; import * as GQL from "src/core/generated-graphql"; import { queryFindGroupsForSelect, queryFindGroupsByIDForSelect, useGroupCreate, } from "src/core/StashService"; import { useConfigurationContext } from "src/hooks/Config"; import { useIntl } from "react-intl"; import { defaultMaxOptionsShown } from "src/core/config"; import { ListFilterModel } from "src/models/list-filter/filter"; import { FilterSelectComponent, IFilterIDProps, IFilterProps, IFilterValueProps, Option as SelectOption, } from "../Shared/FilterSelect"; import { useCompare } from "src/hooks/state"; import { Placement } from "react-bootstrap/esm/Overlay"; import { sortByRelevance } from "src/utils/query"; import { PatchComponent, PatchFunction } from "src/patch"; import { TruncatedText } from "../Shared/TruncatedText"; export type Group = Pick< GQL.Group, "id" | "name" | "date" | "front_image_path" | "aliases" > & { studio?: Pick | null; }; type Option = SelectOption; type FindGroupsResult = Awaited< ReturnType >["data"]["findGroups"]["groups"]; function sortGroupsByRelevance(input: string, groups: FindGroupsResult) { return sortByRelevance( input, groups, (m) => m.name, (m) => (m.aliases ? [m.aliases] : []) ); } const groupSelectSort = PatchFunction( "GroupSelect.sort", sortGroupsByRelevance ); export const GroupSelect: React.FC< IFilterProps & IFilterValueProps & { hoverPlacement?: Placement; excludeIds?: string[]; filterHook?: (f: ListFilterModel) => ListFilterModel; } > = PatchComponent("GroupSelect", (props) => { const [createGroup] = useGroupCreate(); const { configuration } = useConfigurationContext(); const intl = useIntl(); const maxOptionsShown = configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown; const defaultCreatable = !configuration?.interface.disableDropdownCreate.movie; const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); async function loadGroups(input: string): Promise { let filter = new ListFilterModel(GQL.FilterMode.Groups); filter.searchTerm = input; filter.currentPage = 1; filter.itemsPerPage = maxOptionsShown; filter.sortBy = "name"; filter.sortDirection = GQL.SortDirectionEnum.Asc; if (props.filterHook) { filter = props.filterHook(filter); } const query = await queryFindGroupsForSelect(filter); let ret = query.data.findGroups.groups.filter((group) => { // HACK - we should probably exclude these in the backend query, but // this will do in the short-term return !exclude.includes(group.id.toString()); }); return groupSelectSort(input, ret).map((group) => ({ value: group.id, object: group, })); } const GroupOption: React.FC> = (optionProps) => { let thisOptionProps = optionProps; const { object } = optionProps.data; const title = object.name; // if name does not match the input value but an alias does, show the alias const { inputValue } = optionProps.selectProps; let alias: string | undefined = ""; if (!title.toLowerCase().includes(inputValue.toLowerCase())) { alias = object.aliases || undefined; } thisOptionProps = { ...optionProps, children: ( {object.front_image_path && ( )} {title} {alias && ( {` (${alias})`} )} } lineCount={1} /> {object.studio?.name && ( {object.studio?.name} )} {object.date && ( {object.date} )} ), }; return ; }; const GroupMultiValueLabel: React.FC< MultiValueGenericProps > = (optionProps) => { let thisOptionProps = optionProps; const { object } = optionProps.data; thisOptionProps = { ...optionProps, children: object.name, }; return ; }; const GroupValueLabel: React.FC> = ( optionProps ) => { let thisOptionProps = optionProps; const { object } = optionProps.data; thisOptionProps = { ...optionProps, children: <>{object.name}, }; return ; }; const onCreate = async (name: string) => { const result = await createGroup({ variables: { input: { name } }, }); return { value: result.data!.groupCreate!.id, item: result.data!.groupCreate!, message: "Created group", }; }; const getNamedObject = (id: string, name: string) => { return { id, name, }; }; const isValidNewOption = (inputValue: string, options: Group[]) => { if (!inputValue) { return false; } if ( options.some((o) => { return ( o.name.toLowerCase() === inputValue.toLowerCase() || o.aliases?.toLowerCase() === inputValue.toLowerCase() ); }) ) { return false; } return true; }; return ( {...props} className={cx( "group-select", { "group-select-active": props.active, }, props.className )} loadOptions={loadGroups} getNamedObject={getNamedObject} isValidNewOption={isValidNewOption} components={{ Option: GroupOption, MultiValueLabel: GroupMultiValueLabel, SingleValue: GroupValueLabel, }} isMulti={props.isMulti ?? false} creatable={props.creatable ?? defaultCreatable} onCreate={onCreate} placeholder={ props.noSelectionString ?? intl.formatMessage( { id: "actions.select_entity" }, { entityType: intl.formatMessage({ id: props.isMulti ? "groups" : "group", }), } ) } closeMenuOnSelect={!props.isMulti} /> ); }); const _GroupIDSelect: React.FC> = ( props ) => { const { ids, onSelect: onSelectValues } = props; const [values, setValues] = useState([]); const idsChanged = useCompare(ids); function onSelect(items: Group[]) { setValues(items); onSelectValues?.(items); } async function loadObjectsByID(idsToLoad: string[]): Promise { const query = await queryFindGroupsByIDForSelect(idsToLoad); const { groups: loadedGroups } = query.data.findGroups; return loadedGroups; } useEffect(() => { if (!idsChanged) { return; } if (!ids || ids?.length === 0) { setValues([]); return; } // load the values if we have ids and they haven't been loaded yet const filteredValues = values.filter((v) => ids.includes(v.id.toString())); if (filteredValues.length === ids.length) { return; } const load = async () => { const items = await loadObjectsByID(ids); setValues(items); }; load(); }, [ids, idsChanged, values]); return ; }; export const GroupIDSelect = PatchComponent("GroupIDSelect", _GroupIDSelect); ================================================ FILE: ui/v2.5/src/components/Groups/GroupTag.tsx ================================================ import React from "react"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import { GroupLink } from "../Shared/TagLink"; export const GroupTag: React.FC<{ group: Pick; linkType?: "scene" | "sub_group" | "details"; description?: string; }> = ({ group, linkType, description }) => { return (
{group.name
); }; ================================================ FILE: ui/v2.5/src/components/Groups/Groups.tsx ================================================ import React from "react"; import { Route, Switch } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import Group from "./GroupDetails/Group"; import GroupCreate from "./GroupDetails/GroupCreate"; import { FilteredGroupList } from "./GroupList"; import { View } from "../List/views"; const Groups: React.FC = () => { return ; }; const GroupRoutes: React.FC = () => { const titleProps = useTitleProps({ id: "groups" }); return ( <> ); }; export default GroupRoutes; ================================================ FILE: ui/v2.5/src/components/Groups/RelatedGroupPopover.tsx ================================================ import { faFilm, faArrowUpLong, faArrowDownLong, } from "@fortawesome/free-solid-svg-icons"; import React, { useMemo } from "react"; import { Button, OverlayTrigger, Tooltip } from "react-bootstrap"; import { Count } from "../Shared/PopoverCountButton"; import { Icon } from "../Shared/Icon"; import { HoverPopover } from "../Shared/HoverPopover"; import { Link } from "react-router-dom"; import NavUtils from "src/utils/navigation"; import * as GQL from "src/core/generated-graphql"; import { useIntl } from "react-intl"; import { GroupTag } from "./GroupTag"; interface IProps { group: Pick< GQL.ListGroupDataFragment, "id" | "name" | "containing_groups" | "sub_group_count" >; } const ContainingGroupsCount: React.FC = ({ group }) => { const { containing_groups: containingGroups } = group; const popoverContent = useMemo(() => { if (!containingGroups.length) { return []; } return containingGroups.map((entry) => ( )); }, [containingGroups]); if (!containingGroups.length) { return null; } return ( ); }; const SubGroupCount: React.FC = ({ group }) => { const intl = useIntl(); const count = group.sub_group_count; if (!count) { return null; } function getTitle() { const pluralCategory = intl.formatPlural(count); const options = { one: "sub_group", other: "sub_groups", }; const plural = intl.formatMessage({ id: options[pluralCategory as "one"] || options.other, }); return `${count} ${plural}`; } return ( {getTitle()}} placement="bottom" > ); }; export const RelatedGroupPopoverButton: React.FC = ({ group }) => { return ( ); }; ================================================ FILE: ui/v2.5/src/components/Groups/styles.scss ================================================ .group-card { width: 240px; @media (max-width: 576px) { width: 100%; } &.card { padding: 0 0 1rem; } &-image { object-fit: contain; width: 100%; } .group-scene-number, .group-containing-group-description { text-align: center; } &__details { margin-bottom: 1rem; } } .group-images { align-items: center; display: flex; flex-direction: row; justify-content: space-evenly; max-width: 100%; .group-image-container { box-shadow: none; } img { max-width: 100%; object-fit: contain; } } #group-page { .rating-number .text-input { width: auto; } // the detail element ids are the same as field type name // which don't follow the correct convention /* stylelint-disable selector-class-pattern */ .collapsed { .detail-item.tags, .detail-item.containing_groups { display: none; } } /* stylelint-enable selector-class-pattern */ } .group-select-option { .group-select-row { align-items: center; display: flex; width: 100%; .group-select-image { background-color: $body-bg; margin-right: 0.4em; max-height: 50px; max-width: 89px; object-fit: contain; object-position: center; } .group-select-details { display: flex; flex-direction: column; justify-content: flex-start; max-height: 4.1rem; overflow: hidden; .group-select-title { flex-shrink: 0; white-space: pre-wrap; word-break: break-all; .group-select-alias { font-size: 0.8rem; font-weight: bold; } } .group-select-date, .group-select-studio { color: $text-muted; flex-shrink: 0; font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } } } } button.flip { display: none; } #group-page .detail-header:not(.collapsed) { .group-images { padding: 0.375rem 0.75rem; position: relative; z-index: 1; button.btn-link { padding: 0; position: relative; transition: all 0.3s; z-index: 1; } button:has(.active) { z-index: 2; } button:has(.inactive) { opacity: 0.5; padding: 0; transform: rotateY(180deg); } button.flip { align-items: center; border-radius: 50%; bottom: -5px; display: flex; font-size: 20px; height: 40px; justify-content: center; padding: 0; position: absolute; right: -5px; width: 40px; z-index: 2; } img.active { max-width: 22rem; } img.inactive { display: none; } } .detail-item .detail-item-title { width: 150px; } } .groups-list { list-style-type: none; padding-inline-start: 0; li { display: inline; } } .related-group-popover-button { .containing-group-count { display: inline-block; } .related-group-count .fa-icon { color: $text-muted; margin-left: 0; margin-right: 0.25rem; } } ================================================ FILE: ui/v2.5/src/components/Help/Manual.tsx ================================================ import React, { useState, useEffect } from "react"; import { Modal, Container, Row, Col, Nav, Tab } from "react-bootstrap"; import Introduction from "src/docs/en/Manual/Introduction.md"; import Tasks from "src/docs/en/Manual/Tasks.md"; import AutoTagging from "src/docs/en/Manual/AutoTagging.md"; import JSONSpec from "src/docs/en/Manual/JSONSpec.md"; import Configuration from "src/docs/en/Manual/Configuration.md"; import Interface from "src/docs/en/Manual/Interface.md"; import Images from "src/docs/en/Manual/Images.md"; import Scraping from "src/docs/en/Manual/Scraping.md"; import ScraperDevelopment from "src/docs/en/Manual/ScraperDevelopment.md"; import Plugins from "src/docs/en/Manual/Plugins.md"; import ExternalPlugins from "src/docs/en/Manual/ExternalPlugins.md"; import EmbeddedPlugins from "src/docs/en/Manual/EmbeddedPlugins.md"; import UIPluginApi from "src/docs/en/Manual/UIPluginApi.md"; import Tagger from "src/docs/en/Manual/Tagger.md"; import Contributing from "src/docs/en/Manual/Contributing.md"; import SceneFilenameParser from "src/docs/en/Manual/SceneFilenameParser.md"; import KeyboardShortcuts from "src/docs/en/Manual/KeyboardShortcuts.md"; import Help from "src/docs/en/Manual/Help.md"; import Deduplication from "src/docs/en/Manual/Deduplication.md"; import Interactive from "src/docs/en/Manual/Interactive.md"; import Captions from "src/docs/en/Manual/Captions.md"; import Identify from "src/docs/en/Manual/Identify.md"; import Browsing from "src/docs/en/Manual/Browsing.md"; import TroubleshootingMode from "src/docs/en/Manual/TroubleshootingMode.md"; import { MarkdownPage } from "../Shared/MarkdownPage"; interface IManualProps { animation?: boolean; show: boolean; onClose: () => void; defaultActiveTab?: string; } export const Manual: React.FC = ({ animation, show, onClose, defaultActiveTab, }) => { const content = [ { key: "Introduction.md", title: "Introduction", content: Introduction, }, { key: "Configuration.md", title: "Configuration", content: Configuration, }, { key: "Interface.md", title: "Interface Options", content: Interface, }, { key: "Tasks.md", title: "Tasks", content: Tasks, }, { key: "Identify.md", title: "Identify", content: Identify, className: "indent-1", }, { key: "AutoTagging.md", title: "Auto Tagging", content: AutoTagging, className: "indent-1", }, { key: "SceneFilenameParser.md", title: "Scene Filename Parser", content: SceneFilenameParser, className: "indent-1", }, { key: "JSONSpec.md", title: "JSON Specification", content: JSONSpec, className: "indent-1", }, { key: "Browsing.md", title: "Browsing", content: Browsing, }, { key: "Images.md", title: "Images and Galleries", content: Images, }, { key: "Scraping.md", title: "Metadata Scraping", content: Scraping, }, { key: "ScraperDevelopment.md", title: "Scraper Development", content: ScraperDevelopment, className: "indent-1", }, { key: "Plugins.md", title: "Plugins", content: Plugins, }, { key: "ExternalPlugins.md", title: "External", content: ExternalPlugins, className: "indent-1", }, { key: "EmbeddedPlugins.md", title: "Embedded", content: EmbeddedPlugins, className: "indent-1", }, { key: "UIPluginApi.md", title: "UI Plugin API", content: UIPluginApi, className: "indent-1", }, { key: "Tagger.md", title: "Scene Tagger", content: Tagger, }, { key: "Deduplication.md", title: "Dupe Checker", content: Deduplication, }, { key: "Interactive.md", title: "Interactivity", content: Interactive, }, { key: "Captions.md", title: "Captions", content: Captions, }, { key: "KeyboardShortcuts.md", title: "Keyboard Shortcuts", content: KeyboardShortcuts, }, { key: "TroubleshootingMode.md", title: "Troubleshooting Mode", content: TroubleshootingMode, }, { key: "Contributing.md", title: "Contributing", content: Contributing, }, { key: "Help.md", title: "Further Help", content: Help, }, ]; const [activeTab, setActiveTab] = useState(); useEffect(() => { setActiveTab(defaultActiveTab); }, [defaultActiveTab]); // links to other manual pages are specified as "/help/page.md" // intercept clicks to these pages and set the tab accordingly function interceptLinkClick( event: React.MouseEvent ) { if (event.target instanceof HTMLAnchorElement) { const href = event.target.getAttribute("href"); if (href && href.startsWith("/help")) { const newKey = event.target.pathname.substring("/help/".length); setActiveTab(newKey); event.preventDefault(); } } } return ( Help k && setActiveTab(k)} id="manual-tabs" > {content.map((c) => { return ( ); })} ); }; export default Manual; ================================================ FILE: ui/v2.5/src/components/Help/context.tsx ================================================ import React, { Suspense, useState } from "react"; import { Link } from "react-router-dom"; import { lazyComponent } from "src/utils/lazyComponent"; const Manual = lazyComponent(() => import("./Manual")); interface IManualContextState { openManual: (tab?: string) => void; } export const ManualStateContext = React.createContext({ openManual: () => {}, }); export const ManualProvider: React.FC = ({ children }) => { const [showManual, setShowManual] = useState(false); const [manualLink, setManualLink] = useState(); function openManual(tab?: string) { setManualLink(tab); setShowManual(true); } return ( }> {showManual && ( setShowManual(false)} defaultActiveTab={manualLink} /> )} {children} ); }; interface IManualLink { tab: string; } export const ManualLink: React.FC = ({ tab, children }) => { const { openManual } = React.useContext(ManualStateContext); return ( { openManual(`${tab}.md`); e.preventDefault(); }} > {children} ); }; ================================================ FILE: ui/v2.5/src/components/Help/styles.scss ================================================ .manual { color: $text-color; .close { color: $text-color; } &-container { padding-left: 1px; padding-right: 5px; } &-header, &-body { background-color: $card-bg; color: $text-color; overflow-y: hidden; } .indent-1 { padding-left: 2rem; } .modal-body { // reset max-height so that we don't end up with two scroll bars max-height: initial; } .manual-content, .manual-toc { max-height: calc(100vh - 10rem); overflow-y: auto; } @media (max-width: 992px) { .modal-body { overflow-y: auto; .manual-content, .manual-toc { max-height: inherit; overflow-y: hidden; } } } } ================================================ FILE: ui/v2.5/src/components/Images/DeleteImagesDialog.tsx ================================================ import React, { useState } from "react"; import { Form } from "react-bootstrap"; import { useImagesDestroy } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "src/components/Shared/Modal"; import { useToast } from "src/hooks/Toast"; import { useConfigurationContext } from "src/hooks/Config"; import { FormattedMessage, useIntl } from "react-intl"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; interface IDeleteImageDialogProps { selected: GQL.SlimImageDataFragment[]; onClose: (confirmed: boolean) => void; } export const DeleteImagesDialog: React.FC = ( props: IDeleteImageDialogProps ) => { const intl = useIntl(); const singularEntity = intl.formatMessage({ id: "image" }); const pluralEntity = intl.formatMessage({ id: "images" }); const header = intl.formatMessage( { id: "dialogs.delete_entity_title" }, { count: props.selected.length, singularEntity, pluralEntity } ); const toastMessage = intl.formatMessage( { id: "toast.delete_past_tense" }, { count: props.selected.length, singularEntity, pluralEntity } ); const message = intl.formatMessage( { id: "dialogs.delete_entity_desc" }, { count: props.selected.length, singularEntity, pluralEntity } ); const { configuration: config } = useConfigurationContext(); const [deleteFile, setDeleteFile] = useState( config?.defaults.deleteFile ?? false ); const [deleteGenerated, setDeleteGenerated] = useState( config?.defaults.deleteGenerated ?? true ); const Toast = useToast(); const [deleteImage] = useImagesDestroy(getImagesDeleteInput()); // Network state const [isDeleting, setIsDeleting] = useState(false); function getImagesDeleteInput(): GQL.ImagesDestroyInput { return { ids: props.selected.map((image) => image.id), delete_file: deleteFile, delete_generated: deleteGenerated, }; } async function onDelete() { setIsDeleting(true); try { await deleteImage(); Toast.success(toastMessage); } catch (e) { Toast.error(e); } setIsDeleting(false); props.onClose(true); } function maybeRenderDeleteFileAlert() { if (!deleteFile) { return; } const deletedFiles: string[] = []; props.selected.forEach((s) => { const paths = s.visual_files.map((f) => f.path); deletedFiles.push(...paths); }); const deleteTrashPath = config?.general.deleteTrashPath; const deleteAlertId = deleteTrashPath ? "dialogs.delete_alert_to_trash" : "dialogs.delete_alert"; return (

    {deletedFiles.slice(0, 5).map((s) => (
  • {s}
  • ))} {deletedFiles.length > 5 && ( )}
); } return ( props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} isRunning={isDeleting} >

{message}

{maybeRenderDeleteFileAlert()}
setDeleteFile(!deleteFile)} /> setDeleteGenerated(!deleteGenerated)} />
); }; ================================================ FILE: ui/v2.5/src/components/Images/EditImagesDialog.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import { useBulkImageUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { StudioSelect } from "../Shared/Select"; import { ModalComponent } from "../Shared/Modal"; import { MultiSet } from "../Shared/MultiSet"; import { useToast } from "src/hooks/Toast"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { getAggregateInputValue, getAggregatePerformerIds, getAggregateStateObject, getAggregateTagIds, getAggregateStudioId, getAggregateGalleryIds, } from "src/utils/bulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; import { BulkUpdateDateInput } from "../Shared/DateInput"; import { getDateError } from "src/utils/yup"; interface IListOperationProps { selected: GQL.SlimImageDataFragment[]; onClose: (applied: boolean) => void; } const imageFields = [ "code", "rating100", "details", "organized", "photographer", "date", ]; export const EditImagesDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); const [updateInput, setUpdateInput] = useState({ ids: props.selected.map((image) => { return image.id; }), }); const [performerIds, setPerformerIds] = useState({ mode: GQL.BulkUpdateIdMode.Add, }); const [tagIds, setTagIds] = useState({ mode: GQL.BulkUpdateIdMode.Add, }); const [galleryIds, setGalleryIds] = useState({ mode: GQL.BulkUpdateIdMode.Add, }); const unsetDisabled = props.selected.length < 2; const [dateError, setDateError] = useState(); const [updateImages] = useBulkImageUpdate(); // Network state const [isUpdating, setIsUpdating] = useState(false); const aggregateState = useMemo(() => { const updateState: Partial = {}; const state = props.selected; updateState.studio_id = getAggregateStudioId(props.selected); const updateTagIds = getAggregateTagIds(props.selected); const updatePerformerIds = getAggregatePerformerIds(props.selected); const updateGalleryIds = getAggregateGalleryIds(props.selected); let first = true; state.forEach((image: GQL.SlimImageDataFragment) => { getAggregateStateObject(updateState, image, imageFields, first); first = false; }); return { state: updateState, tagIds: updateTagIds, performerIds: updatePerformerIds, galleryIds: updateGalleryIds, }; }, [props.selected]); // update initial state from aggregate useEffect(() => { setUpdateInput((current) => ({ ...current, ...aggregateState.state })); }, [aggregateState]); useEffect(() => { setDateError(getDateError(updateInput.date ?? "", intl)); }, [updateInput.date, intl]); function setUpdateField(input: Partial) { setUpdateInput((current) => ({ ...current, ...input })); } function getImageInput(): GQL.BulkImageUpdateInput { const imageInput: GQL.BulkImageUpdateInput = { ...updateInput, tag_ids: tagIds, performer_ids: performerIds, gallery_ids: galleryIds, }; // we don't have unset functionality for the rating star control // so need to determine if we are setting a rating or not imageInput.rating100 = getAggregateInputValue( updateInput.rating100, aggregateState.state.rating100 ); return imageInput; } async function onSave() { setIsUpdating(true); try { await updateImages({ variables: { input: getImageInput() } }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl.formatMessage({ id: "images" }).toLocaleLowerCase() } ) ); props.onClose(true); } catch (e) { Toast.error(e); } setIsUpdating(false); } function render() { return ( props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} isRunning={isUpdating} >
setUpdateField({ rating100: value ?? undefined }) } disabled={isUpdating} /> setUpdateField({ code: newValue })} unsetDisabled={unsetDisabled} /> setUpdateField({ date: newValue })} unsetDisabled={unsetDisabled} error={dateError} /> setUpdateField({ photographer: newValue }) } unsetDisabled={unsetDisabled} /> setUpdateField({ studio_id: items.length > 0 ? items[0]?.id : undefined, }) } ids={updateInput.studio_id ? [updateInput.studio_id] : []} isDisabled={isUpdating} menuPortalTarget={document.body} /> { setPerformerIds((c) => ({ ...c, ids: itemIDs })); }} onSetMode={(newMode) => { setPerformerIds((c) => ({ ...c, mode: newMode })); }} ids={performerIds.ids ?? []} existingIds={aggregateState.performerIds} mode={performerIds.mode} menuPortalTarget={document.body} /> { setGalleryIds((c) => ({ ...c, ids: itemIDs })); }} onSetMode={(newMode) => { setGalleryIds((c) => ({ ...c, mode: newMode })); }} ids={galleryIds.ids ?? []} existingIds={aggregateState.galleryIds} mode={galleryIds.mode} menuPortalTarget={document.body} /> { setTagIds((c) => ({ ...c, ids: itemIDs })); }} onSetMode={(newMode) => { setTagIds((c) => ({ ...c, mode: newMode })); }} ids={tagIds.ids ?? []} existingIds={aggregateState.tagIds} mode={tagIds.mode} menuPortalTarget={document.body} /> setUpdateField({ details: newValue })} unsetDisabled={unsetDisabled} as="textarea" /> setUpdateField({ organized: checked })} checked={updateInput.organized ?? undefined} />
); } return render(); }; ================================================ FILE: ui/v2.5/src/components/Images/ImageCard.tsx ================================================ import React, { MouseEvent, useMemo } from "react"; import { Button, ButtonGroup } from "react-bootstrap"; import cx from "classnames"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "src/components/Shared/Icon"; import { GalleryLink, TagLink } from "src/components/Shared/TagLink"; import { HoverPopover } from "src/components/Shared/HoverPopover"; import { PerformerPopoverButton } from "src/components/Shared/PerformerPopoverButton"; import { GridCard } from "src/components/Shared/GridCard/GridCard"; import { RatingBanner } from "src/components/Shared/RatingBanner"; import { faBox, faImages, faSearch, faTag, } from "@fortawesome/free-solid-svg-icons"; import { imageTitle } from "src/core/files"; import { PatchComponent } from "src/patch"; import { TruncatedText } from "../Shared/TruncatedText"; import { StudioOverlay } from "../Shared/GridCard/StudioOverlay"; import { OCounterButton } from "../Shared/CountButton"; interface IImageCardProps { image: GQL.SlimImageDataFragment; cardWidth?: number; selecting?: boolean; selected?: boolean | undefined; zoomIndex: number; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; onPreview?: (ev: MouseEvent) => void; } const ImageCardPopovers = PatchComponent( "ImageCard.Popovers", (props: IImageCardProps) => { function maybeRenderTagPopoverButton() { if (props.image.tags.length <= 0) return; const popoverContent = props.image.tags.map((tag) => ( )); return ( ); } function maybeRenderPerformerPopoverButton() { if (props.image.performers.length <= 0) return; return ( ); } function maybeRenderOCounter() { if (props.image.o_counter) { return ; } } function maybeRenderGallery() { if (props.image.galleries.length <= 0) return; const popoverContent = props.image.galleries.map((gallery) => ( )); return ( ); } function maybeRenderOrganized() { if (props.image.organized) { return (
); } } if ( props.image.tags.length > 0 || props.image.performers.length > 0 || props.image.o_counter || props.image.galleries.length > 0 || props.image.organized ) { return ( <>
{maybeRenderTagPopoverButton()} {maybeRenderPerformerPopoverButton()} {maybeRenderOCounter()} {maybeRenderGallery()} {maybeRenderOrganized()} ); } return null; } ); const ImageCardDetails = PatchComponent( "ImageCard.Details", (props: IImageCardProps) => { return (
{props.image.date}
); } ); const ImageCardOverlays = PatchComponent( "ImageCard.Overlays", (props: IImageCardProps) => { const ret = useMemo(() => { return ( ); }, [props.image.studio, props.selecting]); return ret; } ); const ImageCardImage = PatchComponent( "ImageCard.Image", (props: IImageCardProps) => { const file = useMemo( () => props.image.visual_files.length > 0 ? props.image.visual_files[0] : undefined, [props.image] ); function isPortrait() { const width = file?.width ? file.width : 0; const height = file?.height ? file.height : 0; return height > width; } const source = props.image.paths.preview != "" ? props.image.paths.preview ?? "" : props.image.paths.thumbnail ?? ""; const video = source.includes("preview"); const ImagePreview = video ? "video" : "img"; return ( <>
{props.onPreview ? (
) : undefined}
); } ); export const ImageCard: React.FC = PatchComponent( "ImageCard", (props: IImageCardProps) => { return ( } details={} overlays={} popovers={} selected={props.selected} selecting={props.selecting} onSelectedChanged={props.onSelectedChanged} /> ); } ); ================================================ FILE: ui/v2.5/src/components/Images/ImageCardGrid.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { ImageCard } from "./ImageCard"; import { useCardWidth, useContainerDimensions, } from "../Shared/GridCard/GridCard"; import { PatchComponent } from "src/patch"; interface IImageCardGrid { images: GQL.SlimImageDataFragment[]; selectedIds: Set; zoomIndex: number; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; onPreview: (index: number, ev: React.MouseEvent) => void; } const zoomWidths = [280, 340, 480, 640]; export const ImageCardGrid: React.FC = PatchComponent( "ImageCardGrid", ({ images, selectedIds, zoomIndex, onSelectChange, onPreview }) => { const [componentRef, { width: containerWidth }] = useContainerDimensions(); const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); return (
{images.map((image, index) => ( 0} selected={selectedIds.has(image.id)} onSelectedChanged={(selected: boolean, shiftKey: boolean) => onSelectChange(image.id, selected, shiftKey) } onPreview={ selectedIds.size < 1 ? (ev) => onPreview(index, ev) : undefined } /> ))}
); } ); ================================================ FILE: ui/v2.5/src/components/Images/ImageDetails/Image.tsx ================================================ import { Tab, Nav, Dropdown } from "react-bootstrap"; import React, { useEffect, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { useHistory, Link, RouteComponentProps } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useFindImage, useImageIncrementO, useImageUpdate, mutateMetadataScan, useImageDecrementO, useImageResetO, } from "src/core/StashService"; import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { Icon } from "src/components/Shared/Icon"; import { Counter } from "src/components/Shared/Counter"; import { useToast } from "src/hooks/Toast"; import * as Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { OCounterButton } from "src/components/Scenes/SceneDetails/OCounterButton"; import { OrganizedButton } from "src/components/Scenes/SceneDetails/OrganizedButton"; import { ImageFileInfoPanel } from "./ImageFileInfoPanel"; import { ImageEditPanel } from "./ImageEditPanel"; import { ImageDetailPanel } from "./ImageDetailPanel"; import { DeleteImagesDialog } from "../DeleteImagesDialog"; import { faEllipsisV } from "@fortawesome/free-solid-svg-icons"; import { imagePath, imageTitle } from "src/core/files"; import { isVideo } from "src/utils/visualFile"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { useRatingKeybinds } from "src/hooks/keybinds"; import { useConfigurationContext } from "src/hooks/Config"; import TextUtils from "src/utils/text"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import cx from "classnames"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { goBackOrReplace } from "src/utils/history"; import { FormattedDate } from "src/components/Shared/Date"; import { GenerateDialog } from "src/components/Dialogs/GenerateDialog"; interface IProps { image: GQL.ImageDataFragment; } interface IImageParams { id: string; } const ImagePage: React.FC = ({ image }) => { const history = useHistory(); const Toast = useToast(); const intl = useIntl(); const { configuration } = useConfigurationContext(); const [incrementO] = useImageIncrementO(image.id); const [decrementO] = useImageDecrementO(image.id); const [resetO] = useImageResetO(image.id); const [updateImage] = useImageUpdate(); const [organizedLoading, setOrganizedLoading] = useState(false); const [activeTabKey, setActiveTabKey] = useState("image-details-panel"); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false); async function onSave(input: GQL.ImageUpdateInput) { await updateImage({ variables: { input }, }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl.formatMessage({ id: "image" }).toLocaleLowerCase() } ) ); } async function onRescan() { if (!image || !image.visual_files.length) { return; } await mutateMetadataScan({ paths: [imagePath(image)], rescan: true, }); Toast.success( intl.formatMessage( { id: "toast.rescanning_entity" }, { count: 1, singularEntity: intl.formatMessage({ id: "image" }), } ) ); } const onOrganizedClick = async () => { try { setOrganizedLoading(true); await updateImage({ variables: { input: { id: image.id, organized: !image.organized, }, }, }); } catch (e) { Toast.error(e); } finally { setOrganizedLoading(false); } }; const onIncrementClick = async () => { try { await incrementO(); } catch (e) { Toast.error(e); } }; const onDecrementClick = async () => { try { await decrementO(); } catch (e) { Toast.error(e); } }; const onResetClick = async () => { try { await resetO(); } catch (e) { Toast.error(e); } }; function setRating(v: number | null) { updateImage({ variables: { input: { id: image.id, rating100: v, }, }, }); } useRatingKeybinds( true, configuration?.ui.ratingSystemOptions?.type, setRating ); function onDeleteDialogClosed(deleted: boolean) { setIsDeleteAlertOpen(false); if (deleted) { goBackOrReplace(history, "/images"); } } function maybeRenderDeleteDialog() { if (isDeleteAlertOpen && image) { return ( ); } } function maybeRenderSceneGenerateDialog() { if (isGenerateDialogOpen) { return ( { setIsGenerateDialogOpen(false); }} type="image" /> ); } } function renderOperations() { return ( onRescan()} > setIsGenerateDialogOpen(true)} > setIsDeleteAlertOpen(true)} > ); } function renderTabs() { if (!image) { return; } return ( k && setActiveTabKey(k)} >
setIsDeleteAlertOpen(true)} />
); } // set up hotkeys useEffect(() => { Mousetrap.bind("a", () => setActiveTabKey("image-details-panel")); Mousetrap.bind("e", () => setActiveTabKey("image-edit-panel")); Mousetrap.bind("f", () => setActiveTabKey("image-file-info-panel")); Mousetrap.bind("o", () => { onIncrementClick(); }); return () => { Mousetrap.unbind("a"); Mousetrap.unbind("e"); Mousetrap.unbind("f"); Mousetrap.unbind("o"); }; }); const file = useMemo( () => (image.visual_files.length > 0 ? image.visual_files[0] : undefined), [image] ); const title = imageTitle(image); const ImageView = image.visual_files.length > 0 && isVideo(image.visual_files[0]) ? "video" : "img"; const resolution = useMemo(() => { return file?.width && file?.height ? TextUtils.resolution(file?.width, file?.height) : undefined; }, [file?.width, file?.height]); return (
{title} {maybeRenderDeleteDialog()} {maybeRenderSceneGenerateDialog()}
{image.studio && (

{`${image.studio.name}

)}

{!!image.date && } {resolution ? ( {resolution} ) : undefined}
{renderOperations()}
{renderTabs()}
{image.visual_files.length > 0 && ( )}
); }; const ImageLoader: React.FC> = ({ match, }) => { const { id } = match.params; const { data, loading, error } = useFindImage(id); useScrollToTopOnMount(); if (loading) return ; if (error) return ; if (!data?.findImage) return ; return ; }; export default ImageLoader; ================================================ FILE: ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; import { GalleryLink, TagLink } from "src/components/Shared/TagLink"; import { PerformerCard } from "src/components/Performers/PerformerCard"; import { sortPerformers } from "src/core/performers"; import { FormattedMessage, useIntl } from "react-intl"; import { PhotographerLink } from "src/components/Shared/Link"; import { PatchComponent } from "../../../patch"; import { CustomFields } from "src/components/Shared/CustomFields"; interface IImageDetailProps { image: GQL.ImageDataFragment; } export const ImageDetailPanel: React.FC = PatchComponent( "ImageDetailPanel", (props) => { const intl = useIntl(); function renderDetails() { if (!props.image.details) return; return ( <>
:{" "}

{props.image.details}

); } function renderTags() { if (props.image.tags.length === 0) return; const tags = props.image.tags.map((tag) => ( )); return ( <>
{tags} ); } function renderPerformers() { if (props.image.performers.length === 0) return; const performers = sortPerformers(props.image.performers); const cards = performers.map((performer) => ( )); return ( <>
{cards}
); } function renderGalleries() { if (props.image.galleries.length === 0) return; const galleries = props.image.galleries.map((gallery) => ( )); return ( <>
{galleries} ); } // filename should use entire row if there is no studio const imageDetailsWidth = props.image.studio ? "col-9" : "col-12"; return ( <>
{renderGalleries()} {
{" "} :{" "} {TextUtils.formatDateTime(intl, props.image.created_at)}{" "}
} {
:{" "} {TextUtils.formatDateTime(intl, props.image.updated_at)}{" "}
} {props.image.code && (
: {props.image.code}{" "}
)} {props.image.photographer && (
:{" "}
)}
{renderDetails()} {renderTags()} {renderPerformers()}
); } ); ================================================ FILE: ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { Button, Form, Col, Row } from "react-bootstrap"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useToast } from "src/hooks/Toast"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; import isEqual from "lodash-es/isEqual"; import { yupDateString, yupFormikValidate, yupUniqueStringList, } from "src/utils/yup"; import { Performer, PerformerSelect, } from "src/components/Performers/PerformerSelect"; import { formikUtils } from "src/utils/form"; import { queryScrapeImage, queryScrapeImageURL, useListImageScrapers, mutateReloadScrapers, } from "../../../core/StashService"; import { ImageScrapeDialog } from "./ImageScrapeDialog"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { galleryTitle } from "src/core/galleries"; import { Gallery, GallerySelect, excludeFileBasedGalleries, } from "src/components/Galleries/GallerySelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; import { CustomFieldsInput, formatCustomFieldInput, } from "src/components/Shared/CustomFields"; import { cloneDeep } from "@apollo/client/utilities"; interface IProps { image: GQL.ImageDataFragment; isVisible: boolean; onSubmit: (input: GQL.ImageUpdateInput) => Promise; onDelete: () => void; } export const ImageEditPanel: React.FC = ({ image, isVisible, onSubmit, onDelete, }) => { const intl = useIntl(); const Toast = useToast(); // Network state const [isLoading, setIsLoading] = useState(false); const [galleries, setGalleries] = useState([]); const [performers, setPerformers] = useState([]); const [studio, setStudio] = useState(null); const isNew = image.id === undefined; useEffect(() => { setGalleries( image.galleries?.map((g) => ({ id: g.id, title: galleryTitle(g), files: g.files, folder: g.folder, })) ?? [] ); }, [image.galleries]); const scrapers = useListImageScrapers(); const [scrapedImage, setScrapedImage] = useState(); const schema = yup.object({ title: yup.string().ensure(), code: yup.string().ensure(), urls: yupUniqueStringList(intl), date: yupDateString(intl), details: yup.string().ensure(), photographer: yup.string().ensure(), gallery_ids: yup.array(yup.string().required()).defined(), studio_id: yup.string().required().nullable(), performer_ids: yup.array(yup.string().required()).defined(), tag_ids: yup.array(yup.string().required()).defined(), custom_fields: yup.object().required().defined(), }); const initialValues = { title: image.title ?? "", code: image.code ?? "", urls: image?.urls ?? [], date: image?.date ?? "", details: image.details ?? "", photographer: image.photographer ?? "", gallery_ids: (image.galleries ?? []).map((g) => g.id), studio_id: image.studio?.id ?? null, performer_ids: (image.performers ?? []).map((p) => p.id), tag_ids: (image.tags ?? []).map((t) => t.id), custom_fields: cloneDeep(image.custom_fields ?? {}), }; type InputValues = yup.InferType; const [customFieldsError, setCustomFieldsError] = useState(); function submit(values: InputValues) { const input = { ...schema.cast(values), custom_fields: formatCustomFieldInput(isNew, values.custom_fields), }; onSave(input); } const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), onSubmit: submit, }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( image.tags, (ids) => formik.setFieldValue("tag_ids", ids) ); function onSetGalleries(items: Gallery[]) { setGalleries(items); formik.setFieldValue( "gallery_ids", items.map((i) => i.id) ); } function onSetPerformers(items: Performer[]) { setPerformers(items); formik.setFieldValue( "performer_ids", items.map((item) => item.id) ); } function onSetStudio(item: Studio | null) { setStudio(item); formik.setFieldValue("studio_id", item ? item.id : null); } useEffect(() => { setPerformers(image.performers ?? []); }, [image.performers]); useEffect(() => { setStudio(image.studio ?? null); }, [image.studio]); useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { if (formik.dirty) { formik.submitForm(); } }); Mousetrap.bind("d d", () => { onDelete(); }); return () => { Mousetrap.unbind("s s"); Mousetrap.unbind("d d"); }; } }); const fragmentScrapers = useMemo(() => { return (scrapers?.data?.listScrapers ?? []).filter((s) => s.image?.supported_scrapes.includes(GQL.ScrapeType.Fragment) ); }, [scrapers]); async function onSave(input: InputValues) { setIsLoading(true); try { await onSubmit({ id: image.id, ...input, }); formik.resetForm(); } catch (e) { Toast.error(e); } setIsLoading(false); } async function onScrapeClicked(s: GQL.ScraperSourceInput) { if (!image || !image.id) return; setIsLoading(true); try { const result = await queryScrapeImage(s.scraper_id!, image.id); if (!result.data || !result.data.scrapeSingleImage?.length) { Toast.success("No images found"); return; } setScrapedImage(result.data.scrapeSingleImage[0]); } catch (e) { Toast.error(e); } finally { setIsLoading(false); } } function urlScrapable(scrapedUrl: string): boolean { return (scrapers?.data?.listScrapers ?? []).some((s) => (s?.image?.urls ?? []).some((u) => scrapedUrl.includes(u)) ); } function updateImageFromScrapedGallery( imageData: GQL.ScrapedImageDataFragment ) { if (imageData.title) { formik.setFieldValue("title", imageData.title); } if (imageData.code) { formik.setFieldValue("code", imageData.code); } if (imageData.details) { formik.setFieldValue("details", imageData.details); } if (imageData.photographer) { formik.setFieldValue("photographer", imageData.photographer); } if (imageData.date) { formik.setFieldValue("date", imageData.date); } if (imageData.urls) { formik.setFieldValue("urls", imageData.urls); } if (imageData.studio?.stored_id) { onSetStudio({ id: imageData.studio.stored_id, name: imageData.studio.name ?? "", aliases: [], }); } if (imageData.performers?.length) { const idPerfs = imageData.performers.filter((p) => { return p.stored_id !== undefined && p.stored_id !== null; }); if (idPerfs.length > 0) { onSetPerformers( idPerfs.map((p) => { return { id: p.stored_id!, name: p.name ?? "", alias_list: [], }; }) ); } } updateTagsStateFromScraper(imageData.tags ?? undefined); } async function onReloadScrapers() { setIsLoading(true); try { await mutateReloadScrapers(); } catch (e) { Toast.error(e); } finally { setIsLoading(false); } } async function onScrapeDialogClosed(data?: GQL.ScrapedImageDataFragment) { if (data) { updateImageFromScrapedGallery(data); } setScrapedImage(undefined); } async function onScrapeImageURL(url: string) { if (!url) { return; } setIsLoading(true); try { const result = await queryScrapeImageURL(url); if (!result || !result.data || !result.data.scrapeImageURL) { return; } setScrapedImage(result.data.scrapeImageURL); } catch (e) { Toast.error(e); } finally { setIsLoading(false); } } if (isLoading) return ; const splitProps = { labelProps: { column: true, sm: 3, }, fieldProps: { sm: 9, }, }; const fullWidthProps = { labelProps: { column: true, sm: 3, xl: 12, }, fieldProps: { sm: 9, xl: 12, }, }; const urlProps = isNew ? splitProps : { labelProps: { column: true, md: 3, lg: 12, }, fieldProps: { md: 9, lg: 12, }, }; const { renderField, renderInputField, renderDateField, renderURLListField } = formikUtils(intl, formik, splitProps); function renderGalleriesField() { const title = intl.formatMessage({ id: "galleries" }); const control = ( onSetGalleries(items)} isMulti extraCriteria={excludeFileBasedGalleries} /> ); return renderField("gallery_ids", title, control); } function renderStudioField() { const title = intl.formatMessage({ id: "studio" }); const control = ( onSetStudio(items.length > 0 ? items[0] : null)} values={studio ? [studio] : []} /> ); return renderField("studio_id", title, control); } function renderPerformersField() { const date = (() => { try { return schema.validateSyncAt("date", formik.values); } catch (e) { return undefined; } })(); const title = intl.formatMessage({ id: "performers" }); const control = ( ); return renderField("performer_ids", title, control, fullWidthProps); } function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); return renderField("tag_ids", title, tagsControl(), fullWidthProps); } function renderDetailsField() { const props = { labelProps: { column: true, sm: 3, lg: 12, }, fieldProps: { sm: 9, lg: 12, }, }; return renderInputField("details", "textarea", "details", props); } function maybeRenderScrapeDialog() { if (!scrapedImage) { return; } const currentImage = { id: image.id!, ...formik.values, }; return ( { onScrapeDialogClosed(data); }} /> ); } return (
{maybeRenderScrapeDialog()}
{!isNew && ( )}
{renderInputField("title")} {renderInputField("code", "text", "scene_code")} {renderURLListField( "urls", onScrapeImageURL, urlScrapable, "urls", urlProps )} {renderDateField("date")} {renderInputField("photographer")} {renderGalleriesField()} {renderStudioField()} {renderPerformersField()} {renderTagsField()} {renderDetailsField()} formik.setFieldValue("custom_fields", v)} error={customFieldsError} setError={(e) => setCustomFieldsError(e)} />
); }; ================================================ FILE: ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx ================================================ import React, { useState } from "react"; import { Accordion, Button, Card } from "react-bootstrap"; import { FormattedMessage, FormattedTime, useIntl } from "react-intl"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { DeleteFilesDialog } from "src/components/Shared/DeleteFilesDialog"; import { RevealInFilesystemButton } from "src/components/Shared/RevealInFilesystemButton"; import * as GQL from "src/core/generated-graphql"; import { mutateImageSetPrimaryFile } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import TextUtils from "src/utils/text"; import { TextField, URLField, URLsField } from "src/utils/field"; import { FileSize } from "src/components/Shared/FileSize"; import NavUtils from "src/utils/navigation"; interface IFileInfoPanelProps { file: GQL.ImageFileDataFragment | GQL.VideoFileDataFragment; primary?: boolean; ofMany?: boolean; onSetPrimaryFile?: () => void; onDeleteFile?: () => void; loading?: boolean; } const FileInfoPanel: React.FC = ( props: IFileInfoPanelProps ) => { const intl = useIntl(); const checksum = props.file.fingerprints.find((f) => f.type === "md5"); const phash = props.file.fingerprints.find((f) => f.type === "phash"); return (
{props.primary && ( <>
)}
{props.ofMany && props.onSetPrimaryFile && !props.primary && (
)}
); }; interface IImageFileInfoPanelProps { image: GQL.ImageDataFragment; } export const ImageFileInfoPanel: React.FC = ( props: IImageFileInfoPanelProps ) => { const Toast = useToast(); const [loading, setLoading] = useState(false); const [deletingFile, setDeletingFile] = useState< GQL.ImageFileDataFragment | GQL.VideoFileDataFragment | undefined >(); if (props.image.visual_files.length === 0) { return <>; } if (props.image.visual_files.length === 1) { return ( <>
); } async function onSetPrimaryFile(fileID: string) { try { setLoading(true); await mutateImageSetPrimaryFile(props.image.id, fileID); } catch (e) { Toast.error(e); } finally { setLoading(false); } } return ( {deletingFile && ( setDeletingFile(undefined)} selected={[deletingFile]} /> )} {props.image.visual_files.map((file, index) => ( onSetPrimaryFile(file.id)} onDeleteFile={() => setDeletingFile(file)} loading={loading} /> ))} ); }; ================================================ FILE: ui/v2.5/src/components/Images/ImageDetails/ImageScrapeDialog.tsx ================================================ import React, { useState } from "react"; import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { ScrapedInputGroupRow, ScrapedStringListRow, ScrapedTextAreaRow, } from "src/components/Shared/ScrapeDialog/ScrapeDialogRow"; import { ScrapeDialog } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; import { ObjectListScrapeResult, ObjectScrapeResult, ScrapeResult, } from "src/components/Shared/ScrapeDialog/scrapeResult"; import { ScrapedPerformersRow, ScrapedStudioRow, } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow"; import { sortStoredIdObjects } from "src/utils/data"; import { Performer } from "src/components/Performers/PerformerSelect"; import { useCreateScrapedPerformer, useCreateScrapedStudio, } from "src/components/Shared/ScrapeDialog/createObjects"; import { uniq } from "lodash-es"; import { Tag } from "src/components/Tags/TagSelect"; import { Studio } from "src/components/Studios/StudioSelect"; import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags"; interface IImageScrapeDialogProps { image: Partial; imageStudio: Studio | null; imageTags: Tag[]; imagePerformers: Performer[]; scraped: GQL.ScrapedImage; onClose: (scrapedImage?: GQL.ScrapedImage) => void; } export const ImageScrapeDialog: React.FC = ({ image, imageStudio, imageTags, imagePerformers, scraped, onClose, }) => { const intl = useIntl(); const [title, setTitle] = useState>( new ScrapeResult(image.title, scraped.title) ); const [code, setCode] = useState>( new ScrapeResult(image.code, scraped.code) ); const [urls, setURLs] = useState>( new ScrapeResult( image.urls, scraped.urls ? uniq((image.urls ?? []).concat(scraped.urls ?? [])) : undefined ) ); const [date, setDate] = useState>( new ScrapeResult(image.date, scraped.date) ); const [photographer, setPhotographer] = useState>( new ScrapeResult(image.photographer, scraped.photographer) ); const [studio, setStudio] = useState>( new ObjectScrapeResult( imageStudio ? { stored_id: imageStudio.id, name: imageStudio.name, } : undefined, scraped.studio?.stored_id ? scraped.studio : undefined ) ); const [newStudio, setNewStudio] = useState( scraped.studio && !scraped.studio.stored_id ? scraped.studio : undefined ); const [performers, setPerformers] = useState< ObjectListScrapeResult >( new ObjectListScrapeResult( sortStoredIdObjects( imagePerformers.map((p) => ({ stored_id: p.id, name: p.name, })) ), sortStoredIdObjects(scraped.performers ?? undefined) ) ); const [newPerformers, setNewPerformers] = useState( scraped.performers?.filter((t) => !t.stored_id) ?? [] ); const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags( imageTags, scraped.tags ); const [details, setDetails] = useState>( new ScrapeResult(image.details, scraped.details) ); const createNewStudio = useCreateScrapedStudio({ scrapeResult: studio, setScrapeResult: setStudio, setNewObject: setNewStudio, }); const createNewPerformer = useCreateScrapedPerformer({ scrapeResult: performers, setScrapeResult: setPerformers, newObjects: newPerformers, setNewObjects: setNewPerformers, }); // don't show the dialog if nothing was scraped if ( [ title, code, urls, date, photographer, studio, performers, tags, details, ].every((r) => !r.scraped) && !newStudio && newPerformers.length === 0 && newTags.length === 0 ) { onClose(); return <>; } function makeNewScrapedItem(): GQL.ScrapedImageDataFragment { const newStudioValue = studio.getNewValue(); return { title: title.getNewValue(), code: code.getNewValue(), urls: urls.getNewValue(), date: date.getNewValue(), photographer: photographer.getNewValue(), studio: newStudioValue, performers: performers.getNewValue(), tags: tags.getNewValue(), details: details.getNewValue(), }; } function renderScrapeRows() { return ( <> setTitle(value)} /> setCode(value)} /> setURLs(value)} /> setDate(value)} /> setPhotographer(value)} /> setStudio(value)} newStudio={newStudio} onCreateNew={createNewStudio} /> setPerformers(value)} newObjects={newPerformers} onCreateNew={createNewPerformer} /> {scrapedTagsRow} setDetails(value)} /> ); } if (linkDialog) { return linkDialog; } return ( { onClose(apply ? makeNewScrapedItem() : undefined); }} > {renderScrapeRows()} ); }; ================================================ FILE: ui/v2.5/src/components/Images/ImageList.tsx ================================================ import React, { useCallback, useState, useMemo, MouseEvent, useEffect, } from "react"; import { FormattedMessage, FormattedNumber, useIntl } from "react-intl"; import cloneDeep from "lodash-es/cloneDeep"; import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { queryFindImages, useFindImages, useFindImagesMetadata, } from "src/core/StashService"; import { useFilteredItemList } from "../List/ItemList"; import { useLightbox } from "src/hooks/Lightbox/hooks"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { ImageWallItem } from "./ImageWallItem"; import { EditImagesDialog } from "./EditImagesDialog"; import { DeleteImagesDialog } from "./DeleteImagesDialog"; import "flexbin/flexbin.css"; import Gallery, { RenderImageProps } from "react-photo-gallery"; import { ExportDialog } from "../Shared/ExportDialog"; import { objectTitle } from "src/core/files"; import { useConfigurationContext } from "src/hooks/Config"; import { ImageCardGrid } from "./ImageCardGrid"; import { View } from "../List/views"; import { FilteredListToolbar, IItemListOperation, } from "../List/FilteredListToolbar"; import { FileSize } from "../Shared/FileSize"; import { PatchComponent, PatchContainerComponent } from "src/patch"; import { GenerateDialog } from "../Dialogs/GenerateDialog"; import { Sidebar, SidebarPane, SidebarPaneContent, SidebarStateContext, useSidebarState, } from "../Shared/Sidebar"; import { useCloseEditDelete, useFilterOperations } from "../List/util"; import { FilteredSidebarHeader, useFilteredSidebarKeybinds, } from "../List/Filters/FilterSidebar"; import { IListFilterOperation, ListOperations, } from "../List/ListOperationButtons"; import { FilterTags } from "../List/FilterTags"; import { Pagination, PaginationIndex } from "../List/Pagination"; import { LoadedContent } from "../List/PagedList"; import useFocus from "src/utils/focus"; import cx from "classnames"; import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter"; import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter"; import { SidebarTagsFilter } from "../List/Filters/TagsFilter"; import { SidebarRatingFilter } from "../List/Filters/RatingFilter"; import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; import { Button } from "react-bootstrap"; import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized"; import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter"; import { PerformerAgeCriterionOption } from "src/models/list-filter/images"; import { SidebarFolderFilter } from "../List/Filters/FolderFilter"; interface IImageWallProps { images: GQL.SlimImageDataFragment[]; onChangePage: (page: number) => void; currentPage: number; pageCount: number; handleImageOpen: (index: number) => void; zoomIndex: number; selectedIds?: Set; onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; selecting?: boolean; } const zoomWidths = [280, 340, 480, 640]; const breakpointZoomHeights = [ { minWidth: 576, heights: [100, 120, 240, 360] }, { minWidth: 768, heights: [120, 160, 240, 480] }, { minWidth: 1200, heights: [120, 160, 240, 300] }, { minWidth: 1400, heights: [160, 240, 300, 480] }, ]; const ImageWall: React.FC = ({ images, zoomIndex, handleImageOpen, selectedIds, onSelectChange, selecting, }) => { const { configuration } = useConfigurationContext(); const uiConfig = configuration?.ui; const containerRef = React.useRef(null); let photos: { src: string; srcSet?: string | string[] | undefined; sizes?: string | string[] | undefined; width: number; height: number; alt?: string | undefined; key?: string | undefined; }[] = []; images.forEach((image, index) => { let imageData = { src: image.paths.preview != "" ? image.paths.preview! : image.paths.thumbnail!, width: image.visual_files?.[0]?.width ?? 0, height: image.visual_files?.[0]?.height ?? 0, tabIndex: index, key: image.id, loading: "lazy", className: "gallery-image", alt: objectTitle(image), }; photos.push(imageData); }); const showLightboxOnClick = useCallback( (event, { index }) => { handleImageOpen(index); }, [handleImageOpen] ); function columns(containerWidth: number) { let preferredSize = zoomWidths[zoomIndex]; let columnCount = containerWidth / preferredSize; return Math.round(columnCount); } const targetRowHeight = useCallback( (containerWidth: number) => { let zoomHeight = 280; breakpointZoomHeights.forEach((e) => { if (containerWidth >= e.minWidth) { zoomHeight = e.heights[zoomIndex]; } }); return zoomHeight; }, [zoomIndex] ); // set the max height as a factor of the targetRowHeight // this allows some images to be taller than the target row height // but prevents images from becoming too tall when there is a small number of items const maxHeightFactor = 1.3; const renderImage = useCallback( (props: RenderImageProps) => { // #6165 - only use targetRowHeight in row direction const maxHeight = props.direction === "column" ? props.photo.height : targetRowHeight(containerRef.current?.offsetWidth ?? 0) * maxHeightFactor; const imageId = props.photo.key; if (!imageId) { return null; } return ( onSelectChange(imageId, selected, shiftKey) : undefined } selecting={selecting} /> ); }, [targetRowHeight, selectedIds, onSelectChange, selecting] ); return (
{photos.length ? ( ) : null}
); }; interface IImageListImages { images: GQL.SlimImageDataFragment[]; filter: ListFilterModel; selectedIds: Set; onChangePage: (page: number) => void; pageCount: number; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; slideshowRunning: boolean; setSlideshowRunning: (running: boolean) => void; chapters?: GQL.GalleryChapterDataFragment[]; } const ImageList: React.FC = PatchComponent( "ImageList", ({ images, filter, selectedIds, onChangePage, pageCount, onSelectChange, slideshowRunning, setSlideshowRunning, chapters = [], }) => { const handleLightBoxPage = useCallback( (props: { direction?: number; page?: number }) => { const { direction, page: newPage } = props; if (direction !== undefined) { if (direction < 0) { if (filter.currentPage === 1) { onChangePage(pageCount); } else { onChangePage(filter.currentPage + direction); } } else if (direction > 0) { if (filter.currentPage === pageCount) { // return to the first page onChangePage(1); } else { onChangePage(filter.currentPage + direction); } } } else if (newPage !== undefined) { onChangePage(newPage); } }, [onChangePage, filter.currentPage, pageCount] ); const handleClose = useCallback(() => { setSlideshowRunning(false); }, [setSlideshowRunning]); const lightboxState = useMemo(() => { return { images, showNavigation: false, pageCallback: pageCount > 1 ? handleLightBoxPage : undefined, page: filter.currentPage, pages: pageCount, pageSize: filter.itemsPerPage, slideshowEnabled: slideshowRunning, onClose: handleClose, }; }, [ images, pageCount, filter.currentPage, filter.itemsPerPage, slideshowRunning, handleClose, handleLightBoxPage, ]); const showLightbox = useLightbox( lightboxState, filter.sortBy === "path" && filter.sortDirection === GQL.SortDirectionEnum.Asc ? chapters : [] ); const handleImageOpen = useCallback( (index) => { setSlideshowRunning(true); showLightbox({ initialIndex: index, slideshowEnabled: true }); }, [showLightbox, setSlideshowRunning] ); function onPreview(index: number, ev: MouseEvent) { handleImageOpen(index); ev.preventDefault(); } if (filter.displayMode === DisplayMode.Grid) { return ( ); } if (filter.displayMode === DisplayMode.Wall) { return ( 0} /> ); } // should not happen return <>; } ); function renderMetadataByline( metadataInfo: GQL.FindImagesMetadataQueryResult | undefined ) { const megapixels = metadataInfo?.data?.findImages?.megapixels; const size = metadataInfo?.data?.findImages?.filesize; if (metadataInfo?.loading) { // return ellipsis return  (...); } if (!megapixels && !size) { return; } const separator = megapixels && size ? " - " : ""; return (  ( {megapixels ? ( Megapixels ) : undefined} {separator} {size ? ( ) : undefined} ) ); } const ImageFilterSidebarSections = PatchContainerComponent( "FilteredImageList.SidebarSections" ); const SidebarContent: React.FC<{ filter: ListFilterModel; setFilter: (filter: ListFilterModel) => void; filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; sidebarOpen: boolean; onClose?: () => void; showEditFilter: (editingCriterion?: string) => void; count?: number; focus?: ReturnType; }> = ({ filter, setFilter, filterHook, view, showEditFilter, sidebarOpen, onClose, count, focus, }) => { const showResultsId = count !== undefined ? "actions.show_count_results" : "actions.show_results"; const hideStudios = view === View.StudioScenes; return ( <> {!hideStudios && ( )} } filter={filter} setFilter={setFilter} sectionID="folder" /> } data-type={OrganizedCriterionOption.type} option={OrganizedCriterionOption} filter={filter} setFilter={setFilter} sectionID="organized" /> } option={PerformerAgeCriterionOption} filter={filter} setFilter={setFilter} sectionID="performer_age" />
); }; function useViewRandom(filter: ListFilterModel, count: number) { const history = useHistory(); const viewRandom = useCallback(async () => { // query for a random image if (count === 0) { return; } const index = Math.floor(Math.random() * count); const filterCopy = cloneDeep(filter); filterCopy.itemsPerPage = 1; filterCopy.currentPage = index + 1; const singleResult = await queryFindImages(filterCopy); if (singleResult.data.findImages.images.length === 1) { const { id } = singleResult.data.findImages.images[0]; // navigate to the image player page history.push(`/images/${id}`); } }, [history, filter, count]); return viewRandom; } function useAddKeybinds(filter: ListFilterModel, count: number) { const viewRandom = useViewRandom(filter, count); useEffect(() => { Mousetrap.bind("p r", () => { viewRandom(); }); return () => { Mousetrap.unbind("p r"); }; }, [viewRandom]); } interface IImageList { filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; alterQuery?: boolean; extraOperations?: IItemListOperation[]; chapters?: GQL.GalleryChapterDataFragment[]; } export const FilteredImageList = PatchComponent( "FilteredImageList", (props: IImageList) => { const intl = useIntl(); const [slideshowRunning, setSlideshowRunning] = useState(false); const searchFocus = useFocus(); const withSidebar = props.view !== View.GalleryImages; const { filterHook, view, alterQuery, extraOperations: providedOperations = [], chapters, } = props; // States const { showSidebar, setShowSidebar, sectionOpen, setSectionOpen, loading: sidebarStateLoading, } = useSidebarState(view); const { filterState, queryResult, metadataInfo, modalState, listSelect, showEditFilter, } = useFilteredItemList({ filterStateProps: { filterMode: GQL.FilterMode.Images, view, useURL: alterQuery, }, queryResultProps: { useResult: useFindImages, useMetadataInfo: useFindImagesMetadata, getCount: (r) => r.data?.findImages.count ?? 0, getItems: (r) => r.data?.findImages.images ?? [], filterHook, }, }); const { filter, setFilter } = filterState; const { effectiveFilter, result, cachedResult, items, totalCount } = queryResult; const metadataByline = useMemo(() => { if (cachedResult.loading) return null; return renderMetadataByline(metadataInfo) ?? null; }, [cachedResult.loading, metadataInfo]); const { selectedIds, selectedItems, onSelectChange, onSelectAll, onSelectNone, onInvertSelection, hasSelection, } = listSelect; const { modal, showModal, closeModal } = modalState; // Utility hooks const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ filter, setFilter, }); useAddKeybinds(filter, totalCount); useFilteredSidebarKeybinds({ showSidebar, setShowSidebar, }); const onCloseEditDelete = useCloseEditDelete({ closeModal, onSelectNone, result, }); const viewRandom = useViewRandom(effectiveFilter, totalCount); function onExport(all: boolean) { showModal( closeModal()} /> ); } const onEdit = useCallback(() => { showModal( ); }, [showModal, selectedItems, onCloseEditDelete]); const onDelete = useCallback(() => { showModal( ); }, [showModal, selectedItems, onCloseEditDelete]); useEffect(() => { Mousetrap.bind("e", () => { if (hasSelection) { onEdit?.(); } }); Mousetrap.bind("d d", () => { if (hasSelection) { onDelete?.(); } }); return () => { Mousetrap.unbind("e"); Mousetrap.unbind("d d"); }; }, [hasSelection, onEdit, onDelete]); const convertedExtraOperations: IListFilterOperation[] = providedOperations.map((o) => ({ ...o, isDisplayed: o.isDisplayed ? () => o.isDisplayed!(result, filter, selectedIds) : undefined, onClick: () => { o.onClick(result, filter, selectedIds); }, })); const otherOperations: IListFilterOperation[] = [ ...convertedExtraOperations, { text: intl.formatMessage({ id: "actions.select_all" }), onClick: () => onSelectAll(), isDisplayed: () => totalCount > 0, }, { text: intl.formatMessage({ id: "actions.select_none" }), onClick: () => onSelectNone(), isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.invert_selection" }), onClick: () => onInvertSelection(), isDisplayed: () => totalCount > 0, }, { text: intl.formatMessage({ id: "actions.view_random" }), onClick: viewRandom, }, { text: `${intl.formatMessage({ id: "actions.generate" })}…`, onClick: () => { showModal( closeModal()} /> ); }, isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export" }), onClick: () => onExport(false), isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export_all" }), onClick: () => onExport(true), }, ]; // render if (sidebarStateLoading) return null; const operations = ( ); const pageCount = Math.ceil(totalCount / filter.itemsPerPage); const content = ( <> showEditFilter(c.criterionOption.type)} onRemoveCriterion={removeCriterion} onRemoveAll={clearAllCriteria} />
{totalCount > filter.itemsPerPage && (
)} ); return ( <> {modal} {!withSidebar ? (
{content}
) : (
setShowSidebar(false)} > setShowSidebar(false)} count={cachedResult.loading ? undefined : totalCount} focus={searchFocus} /> setShowSidebar(!showSidebar)} > {content}
)} ); } ); ================================================ FILE: ui/v2.5/src/components/Images/ImageRecommendationRow.tsx ================================================ import React from "react"; import { useFindImages } from "src/core/StashService"; import { ListFilterModel } from "src/models/list-filter/filter"; import { ImageCard } from "./ImageCard"; import { PatchComponent } from "src/patch"; import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow"; interface IProps { isTouch: boolean; filter: ListFilterModel; header: string; } export const ImageRecommendationRow: React.FC = PatchComponent( "ImageRecommendationRow", (props: IProps) => { const result = useFindImages(props.filter); const count = result.data?.findImages.count ?? 0; return ( {result.loading ? [...Array(props.filter.itemsPerPage)].map((i) => (
)) : result.data?.findImages.images.map((i) => ( ))}
); } ); ================================================ FILE: ui/v2.5/src/components/Images/ImageWallItem.tsx ================================================ import React from "react"; import { Form } from "react-bootstrap"; import type { RenderImageProps } from "react-photo-gallery"; import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect"; interface IExtraProps { maxHeight: number; selected?: boolean; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; selecting?: boolean; } export const ImageWallItem: React.FC = ( props: RenderImageProps & IExtraProps ) => { const { dragProps } = useDragMoveSelect({ selecting: props.selecting || false, selected: props.selected || false, onSelectedChanged: props.onSelectedChanged, }); const height = Math.min(props.maxHeight, props.photo.height); const zoomFactor = height / props.photo.height; const width = props.photo.width * zoomFactor; type style = Record; var divStyle: style = { margin: props.margin, display: "block", position: "relative", }; if (props.direction === "column") { divStyle.position = "absolute"; divStyle.left = props.left; divStyle.top = props.top; } var handleClick = function handleClick( event: React.MouseEvent ) { if (props.selecting && props.onSelectedChanged) { props.onSelectedChanged(!props.selected, event.shiftKey); event.preventDefault(); event.stopPropagation(); return; } if (props.onClick) { props.onClick(event, { index: props.index }); } }; const video = props.photo.src.includes("preview"); const ImagePreview = video ? "video" : "img"; let shiftKey = false; return (
{props.onSelectedChanged && ( props.onSelectedChanged!(!props.selected, shiftKey)} onClick={(event: React.MouseEvent) => { shiftKey = event.shiftKey; event.stopPropagation(); }} /> )}
); }; ================================================ FILE: ui/v2.5/src/components/Images/Images.tsx ================================================ import React from "react"; import { Route, Switch } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import Image from "./ImageDetails/Image"; import { FilteredImageList } from "./ImageList"; import { View } from "../List/views"; const Images: React.FC = () => { return ; }; const ImageRoutes: React.FC = () => { const titleProps = useTitleProps({ id: "images" }); return ( <> ); }; export default ImageRoutes; ================================================ FILE: ui/v2.5/src/components/Images/styles.scss ================================================ @include media-breakpoint-only(lg) { .image-header-container { align-items: center; display: flex; justify-content: space-between; .image-header { flex: 0 0 75%; order: 1; } .image-studio-image { flex: 0 0 25%; order: 2; } } } .image-header { flex-basis: auto; font-size: 1.5rem; margin-top: 30px; @include media-breakpoint-down(xl) { font-size: 1.75rem; } } .image-subheader { display: flex; justify-content: space-between; margin-top: 0.5rem; .date { color: $text-muted; } .resolution { font-weight: bold; } } .image-toolbar { align-items: center; display: flex; justify-content: space-between; margin-bottom: 0.25rem; margin-top: 0.5rem; padding-bottom: 0.25rem; width: 100%; .image-toolbar-group { align-items: center; column-gap: 0.25rem; display: flex; width: 100%; &:last-child { justify-content: flex-end; } } } #image-details-container { .tab-content { min-height: 15rem; } .image-description { width: 100%; } } .image-card { &.card { overflow: hidden; padding: 0; @media (max-width: 576px) { width: 100%; } } .rating-banner { transition: opacity 0.5s; } &-preview { align-items: center; display: flex; justify-content: center; margin-bottom: 5px; position: relative; &-image { height: 100%; object-fit: contain; width: 100%; } &.portrait { .image-card-preview-image { object-fit: contain; } } } &:hover { .rating-banner { opacity: 0; transition: opacity 0.5s; } } } .image-tabs { max-height: calc(100vh - 4rem); overflow-wrap: break-word; word-wrap: break-word; } $imageTabWidth: 450px; @media (min-width: 1200px) { .image-tabs { flex: 0 0 $imageTabWidth; max-width: $imageTabWidth; overflow: auto; } .image-container { flex: 0 0 calc(100% - #{$imageTabWidth}); max-width: calc(100% - #{$imageTabWidth}); } } .image-tabs, .image-container { padding-left: 15px; padding-right: 15px; position: relative; width: 100%; } .image-container { display: flex; img { max-height: calc(100vh - 4rem); max-width: 100%; object-fit: contain; } } @media (min-width: 1200px) { .image-container { height: calc(100vh - 4rem); } } @media (min-width: 1200px), (max-width: 575px) { .image-performers { .performer-card { width: 15rem; &-image { height: 22.5rem; } } } } #image-edit-details { .rating-stars { font-size: 1.3em; height: calc(1.5em + 0.75rem + 2px); } .form-group[data-field="urls"] .string-list-input input.form-control { font-size: 0.85em; } @include media-breakpoint-up(xl) { .custom-fields-input { .custom-fields-field { flex: 0 0 25%; max-width: 25%; } .custom-fields-value { flex: 0 0 75%; max-width: 75%; } } } } .image-file-card.card { margin: 0; padding: 0; .card-header { cursor: pointer; } dl { margin-bottom: 0; } } .col-form-label { padding-right: 2px; } ================================================ FILE: ui/v2.5/src/components/List/CriterionEditor.tsx ================================================ import cloneDeep from "lodash-es/cloneDeep"; import React, { useCallback, useMemo } from "react"; import { CriterionModifier } from "src/core/generated-graphql"; import { DurationCriterion, CriterionValue, ModifierCriterion, IHierarchicalLabeledIdCriterion, NumberCriterion, ILabeledIdCriterion, DateCriterion, TimestampCriterion, BooleanCriterion, Criterion, } from "src/models/list-filter/criteria/criterion"; import { criterionIsHierarchicalLabelValue, criterionIsNumberValue, criterionIsStashIDValue, criterionIsDateValue, criterionIsTimestampValue, } from "src/models/list-filter/types"; import { DurationFilter } from "./Filters/DurationFilter"; import { NumberFilter } from "./Filters/NumberFilter"; import { LabeledIdFilter } from "./Filters/LabeledIdFilter"; import { HierarchicalLabelValueFilter } from "./Filters/HierarchicalLabelValueFilter"; import { InputFilter } from "./Filters/InputFilter"; import { DateFilter } from "./Filters/DateFilter"; import { TimestampFilter } from "./Filters/TimestampFilter"; import { CountryCriterion } from "src/models/list-filter/criteria/country"; import { CountrySelect } from "../Shared/CountrySelect"; import { StashIDCriterion } from "src/models/list-filter/criteria/stash-ids"; import { StashIDFilter } from "./Filters/StashIDFilter"; import { RatingCriterion } from "../../models/list-filter/criteria/rating"; import { RatingFilter } from "./Filters/RatingFilter"; import { BooleanFilter } from "./Filters/BooleanFilter"; import { OptionFilter, OptionListFilter } from "./Filters/OptionFilter"; import { PathFilter } from "./Filters/PathFilter"; import { PerformersCriterion } from "src/models/list-filter/criteria/performers"; import PerformersFilter from "./Filters/PerformersFilter"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; import StudiosFilter from "./Filters/StudiosFilter"; import { TagsCriterion } from "src/models/list-filter/criteria/tags"; import TagsFilter from "./Filters/TagsFilter"; import { PhashCriterion, DuplicatedCriterion, } from "src/models/list-filter/criteria/phash"; import { PhashFilter } from "./Filters/PhashFilter"; import { DuplicatedFilter } from "./Filters/DuplicateFilter"; import { PathCriterion } from "src/models/list-filter/criteria/path"; import { ModifierSelectorButtons } from "./ModifierSelect"; import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields"; import { CustomFieldsFilter } from "./Filters/CustomFieldsFilter"; import { FolderFilter } from "./Filters/FolderFilter"; import { FolderCriterion, ParentFolderCriterion, } from "src/models/list-filter/criteria/folder"; interface IGenericCriterionEditor { criterion: ModifierCriterion; setCriterion: (c: ModifierCriterion) => void; } const GenericCriterionEditor: React.FC = ({ criterion, setCriterion, }) => { const { options, modifierOptions } = criterion.modifierCriterionOption(); const showModifierSelector = useMemo(() => { if ( criterion instanceof PerformersCriterion || criterion instanceof StudiosCriterion || criterion instanceof TagsCriterion || criterion instanceof FolderCriterion || criterion instanceof ParentFolderCriterion ) { return false; } return modifierOptions && modifierOptions.length > 1; }, [criterion, modifierOptions]); const alwaysShowFilter = useMemo(() => { return ( criterion instanceof StashIDCriterion || criterion instanceof PerformersCriterion || criterion instanceof StudiosCriterion || criterion instanceof TagsCriterion ); }, [criterion]); const onChangedModifierSelect = useCallback( (m: CriterionModifier) => { const newCriterion = cloneDeep(criterion); newCriterion.modifier = m; setCriterion(newCriterion); }, [criterion, setCriterion] ); const modifierSelector = useMemo(() => { if (!showModifierSelector) { return; } return ( ); }, [ showModifierSelector, modifierOptions, onChangedModifierSelect, criterion.modifier, ]); const valueControl = useMemo(() => { function onValueChanged(value: CriterionValue) { const newCriterion = cloneDeep(criterion); newCriterion.value = value; setCriterion(newCriterion); } // always show stashID filter if (criterion instanceof StashIDCriterion) { return ( ); } // Hide the value select if the modifier is "IsNull" or "NotNull" if ( !alwaysShowFilter && (criterion.modifier === CriterionModifier.IsNull || criterion.modifier === CriterionModifier.NotNull) ) { return; } if (criterion instanceof PerformersCriterion) { return ( setCriterion(c)} /> ); } if (criterion instanceof StudiosCriterion) { return ( setCriterion(c)} /> ); } if (criterion instanceof TagsCriterion) { return ( setCriterion(c)} /> ); } if ( criterion instanceof FolderCriterion || criterion instanceof ParentFolderCriterion ) { return ( setCriterion(c)} /> ); } if (criterion instanceof ILabeledIdCriterion) { return ( ); } if (criterion instanceof IHierarchicalLabeledIdCriterion) { return ( ); } if ( options && !criterionIsHierarchicalLabelValue(criterion.value) && !criterionIsNumberValue(criterion.value) && !criterionIsStashIDValue(criterion.value) && !criterionIsDateValue(criterion.value) && !criterionIsTimestampValue(criterion.value) ) { if (!Array.isArray(criterion.value)) { return ( ); } else { return ( ); } } if (criterion instanceof PathCriterion) { return ( ); } if (criterion instanceof DurationCriterion) { return ( ); } if (criterion instanceof DateCriterion) { return ( ); } if (criterion instanceof TimestampCriterion) { return ( ); } if (criterion instanceof NumberCriterion) { return ( ); } if (criterion instanceof RatingCriterion) { return ( ); } if (criterion instanceof PhashCriterion) { return ( ); } if ( criterion instanceof CountryCriterion && (criterion.modifier === CriterionModifier.Equals || criterion.modifier === CriterionModifier.NotEquals) ) { return ( onValueChanged(v)} menuPortalTarget={document.body} /> ); } return ( ); }, [criterion, setCriterion, options, alwaysShowFilter]); return (
{modifierSelector} {valueControl}
); }; interface ICriterionEditor { criterion: Criterion; setCriterion: (c: Criterion) => void; } export const CriterionEditor: React.FC = ({ criterion, setCriterion, }) => { const filterControl = useMemo(() => { if (criterion instanceof BooleanCriterion) { return ( ); } if (criterion instanceof DuplicatedCriterion) { return ( ); } if (criterion instanceof CustomFieldsCriterion) { return ( ); } if (criterion instanceof ModifierCriterion) { return ( ); } return null; }, [criterion, setCriterion]); return
{filterControl}
; }; ================================================ FILE: ui/v2.5/src/components/List/EditFilterDialog.tsx ================================================ import cloneDeep from "lodash-es/cloneDeep"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { Accordion, Button, Card, Form, Modal } from "react-bootstrap"; import cx from "classnames"; import { Criterion, CriterionOption, } from "src/models/list-filter/criteria/criterion"; import { FormattedMessage, useIntl } from "react-intl"; import { useConfigurationContext } from "src/hooks/Config"; import { ListFilterModel } from "src/models/list-filter/filter"; import { getFilterOptions } from "src/models/list-filter/factory"; import { FilterTags } from "./FilterTags"; import { CriterionEditor } from "./CriterionEditor"; import { Icon } from "../Shared/Icon"; import { faChevronDown, faChevronRight, faTimes, faThumbtack, } from "@fortawesome/free-solid-svg-icons"; import { useCompare, usePrevious } from "src/hooks/state"; import { CriterionType } from "src/models/list-filter/types"; import { useToast } from "src/hooks/Toast"; import { useConfigureUI, useSaveFilter } from "src/core/StashService"; import { FilterMode, SavedFilterDataFragment, } from "src/core/generated-graphql"; import { useFocusOnce } from "src/utils/focus"; import Mousetrap from "mousetrap"; import ScreenUtils from "src/utils/screen"; import { LoadFilterDialog, SaveFilterDialog } from "./SavedFilterList"; import { SearchTermInput } from "./ListFilter"; interface ICriterionList { criteria: string[]; currentCriterion?: Criterion; setCriterion: (c: Criterion) => void; criterionOptions: CriterionOption[]; pinnedCriterionOptions: CriterionOption[]; selected?: CriterionOption; optionSelected: (o?: CriterionOption) => void; onRemoveCriterion: (c: string) => void; onTogglePin: (c: CriterionOption) => void; externallySelected?: boolean; } const CriterionOptionList: React.FC = ({ criteria, currentCriterion, setCriterion, criterionOptions, pinnedCriterionOptions, selected, optionSelected, onRemoveCriterion, onTogglePin, externallySelected = false, }) => { const { configuration } = useConfigurationContext(); const { sfwContentMode } = configuration.interface; const prevCriterion = usePrevious(currentCriterion); const scrolled = useRef(false); const type = currentCriterion?.criterionOption.type; const prevType = prevCriterion?.criterionOption.type; const criteriaRefs = useMemo(() => { const refs: Record> = {}; criterionOptions.forEach((c) => { refs[c.type] = React.createRef(); }); pinnedCriterionOptions.forEach((c) => { refs[c.type] = React.createRef(); }); return refs; }, [criterionOptions, pinnedCriterionOptions]); function onSelect(k: string | null) { if (!k) { optionSelected(undefined); return; } let option = criterionOptions.find((c) => c.type === k); if (!option) { option = pinnedCriterionOptions.find((c) => c.type === k); } if (option) { optionSelected(option); } } useEffect(() => { // scrolling to the current criterion doesn't work well when the // dialog is already open, so limit to when we click on the // criterion from the external tags if ( externallySelected && !scrolled.current && type && criteriaRefs[type]?.current ) { criteriaRefs[type].current!.scrollIntoView({ behavior: "smooth", block: "start", }); scrolled.current = true; } }, [externallySelected, currentCriterion, criteriaRefs, type]); function getReleventCriterion(t: CriterionType) { if (currentCriterion?.criterionOption.type === t) { return currentCriterion; } return prevCriterion; } function removeClicked(ev: React.MouseEvent, t: string) { // needed to prevent the nav item from being selected ev.stopPropagation(); ev.preventDefault(); onRemoveCriterion(t); } function togglePin(ev: React.MouseEvent, c: CriterionOption) { // needed to prevent the nav item from being selected ev.stopPropagation(); ev.preventDefault(); onTogglePin(c); } function renderCard(c: CriterionOption, isPin: boolean) { return ( {criteria.some((cc) => c.type === cc) && ( )} {(type === c.type && currentCriterion) || (prevType === c.type && prevCriterion) ? ( ) : ( )} ); } return ( {pinnedCriterionOptions.length !== 0 && ( <> {pinnedCriterionOptions.map((c) => renderCard(c, true))}
)} {criterionOptions.map((c) => renderCard(c, false))} ); }; const FilterModeToConfigKey = { [FilterMode.Galleries]: "galleries", [FilterMode.Images]: "images", [FilterMode.Movies]: "groups", [FilterMode.Groups]: "groups", [FilterMode.Performers]: "performers", [FilterMode.SceneMarkers]: "sceneMarkers", [FilterMode.Scenes]: "scenes", [FilterMode.Studios]: "studios", [FilterMode.Tags]: "tags", }; function filterModeToConfigKey(filterMode: FilterMode) { return FilterModeToConfigKey[filterMode]; } interface IEditFilterProps { filter: ListFilterModel; editingCriterion?: string; onApply: (filter: ListFilterModel) => void; onCancel: () => void; } export const EditFilterDialog: React.FC = ({ filter, editingCriterion, onApply, onCancel, }) => { const Toast = useToast(); const intl = useIntl(); const { configuration } = useConfigurationContext(); const { sfwContentMode } = configuration.interface; const [searchValue, setSearchValue] = useState(""); const [currentFilter, setCurrentFilter] = useState( cloneDeep(filter) ); const [criterion, setCriterion] = useState(); const [searchRef, setSearchFocus] = useFocusOnce(!ScreenUtils.isTouch()); const [showSaveDialog, setShowSaveDialog] = useState(false); const [savingFilter, setSavingFilter] = useState(false); const [showLoadDialog, setShowLoadDialog] = useState(false); const saveFilter = useSaveFilter(); const { criteria } = currentFilter; const criteriaList = useMemo(() => { return criteria.map((c) => c.criterionOption.type); }, [criteria]); const filterOptions = useMemo(() => { return getFilterOptions(currentFilter.mode); }, [currentFilter.mode]); const criterionOptions = useMemo(() => { return [...filterOptions.criterionOptions] .filter((c) => !c.hidden) .sort((a, b) => { return intl .formatMessage({ id: !sfwContentMode ? a.messageID : a.sfwMessageID ?? a.messageID, }) .localeCompare( intl.formatMessage({ id: !sfwContentMode ? b.messageID : b.sfwMessageID ?? b.messageID, }) ); }); }, [intl, sfwContentMode, filterOptions.criterionOptions]); const optionSelected = useCallback( (option?: CriterionOption) => { if (!option) { setCriterion(undefined); return; } // find the existing criterion if present const existing = criteria.find( (c) => c.criterionOption.type === option.type ); if (existing) { setCriterion(existing); } else { const newCriterion = filter.makeCriterion(option.type); setCriterion(newCriterion); } }, [filter, criteria] ); const ui = configuration?.ui ?? {}; const [saveUI] = useConfigureUI(); const filteredOptions = useMemo(() => { const trimmedSearch = searchValue.trim().toLowerCase(); if (!trimmedSearch) { return criterionOptions; } return criterionOptions.filter((c) => { return intl .formatMessage({ id: !sfwContentMode ? c.messageID : c.sfwMessageID ?? c.messageID, }) .toLowerCase() .includes(trimmedSearch); }); }, [intl, sfwContentMode, searchValue, criterionOptions]); const pinnedFilters = useMemo( () => ui.pinnedFilters?.[filterModeToConfigKey(currentFilter.mode)] ?? [], [currentFilter.mode, ui.pinnedFilters] ); const pinnedElements = useMemo( () => filteredOptions.filter((c) => pinnedFilters.includes(c.messageID)), [pinnedFilters, filteredOptions] ); const unpinnedElements = useMemo( () => filteredOptions.filter((c) => !pinnedFilters.includes(c.messageID)), [pinnedFilters, filteredOptions] ); const editingCriterionChanged = useCompare(editingCriterion); useEffect(() => { if (editingCriterionChanged && editingCriterion) { const option = criterionOptions.find((c) => c.type === editingCriterion); if (option) { optionSelected(option); } } }, [ editingCriterion, criterionOptions, optionSelected, editingCriterionChanged, ]); useEffect(() => { Mousetrap.bind("/", (e) => { setSearchFocus(); e.preventDefault(); }); return () => { Mousetrap.unbind("/"); }; }); async function updatePinnedFilters(filters: string[]) { const configKey = filterModeToConfigKey(currentFilter.mode); try { await saveUI({ variables: { input: { ...configuration?.ui, pinnedFilters: { ...ui.pinnedFilters, [configKey]: filters, }, }, }, }); } catch (e) { Toast.error(e); } } async function onTogglePinFilter(f: CriterionOption) { try { const existing = pinnedFilters.find((name) => name === f.messageID); if (existing) { await updatePinnedFilters( pinnedFilters.filter((name) => name !== f.messageID) ); } else { await updatePinnedFilters([...pinnedFilters, f.messageID]); } } catch (err) { Toast.error(err); } } function replaceCriterion(c: Criterion) { const newFilter = cloneDeep(currentFilter); if (!c.isValid()) { // remove from the filter if present const newCriteria = criteria.filter((cc) => { return cc.criterionOption.type !== c.criterionOption.type; }); newFilter.criteria = newCriteria; } else { let found = false; const newCriteria = criteria.map((cc) => { if (cc.criterionOption.type === c.criterionOption.type) { found = true; return c; } return cc; }); if (!found) { newCriteria.push(c); } newFilter.criteria = newCriteria; } setCurrentFilter(newFilter); setCriterion(c); } function removeCriterion(c: Criterion, valueIndex?: number) { if (valueIndex !== undefined) { setCurrentFilter( currentFilter.removeCustomFieldCriterion( c.criterionOption.type, valueIndex ) ); } else { const newFilter = cloneDeep(currentFilter); const newCriteria = criteria.filter((cc) => { return cc.getId() !== c.getId(); }); newFilter.criteria = newCriteria; setCurrentFilter(newFilter); if (criterion?.getId() === c.getId()) { optionSelected(undefined); } } } function removeCriterionString(c: string) { const cc = criteria.find((ccc) => ccc.criterionOption.type === c); if (cc) { removeCriterion(cc); } } function onClearAll() { const newFilter = cloneDeep(currentFilter); newFilter.criteria = []; setCurrentFilter(newFilter); } function onLoadFilter(f: SavedFilterDataFragment) { const newFilter = filter.clone(); newFilter.currentPage = 1; // #1795 - reset search term if not present in saved filter newFilter.searchTerm = ""; newFilter.configureFromSavedFilter(f); // #1507 - reset random seed when loaded newFilter.randomSeed = -1; onApply(newFilter); } async function onSaveFilter(name: string, id?: string) { try { setSavingFilter(true); await saveFilter(filter, name, id); Toast.success( intl.formatMessage( { id: "toast.saved_entity", }, { entity: intl.formatMessage({ id: "filter" }).toLocaleLowerCase(), } ) ); setShowSaveDialog(false); onApply(currentFilter); } catch (err) { Toast.error(err); } finally { setSavingFilter(false); } } return ( <> {showSaveDialog && ( { if (name) { onSaveFilter(name, id); } else { setShowSaveDialog(false); } }} isSaving={savingFilter} /> )} {showLoadDialog && ( { if (f) { onLoadFilter(f); } setShowLoadDialog(false); }} /> )} onCancel()} // need sfw mode class because dialog is outside body className={cx("edit-filter-dialog", { "sfw-content-mode": sfwContentMode, })} >
setSearchValue(e.target.value)} value={searchValue} placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} ref={searchRef} />
removeCriterionString(c)} onTogglePin={(c) => onTogglePinFilter(c)} externallySelected={!!editingCriterion} /> {criteria.length > 0 && (
optionSelected(c.criterionOption)} onRemoveCriterion={removeCriterion} onRemoveAll={() => onClearAll()} />
)}
); }; export function useShowEditFilter(props: { filter: ListFilterModel; setFilter: (f: ListFilterModel) => void; showModal: (content: React.ReactNode) => void; closeModal: () => void; }) { const { filter, setFilter, showModal, closeModal } = props; const showEditFilter = useCallback( (editingCriterion?: string) => { function onApplyEditFilter(f: ListFilterModel) { closeModal(); setFilter(f); } showModal( closeModal()} editingCriterion={editingCriterion} /> ); }, [filter, setFilter, showModal, closeModal] ); return showEditFilter; } ================================================ FILE: ui/v2.5/src/components/List/FilterProvider.tsx ================================================ import React from "react"; import { ListFilterModel } from "src/models/list-filter/filter"; import { isFunction } from "lodash-es"; import { useFilterURL } from "./util"; interface IFilterContextOptions { filter: ListFilterModel; setFilter: React.Dispatch>; } export interface IFilterContextState { filter: ListFilterModel; setFilter: React.Dispatch>; } export const FilterStateContext = React.createContext(null); export const FilterContext = ( props: IFilterContextOptions & { children?: | ((props: IFilterContextState) => React.ReactNode) | React.ReactNode; } ) => { const { filter, setFilter, children } = props; const state = { filter, setFilter, }; return ( {isFunction(children) ? (children as (props: IFilterContextState) => React.ReactNode)(state) : children} ); }; export function useFilter() { const context = React.useContext(FilterStateContext); if (context === null) { throw new Error("useFilter must be used within a FilterStateContext"); } return context; } // This component is used to set the filter from the URL. // It replaces the setFilter function to set the URL instead. // It also loads the default filter if the URL is empty. export const SetFilterURL = (props: { defaultFilter?: ListFilterModel; setURL?: boolean; children?: | ((props: IFilterContextState) => React.ReactNode) | React.ReactNode; }) => { const { defaultFilter, setURL = true, children } = props; const { filter, setFilter: setFilterOrig } = useFilter(); const { setFilter } = useFilterURL(filter, setFilterOrig, { defaultFilter, active: setURL, }); return ( {children} ); }; ================================================ FILE: ui/v2.5/src/components/List/FilterTags.tsx ================================================ import React, { PropsWithChildren, useEffect, useLayoutEffect, useReducer, useRef, } from "react"; import { Badge, BadgeProps, Button, Overlay, Popover } from "react-bootstrap"; import { Criterion, UnsupportedCriterion, } from "src/models/list-filter/criteria/criterion"; import { FormattedMessage, useIntl } from "react-intl"; import { Icon } from "../Shared/Icon"; import { faExclamationTriangle, faMagnifyingGlass, faTimes, } from "@fortawesome/free-solid-svg-icons"; import { BsPrefixProps, ReplaceProps } from "react-bootstrap/esm/helpers"; import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields"; import { useDebounce } from "src/hooks/debounce"; import cx from "classnames"; import { useConfigurationContext } from "src/hooks/Config"; type TagItemProps = PropsWithChildren< ReplaceProps<"span", BsPrefixProps<"span"> & BadgeProps> >; export const TagItem: React.FC = (props) => { const { className, children, ...others } = props; return ( {children} ); }; export const FilterTag: React.FC<{ className?: string; label: React.ReactNode; onClick: React.MouseEventHandler; onRemove: React.MouseEventHandler; unsupported?: boolean; }> = ({ className, label, onClick, onRemove, unsupported }) => { function handleClick(e: React.MouseEvent) { if (unsupported) { return; } onClick(e); } return ( {unsupported && ( )} {label} ); }; const MoreFilterTags: React.FC<{ tags: React.ReactNode[]; }> = ({ tags }) => { const [showTooltip, setShowTooltip] = React.useState(false); const target = useRef(null); if (!tags.length) { return null; } function handleMouseEnter() { setShowTooltip(true); } function handleMouseLeave() { setShowTooltip(false); } return ( <> {tags} ); }; interface IFilterTagsProps { searchTerm?: string; criteria: Criterion[]; onEditSearchTerm?: () => void; onEditCriterion: (c: Criterion) => void; onRemoveCriterion: (c: Criterion, valueIndex?: number) => void; onRemoveAll: () => void; onRemoveSearchTerm?: () => void; truncateOnOverflow?: boolean; } export const FilterTags: React.FC = ({ searchTerm, criteria, onEditCriterion, onRemoveCriterion, onRemoveAll, onEditSearchTerm, onRemoveSearchTerm, truncateOnOverflow = false, }) => { const intl = useIntl(); const ref = useRef(null); const { configuration } = useConfigurationContext(); const { sfwContentMode } = configuration.interface; const [cutoff, setCutoff] = React.useState(); const elementGap = 10; // Adjust this value based on your CSS gap or margin const moreTagWidth = 80; // reserve space for the "more" tag const [, forceUpdate] = useReducer((x) => x + 1, 0); const debounceResetCutoff = useDebounce( () => { setCutoff(undefined); // setting cutoff won't trigger a re-render if it's already undefined // so we force a re-render to recalculate the cutoff forceUpdate(); }, 100 // Adjust the debounce delay as needed ); // trigger recalculation of cutoff when control resizes useEffect(() => { if (!truncateOnOverflow || !ref.current) { return; } const resizeObserver = new ResizeObserver(() => { debounceResetCutoff(); }); const { current } = ref; resizeObserver.observe(current); return () => { resizeObserver.disconnect(); }; }, [truncateOnOverflow, debounceResetCutoff]); // we need to check this on every render, and the call to setCutoff _should_ be safe /* eslint-disable-next-line react-hooks/exhaustive-deps */ useLayoutEffect(() => { if (!truncateOnOverflow) { setCutoff(undefined); return; } const { current } = ref; if (current) { // calculate the number of tags that can fit in the container const containerWidth = current.clientWidth; const children = Array.from(current.children); // don't recalculate anything if the more tag is visible and cutoff is already set const moreTags = children.find((child) => { return (child as HTMLElement).classList.contains("more-tags"); }); if (moreTags && cutoff !== undefined) { return; } const childTags = children.filter((child) => { return ( (child as HTMLElement).classList.contains("tag-item") || (child as HTMLElement).classList.contains("clear-all-button") ); }); const clearAllButton = children.find((child) => { return (child as HTMLElement).classList.contains("clear-all-button"); }); // calculate the total width without the more tag const defaultTotalWidth = childTags.reduce((total, child, idx) => { return ( total + ((child as HTMLElement).offsetWidth ?? 0) + (idx === childTags.length - 1 ? 0 : elementGap) ); }, 0); if (containerWidth >= defaultTotalWidth) { // if the container is wide enough to fit all tags, reset cutoff setCutoff(undefined); return; } let totalWidth = 0; let visibleCount = 0; // reserve space for the more tags control totalWidth += moreTagWidth; // reserve space for the clear all button if present if (clearAllButton) { totalWidth += (clearAllButton as HTMLElement).offsetWidth ?? 0; } for (const child of children) { totalWidth += ((child as HTMLElement).offsetWidth ?? 0) + elementGap; if (totalWidth > containerWidth) { break; } visibleCount++; } setCutoff(visibleCount); } }); function onRemoveCriterionTag( criterion: Criterion, $event: React.MouseEvent, valueIndex?: number ) { if (!criterion) { return; } onRemoveCriterion(criterion, valueIndex); $event.stopPropagation(); } function onClickCriterionTag(criterion: Criterion) { onEditCriterion(criterion); } function getFilterTags(criterion: Criterion) { if ( criterion instanceof CustomFieldsCriterion && criterion.value.length > 1 ) { return criterion.value.map((value, index) => { return ( onClickCriterionTag(criterion)} onRemove={($event) => onRemoveCriterionTag(criterion, $event, index) } /> ); }); } const unsupported = criterion instanceof UnsupportedCriterion; return ( onClickCriterionTag(criterion)} onRemove={($event) => onRemoveCriterionTag(criterion, $event)} /> ); } if (criteria.length === 0 && !searchTerm) { return null; } const className = "wrap-tags filter-tags"; const filterTags = criteria.map((c) => getFilterTags(c)).flat(); if (searchTerm && searchTerm.length > 0) { filterTags.unshift( {searchTerm} } onClick={() => onEditSearchTerm?.()} onRemove={() => onRemoveSearchTerm?.()} /> ); } const visibleCriteria = cutoff !== undefined ? filterTags.slice(0, cutoff) : filterTags; const hiddenCriteria = cutoff !== undefined ? filterTags.slice(cutoff) : []; return (
{visibleCriteria} {filterTags.length >= 3 && ( )}
); }; ================================================ FILE: ui/v2.5/src/components/List/FilteredListToolbar.tsx ================================================ import React from "react"; import { QueryResult } from "@apollo/client"; import { ListFilterModel } from "src/models/list-filter/filter"; import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; import { PageSizeSelector, SearchTermInput, SortBySelect } from "./ListFilter"; import { ListViewButtonGroup } from "./ListViewOptions"; import { IListFilterOperation, ListOperationButtons, } from "./ListOperationButtons"; import { Button, ButtonGroup, ButtonToolbar } from "react-bootstrap"; import { View } from "./views"; import { IListSelect, useFilterOperations } from "./util"; import { SavedFilterDropdown } from "./SavedFilterList"; import { FilterButton } from "./Filters/FilterButton"; import { Icon } from "../Shared/Icon"; import { faTimes } from "@fortawesome/free-solid-svg-icons"; import { faSquareCheck } from "@fortawesome/free-regular-svg-icons"; import { useIntl } from "react-intl"; import cx from "classnames"; const SelectionSection: React.FC<{ filter: ListFilterModel; selected: number; onSelectAll: () => void; onSelectNone: () => void; }> = ({ selected, onSelectAll, onSelectNone }) => { const intl = useIntl(); return (
{selected}
); }; export interface IItemListOperation { text: string; onClick: ( result: T, filter: ListFilterModel, selectedIds: Set ) => Promise; isDisplayed?: ( result: T, filter: ListFilterModel, selectedIds: Set ) => boolean; postRefetch?: boolean; icon?: IconDefinition; buttonVariant?: string; } export interface IFilteredListToolbar { filter: ListFilterModel; setFilter: ( value: ListFilterModel | ((prevState: ListFilterModel) => ListFilterModel) ) => void; showEditFilter: () => void; view?: View; listSelect: IListSelect; onEdit?: () => void; onDelete?: () => void; operations?: IListFilterOperation[]; operationComponent?: React.ReactNode; zoomable?: boolean; filterable?: boolean; sortable?: boolean; } export const FilteredListToolbar: React.FC = ({ filter, setFilter, showEditFilter, view, listSelect, onEdit, onDelete, operations, operationComponent, zoomable = false, filterable = true, sortable = true, }) => { const filterOptions = filter.options; const { setDisplayMode, setZoom } = useFilterOperations({ filter, setFilter, }); const { selectedIds, onSelectAll, onSelectNone, onInvertSelection } = listSelect; const hasSelection = selectedIds.size > 0; const renderOperations = operationComponent ?? ( 0} onEdit={onEdit} onDelete={onDelete} /> ); return ( {hasSelection ? ( ) : ( <> {filterable && ( )} {filterable && ( showEditFilter()} count={filter.count()} /> )} {sortable && ( setFilter(filter.setSortBy(e ?? undefined)) } onChangeSortDirection={() => setFilter(filter.toggleSortDirection()) } onReshuffleRandomSort={() => setFilter(filter.reshuffleRandomSort()) } /> )} setFilter(filter.setPageSize(size))} /> )} {renderOperations} ); }; ================================================ FILE: ui/v2.5/src/components/List/Filters/BooleanFilter.tsx ================================================ import cloneDeep from "lodash-es/cloneDeep"; import React, { useMemo } from "react"; import { Form } from "react-bootstrap"; import { BooleanCriterion, CriterionOption, } from "src/models/list-filter/criteria/criterion"; import { FormattedMessage, useIntl } from "react-intl"; import { ListFilterModel } from "src/models/list-filter/filter"; import { Option, SidebarListFilter } from "./SidebarListFilter"; interface IBooleanFilter { criterion: BooleanCriterion; setCriterion: (c: BooleanCriterion) => void; } export const BooleanFilter: React.FC = ({ criterion, setCriterion, }) => { function onSelect(v: boolean) { const c = cloneDeep(criterion); if ((v && c.value === "true") || (!v && c.value === "false")) { c.value = ""; } else { c.value = v ? "true" : "false"; } setCriterion(c); } return (
onSelect(true)} checked={criterion.value === "true"} type="radio" label={} /> onSelect(false)} checked={criterion.value === "false"} type="radio" label={} />
); }; interface ISidebarFilter { title?: React.ReactNode; option: CriterionOption; filter: ListFilterModel; setFilter: (f: ListFilterModel) => void; sectionID?: string; } export const SidebarBooleanFilter: React.FC = ({ title, option, filter, setFilter, sectionID, }) => { const intl = useIntl(); const trueLabel = intl.formatMessage({ id: "true", }); const falseLabel = intl.formatMessage({ id: "false", }); const trueOption = useMemo( () => ({ id: "true", label: trueLabel, }), [trueLabel] ); const falseOption = useMemo( () => ({ id: "false", label: falseLabel, }), [falseLabel] ); const criteria = filter.criteriaFor(option.type) as BooleanCriterion[]; const criterion = criteria.length > 0 ? criteria[0] : null; const selected: Option[] = useMemo(() => { if (!criterion) return []; if (criterion.value === "true") { return [trueOption]; } else if (criterion.value === "false") { return [falseOption]; } return []; }, [trueOption, falseOption, criterion]); const options: Option[] = useMemo(() => { return [trueOption, falseOption].filter((o) => !selected.includes(o)); }, [selected, trueOption, falseOption]); function onSelect(item: Option) { const newCriterion = criterion ? criterion.clone() : option.makeCriterion(); newCriterion.value = item.id; setFilter(filter.replaceCriteria(option.type, [newCriterion])); } function onUnselect() { setFilter(filter.removeCriterion(option.type)); } return ( <> ); }; ================================================ FILE: ui/v2.5/src/components/List/Filters/CustomFieldsFilter.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields"; import { Button, Col, Form, Row } from "react-bootstrap"; import { CriterionModifier, CustomFieldCriterionInput, } from "src/core/generated-graphql"; import { cloneDeep } from "@apollo/client/utilities"; import { ModifierSelect } from "../ModifierSelect"; import { useIntl } from "react-intl"; import { Icon } from "src/components/Shared/Icon"; import { faCheck, faPencil, faTimes } from "@fortawesome/free-solid-svg-icons"; import { FilterTag } from "../FilterTags"; import { ModifierCriterion } from "src/models/list-filter/criteria/criterion"; interface ICustomFieldCriterionEditor { criterion?: CustomFieldCriterionInput; setCriterion: (c: CustomFieldCriterionInput) => void; cancel: () => void; editing?: boolean; } function getValue(v: string) { // if the value is numeric, convert it to a number const num = Number(v); if (!isNaN(num)) { return num; } else { return v; } } const CustomFieldCriterionEditor: React.FC = ({ criterion, setCriterion, editing = false, cancel, }) => { const intl = useIntl(); const [field, setField] = React.useState(criterion?.field ?? ""); const [value, setValue] = React.useState(criterion?.value); const [modifier, setModifier] = React.useState( criterion?.modifier ?? CriterionModifier.Equals ); const firstValue = value && value.length > 0 ? (value[0] as string) : ""; const secondValue = value && value.length > 1 ? (value[1] as string) : ""; useEffect(() => { setField((criterion?.field as string) ?? ""); setValue(criterion?.value ?? []); setModifier(criterion?.modifier ?? CriterionModifier.Equals); }, [criterion]); function setFirstValue(v: string) { // convert to numeric if possible const nv = getValue(v); if ( modifier === CriterionModifier.Between || modifier === CriterionModifier.NotBetween ) { setValue([nv, secondValue]); } else { setValue([nv]); } } function setSecondValue(v: string) { setValue([firstValue, getValue(v)]); } function onChangeModifier(m: CriterionModifier) { setModifier(m); if (m === CriterionModifier.IsNull || m === CriterionModifier.NotNull) { setValue(undefined); } } function onConfirm() { setCriterion({ field, value, modifier, }); } const firstPlaceholder = modifier === CriterionModifier.Between || modifier === CriterionModifier.NotBetween ? intl.formatMessage({ id: "criterion.greater_than" }) : intl.formatMessage({ id: "custom_fields.value" }); const hasTwoValues = modifier === CriterionModifier.Between || modifier === CriterionModifier.NotBetween; return (
setField(e.target.value)} value={field} /> onChangeModifier(m)} /> {modifier !== CriterionModifier.IsNull && modifier !== CriterionModifier.NotNull && ( setFirstValue(e.target.value)} value={firstValue} /> )} {(modifier === CriterionModifier.Between || modifier === CriterionModifier.NotBetween) && ( setSecondValue(e.target.value)} value={secondValue} /> )}
{editing && ( )}
); }; function valueToString(value: unknown[] | undefined | null) { if (!value) return ""; return value.map((v) => v as string).join(", "); } const CustomFieldFilterTag: React.FC<{ criterion: CustomFieldCriterionInput; editing?: boolean; onEditCriterion: () => void; onRemoveCriterion: () => void; }> = ({ criterion, editing, onEditCriterion, onRemoveCriterion }) => { const intl = useIntl(); const label = useMemo(() => { const { field, modifier, value } = criterion; const modifierString = ModifierCriterion.getModifierLabel(intl, modifier); const str = intl.formatMessage( { id: "criterion_modifier.format_string" }, { criterion: field, modifierString, valueString: valueToString(value), } ); if (editing) { return ( {str} ); } return <>{str}; }, [criterion, editing, intl]); return ( ); }; const CustomFieldsCriteriaPills: React.FC<{ criteria: CustomFieldCriterionInput[]; editIndex?: number; onEditCriterion: (index: number) => void; onRemoveCriterion: (index: number) => void; }> = ({ criteria, editIndex, onEditCriterion, onRemoveCriterion }) => { return (
{criteria.map((c, index) => ( onEditCriterion(index)} onRemoveCriterion={() => onRemoveCriterion(index)} /> ))}
); }; interface ICustomFieldsFilter { criterion: CustomFieldsCriterion; setCriterion: (c: CustomFieldsCriterion) => void; } function initCriterion( criterion: CustomFieldsCriterion ): CustomFieldsCriterion { return cloneDeep(criterion); } function createNewCriterion(): CustomFieldCriterionInput { return { field: "", value: [], modifier: CriterionModifier.Equals, }; } export const CustomFieldsFilter: React.FC = ({ criterion, setCriterion, }) => { const [localCriterion, setLocalCriterion] = React.useState( initCriterion(criterion) ); const [editCriterion, setEditCriterion] = useState(createNewCriterion()); const editIndex = useMemo( () => localCriterion.value.indexOf(editCriterion), [localCriterion, editCriterion] ); function updateCriteria(newCriteria: CustomFieldCriterionInput[]) { // update the parent - filter out invalid criteria const validCriteria = newCriteria.filter((c) => c.field !== ""); const newValue = cloneDeep(criterion); newValue.value = validCriteria; setCriterion(newValue); } function onChange(nv: CustomFieldCriterionInput) { const newValue = cloneDeep(localCriterion); // if the criterion is new, add it to the list if (editIndex === -1) { newValue.value.push(nv); } else { newValue.value[editIndex] = nv; } setLocalCriterion(newValue); updateCriteria(newValue.value); setEditCriterion(createNewCriterion()); } function onRemove(index: number) { const c = cloneDeep(localCriterion); c.value.splice(index, 1); setLocalCriterion(c); updateCriteria(c.value); if (index === editIndex) { setEditCriterion(createNewCriterion()); } } return ( setEditCriterion(createNewCriterion())} /> setEditCriterion(localCriterion.value[index]) } onRemoveCriterion={(index) => onRemove(index)} /> ); }; ================================================ FILE: ui/v2.5/src/components/List/Filters/DateFilter.tsx ================================================ import React from "react"; import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import { CriterionModifier } from "../../../core/generated-graphql"; import { IDateValue } from "../../../models/list-filter/types"; import { ModifierCriterion } from "../../../models/list-filter/criteria/criterion"; import { DateInput } from "src/components/Shared/DateInput"; interface IDateFilterProps { criterion: ModifierCriterion; onValueChanged: (value: IDateValue) => void; } export const DateFilter: React.FC = ({ criterion, onValueChanged, }) => { const intl = useIntl(); const { value } = criterion; function onChanged(newValue: string, property: "value" | "value2") { const valueCopy = { ...value }; valueCopy[property] = newValue; onValueChanged(valueCopy); } let equalsControl: JSX.Element | null = null; if ( criterion.modifier === CriterionModifier.Equals || criterion.modifier === CriterionModifier.NotEquals ) { equalsControl = ( onChanged(v, "value")} placeholder={intl.formatMessage({ id: "criterion.value" })} /> ); } let lowerControl: JSX.Element | null = null; if ( criterion.modifier === CriterionModifier.GreaterThan || criterion.modifier === CriterionModifier.Between || criterion.modifier === CriterionModifier.NotBetween ) { lowerControl = ( onChanged(v, "value")} placeholder={intl.formatMessage({ id: "criterion.greater_than" })} /> ); } let upperControl: JSX.Element | null = null; if ( criterion.modifier === CriterionModifier.LessThan || criterion.modifier === CriterionModifier.Between || criterion.modifier === CriterionModifier.NotBetween ) { upperControl = ( onChanged( v, criterion.modifier === CriterionModifier.LessThan ? "value" : "value2" ) } placeholder={intl.formatMessage({ id: "criterion.less_than" })} /> ); } return ( <> {equalsControl} {lowerControl} {upperControl} ); }; ================================================ FILE: ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx ================================================ import React, { useCallback, useMemo, useState } from "react"; import { useIntl } from "react-intl"; import { ListFilterModel } from "src/models/list-filter/filter"; import { Option, SelectedList } from "./SidebarListFilter"; import { DuplicatedCriterion, DuplicatedCriterionOption, DuplicationFieldId, DUPLICATION_FIELD_IDS, DUPLICATION_FIELD_MESSAGE_IDS, } from "src/models/list-filter/criteria/phash"; import { IndeterminateCheckbox } from "src/components/Shared/IndeterminateCheckbox"; import { SidebarSection } from "src/components/Shared/Sidebar"; import { Icon } from "src/components/Shared/Icon"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { keyboardClickHandler } from "src/utils/keyboard"; interface IDuplicatedFilter { criterion: DuplicatedCriterion; setCriterion: (c: DuplicatedCriterion) => void; } export const DuplicatedFilter: React.FC = ({ criterion, setCriterion, }) => { const intl = useIntl(); function onFieldChange( fieldId: DuplicationFieldId, value: boolean | undefined ) { const c = criterion.clone(); if (value === undefined) { delete c.value[fieldId]; } else { c.value[fieldId] = value; } setCriterion(c); } return (
{DUPLICATION_FIELD_IDS.map((fieldId) => ( onFieldChange(fieldId, v)} /> ))}
); }; interface ISidebarDuplicateFilterProps { title?: React.ReactNode; filter: ListFilterModel; setFilter: (f: ListFilterModel) => void; sectionID?: string; } export const SidebarDuplicateFilter: React.FC = ({ title, filter, setFilter, sectionID, }) => { const intl = useIntl(); const [expandedType, setExpandedType] = useState(null); const trueLabel = intl.formatMessage({ id: "true" }); const falseLabel = intl.formatMessage({ id: "false" }); // Get label for a duplicate type const getLabel = useCallback( (typeId: DuplicationFieldId) => intl.formatMessage({ id: DUPLICATION_FIELD_MESSAGE_IDS[typeId] }), [intl] ); // Get the single duplicated criterion from the filter const getCriterion = useCallback((): DuplicatedCriterion | null => { const criteria = filter.criteriaFor( DuplicatedCriterionOption.type ) as DuplicatedCriterion[]; return criteria.length > 0 ? criteria[0] : null; }, [filter]); // Get value for a specific type from the criterion const getTypeValue = useCallback( (typeId: DuplicationFieldId): boolean | undefined => { const criterion = getCriterion(); if (!criterion) return undefined; return criterion.value[typeId]; }, [getCriterion] ); // Build selected items list const selected: Option[] = useMemo(() => { const result: Option[] = []; const criterion = getCriterion(); if (!criterion) return result; for (const typeId of DUPLICATION_FIELD_IDS) { const value = criterion.value[typeId]; if (value !== undefined) { const valueLabel = value ? trueLabel : falseLabel; result.push({ id: typeId, label: `${getLabel(typeId)}: ${valueLabel}`, }); } } return result; }, [getCriterion, trueLabel, falseLabel, getLabel]); // Available options - show options that aren't already selected const options = useMemo(() => { const result: { id: DuplicationFieldId; label: string }[] = []; for (const typeId of DUPLICATION_FIELD_IDS) { if (getTypeValue(typeId) === undefined) { result.push({ id: typeId, label: getLabel(typeId) }); } } return result; }, [getTypeValue, getLabel]); function onToggleExpand(id: string) { setExpandedType(expandedType === id ? null : id); } function onUnselect(item: Option) { const typeId = item.id as DuplicationFieldId; const criterion = getCriterion(); if (!criterion) return; const newCriterion = criterion.clone(); delete newCriterion.value[typeId]; // If no fields are set, remove the criterion entirely const hasAnyValue = DUPLICATION_FIELD_IDS.some( (id) => newCriterion.value[id] !== undefined ); if (!hasAnyValue) { setFilter(filter.removeCriterion(DuplicatedCriterionOption.type)); } else { setFilter( filter.replaceCriteria(DuplicatedCriterionOption.type, [newCriterion]) ); } setExpandedType(null); } function onSelectValue(typeId: string, value: boolean) { const criterion = getCriterion(); const newCriterion = criterion ? criterion.clone() : (DuplicatedCriterionOption.makeCriterion() as DuplicatedCriterion); newCriterion.value[typeId as DuplicationFieldId] = value; setFilter( filter.replaceCriteria(DuplicatedCriterionOption.type, [newCriterion]) ); setExpandedType(null); } return ( onUnselect(i)} /> } >
); }; ================================================ FILE: ui/v2.5/src/components/List/Filters/DurationFilter.tsx ================================================ import React from "react"; import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import { CriterionModifier } from "src/core/generated-graphql"; import { DurationInput } from "src/components/Shared/DurationInput"; import { INumberValue } from "src/models/list-filter/types"; import { ModifierCriterion } from "src/models/list-filter/criteria/criterion"; interface IDurationFilterProps { criterion: ModifierCriterion; onValueChanged: (value: INumberValue) => void; } export const DurationFilter: React.FC = ({ criterion, onValueChanged, }) => { const intl = useIntl(); function onChanged(v: number | null, property: "value" | "value2") { const { value } = criterion; value[property] = v ?? undefined; onValueChanged(value); } function renderTop() { let placeholder: string; if ( criterion.modifier === CriterionModifier.GreaterThan || criterion.modifier === CriterionModifier.Between || criterion.modifier === CriterionModifier.NotBetween ) { placeholder = intl.formatMessage({ id: "criterion.greater_than" }); } else if (criterion.modifier === CriterionModifier.LessThan) { placeholder = intl.formatMessage({ id: "criterion.less_than" }); } else { placeholder = intl.formatMessage({ id: "criterion.value" }); } return ( onChanged(v, "value")} placeholder={placeholder} /> ); } function renderBottom() { if ( criterion.modifier !== CriterionModifier.Between && criterion.modifier !== CriterionModifier.NotBetween ) { return; } return ( onChanged(v, "value2")} placeholder={intl.formatMessage({ id: "criterion.less_than" })} /> ); } return ( <> {renderTop()} {renderBottom()} ); }; ================================================ FILE: ui/v2.5/src/components/List/Filters/FilterButton.tsx ================================================ import React from "react"; import { Badge, Button } from "react-bootstrap"; import { faFilter } from "@fortawesome/free-solid-svg-icons"; import { Icon } from "src/components/Shared/Icon"; import { useIntl } from "react-intl"; interface IFilterButtonProps { count?: number; onClick: () => void; title?: string; } export const FilterButton: React.FC = ({ count = 0, onClick, title, }) => { const intl = useIntl(); if (!title) { title = intl.formatMessage({ id: "search_filter.edit_filter" }); } return ( ); }; ================================================ FILE: ui/v2.5/src/components/List/Filters/FilterSidebar.tsx ================================================ import React, { useEffect } from "react"; import { FormattedMessage } from "react-intl"; import { SidebarSection } from "src/components/Shared/Sidebar"; import { ListFilterModel } from "src/models/list-filter/filter"; import { SearchTermInput } from "../ListFilter"; import { SidebarSavedFilterList } from "../SavedFilterList"; import { View } from "../views"; import useFocus from "src/utils/focus"; import ScreenUtils from "src/utils/screen"; import Mousetrap from "mousetrap"; import { Button } from "react-bootstrap"; const savedFiltersSectionID = "saved-filters"; export const FilteredSidebarHeader: React.FC<{ sidebarOpen: boolean; showEditFilter: () => void; filter: ListFilterModel; setFilter: (filter: ListFilterModel) => void; view?: View; focus?: ReturnType; }> = ({ sidebarOpen, showEditFilter, filter, setFilter, view, focus: providedFocus, }) => { const localFocus = useFocus(); const focus = providedFocus ?? localFocus; const [, setFocus] = focus; // Set the focus on the input field when the sidebar is opened // Don't do this on touch devices useEffect(() => { if (sidebarOpen && !ScreenUtils.isTouch()) { setFocus(); } }, [sidebarOpen, setFocus]); return ( <>
} sectionID={savedFiltersSectionID} > ); }; export function useFilteredSidebarKeybinds(props: { showSidebar: boolean; setShowSidebar: (show: boolean) => void; }) { const { showSidebar, setShowSidebar } = props; // Hide the sidebar when the user presses the "Esc" key useEffect(() => { Mousetrap.bind("esc", (e) => { if (showSidebar) { setShowSidebar(false); e.preventDefault(); } }); return () => { Mousetrap.unbind("esc"); }; }, [showSidebar, setShowSidebar]); } ================================================ FILE: ui/v2.5/src/components/List/Filters/FolderFilter.tsx ================================================ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { FolderDataFragment, useFindFoldersForQueryQuery, useFindRootFoldersForSelectQuery, } from "src/core/generated-graphql"; import { ISidebarSectionProps, SidebarSection, } from "src/components/Shared/Sidebar"; import { faChevronDown, faChevronRight, faMinus, faPlus, } from "@fortawesome/free-solid-svg-icons"; import { ExpandCollapseButton } from "src/components/Shared/CollapseButton"; import cx from "classnames"; import { queryFindSubFolders } from "src/core/StashService"; import { keyboardClickHandler } from "src/utils/keyboard"; import { ListFilterModel } from "src/models/list-filter/filter"; import { FolderCriterion, FolderCriterionOption, } from "src/models/list-filter/criteria/folder"; import { Option, SelectedList } from "./SidebarListFilter"; import { defineMessages, FormattedMessage, MessageDescriptor, useIntl, } from "react-intl"; import { Icon } from "src/components/Shared/Icon"; import { Button, Form } from "react-bootstrap"; import { DepthSelector } from "./SelectableFilter"; import ClearableInput from "src/components/Shared/ClearableInput"; import { useDebouncedState } from "src/hooks/debounce"; import { ModifierCriterionOption } from "src/models/list-filter/criteria/criterion"; interface IFolder extends FolderDataFragment { children?: IFolder[]; expanded: boolean; } const FolderRow: React.FC<{ folder: IFolder; level?: number; canExclude?: boolean; toggleExpanded: (folder: IFolder) => void; onSelect: (folder: IFolder, exclude?: boolean) => void; }> = ({ folder, level, toggleExpanded, onSelect, canExclude }) => { return ( <>
  • onSelect(folder)} onKeyDown={keyboardClickHandler(() => onSelect(folder))} tabIndex={0} > toggleExpanded(folder)} collapsedIcon={faChevronRight} notCollapsedIcon={faChevronDown} /> {folder.basename} {canExclude && ( )}
  • {folder.expanded && folder.children?.map((child) => ( ))} ); }; function toggleExpandedFn(object: IFolder): (f: IFolder) => IFolder { return (f: IFolder) => { if (f.id === object.id) { return { ...f, expanded: !f.expanded }; } if (f.children) { return { ...f, children: f.children.map(toggleExpandedFn(object)), }; } return f; }; } function replaceFolder(folder: IFolder): (f: IFolder) => IFolder { return (f: IFolder) => { if (f.id === folder.id) { return folder; } if (f.children) { return { ...f, children: f.children.map(replaceFolder(folder)), }; } return f; }; } function useFolderMap(query: string, skip?: boolean) { const { data: rootFoldersResult } = useFindRootFoldersForSelectQuery({ skip, }); const { data: queryFoldersResult } = useFindFoldersForQueryQuery({ skip: !query, variables: { filter: { q: query, per_page: 200 }, }, }); const rootFolders: IFolder[] = useMemo(() => { const ret = rootFoldersResult?.findFolders.folders ?? []; return ret.map((f) => ({ ...f, expanded: false, children: undefined })); }, [rootFoldersResult]); const queryFolders: IFolder[] = useMemo(() => { // construct the folder list from the query result const ret: IFolder[] = []; (queryFoldersResult?.findFolders.folders ?? []).forEach((folder) => { if (!folder.parent_folders.length) { // no parents, just add it if not present if (!ret.find((f) => f.id === folder.id)) { ret.push({ ...folder, expanded: true, children: [] }); } return; } // expand the parent folders let currentParent: IFolder | undefined; for (let i = folder.parent_folders.length - 1; i >= 0; i--) { const thisFolder = folder.parent_folders[i]; let existing: IFolder | undefined; if (i === folder.parent_folders.length - 1) { // last parent, add the folder as root existing = ret.find((f) => f.id === thisFolder.id); if (!existing) { existing = { ...folder.parent_folders[i], expanded: true, children: [], }; ret.push(existing); } currentParent = existing; continue; } // find folder in current parent's children // currentParent is guaranteed to be defined here existing = currentParent!.children?.find((f) => f.id === thisFolder.id); if (!existing) { // add to current parent's children existing = { ...thisFolder, expanded: true, children: [], }; currentParent!.children!.push(existing); } currentParent = existing; } if (!currentParent) { return; } if (!currentParent.children) { currentParent.children = []; } // currentParent is now the immediate parent folder currentParent!.children!.push({ ...folder, expanded: false, children: undefined, }); }); return ret; }, [queryFoldersResult]); const [folderMap, setFolderMap] = React.useState([]); useEffect(() => { if (!query) { setFolderMap(rootFolders); } else { setFolderMap(queryFolders); } }, [query, rootFolders, queryFolders]); async function onToggleExpanded(folder: IFolder) { setFolderMap(folderMap.map(toggleExpandedFn(folder))); // query children folders if not already loaded if (folder.children === undefined) { const subFolderResult = await queryFindSubFolders(folder.id); setFolderMap((current) => current.map( replaceFolder({ ...folder, expanded: true, children: subFolderResult.data.findFolders.folders.map((f) => ({ ...f, expanded: false, })), }) ) ); } } return { folderMap, onToggleExpanded }; } function getMatchingFolders(folders: IFolder[], query: string): IFolder[] { let matches: IFolder[] = []; const queryLower = query.toLowerCase(); folders.forEach((folder) => { if ( folder.basename.toLowerCase().includes(queryLower) || folder.path.toLowerCase() === queryLower ) { matches.push(folder); } if (folder.children) { matches = matches.concat(getMatchingFolders(folder.children, query)); } }); return matches; } export const FolderSelector: React.FC<{ onSelect: (folder: IFolder, exclude?: boolean) => void; canExclude?: boolean; preListContent?: React.ReactNode; folderMap: IFolder[]; onToggleExpanded: (folder: IFolder) => void; }> = ({ onSelect, preListContent, canExclude = false, folderMap, onToggleExpanded, }) => { return (
      {preListContent} {folderMap.map((folder) => ( onSelect(f, exclude)} toggleExpanded={onToggleExpanded} canExclude={canExclude} /> ))}
    ); }; interface IInputFilterProps { criterion: FolderCriterion; setCriterion: (c: FolderCriterion) => void; } export const FolderFilter: React.FC = ({ criterion, setCriterion, }) => { const intl = useIntl(); const [query, setQuery] = useState(""); const [displayQuery, onQueryChange] = useDebouncedState(query, setQuery, 250); const { folderMap, onToggleExpanded } = useFolderMap(query); const messages = defineMessages({ sub_folder_depth: { id: "sub_folder_depth", defaultMessage: "Levels (empty for all)", }, }); function criterionOptionTypeToIncludeID(): string { return "include-sub-folders"; } function criterionOptionTypeToIncludeUIString(): MessageDescriptor { const optionType = "include_sub_folders"; return { id: optionType, }; } function onDepthChanged(depth: number) { // this could be ParentFolderCriterion, but the types are the same const newValue = criterion.clone() as FolderCriterion; newValue.value.depth = depth; setCriterion(newValue); } function onSelect(folder: IFolder, exclude: boolean = false) { // toggle selection const newValue = criterion.clone() as FolderCriterion; if (!exclude) { if (newValue.value.items.find((i) => i.id === folder.id)) { return; } newValue.value.items.push({ id: folder.id, label: folder.path }); } else { if (newValue.value.excluded.find((i) => i.id === folder.id)) { return; } newValue.value.excluded.push({ id: folder.id, label: folder.path }); } setCriterion(newValue); } const onUnselect = useCallback( (i: Option, excluded?: boolean) => { const newValue = criterion.clone() as FolderCriterion; if (!excluded) { newValue.value.items = newValue.value.items.filter( (item) => item.id !== i.id ); } else { newValue.value.excluded = newValue.value.excluded.filter( (item) => item.id !== i.id ); } setCriterion(newValue); }, [criterion, setCriterion] ); function onEnter() { if (!query) return; // if there is a single folder that matches the query, select it const matchingFolders = getMatchingFolders(folderMap, query); if (matchingFolders.length === 1) { onSelect(matchingFolders[0]); } } const selectedList = useMemo(() => { const selected: Option[] = criterion.value?.items.map((item) => ({ id: item.id, label: item.label, })) ?? []; return ; }, [criterion, onUnselect]); const excludedList = useMemo(() => { const selected: Option[] = criterion.value?.excluded.map((item) => ({ id: item.id, label: item.label, })) ?? []; return ( onUnselect(i, true)} /> ); }, [criterion, onUnselect]); return (
    {selectedList} {excludedList} onQueryChange(v)} placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} onEnter={onEnter} />
    ); }; export const SidebarFolderFilter: React.FC< ISidebarSectionProps & { filter: ListFilterModel; setFilter: (f: ListFilterModel) => void; criterionOption?: ModifierCriterionOption; } > = (props) => { const intl = useIntl(); const [skip, setSkip] = useState(true); const [query, setQuery] = useState(""); const [displayQuery, onQueryChange] = useDebouncedState(query, setQuery, 250); function onOpen() { setSkip(false); props.onOpen?.(); } const { folderMap, onToggleExpanded } = useFolderMap(query, skip); const option = props.criterionOption ?? FolderCriterionOption; const { filter, setFilter } = props; const criterion = useMemo(() => { const ret = filter.criteria.find( (c) => c.criterionOption.type === option.type ); if (ret) return ret as FolderCriterion; const newCriterion = filter.makeCriterion(option.type) as FolderCriterion; return newCriterion; }, [option.type, filter]); // if there are multiple values or excluded values, then we show none of the // current values const multipleSelected = criterion.value.items.length > 1 || criterion.value.excluded.length > 0; function onSelect(folder: IFolder) { const c = criterion.clone() as FolderCriterion; c.value = { items: [{ id: folder.id, label: folder.path }], depth: 0, excluded: [], }; const newCriteria = props.filter.criteria.filter( (cc) => cc.criterionOption.type !== option.type ); if (c.isValid()) newCriteria.push(c); setFilter(props.filter.setCriteria(newCriteria)); } function onSelectSubfolders() { const c = criterion.clone() as FolderCriterion; c.value = { items: c.value?.items ?? [], depth: -1, excluded: c.value?.excluded ?? [], }; setFilter(props.filter.replaceCriteria(option.type, [c])); } const onUnselect = useCallback( (i: Option) => { if (i.className === "modifier-object") { // subfolders option const c = criterion.clone() as FolderCriterion; c.value = { items: c.value?.items ?? [], depth: 0, excluded: c.value?.excluded ?? [], }; setFilter(props.filter.replaceCriteria(option.type, [c])); return; } setFilter(props.filter.removeCriterion(option.type)); }, [props.filter, setFilter, option.type, criterion] ); function onEnter() { if (!query) return; // if there is a single folder that matches the query, select it const matchingFolders = getMatchingFolders(folderMap, query); if (matchingFolders.length === 1) { onSelect(matchingFolders[0]); } } const subDirsSelected = criterion.value?.depth === -1; const selectedList = useMemo(() => { if (multipleSelected) { return null; } const selected: Option[] = criterion.value?.items.map((item) => ({ id: item.id, label: item.label, })) ?? []; if (subDirsSelected) { selected.push({ id: "subfolders", label: "(" + intl.formatMessage({ id: "sub_folders" }) + ")", className: "modifier-object", }); } return ; }, [intl, multipleSelected, subDirsSelected, criterion, onUnselect]); const modifierItem = criterion.value.items.length > 0 && !multipleSelected && !subDirsSelected && (
  • ()
  • ); return ( onQueryChange(v)} placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} onEnter={onEnter} /> onSelect(f)} /> ); }; ================================================ FILE: ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx ================================================ import React from "react"; import { Form } from "react-bootstrap"; import { defineMessages, MessageDescriptor, useIntl } from "react-intl"; import { FilterSelect, SelectObject } from "src/components/Shared/Select"; import { ModifierCriterion } from "src/models/list-filter/criteria/criterion"; import { IHierarchicalLabelValue } from "src/models/list-filter/types"; import { NumberField } from "src/utils/form"; interface IHierarchicalLabelValueFilterProps { criterion: ModifierCriterion; onValueChanged: (value: IHierarchicalLabelValue) => void; } export const HierarchicalLabelValueFilter: React.FC< IHierarchicalLabelValueFilterProps > = ({ criterion, onValueChanged }) => { const criterionOption = criterion.modifierCriterionOption(); const { type, inputType } = criterionOption; const intl = useIntl(); if ( inputType !== "studios" && inputType !== "tags" && inputType !== "scene_tags" && inputType !== "performer_tags" && inputType !== "groups" ) { return null; } const messages = defineMessages({ studio_depth: { id: "studio_depth", defaultMessage: "Levels (empty for all)", }, }); function onSelectionChanged(items: SelectObject[]) { const { value } = criterion; value.items = items.map((i) => ({ id: i.id, label: i.name ?? i.title ?? "", })); onValueChanged(value); } function onDepthChanged(depth: number) { const { value } = criterion; value.depth = depth; onValueChanged(value); } function criterionOptionTypeToIncludeID(): string { if (inputType === "studios") { return "include-sub-studios"; } if (inputType === "groups") { return "include-sub-groups"; } if (type === "children") { return "include-parent-tags"; } console.log(inputType); return "include-sub-tags"; } function criterionOptionTypeToIncludeUIString(): MessageDescriptor { let id: string; if (inputType === "studios") { id = "include_sub_studios"; } else if (inputType === "groups") { id = "include_sub_groups"; } else if (type === "children") { id = "include_parent_tags"; } else { id = "include_sub_tags"; } return { id, }; } return ( <> labeled.id)} menuPortalTarget={document.body} /> onDepthChanged(criterion.value.depth !== 0 ? 0 : -1)} /> {criterion.value.depth !== 0 && ( onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1) } defaultValue={ criterion.value && criterion.value.depth !== -1 ? criterion.value.depth : "" } min="1" /> )} ); }; ================================================ FILE: ui/v2.5/src/components/List/Filters/InputFilter.tsx ================================================ import React from "react"; import { Form } from "react-bootstrap"; import { ModifierCriterion, CriterionValue, } from "../../../models/list-filter/criteria/criterion"; interface IInputFilterProps { criterion: ModifierCriterion; onValueChanged: (value: string) => void; } export const InputFilter: React.FC = ({ criterion, onValueChanged, }) => { function onChanged(event: React.ChangeEvent) { onValueChanged(event.target.value); } return ( <> ); }; ================================================ FILE: ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx ================================================ import React, { useCallback, useMemo, useState } from "react"; import { Form } from "react-bootstrap"; import { FilterSelect, SelectObject } from "src/components/Shared/Select"; import { objectTitle } from "src/core/files"; import { galleryTitle } from "src/core/galleries"; import { ILoadResults, useCacheResults } from "src/hooks/data"; import { CriterionOption, ModifierCriterion, } from "src/models/list-filter/criteria/criterion"; import { ListFilterModel } from "src/models/list-filter/filter"; import { IHierarchicalLabelValue, ILabeledId, ILabeledValueListValue, } from "src/models/list-filter/types"; import { Option } from "./SidebarListFilter"; import { CriterionModifier, FilterMode, GalleryFilterType, GroupFilterType, ImageFilterType, InputMaybe, IntCriterionInput, PerformerFilterType, SceneFilterType, SceneMarkerFilterType, StudioFilterType, } from "src/core/generated-graphql"; import { useIntl } from "react-intl"; interface ILabeledIdFilterProps { criterion: ModifierCriterion; onValueChanged: (value: ILabeledId[]) => void; } export const LabeledIdFilter: React.FC = ({ criterion, onValueChanged, }) => { const criterionOption = criterion.modifierCriterionOption(); const { inputType } = criterionOption; if ( inputType !== "performers" && inputType !== "studios" && inputType !== "scene_tags" && inputType !== "performer_tags" && inputType !== "tags" && inputType !== "scenes" && inputType !== "groups" && inputType !== "galleries" ) { return null; } function getLabel(i: SelectObject) { switch (inputType) { case "galleries": return galleryTitle(i); case "scenes": return objectTitle(i); } return i.name ?? i.title ?? ""; } function onSelectionChanged(items: SelectObject[]) { onValueChanged( items.map((i) => ({ id: i.id, label: getLabel(i), })) ); } return ( labeled.id)} menuPortalTarget={document.body} /> ); }; export type ModifierValue = "any" | "none" | "any_of" | "only" | "include_subs"; export function getModifierCandidates(props: { modifier: CriterionModifier; defaultModifier: CriterionModifier; hasSelected?: boolean; hasExcluded?: boolean; singleValue?: boolean; hierarchical?: boolean; }) { const { modifier, defaultModifier, hasSelected, hasExcluded, singleValue, hierarchical, } = props; const ret: ModifierValue[] = []; if (modifier === defaultModifier && !hasSelected && !hasExcluded) { ret.push("any"); } if (modifier === defaultModifier && !hasSelected && !hasExcluded) { ret.push("none"); } if (!singleValue && modifier === defaultModifier && hasSelected) { ret.push("any_of"); } if ( hierarchical && modifier === defaultModifier && (hasSelected || hasExcluded) ) { ret.push("include_subs"); } if ( !singleValue && modifier === defaultModifier && hasSelected && !hasExcluded ) { ret.push("only"); } return ret; } export function modifierValueToModifier(key: ModifierValue): CriterionModifier { switch (key) { case "any": return CriterionModifier.NotNull; case "none": return CriterionModifier.IsNull; case "any_of": return CriterionModifier.Includes; case "only": return CriterionModifier.Equals; } throw new Error("Invalid modifier value"); } function getDefaultModifier(singleValue: boolean) { if (singleValue) { return CriterionModifier.Includes; } return CriterionModifier.IncludesAll; } export function useSelectionState(props: { criterion: ModifierCriterion; setCriterion: (c: ModifierCriterion) => void; singleValue?: boolean; hierarchical?: boolean; includeSubMessageID?: string; }) { const intl = useIntl(); const { criterion, setCriterion, singleValue = false, hierarchical = false, includeSubMessageID, } = props; const { modifier } = criterion; const defaultModifier = getDefaultModifier(singleValue); const selectedModifiers = useMemo(() => { return { any: modifier === CriterionModifier.NotNull, none: modifier === CriterionModifier.IsNull, any_of: !singleValue && modifier === CriterionModifier.Includes, only: !singleValue && modifier === CriterionModifier.Equals, include_subs: hierarchical && modifier === defaultModifier && (criterion.value as IHierarchicalLabelValue).depth === -1, }; }, [modifier, singleValue, criterion.value, defaultModifier, hierarchical]); const selected = useMemo(() => { const modifierValues: Option[] = Object.entries(selectedModifiers) .filter((v) => v[1]) .map((v) => { const messageID = v[0] === "include_subs" ? includeSubMessageID : `criterion_modifier_values.${v[0]}`; return { id: v[0], label: `(${intl.formatMessage({ id: messageID, })})`, className: "modifier-object", }; }); return modifierValues.concat( criterion.value.items.map((s) => ({ id: s.id, label: s.label, })) ); }, [intl, selectedModifiers, criterion.value.items, includeSubMessageID]); const excluded = useMemo(() => { return criterion.value.excluded.map((s) => ({ id: s.id, label: s.label, })); }, [criterion.value.excluded]); const includingOnly = modifier == CriterionModifier.Equals; const excludingOnly = modifier == CriterionModifier.Excludes || modifier == CriterionModifier.NotEquals; const onSelect = useCallback( (v: Option, exclude: boolean) => { const newCriterion: ModifierCriterion = criterion.clone(); if (v.className === "modifier-object") { if (v.id === "include_subs") { (newCriterion.value as IHierarchicalLabelValue).depth = -1; setCriterion(newCriterion); return; } newCriterion.modifier = modifierValueToModifier(v.id as ModifierValue); setCriterion(newCriterion); return; } // if only exclude is allowed, then add to excluded if (excludingOnly) { exclude = true; } const items = !exclude ? criterion.value.items : criterion.value.excluded; const newItems = [...items, v]; if (!exclude) { newCriterion.value.items = newItems; } else { newCriterion.value.excluded = newItems; } setCriterion(newCriterion); }, [excludingOnly, criterion, setCriterion] ); const onUnselect = useCallback( (v: Option, exclude: boolean) => { const newCriterion = criterion.clone(); if (v.className === "modifier-object") { if (v.id === "include_subs") { newCriterion.value.depth = 0; setCriterion(newCriterion); return; } newCriterion.modifier = defaultModifier; setCriterion(newCriterion); return; } const items = !exclude ? criterion.value.items : criterion.value.excluded; const newItems = items.filter((i) => i.id !== v.id); if (!exclude) { newCriterion.value.items = newItems; } else { newCriterion.value.excluded = newItems; } setCriterion(newCriterion); }, [criterion, setCriterion, defaultModifier] ); return { selected, excluded, onSelect, onUnselect, includingOnly }; } export function useCriterion( option: CriterionOption, filter: ListFilterModel, setFilter: (f: ListFilterModel) => void ) { const criterion = useMemo(() => { const ret = filter.criteria.find( (c) => c.criterionOption.type === option.type ); if (ret) return ret as ModifierCriterion; const newCriterion = filter.makeCriterion( option.type ) as ModifierCriterion; return newCriterion; }, [filter, option]); const setCriterion = useCallback( (c: ModifierCriterion) => { const newCriteria = filter.criteria.filter( (cc) => cc.criterionOption.type !== option.type ); if (c.isValid()) newCriteria.push(c); setFilter(filter.setCriteria(newCriteria)); }, [option.type, setFilter, filter] ); return { criterion, setCriterion }; } export interface IUseQueryHookProps { q: string; filter?: ListFilterModel; filterHook?: (filter: ListFilterModel) => ListFilterModel; skip: boolean; } export function useQueryState( useQuery: (props: IUseQueryHookProps) => ILoadResults, filter: ListFilterModel, skip: boolean, options?: { filterHook?: (filter: ListFilterModel) => ListFilterModel; } ) { const [query, setQuery] = useState(""); const { results: queryResults } = useCacheResults( useQuery({ q: query, filter, filterHook: options?.filterHook, skip }) ); return { query, setQuery, queryResults }; } export function useCandidates(props: { criterion: ModifierCriterion; queryResults: ILabeledId[] | undefined; selected: Option[]; excluded: Option[]; hierarchical?: boolean; singleValue?: boolean; includeSubMessageID?: string; }) { const intl = useIntl(); const { criterion, queryResults, selected, excluded, hierarchical = false, singleValue = false, includeSubMessageID, } = props; const { modifier } = criterion; const results = useMemo(() => { if ( !queryResults || modifier === CriterionModifier.IsNull || modifier === CriterionModifier.NotNull ) { return []; } return queryResults.filter( (p) => selected.find((s) => s.id === p.id) === undefined && excluded.find((s) => s.id === p.id) === undefined ); }, [queryResults, modifier, selected, excluded]); const defaultModifier = getDefaultModifier(singleValue); const candidates = useMemo(() => { return (results ?? []).map((r) => ({ id: r.id, label: r.label, })); }, [results]); const modifierCandidates = useMemo(() => { const hierarchicalCandidate = hierarchical && (criterion.value as IHierarchicalLabelValue).depth !== -1; return getModifierCandidates({ modifier, defaultModifier, hasSelected: selected.length > 0, hasExcluded: excluded.length > 0, singleValue, hierarchical: hierarchicalCandidate, }).map((v) => { const messageID = v === "include_subs" ? includeSubMessageID : `criterion_modifier_values.${v}`; return { id: v, label: `(${intl.formatMessage({ id: messageID, })})`, className: "modifier-object", canExclude: false, }; }); }, [ defaultModifier, intl, modifier, singleValue, selected, excluded, criterion.value, hierarchical, includeSubMessageID, ]); return { candidates, modifierCandidates }; } export function useLabeledIdFilterState(props: { option: CriterionOption; filter: ListFilterModel; setFilter: (f: ListFilterModel) => void; filterHook?: (filter: ListFilterModel) => ListFilterModel; useQuery: (props: IUseQueryHookProps) => ILoadResults; singleValue?: boolean; hierarchical?: boolean; includeSubMessageID?: string; }) { const { option, filter, setFilter, filterHook, useQuery, singleValue = false, hierarchical = false, includeSubMessageID, } = props; // defer querying until the user opens the filter const [skip, setSkip] = useState(true); const { query, setQuery, queryResults } = useQueryState( useQuery, filter, skip, { filterHook } ); const { criterion, setCriterion } = useCriterion(option, filter, setFilter); const { selected, excluded, onSelect, onUnselect, includingOnly } = useSelectionState({ criterion, setCriterion, singleValue, hierarchical, includeSubMessageID, }); const { candidates, modifierCandidates } = useCandidates({ criterion, queryResults, selected, excluded, hierarchical, singleValue, includeSubMessageID, }); const onOpen = useCallback(() => { setSkip(false); }, []); return { candidates, modifierCandidates, onSelect, onUnselect, selected, excluded, canExclude: !includingOnly, query, setQuery, onOpen, }; } export function makeQueryVariables(query: string, extraProps: {}) { return { filter: { q: query, per_page: 200, }, ...extraProps, }; } interface IFilterType { scenes_filter?: InputMaybe; scene_count?: InputMaybe; performers_filter?: InputMaybe; performer_count?: InputMaybe; galleries_filter?: InputMaybe; gallery_count?: InputMaybe; images_filter?: InputMaybe; image_count?: InputMaybe; groups_filter?: InputMaybe; group_count?: InputMaybe; studios_filter?: InputMaybe; studio_count?: InputMaybe; marker_count?: InputMaybe; markers_filter?: InputMaybe; } export function setObjectFilter( out: IFilterType, mode: FilterMode, relatedFilterOutput: | SceneFilterType | PerformerFilterType | GalleryFilterType | GroupFilterType | StudioFilterType ) { const empty = Object.keys(relatedFilterOutput).length === 0; switch (mode) { case FilterMode.Scenes: // if empty, only get objects with scenes if (empty) { out.scene_count = { modifier: CriterionModifier.GreaterThan, value: 0, }; break; } out.scenes_filter = relatedFilterOutput as SceneFilterType; break; case FilterMode.Performers: // if empty, only get objects with performers if (empty) { out.performer_count = { modifier: CriterionModifier.GreaterThan, value: 0, }; break; } out.performers_filter = relatedFilterOutput as PerformerFilterType; break; case FilterMode.Galleries: // if empty, only get objects with galleries if (empty) { out.gallery_count = { modifier: CriterionModifier.GreaterThan, value: 0, }; break; } out.galleries_filter = relatedFilterOutput as GalleryFilterType; break; case FilterMode.Images: // if empty, only get objects with galleries if (empty) { out.image_count = { modifier: CriterionModifier.GreaterThan, value: 0, }; break; } out.images_filter = relatedFilterOutput as ImageFilterType; break; case FilterMode.Groups: // if empty, only get objects with groups if (empty) { out.group_count = { modifier: CriterionModifier.GreaterThan, value: 0, }; break; } out.groups_filter = relatedFilterOutput as GroupFilterType; break; case FilterMode.Studios: // if empty, only get objects with studios if (empty) { out.studio_count = { modifier: CriterionModifier.GreaterThan, value: 0, }; break; } out.studios_filter = relatedFilterOutput as StudioFilterType; break; case FilterMode.SceneMarkers: // if empty, only get objects with scene markers if (empty) { out.marker_count = { modifier: CriterionModifier.GreaterThan, value: 0, }; break; } out.markers_filter = relatedFilterOutput as SceneMarkerFilterType; break; default: throw new Error("Invalid filter mode"); } } ================================================ FILE: ui/v2.5/src/components/List/Filters/NumberFilter.tsx ================================================ import React from "react"; import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import { CriterionModifier } from "../../../core/generated-graphql"; import { INumberValue } from "../../../models/list-filter/types"; import { NumberCriterion } from "../../../models/list-filter/criteria/criterion"; import { NumberField } from "src/utils/form"; interface IDurationFilterProps { criterion: NumberCriterion; onValueChanged: (value: INumberValue) => void; } export const NumberFilter: React.FC = ({ criterion, onValueChanged, }) => { const intl = useIntl(); const { value } = criterion; function onChanged( event: React.ChangeEvent, property: "value" | "value2" ) { const numericValue = parseInt(event.target.value, 10); const valueCopy = { ...value }; valueCopy[property] = !Number.isNaN(numericValue) ? numericValue : 0; onValueChanged(valueCopy); } let equalsControl: JSX.Element | null = null; if ( criterion.modifier === CriterionModifier.Equals || criterion.modifier === CriterionModifier.NotEquals ) { equalsControl = ( ) => onChanged(e, "value") } value={value?.value ?? ""} placeholder={intl.formatMessage({ id: "criterion.value" })} /> ); } let lowerControl: JSX.Element | null = null; if ( criterion.modifier === CriterionModifier.GreaterThan || criterion.modifier === CriterionModifier.Between || criterion.modifier === CriterionModifier.NotBetween ) { lowerControl = ( ) => onChanged(e, "value") } value={value?.value ?? ""} placeholder={intl.formatMessage({ id: "criterion.greater_than" })} /> ); } let upperControl: JSX.Element | null = null; if ( criterion.modifier === CriterionModifier.LessThan || criterion.modifier === CriterionModifier.Between || criterion.modifier === CriterionModifier.NotBetween ) { upperControl = ( ) => onChanged( e, criterion.modifier === CriterionModifier.LessThan ? "value" : "value2" ) } value={ (criterion.modifier === CriterionModifier.LessThan ? value?.value : value?.value2) ?? "" } placeholder={intl.formatMessage({ id: "criterion.less_than" })} /> ); } return ( <> {equalsControl} {lowerControl} {upperControl} ); }; ================================================ FILE: ui/v2.5/src/components/List/Filters/OptionFilter.tsx ================================================ import cloneDeep from "lodash-es/cloneDeep"; import React, { useMemo } from "react"; import { Form } from "react-bootstrap"; import { CriterionValue, ModifierCriterion, ModifierCriterionOption, } from "src/models/list-filter/criteria/criterion"; import { ListFilterModel } from "src/models/list-filter/filter"; import { Option, SidebarListFilter } from "./SidebarListFilter"; import { CriterionModifier } from "src/core/generated-graphql"; import { getModifierCandidates, ModifierValue, modifierValueToModifier, } from "./LabeledIdFilter"; import { useIntl } from "react-intl"; interface IOptionsFilter { criterion: ModifierCriterion; setCriterion: (c: ModifierCriterion) => void; } export const OptionFilter: React.FC = ({ criterion, setCriterion, }) => { function onSelect(v: string) { const c = cloneDeep(criterion); if (c.value === v) { c.value = ""; } else { c.value = v; } setCriterion(c); } const { options } = criterion.modifierCriterionOption(); return (
    {options?.map((o) => ( onSelect(o.toString())} checked={criterion.value === o.toString()} type="radio" label={o.toString()} /> ))}
    ); }; interface IOptionsListFilter { criterion: ModifierCriterion; setCriterion: (c: ModifierCriterion) => void; } export const OptionListFilter: React.FC = ({ criterion, setCriterion, }) => { function onSelect(v: string) { const c = cloneDeep(criterion); const cv = c.value as string[]; if (cv.includes(v)) { c.value = cv.filter((x) => x !== v); } else { c.value = [...cv, v]; } setCriterion(c); } const { options } = criterion.modifierCriterionOption(); const value = criterion.value as string[]; return (
    {options?.map((o) => ( onSelect(o.toString())} checked={value.includes(o.toString())} type="checkbox" label={o.toString()} /> ))}
    ); }; interface ISidebarFilter { title?: React.ReactNode; option: ModifierCriterionOption; filter: ListFilterModel; setFilter: (f: ListFilterModel) => void; sectionID?: string; } export const SidebarOptionFilter: React.FC = ({ title, option, filter, setFilter, sectionID, }) => { const intl = useIntl(); const criteria = filter.criteriaFor( option.type ) as ModifierCriterion[]; const criterion = criteria.length > 0 ? criteria[0] : null; const { options: criterionOptions = [] } = option; const currentValues = criteria.flatMap((c) => c.value as string[]); const hasNullModifiers = option.modifierOptions.includes(CriterionModifier.IsNull) && option.modifierOptions.includes(CriterionModifier.NotNull); const selected: Option[] = useMemo(() => { if (!criterion) return []; if (criterion.modifier === CriterionModifier.IsNull) { return [ { id: "none", label: intl.formatMessage({ id: "criterion_modifier_values.none" }), }, ]; } else if (criterion.modifier === CriterionModifier.NotNull) { return [ { id: "any", label: intl.formatMessage({ id: "criterion_modifier_values.any" }), }, ]; } return criterionOptions .filter((o) => currentValues.includes(o.toString())) .map((o) => ({ id: o.toString(), label: o.toLocaleString(), })); }, [criterion, currentValues, criterionOptions, intl]); const modifierCandidates: Option[] = useMemo(() => { if (!hasNullModifiers) return []; const c = getModifierCandidates({ modifier: criterion?.modifier ?? option.defaultModifier, defaultModifier: option.defaultModifier, hasExcluded: false, hasSelected: selected.length > 0, singleValue: true, // so that it doesn't include any_of }); return c.map((v) => { const messageID = `criterion_modifier_values.${v}`; return { id: v, label: `(${intl.formatMessage({ id: messageID, })})`, className: "modifier-object", canExclude: false, }; }); }, [criterion, option, selected, hasNullModifiers, intl]); const options = useMemo(() => { const o = criterionOptions .filter((oo) => !currentValues.includes(oo.toString())) .map((oo) => ({ id: oo.toString(), label: oo.toString(), })); return [...modifierCandidates, ...o]; }, [criterionOptions, currentValues, modifierCandidates]); function onSelect(item: Option) { const newCriterion = criterion ? criterion.clone() : option.makeCriterion(); if (item.className === "modifier-object") { newCriterion.modifier = modifierValueToModifier(item.id as ModifierValue); newCriterion.value = []; setFilter(filter.replaceCriteria(option.type, [newCriterion])); return; } const cv = newCriterion.value as string[]; if (cv.includes(item.id)) { return; } else { newCriterion.value = [...cv, item.id]; } setFilter(filter.replaceCriteria(option.type, [newCriterion])); } function onUnselect(item: Option) { if (item.className === "modifier-object") { const newCriterion = criterion ? criterion.clone() : option.makeCriterion(); newCriterion.modifier = option.defaultModifier; setFilter(filter.replaceCriteria(option.type, [newCriterion])); return; } setFilter(filter.removeCriterion(option.type)); } return ( <> ); }; ================================================ FILE: ui/v2.5/src/components/List/Filters/PathFilter.tsx ================================================ import React from "react"; import { Form } from "react-bootstrap"; import { FolderSelect } from "src/components/Shared/FolderSelect/FolderSelect"; import { CriterionModifier } from "src/core/generated-graphql"; import { useConfigurationContext } from "src/hooks/Config"; import { ModifierCriterion, CriterionValue, } from "../../../models/list-filter/criteria/criterion"; interface IInputFilterProps { criterion: ModifierCriterion; onValueChanged: (value: string) => void; } export const PathFilter: React.FC = ({ criterion, onValueChanged, }) => { const { configuration } = useConfigurationContext(); const libraryPaths = configuration?.general.stashes.map((s) => s.path); // don't show folder select for regex const regex = criterion.modifier === CriterionModifier.MatchesRegex || criterion.modifier === CriterionModifier.NotMatchesRegex; return ( {regex ? ( onValueChanged(v.target.value)} value={criterion.value ? criterion.value.toString() : ""} /> ) : ( )} ); }; ================================================ FILE: ui/v2.5/src/components/List/Filters/PerformersFilter.tsx ================================================ import React, { ReactNode, useMemo } from "react"; import { PerformersCriterion, PerformersCriterionOption, } from "src/models/list-filter/criteria/performers"; import { CriterionModifier, FindPerformersForSelectQueryVariables, PerformerDataFragment, PerformerFilterType, useFindPerformersForSelectQuery, } from "src/core/generated-graphql"; import { ObjectsFilter } from "./SelectableFilter"; import { sortByRelevance } from "src/utils/query"; import { ListFilterModel } from "src/models/list-filter/filter"; import { CriterionOption } from "src/models/list-filter/criteria/criterion"; import { IUseQueryHookProps, makeQueryVariables, setObjectFilter, useLabeledIdFilterState, } from "./LabeledIdFilter"; import { SidebarListFilter } from "./SidebarListFilter"; import { FormattedMessage } from "react-intl"; interface IPerformersFilter { criterion: PerformersCriterion; setCriterion: (c: PerformersCriterion) => void; } interface IHasModifier { modifier: CriterionModifier; } function queryVariables( query: string, f?: ListFilterModel ): FindPerformersForSelectQueryVariables { const performerFilter: PerformerFilterType = {}; if (f) { const filterOutput = f.makeFilter(); // if performer modifier is includes, take it out of the filter if ( (filterOutput.performers as IHasModifier)?.modifier === CriterionModifier.Includes ) { delete filterOutput.performers; // TODO - look for same in AND? } setObjectFilter(performerFilter, f.mode, filterOutput); } return makeQueryVariables(query, { performer_filter: performerFilter }); } function sortResults( query: string, performers?: Pick[] ) { return sortByRelevance( query, performers ?? [], (p) => p.name, (p) => p.alias_list ).map((p) => { return { id: p.id, label: p.name, }; }); } function usePerformerQueryFilter(props: IUseQueryHookProps) { const { q: query, filter: f, skip, filterHook } = props; const appliedFilter = filterHook && f ? filterHook(f.clone()) : f; const { data, loading } = useFindPerformersForSelectQuery({ variables: queryVariables(query, appliedFilter), skip, }); const results = useMemo( () => sortResults(query, data?.findPerformers.performers), [data, query] ); return { results, loading }; } function usePerformerQuery(query: string, skip?: boolean) { return usePerformerQueryFilter({ q: query, skip: !!skip }); } const PerformersFilter: React.FC = ({ criterion, setCriterion, }) => { return ( ); }; export const SidebarPerformersFilter: React.FC<{ title?: ReactNode; option?: CriterionOption; filter: ListFilterModel; setFilter: (f: ListFilterModel) => void; filterHook?: (f: ListFilterModel) => ListFilterModel; sectionID?: string; }> = ({ title = , option = PerformersCriterionOption, filter, setFilter, filterHook, sectionID = "performers", }) => { const state = useLabeledIdFilterState({ filter, setFilter, filterHook, option, useQuery: usePerformerQueryFilter, }); return ( ); }; export default PerformersFilter; ================================================ FILE: ui/v2.5/src/components/List/Filters/PhashFilter.tsx ================================================ import React from "react"; import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import { IPhashDistanceValue } from "../../../models/list-filter/types"; import { ModifierCriterion } from "../../../models/list-filter/criteria/criterion"; import { CriterionModifier } from "src/core/generated-graphql"; import { NumberField } from "src/utils/form"; interface IPhashFilterProps { criterion: ModifierCriterion; onValueChanged: (value: IPhashDistanceValue) => void; } export const PhashFilter: React.FC = ({ criterion, onValueChanged, }) => { const intl = useIntl(); const { value } = criterion; function valueChanged(event: React.ChangeEvent) { onValueChanged({ value: event.target.value, distance: criterion.value.distance, }); } function distanceChanged(event: React.ChangeEvent) { let distance = parseInt(event.target.value); if (distance < 0 || isNaN(distance)) { distance = 0; } onValueChanged({ distance, value: criterion.value.value, }); } return (
    {criterion.modifier !== CriterionModifier.IsNull && criterion.modifier !== CriterionModifier.NotNull && ( )}
    ); }; ================================================ FILE: ui/v2.5/src/components/List/Filters/RatingFilter.tsx ================================================ import React, { useMemo } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { CriterionModifier } from "../../../core/generated-graphql"; import { INumberValue } from "../../../models/list-filter/types"; import { CriterionOption, ModifierCriterion, } from "../../../models/list-filter/criteria/criterion"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { RatingStars } from "src/components/Shared/Rating/RatingStars"; import { defaultRatingStarPrecision, defaultRatingSystemOptions, } from "src/utils/rating"; import { useConfigurationContext } from "src/hooks/Config"; import { RatingCriterion, RatingCriterionOption, } from "src/models/list-filter/criteria/rating"; import { ListFilterModel } from "src/models/list-filter/filter"; import { Option, SidebarListFilter } from "./SidebarListFilter"; interface IRatingFilterProps { criterion: ModifierCriterion; onValueChanged: (value: INumberValue) => void; } export const RatingFilter: React.FC = ({ criterion, onValueChanged, }) => { function getRatingSystem(field: "value" | "value2") { const defaultValue = field === "value" ? 0 : undefined; return (
    { onValueChanged({ ...criterion.value, [field]: value ?? defaultValue, }); }} valueRequired />
    ); } if ( criterion.modifier === CriterionModifier.Equals || criterion.modifier === CriterionModifier.NotEquals || criterion.modifier === CriterionModifier.GreaterThan || criterion.modifier === CriterionModifier.LessThan ) { return getRatingSystem("value"); } if ( criterion.modifier === CriterionModifier.Between || criterion.modifier === CriterionModifier.NotBetween ) { return (
    {getRatingSystem("value")} {getRatingSystem("value2")}
    ); } return <>; }; interface ISidebarFilter { title?: React.ReactNode; option?: CriterionOption; filter: ListFilterModel; setFilter: (f: ListFilterModel) => void; sectionID?: string; } const any = "any"; const none = "none"; export const SidebarRatingFilter: React.FC = ({ title = , option = RatingCriterionOption, filter, setFilter, sectionID = "rating", }) => { const intl = useIntl(); const anyLabel = `(${intl.formatMessage({ id: "criterion_modifier_values.any", })})`; const noneLabel = `(${intl.formatMessage({ id: "criterion_modifier_values.none", })})`; const anyOption = useMemo( () => ({ id: "any", label: anyLabel, className: "modifier-object", }), [anyLabel] ); const noneOption = useMemo( () => ({ id: "none", label: noneLabel, className: "modifier-object", }), [noneLabel] ); const { configuration: config } = useConfigurationContext(); const ratingSystemOptions = config?.ui.ratingSystemOptions ?? defaultRatingSystemOptions; const options: Option[] = useMemo(() => { return [anyOption, noneOption]; }, [anyOption, noneOption]); const criteria = filter.criteriaFor(option.type) as RatingCriterion[]; const criterion = criteria.length > 0 ? criteria[0] : null; const selected: Option[] = useMemo(() => { if (!criterion) return []; if (criterion.modifier === CriterionModifier.NotNull) { return [anyOption]; } else if (criterion.modifier === CriterionModifier.IsNull) { return [noneOption]; } return []; }, [anyOption, noneOption, criterion]); const ratingValue = useMemo(() => { if (!criterion || criterion.modifier !== CriterionModifier.GreaterThan) { return null; } return criterion.value.value ?? null; }, [criterion]); function onSelect(item: Option) { const newCriterion = criterion ? criterion.clone() : option.makeCriterion(); if (item.id === any) { newCriterion.modifier = CriterionModifier.NotNull; // newCriterion.value } else if (item.id === none) { newCriterion.modifier = CriterionModifier.IsNull; } setFilter(filter.replaceCriteria(option.type, [newCriterion])); } function onUnselect() { setFilter(filter.removeCriterion(option.type)); } function onRatingValueChange(value: number | null) { const newCriterion = criterion ? criterion.clone() : option.makeCriterion(); if (value === null) { setFilter(filter.removeCriterion(option.type)); return; } newCriterion.modifier = CriterionModifier.GreaterThan; newCriterion.value.value = value - 1; setFilter(filter.replaceCriteria(option.type, [newCriterion])); } const ratingStars = (
    ); return ( <>
    ); }; ================================================ FILE: ui/v2.5/src/components/List/Filters/SelectableFilter.tsx ================================================ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Button, Form } from "react-bootstrap"; import { Icon } from "src/components/Shared/Icon"; import { faCheckCircle, faMinus, faPlus, faTimesCircle, } from "@fortawesome/free-solid-svg-icons"; import { faTimesCircle as faTimesCircleRegular } from "@fortawesome/free-regular-svg-icons"; import { ClearableInput } from "src/components/Shared/ClearableInput"; import { IHierarchicalLabelValue, ILabeledId, ILabeledValueListValue, } from "src/models/list-filter/types"; import { cloneDeep } from "lodash-es"; import { ModifierCriterion, IHierarchicalLabeledIdCriterion, } from "src/models/list-filter/criteria/criterion"; import { defineMessages, FormattedMessage, MessageDescriptor, useIntl, } from "react-intl"; import { CriterionModifier } from "src/core/generated-graphql"; import { keyboardClickHandler } from "src/utils/keyboard"; import { useDebounce } from "src/hooks/debounce"; import useFocus from "src/utils/focus"; import cx from "classnames"; import ScreenUtils from "src/utils/screen"; import { NumberField } from "src/utils/form"; interface ISelectedItem { label: string; excluded?: boolean; onClick: () => void; // true if the object is a special modifier value modifier?: boolean; } const SelectedItem: React.FC = ({ label, excluded = false, onClick, modifier = false, }) => { const iconClassName = excluded ? "exclude-icon" : "include-button"; const spanClassName = excluded ? "excluded-object-label" : "selected-object-label"; const [hovered, setHovered] = useState(false); const icon = useMemo(() => { if (!hovered) { return excluded ? faTimesCircle : faCheckCircle; } return faTimesCircleRegular; }, [hovered, excluded]); function onMouseOver() { setHovered(true); } function onMouseOut() { setHovered(false); } return (
  • onClick()} onKeyDown={keyboardClickHandler(onClick)} onMouseEnter={() => onMouseOver()} onMouseLeave={() => onMouseOut()} onFocus={() => onMouseOver()} onBlur={() => onMouseOut()} tabIndex={0} >
    {label}
  • ); }; const UnselectedItem: React.FC<{ onSelect: (exclude: boolean) => void; label: string; canExclude: boolean; // true if the object is a special modifier value modifier?: boolean; }> = ({ onSelect, label, canExclude, modifier = false }) => { const includeIcon = ; const excludeIcon = ; return (
  • onSelect(false)} onKeyDown={keyboardClickHandler(() => onSelect(false))} tabIndex={0} >
    {includeIcon} {label}
    {/* TODO item count */} {/* {p.id} */} {canExclude && ( )}
  • ); }; interface ISelectableFilter { query: string; onQueryChange: (query: string) => void; modifier: CriterionModifier; showModifierValues: boolean; inputFocus: ReturnType; canExclude: boolean; queryResults: ILabeledId[]; selected: ILabeledId[]; excluded: ILabeledId[]; onSelect: (value: ILabeledId, exclude: boolean) => void; onUnselect: (value: ILabeledId) => void; onSetModifier: (modifier: CriterionModifier) => void; // true if the filter is for a single value singleValue?: boolean; } type SpecialValue = "any" | "none" | "any_of" | "only"; function modifierValueToModifier(key: SpecialValue): CriterionModifier { switch (key) { case "any": return CriterionModifier.NotNull; case "none": return CriterionModifier.IsNull; case "any_of": return CriterionModifier.Includes; case "only": return CriterionModifier.Equals; } } const SelectableFilter: React.FC = ({ query, onQueryChange, modifier, showModifierValues, inputFocus, canExclude, queryResults, selected, excluded, onSelect, onUnselect, onSetModifier, singleValue, }) => { const intl = useIntl(); const objects = useMemo(() => { if ( modifier === CriterionModifier.IsNull || modifier === CriterionModifier.NotNull ) { return []; } return queryResults.filter( (p) => selected.find((s) => s.id === p.id) === undefined && excluded.find((s) => s.id === p.id) === undefined ); }, [modifier, queryResults, selected, excluded]); const includingOnly = modifier == CriterionModifier.Equals; const excludingOnly = modifier == CriterionModifier.Excludes || modifier == CriterionModifier.NotEquals; const modifierValues = useMemo(() => { return { any: modifier === CriterionModifier.NotNull, none: modifier === CriterionModifier.IsNull, any_of: !singleValue && modifier === CriterionModifier.Includes, only: !singleValue && modifier === CriterionModifier.Equals, }; }, [modifier, singleValue]); const defaultModifier = useMemo(() => { if (singleValue) { return CriterionModifier.Includes; } return CriterionModifier.IncludesAll; }, [singleValue]); const availableModifierValues: Record = useMemo(() => { return { any: modifier === defaultModifier && selected.length === 0 && excluded.length === 0, none: modifier === defaultModifier && selected.length === 0 && excluded.length === 0, any_of: !singleValue && modifier === defaultModifier && selected.length > 1, only: !singleValue && modifier === defaultModifier && selected.length > 0 && excluded.length === 0, }; }, [singleValue, defaultModifier, modifier, selected, excluded]); function onModifierValueSelect(key: SpecialValue) { const m = modifierValueToModifier(key); onSetModifier(m); } function onModifierValueUnselect() { onSetModifier(defaultModifier); } function onEnter() { if (objects.length === 1) { onSelect(objects[0], false); } } return (
    onQueryChange(v)} onEnter={onEnter} placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} />
      {Object.entries(modifierValues).map(([key, value]) => { if (!value) { return null; } return ( onModifierValueUnselect()} label={`(${intl.formatMessage({ id: `criterion_modifier_values.${key}`, })})`} modifier /> ); })} {selected.map((p) => ( onUnselect(p)} /> ))} {excluded.map((p) => (
    • onUnselect(p)} />
    • ))} {showModifierValues && ( <> {Object.entries(availableModifierValues).map(([key, value]) => { if (!value) { return null; } return ( onModifierValueSelect(key as SpecialValue)} label={`(${intl.formatMessage({ id: `criterion_modifier_values.${key}`, })})`} canExclude={false} modifier /> ); })} )} {objects.map((p) => ( onSelect(p, exclude)} label={p.label} canExclude={canExclude && !includingOnly && !excludingOnly} /> ))}
    ); }; interface IObjectsFilter> { criterion: T; setCriterion: (criterion: T) => void; useResults: (query: string) => { results: ILabeledId[]; loading: boolean }; singleValue?: boolean; } export const ObjectsFilter = < T extends ModifierCriterion >({ criterion, setCriterion, useResults, singleValue, }: IObjectsFilter) => { const [query, setQuery] = useState(""); const [displayQuery, setDisplayQuery] = useState(query); const debouncedSetQuery = useDebounce(setQuery, 250); const onQueryChange = useCallback( (input: string) => { setDisplayQuery(input); debouncedSetQuery(input); }, [debouncedSetQuery, setDisplayQuery] ); const [queryResults, setQueryResults] = useState([]); const { results, loading: resultsLoading } = useResults(query); useEffect(() => { if (!resultsLoading) { setQueryResults(results); } }, [results, resultsLoading]); const inputFocus = useFocus(); const [, setInputFocus] = inputFocus; function onSelect(value: ILabeledId, newExclude: boolean) { let newCriterion: T = cloneDeep(criterion); if (newExclude) { if (newCriterion.value.excluded) { newCriterion.value.excluded.push(value); } else { newCriterion.value.excluded = [value]; } } else { newCriterion.value.items.push(value); } setCriterion(newCriterion); // reset filter query after selecting debouncedSetQuery.cancel(); setQuery(""); setDisplayQuery(""); // focus the input box // don't do this on touch devices, as it's annoying if (!ScreenUtils.isTouch()) { setInputFocus(); } } const onUnselect = useCallback( (value: ILabeledId) => { if (!criterion) return; let newCriterion: T = cloneDeep(criterion); newCriterion.value.items = criterion.value.items.filter( (v) => v.id !== value.id ); newCriterion.value.excluded = criterion.value.excluded.filter( (v) => v.id !== value.id ); setCriterion(newCriterion); // focus the input box setInputFocus(); }, [criterion, setCriterion, setInputFocus] ); const onSetModifier = useCallback( (modifier: CriterionModifier) => { let newCriterion: T = criterion.clone(); newCriterion.modifier = modifier; setCriterion(newCriterion); }, [criterion, setCriterion] ); const sortedSelected = useMemo(() => { const ret = criterion.value.items.slice(); ret.sort((a, b) => a.label.localeCompare(b.label)); return ret; }, [criterion]); const sortedExcluded = useMemo(() => { if (!criterion.value.excluded) return []; const ret = criterion.value.excluded.slice(); ret.sort((a, b) => a.label.localeCompare(b.label)); return ret; }, [criterion]); // if excludes is not a valid modifierOption then we can use `value.excluded` const canExclude = criterion .modifierCriterionOption() .modifierOptions.find((m) => m === CriterionModifier.Excludes) === undefined; return ( ); }; export const DepthSelector: React.FC<{ depth: number | undefined; onDepthChanged: (depth: number) => void; id: string; label?: React.ReactNode; placeholder?: string; disabled?: boolean; }> = ({ depth, onDepthChanged, id, label, disabled, placeholder }) => { return ( onDepthChanged(depth !== 0 ? 0 : -1)} disabled={disabled} /> {depth !== 0 && ( onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1) } defaultValue={depth !== -1 ? depth : ""} min="1" /> )} ); }; interface IHierarchicalObjectsFilter extends IObjectsFilter {} export const HierarchicalObjectsFilter = < T extends IHierarchicalLabeledIdCriterion >( props: IHierarchicalObjectsFilter ) => { const intl = useIntl(); const { criterion, setCriterion } = props; const messages = defineMessages({ studio_depth: { id: "studio_depth", defaultMessage: "Levels (empty for all)", }, }); function onDepthChanged(depth: number) { let newCriterion: T = cloneDeep(criterion); newCriterion.value.depth = depth; setCriterion(newCriterion); } function criterionOptionTypeToIncludeID(): string { if (criterion.criterionOption.type === "studios") { return "include-sub-studios"; } if (criterion.criterionOption.type === "children") { return "include-parent-tags"; } return "include-sub-tags"; } function criterionOptionTypeToIncludeUIString(): MessageDescriptor { const optionType = criterion.criterionOption.type === "studios" ? "include_sub_studios" : criterion.criterionOption.type === "children" ? "include_parent_tags" : "include_sub_tags"; return { id: optionType, }; } return (
    ); }; ================================================ FILE: ui/v2.5/src/components/List/Filters/SidebarAgeFilter.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { CriterionModifier } from "../../../core/generated-graphql"; import { CriterionOption } from "../../../models/list-filter/criteria/criterion"; import { NumberCriterion } from "src/models/list-filter/criteria/criterion"; import { ListFilterModel } from "src/models/list-filter/filter"; import { Option, SidebarListFilter } from "./SidebarListFilter"; import { DoubleRangeInput } from "src/components/Shared/DoubleRangeInput"; import { useDebounce } from "src/hooks/debounce"; interface ISidebarFilter { title?: React.ReactNode; option: CriterionOption; filter: ListFilterModel; setFilter: (f: ListFilterModel) => void; sectionID?: string; } // Age presets const AGE_PRESETS = [ { id: "18-25", label: "18-25", min: 18, max: 25 }, { id: "25-35", label: "25-35", min: 25, max: 35 }, { id: "35-45", label: "35-45", min: 35, max: 45 }, { id: "45-60", label: "45-60", min: 45, max: 60 }, { id: "60+", label: "60+", min: 60, max: null }, ]; const MAX_AGE = 60; // Maximum age for the slider const MAX_LABEL = "60+"; // Display label for maximum age export const SidebarAgeFilter: React.FC = ({ title, option, filter, setFilter, sectionID, }) => { const criteria = filter.criteriaFor(option.type) as NumberCriterion[]; const criterion = criteria.length > 0 ? criteria[0] : null; // Get current values from criterion const currentMin = criterion?.value?.value ?? 18; const currentMax = criterion?.value?.value2 ?? MAX_AGE; const [sliderMin, setSliderMin] = useState(currentMin); const [sliderMax, setSliderMax] = useState(currentMax); const [minInput, setMinInput] = useState(currentMin.toString()); const [maxInput, setMaxInput] = useState( currentMax >= MAX_AGE ? MAX_LABEL : currentMax.toString() ); // Reset slider when criterion is removed externally (via filter tag X) useEffect(() => { if (!criterion) { setSliderMin(18); setSliderMax(MAX_AGE); setMinInput("18"); setMaxInput(MAX_LABEL); } }, [criterion]); // Determine which preset is selected const selectedPreset = useMemo(() => { if (!criterion) return null; // Check if current values match any preset for (const preset of AGE_PRESETS) { if (preset.max === null) { // For "60+" preset if ( criterion.modifier === CriterionModifier.GreaterThan && criterion.value.value === preset.min ) { return preset.id; } } else { // For range presets if ( criterion.modifier === CriterionModifier.Between && criterion.value.value === preset.min && criterion.value.value2 === preset.max ) { return preset.id; } } } // Check if it's a custom range or custom GreaterThan if ( criterion.modifier === CriterionModifier.Between || criterion.modifier === CriterionModifier.GreaterThan ) { return "custom"; } return null; }, [criterion]); const options: Option[] = useMemo(() => { return AGE_PRESETS.map((preset) => ({ id: preset.id, label: preset.label, className: "age-preset", })); }, []); const selected: Option[] = useMemo(() => { if (!selectedPreset) return []; if (selectedPreset === "custom") return []; const preset = AGE_PRESETS.find((p) => p.id === selectedPreset); if (preset) { return [ { id: preset.id, label: preset.label, className: "age-preset", }, ]; } return []; }, [selectedPreset]); function onSelectPreset(item: Option) { const preset = AGE_PRESETS.find((p) => p.id === item.id); if (!preset) return; setSliderMin(preset.min); setSliderMax(preset.max ?? MAX_AGE); setMinInput(preset.min.toString()); setMaxInput(preset.max === null ? MAX_LABEL : preset.max.toString()); const currentCriteria = filter.criteriaFor( option.type ) as NumberCriterion[]; const currentCriterion = currentCriteria.length > 0 ? currentCriteria[0] : null; const newCriterion = currentCriterion ? currentCriterion.clone() : option.makeCriterion(); if (preset.max === null) { // "60+" - use GreaterThan newCriterion.modifier = CriterionModifier.GreaterThan; newCriterion.value.value = preset.min; newCriterion.value.value2 = undefined; } else { // Range preset - use Between newCriterion.modifier = CriterionModifier.Between; newCriterion.value.value = preset.min; newCriterion.value.value2 = preset.max; } setFilter(filter.replaceCriteria(option.type, [newCriterion])); } function onUnselectPreset() { setSliderMin(18); setSliderMax(MAX_AGE); setMinInput("18"); setMaxInput(MAX_LABEL); setFilter(filter.removeCriterion(option.type)); } // Parse age input (supports formats like "25", "100+") function parseAgeInput(input: string): number | null { const trimmed = input.trim().toLowerCase(); if (trimmed === "max" || trimmed === MAX_LABEL.toLowerCase()) { return MAX_AGE; } const age = parseInt(trimmed); if (isNaN(age) || age < 18 || age > MAX_AGE) { return null; } return age; } // Filter update function updateFilter(min: number, max: number) { // If slider is at full range (18 to max), remove the filter entirely if (min === 18 && max >= MAX_AGE) { setFilter(filter.removeCriterion(option.type)); return; } const currentCriteria = filter.criteriaFor( option.type ) as NumberCriterion[]; const currentCriterion = currentCriteria.length > 0 ? currentCriteria[0] : null; const newCriterion = currentCriterion ? currentCriterion.clone() : option.makeCriterion(); // If max is at MAX_AGE (but min > 18), use GreaterThan if (max >= MAX_AGE) { newCriterion.modifier = CriterionModifier.GreaterThan; newCriterion.value.value = min; newCriterion.value.value2 = undefined; } else { newCriterion.modifier = CriterionModifier.Between; newCriterion.value.value = min; newCriterion.value.value2 = max; } setFilter(filter.replaceCriteria(option.type, [newCriterion])); } const updateFilterDebounceMS = 300; const debounceUpdateFilter = useDebounce( updateFilter, updateFilterDebounceMS ); function handleSliderChange(min: number, max: number) { setSliderMin(min); setSliderMax(max); setMinInput(min.toString()); setMaxInput(max >= MAX_AGE ? MAX_LABEL : max.toString()); debounceUpdateFilter(min, max); } function handleMinInputChange(value: string) { setMinInput(value); } function handleMaxInputChange(value: string) { setMaxInput(value); } function handleMinInputBlur() { const parsed = parseAgeInput(minInput); if (parsed !== null && parsed >= 18 && parsed < sliderMax) { handleSliderChange(parsed, sliderMax); } else { // Reset to current value if invalid setMinInput(sliderMin.toString()); } } function handleMaxInputBlur() { const parsed = parseAgeInput(maxInput); if (parsed !== null && parsed > sliderMin && parsed <= MAX_AGE) { handleSliderChange(sliderMin, parsed); } else { // Reset to current value if invalid setMaxInput(sliderMax >= MAX_AGE ? MAX_LABEL : sliderMax.toString()); } } const customSlider = (
    handleSliderChange(min, max)} minInput={ handleMinInputChange(e.target.value)} onBlur={handleMinInputBlur} onKeyDown={(e) => { if (e.key === "Enter") { e.currentTarget.blur(); } }} placeholder="18" /> } maxInput={ handleMaxInputChange(e.target.value)} onBlur={handleMaxInputBlur} onKeyDown={(e) => { if (e.key === "Enter") { e.currentTarget.blur(); } }} placeholder={MAX_LABEL} /> } />
    ); return ( ); }; ================================================ FILE: ui/v2.5/src/components/List/Filters/SidebarDurationFilter.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { CriterionModifier } from "../../../core/generated-graphql"; import { CriterionOption } from "../../../models/list-filter/criteria/criterion"; import { DurationCriterion } from "src/models/list-filter/criteria/criterion"; import { ListFilterModel } from "src/models/list-filter/filter"; import { Option, SidebarListFilter } from "./SidebarListFilter"; import TextUtils from "src/utils/text"; import { DoubleRangeInput } from "src/components/Shared/DoubleRangeInput"; import { useDebounce } from "src/hooks/debounce"; import { FormattedMessage } from "react-intl"; import { DurationCriterionOption } from "src/models/list-filter/scenes"; interface ISidebarFilter { title?: React.ReactNode; option?: CriterionOption; filter: ListFilterModel; setFilter: (f: ListFilterModel) => void; sectionID?: string; } // Duration presets in seconds const DURATION_PRESETS = [ { id: "0-5", label: "0-5 min", min: 0, max: 300 }, { id: "5-10", label: "5-10 min", min: 300, max: 600 }, { id: "10-20", label: "10-20 min", min: 600, max: 1200 }, { id: "20-40", label: "20-40 min", min: 1200, max: 2400 }, { id: "40+", label: "40+ min", min: 2400, max: null }, ]; const MAX_DURATION = 7200; // 2 hours in seconds for the slider const MAX_LABEL = "2+ hrs"; // Display label for maximum duration // Custom step values: 0, 2min (120s), 5min (300s), then 5 minute intervals const DURATION_STEPS = [ 0, 120, 300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000, 3300, 3600, 3900, 4200, 4500, 4800, 5100, 5400, 5700, 6000, 6300, 6600, 6900, 7200, ]; // Snap a value to the nearest valid step function snapToStep(value: number): number { if (value <= 0) return 0; if (value >= MAX_DURATION) return MAX_DURATION; // Find the closest step let closest = DURATION_STEPS[0]; let minDiff = Math.abs(value - closest); for (const step of DURATION_STEPS) { const diff = Math.abs(value - step); if (diff < minDiff) { minDiff = diff; closest = step; } } return closest; } export const SidebarDurationFilter: React.FC = ({ title = , option = DurationCriterionOption, filter, setFilter, sectionID = "duration", }) => { const criteria = filter.criteriaFor(option.type) as DurationCriterion[]; const criterion = criteria.length > 0 ? criteria[0] : null; // Get current values from criterion const currentMin = criterion?.value?.value ?? 0; const currentMax = criterion?.value?.value2 ?? MAX_DURATION; const [sliderMin, setSliderMin] = useState(currentMin); const [sliderMax, setSliderMax] = useState(currentMax); const [minInput, setMinInput] = useState( currentMin === 0 ? "0m" : TextUtils.secondsAsTimeString(currentMin) ); const [maxInput, setMaxInput] = useState( currentMax >= MAX_DURATION ? MAX_LABEL : TextUtils.secondsAsTimeString(currentMax) ); // Reset slider when criterion is removed externally (via filter tag X) useEffect(() => { if (!criterion) { setSliderMin(0); setSliderMax(MAX_DURATION); setMinInput("0m"); setMaxInput(MAX_LABEL); } }, [criterion]); // Determine which preset is selected const selectedPreset = useMemo(() => { if (!criterion) return null; // Check if current values match any preset for (const preset of DURATION_PRESETS) { if (preset.max === null) { // For "40+ min" preset if ( criterion.modifier === CriterionModifier.GreaterThan && criterion.value.value === preset.min ) { return preset.id; } } else { // For range presets if ( criterion.modifier === CriterionModifier.Between && criterion.value.value === preset.min && criterion.value.value2 === preset.max ) { return preset.id; } } } // Check if it's a custom range or custom GreaterThan if ( criterion.modifier === CriterionModifier.Between || criterion.modifier === CriterionModifier.GreaterThan ) { return "custom"; } return null; }, [criterion]); const options: Option[] = useMemo(() => { return DURATION_PRESETS.map((preset) => ({ id: preset.id, label: preset.label, className: "duration-preset", })); }, []); const selected: Option[] = useMemo(() => { if (!selectedPreset) return []; if (selectedPreset === "custom") return []; const preset = DURATION_PRESETS.find((p) => p.id === selectedPreset); if (preset) { return [ { id: preset.id, label: preset.label, className: "duration-preset", }, ]; } return []; }, [selectedPreset]); function onSelectPreset(item: Option) { const preset = DURATION_PRESETS.find((p) => p.id === item.id); if (!preset) return; const newCriterion = criterion ? criterion.clone() : option.makeCriterion(); if (preset.max === null) { // "40+ min" - use GreaterThan newCriterion.modifier = CriterionModifier.GreaterThan; newCriterion.value.value = preset.min; newCriterion.value.value2 = undefined; } else { // Range preset - use Between newCriterion.modifier = CriterionModifier.Between; newCriterion.value.value = preset.min; newCriterion.value.value2 = preset.max; } setSliderMin(preset.min); setSliderMax(preset.max ?? MAX_DURATION); setMinInput( preset.min === 0 ? "0m" : TextUtils.secondsAsTimeString(preset.min) ); setMaxInput( preset.max === null ? MAX_LABEL : TextUtils.secondsAsTimeString(preset.max) ); setFilter(filter.replaceCriteria(option.type, [newCriterion])); } function onUnselectPreset() { setFilter(filter.removeCriterion(option.type)); setSliderMin(0); setSliderMax(MAX_DURATION); setMinInput("0m"); setMaxInput(MAX_LABEL); } // Parse time input (supports formats like "10", "1:30", "1:30:00", "2+ hrs") function parseTimeInput(input: string): number | null { const trimmed = input.trim().toLowerCase(); if (trimmed === "max" || trimmed === MAX_LABEL.toLowerCase()) { return MAX_DURATION; } // Try to parse as pure number (minutes) const minutesOnly = parseFloat(trimmed); if (!isNaN(minutesOnly) && trimmed.indexOf(":") === -1) { return Math.round(minutesOnly * 60); } // Parse HH:MM:SS or MM:SS format const parts = trimmed.split(":").map((p) => parseInt(p)); if (parts.some(isNaN)) { return null; } if (parts.length === 2) { // MM:SS return parts[0] * 60 + parts[1]; } else if (parts.length === 3) { // HH:MM:SS return parts[0] * 3600 + parts[1] * 60 + parts[2]; } return null; } // Debounced filter update function updateFilter(min: number, max: number) { // If slider is at full range (0 to max), remove the filter entirely if (min === 0 && max >= MAX_DURATION) { setFilter(filter.removeCriterion(option.type)); return; } const newCriterion = criterion ? criterion.clone() : option.makeCriterion(); // If max is at MAX_DURATION (but min > 0), use GreaterThan if (max >= MAX_DURATION) { newCriterion.modifier = CriterionModifier.GreaterThan; newCriterion.value.value = min; newCriterion.value.value2 = undefined; } else { newCriterion.modifier = CriterionModifier.Between; newCriterion.value.value = min; newCriterion.value.value2 = max; } setFilter(filter.replaceCriteria(option.type, [newCriterion])); } const updateFilterDebounceMS = 300; const debounceUpdateFilter = useDebounce( updateFilter, updateFilterDebounceMS ); function handleSliderChange(min: number, max: number) { if (min < 0 || max > MAX_DURATION || min >= max) { return; } setSliderMin(min); setSliderMax(max); setMinInput(min === 0 ? "0m" : TextUtils.secondsAsTimeString(min)); setMaxInput( max >= MAX_DURATION ? MAX_LABEL : TextUtils.secondsAsTimeString(max) ); debounceUpdateFilter(min, max); } function handleMinInputChange(value: string) { setMinInput(value); } function handleMaxInputChange(value: string) { setMaxInput(value); } function handleMinInputBlur() { const parsed = parseTimeInput(minInput); if (parsed !== null && parsed >= 0 && parsed < sliderMax) { handleSliderChange(parsed, sliderMax); } else { // Reset to current value if invalid setMinInput( sliderMin === 0 ? "0m" : TextUtils.secondsAsTimeString(sliderMin) ); } } function handleMaxInputBlur() { const parsed = parseTimeInput(maxInput); if (parsed !== null && parsed > sliderMin && parsed <= MAX_DURATION) { handleSliderChange(sliderMin, parsed); } else { // Reset to current value if invalid setMaxInput( sliderMax >= MAX_DURATION ? MAX_LABEL : TextUtils.secondsAsTimeString(sliderMax) ); } } const customSlider = ( handleMinInputChange(e.target.value)} onBlur={handleMinInputBlur} onKeyDown={(e) => { if (e.key === "Enter") { e.currentTarget.blur(); } }} placeholder="0:00" /> } maxInput={ handleMaxInputChange(e.target.value)} onBlur={handleMaxInputBlur} onKeyDown={(e) => { if (e.key === "Enter") { e.currentTarget.blur(); } }} placeholder={MAX_LABEL} /> } min={0} max={MAX_DURATION} value={[sliderMin, sliderMax]} onChange={(vals) => { handleSliderChange(snapToStep(vals[0]), snapToStep(vals[1])); }} /> ); return ( ); }; ================================================ FILE: ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx ================================================ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "react-bootstrap"; import { Icon } from "src/components/Shared/Icon"; import { faCheckCircle, faMinus, faPlus, faTimesCircle, } from "@fortawesome/free-solid-svg-icons"; import { faTimesCircle as faTimesCircleRegular } from "@fortawesome/free-regular-svg-icons"; import { ClearableInput } from "src/components/Shared/ClearableInput"; import { useIntl } from "react-intl"; import { keyboardClickHandler } from "src/utils/keyboard"; import { useDebounce } from "src/hooks/debounce"; import useFocus from "src/utils/focus"; import cx from "classnames"; import ScreenUtils from "src/utils/screen"; import { SidebarSection } from "src/components/Shared/Sidebar"; import { TruncatedInlineText } from "src/components/Shared/TruncatedText"; interface ISelectedItem { className?: string; label: string; excluded?: boolean; onClick: () => void; // true if the object is a special modifier value modifier?: boolean; } const SelectedItem: React.FC = ({ className, label, excluded = false, onClick, modifier = false, }) => { const iconClassName = excluded ? "exclude-icon" : "include-button"; const spanClassName = excluded ? "excluded-object-label" : "selected-object-label"; const [hovered, setHovered] = useState(false); const icon = useMemo(() => { if (!hovered) { return excluded ? faTimesCircle : faCheckCircle; } return faTimesCircleRegular; }, [hovered, excluded]); function onMouseOver() { setHovered(true); } function onMouseOut() { setHovered(false); } return (
  • onClick()} onKeyDown={keyboardClickHandler(onClick)} onMouseEnter={() => onMouseOver()} onMouseLeave={() => onMouseOut()} onFocus={() => onMouseOver()} onBlur={() => onMouseOut()} tabIndex={0} >
  • ); }; const CandidateItem: React.FC<{ className?: string; onSelect: (exclude: boolean) => void; label: string; canExclude?: boolean; modifier?: boolean; singleValue?: boolean; }> = ({ onSelect, label, canExclude, modifier = false, singleValue = false, className, }) => { const singleValueClass = singleValue ? "single-value" : ""; const includeIcon = ( ); const excludeIcon = ( ); return (
  • onSelect(false)} onKeyDown={keyboardClickHandler(() => onSelect(false))} tabIndex={0} >
    {includeIcon}
    {/* TODO item count */} {/* {p.id} */} {canExclude && ( )}
  • ); }; export type Option = { id: string; className?: string; value?: T; label: string; canExclude?: boolean; // defaults to true }; export const SelectedList: React.FC<{ items: Option[]; onUnselect: (item: Option) => void; excluded?: boolean; }> = ({ items, onUnselect, excluded }) => { if (items.length === 0) { return null; } return (
      {items.map((p) => ( onUnselect(p)} /> ))}
    ); }; const QueryField: React.FC<{ focus: ReturnType; value: string; setValue: (query: string) => void; onEnter?: () => void; }> = ({ focus, value, setValue, onEnter }) => { const intl = useIntl(); const [displayQuery, setDisplayQuery] = useState(value); const debouncedSetQuery = useDebounce(setValue, 250); useEffect(() => { setDisplayQuery(value); }, [value]); const onQueryChange = useCallback( (input: string) => { setDisplayQuery(input); debouncedSetQuery(input); }, [debouncedSetQuery, setDisplayQuery] ); return ( onQueryChange(v)} placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} onEnter={onEnter} /> ); }; interface IQueryableProps { inputFocus?: ReturnType; query?: string; setQuery?: (query: string) => void; onEnter?: () => void; } export const CandidateList: React.FC< { items: Option[]; onSelect: (item: Option, exclude: boolean) => void; canExclude?: boolean; singleValue?: boolean; } & IQueryableProps > = ({ inputFocus, query, setQuery, onEnter, items, onSelect, canExclude, singleValue, }) => { const showQueryField = inputFocus !== undefined && query !== undefined && setQuery !== undefined; return (
    {showQueryField && ( setQuery(v)} onEnter={onEnter} /> )}
      {items.map((p) => ( onSelect(p, exclude)} label={p.label} canExclude={canExclude && (p.canExclude ?? true)} singleValue={singleValue} /> ))}
    ); }; export const SidebarListFilter: React.FC<{ title: React.ReactNode; selected: Option[]; excluded?: Option[]; candidates: Option[]; modifierCandidates?: Option[]; singleValue?: boolean; onSelect: (item: Option, exclude: boolean) => void; onUnselect: (item: Option, exclude: boolean) => void; canExclude?: boolean; query?: string; setQuery?: (query: string) => void; preSelected?: React.ReactNode; postSelected?: React.ReactNode; preCandidates?: React.ReactNode; postCandidates?: React.ReactNode; onOpen?: () => void; // used to store open/closed state in SidebarStateContext sectionID?: string; }> = ({ title, selected, excluded, candidates, modifierCandidates, onSelect, onUnselect, canExclude, query, setQuery, singleValue = false, preCandidates, postCandidates, preSelected, postSelected, onOpen, sectionID, }) => { // TODO - sort items? const inputFocus = useFocus(); const [, setInputFocus] = inputFocus; function unselectHook(item: Option, exclude: boolean) { onUnselect(item, exclude); // focus the input box // don't do this on touch devices, as it's annoying if (!ScreenUtils.isTouch()) { setInputFocus(); } } function selectHook(item: Option, exclude: boolean) { onSelect(item, exclude); // reset filter query after selecting setQuery?.(""); // focus the input box // don't do this on touch devices, as it's annoying if (!ScreenUtils.isTouch()) { setInputFocus(); } } function onEnter() { if (candidates && candidates.length === 1) { selectHook(candidates[0], false); } } const items = useMemo(() => { if (!modifierCandidates) { return candidates; } return [...modifierCandidates, ...candidates]; }, [candidates, modifierCandidates]); return ( {preSelected ?
    {preSelected}
    : null} unselectHook(i, false)} /> {excluded && ( unselectHook(i, true)} excluded /> )} {postSelected ?
    {postSelected}
    : null} } onOpen={onOpen} > {preCandidates ?
    {preCandidates}
    : null} {postCandidates ?
    {postCandidates}
    : null}
    ); }; export function useStaticResults(r: T) { return () => ({ results: r, loading: false }); } ================================================ FILE: ui/v2.5/src/components/List/Filters/StashIDFilter.tsx ================================================ import React from "react"; import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import { IStashIDValue } from "../../../models/list-filter/types"; import { ModifierCriterion } from "../../../models/list-filter/criteria/criterion"; import { CriterionModifier } from "src/core/generated-graphql"; interface IStashIDFilterProps { criterion: ModifierCriterion; onValueChanged: (value: IStashIDValue) => void; } export const StashIDFilter: React.FC = ({ criterion, onValueChanged, }) => { const intl = useIntl(); const { value } = criterion; function onEndpointChanged(event: React.ChangeEvent) { onValueChanged({ endpoint: event.target.value, stashID: criterion.value.stashID, }); } function onStashIDChanged(event: React.ChangeEvent) { onValueChanged({ stashID: event.target.value, endpoint: criterion.value.endpoint, }); } return (
    {criterion.modifier !== CriterionModifier.IsNull && criterion.modifier !== CriterionModifier.NotNull && ( )}
    ); }; ================================================ FILE: ui/v2.5/src/components/List/Filters/StudiosFilter.tsx ================================================ import React, { ReactNode, useMemo } from "react"; import { StudioDataFragment, StudioFilterType, useFindStudiosForSelectQuery, } from "src/core/generated-graphql"; import { HierarchicalObjectsFilter } from "./SelectableFilter"; import { StudiosCriterion, StudiosCriterionOption, } from "src/models/list-filter/criteria/studios"; import { sortByRelevance } from "src/utils/query"; import { CriterionOption } from "src/models/list-filter/criteria/criterion"; import { ListFilterModel } from "src/models/list-filter/filter"; import { IUseQueryHookProps, makeQueryVariables, setObjectFilter, useLabeledIdFilterState, } from "./LabeledIdFilter"; import { SidebarListFilter } from "./SidebarListFilter"; import { FormattedMessage } from "react-intl"; interface IStudiosFilter { criterion: StudiosCriterion; setCriterion: (c: StudiosCriterion) => void; } function queryVariables(query: string, f?: ListFilterModel) { const studioFilter: StudioFilterType = {}; if (f) { const filterOutput = f.makeFilter(); // always remove studio filter from the filter // since modifier is includes delete filterOutput.studios; // TODO - look for same in AND? setObjectFilter(studioFilter, f.mode, filterOutput); } return makeQueryVariables(query, { studio_filter: studioFilter }); } function sortResults( query: string, studios: Pick[] ) { return sortByRelevance( query, studios ?? [], (s) => s.name, (s) => s.aliases ).map((p) => { return { id: p.id, label: p.name, }; }); } function useStudioQueryFilter(props: IUseQueryHookProps) { const { q: query, filter: f, skip, filterHook } = props; const appliedFilter = filterHook && f ? filterHook(f.clone()) : f; const { data, loading } = useFindStudiosForSelectQuery({ variables: queryVariables(query, appliedFilter), skip, }); const results = useMemo( () => sortResults(query, data?.findStudios.studios ?? []), [data?.findStudios.studios, query] ); return { results, loading }; } function useStudioQuery(query: string, skip?: boolean) { return useStudioQueryFilter({ q: query, skip: !!skip }); } const StudiosFilter: React.FC = ({ criterion, setCriterion, }) => { return ( ); }; export const SidebarStudiosFilter: React.FC<{ title?: ReactNode; option?: CriterionOption; filter: ListFilterModel; setFilter: (f: ListFilterModel) => void; filterHook?: (f: ListFilterModel) => ListFilterModel; sectionID?: string; }> = ({ title = , option = StudiosCriterionOption, filter, setFilter, filterHook, sectionID = "studios", }) => { const state = useLabeledIdFilterState({ filter, setFilter, filterHook, option, useQuery: useStudioQueryFilter, singleValue: true, hierarchical: true, includeSubMessageID: "subsidiary_studios", }); return ( ); }; export default StudiosFilter; ================================================ FILE: ui/v2.5/src/components/List/Filters/TagsFilter.tsx ================================================ import React, { ReactNode, useMemo } from "react"; import { CriterionModifier, TagDataFragment, TagFilterType, useFindTagsForSelectQuery, } from "src/core/generated-graphql"; import { HierarchicalObjectsFilter } from "./SelectableFilter"; import { sortByRelevance } from "src/utils/query"; import { CriterionOption } from "src/models/list-filter/criteria/criterion"; import { ListFilterModel } from "src/models/list-filter/filter"; import { IUseQueryHookProps, makeQueryVariables, setObjectFilter, useLabeledIdFilterState, } from "./LabeledIdFilter"; import { SidebarListFilter } from "./SidebarListFilter"; import { TagsCriterion, TagsCriterionOption, } from "src/models/list-filter/criteria/tags"; import { FormattedMessage } from "react-intl"; interface ITagsFilter { criterion: TagsCriterion; setCriterion: (c: TagsCriterion) => void; } interface IHasModifier { modifier: CriterionModifier; } function queryVariables(query: string, f?: ListFilterModel) { const tagFilter: TagFilterType = {}; if (f) { const filterOutput = f.makeFilter(); // if tag modifier is includes, take it out of the filter if ( (filterOutput.tags as IHasModifier)?.modifier === CriterionModifier.Includes ) { delete filterOutput.tags; // TODO - look for same in AND? } setObjectFilter(tagFilter, f.mode, filterOutput); } return makeQueryVariables(query, { tag_filter: tagFilter }); } function sortResults( query: string, tags: Pick[] ) { return sortByRelevance( query, tags ?? [], (t) => t.name, (t) => t.aliases ).map((p) => { return { id: p.id, label: p.name, }; }); } function useTagQueryFilter(props: IUseQueryHookProps) { const { q: query, filter: f, skip, filterHook } = props; const appliedFilter = filterHook && f ? filterHook(f.clone()) : f; const { data, loading } = useFindTagsForSelectQuery({ variables: queryVariables(query, appliedFilter), skip, }); const results = useMemo( () => sortResults(query, data?.findTags.tags ?? []), [data, query] ); return { results, loading }; } function useTagQuery(query: string, skip?: boolean) { return useTagQueryFilter({ q: query, skip: !!skip }); } const TagsFilter: React.FC = ({ criterion, setCriterion }) => { return ( ); }; export const SidebarTagsFilter: React.FC<{ title?: ReactNode; option?: CriterionOption; filter: ListFilterModel; setFilter: (f: ListFilterModel) => void; filterHook?: (f: ListFilterModel) => ListFilterModel; sectionID?: string; }> = ({ title = , option = TagsCriterionOption, filter, setFilter, filterHook, sectionID = "tags", }) => { const state = useLabeledIdFilterState({ filter, setFilter, filterHook, option, useQuery: useTagQueryFilter, hierarchical: true, includeSubMessageID: "sub_tags", }); return ( ); }; export default TagsFilter; ================================================ FILE: ui/v2.5/src/components/List/Filters/TimestampFilter.tsx ================================================ import React from "react"; import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import { CriterionModifier } from "../../../core/generated-graphql"; import { ITimestampValue } from "../../../models/list-filter/types"; import { ModifierCriterion } from "../../../models/list-filter/criteria/criterion"; import { DateInput } from "src/components/Shared/DateInput"; interface ITimestampFilterProps { criterion: ModifierCriterion; onValueChanged: (value: ITimestampValue) => void; } export const TimestampFilter: React.FC = ({ criterion, onValueChanged, }) => { const intl = useIntl(); const { value } = criterion; function onChanged(newValue: string, property: "value" | "value2") { const valueCopy = { ...value }; valueCopy[property] = newValue; onValueChanged(valueCopy); } let equalsControl: JSX.Element | null = null; if ( criterion.modifier === CriterionModifier.Equals || criterion.modifier === CriterionModifier.NotEquals ) { equalsControl = ( onChanged(v, "value")} placeholder={intl.formatMessage({ id: "criterion.value" })} isTime /> {/* ) => onChanged(e, "value") } value={value?.value ?? ""} placeholder={ intl.formatMessage({ id: "criterion.value" }) + " (YYYY-MM-DD HH:MM)" } /> */} ); } let lowerControl: JSX.Element | null = null; if ( criterion.modifier === CriterionModifier.GreaterThan || criterion.modifier === CriterionModifier.Between || criterion.modifier === CriterionModifier.NotBetween ) { lowerControl = ( onChanged(v, "value")} placeholder={intl.formatMessage({ id: "criterion.greater_than" })} isTime /> {/* ) => onChanged(e, "value") } value={value?.value ?? ""} placeholder={ intl.formatMessage({ id: "criterion.greater_than" }) + " (YYYY-MM-DD HH:MM)" } /> */} ); } let upperControl: JSX.Element | null = null; if ( criterion.modifier === CriterionModifier.LessThan || criterion.modifier === CriterionModifier.Between || criterion.modifier === CriterionModifier.NotBetween ) { upperControl = ( onChanged( v, criterion.modifier === CriterionModifier.LessThan ? "value" : "value2" ) } placeholder={intl.formatMessage({ id: "criterion.less_than" })} isTime /> {/* ) => onChanged( e, criterion.modifier === CriterionModifier.LessThan ? "value" : "value2" ) } value={ (criterion.modifier === CriterionModifier.LessThan ? value?.value : value?.value2) ?? "" } placeholder={ intl.formatMessage({ id: "criterion.less_than" }) + " (YYYY-MM-DD HH:MM)" } /> */} ); } return ( <> {equalsControl} {lowerControl} {upperControl} ); }; ================================================ FILE: ui/v2.5/src/components/List/ItemList.tsx ================================================ import { QueryResult } from "@apollo/client"; import { ListFilterModel } from "src/models/list-filter/filter"; import { useShowEditFilter } from "src/components/List/EditFilterDialog"; import { IHasID } from "src/utils/data"; import { useModal } from "src/hooks/modal"; import { IFilterStateHook, IQueryResultHook, useEnsureValidPage, useFilterOperations, useFilterState, useListKeyboardShortcuts, useListSelect, useQueryResult, useScrollToTopOnPageChange, } from "./util"; import { useConfigurationContext } from "src/hooks/Config"; interface IFilteredItemList< T extends QueryResult, E extends IHasID = IHasID, M = unknown > { filterStateProps: IFilterStateHook; queryResultProps: IQueryResultHook; } // Provides the common state and behaviour for filtered item list components export function useFilteredItemList< T extends QueryResult, E extends IHasID = IHasID, M = unknown >(props: IFilteredItemList) { const { configuration: config } = useConfigurationContext(); // States const filterState = useFilterState({ config, ...props.filterStateProps, }); const { filter, setFilter } = filterState; const queryResult = useQueryResult({ filter, ...props.queryResultProps, }); const { result, items, totalCount, pages, metadataInfo } = queryResult; const listSelect = useListSelect(items); const { onSelectAll, onSelectNone, onInvertSelection } = listSelect; const modalState = useModal(); const { showModal, closeModal } = modalState; // Utility hooks const { setPage } = useFilterOperations({ filter, setFilter }); // scroll to the top of the page when the page changes useScrollToTopOnPageChange(filter.currentPage, result.loading); // ensure that the current page is valid useEnsureValidPage(filter, totalCount, setFilter); const showEditFilter = useShowEditFilter({ showModal, closeModal, filter, setFilter, }); useListKeyboardShortcuts({ currentPage: filter.currentPage, onChangePage: setPage, onSelectAll, onSelectNone, onInvertSelection, pages, showEditFilter, }); return { filterState, queryResult, metadataInfo, listSelect, modalState, showEditFilter, }; } export const showWhenSelected = ( result: T, filter: ListFilterModel, selectedIds: Set ) => { return selectedIds.size > 0; }; export const showWhenSingleSelection = ( result: T, filter: ListFilterModel, selectedIds: Set ) => { return selectedIds.size == 1; }; export const showWhenNoneSelected = ( result: T, filter: ListFilterModel, selectedIds: Set ) => { return selectedIds.size === 0; }; ================================================ FILE: ui/v2.5/src/components/List/ListFilter.tsx ================================================ import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import Mousetrap from "mousetrap"; import { SortDirectionEnum } from "src/core/generated-graphql"; import { Button, ButtonGroup, Dropdown, Form, OverlayTrigger, Tooltip, InputGroup, Popover, Overlay, } from "react-bootstrap"; import { Icon } from "../Shared/Icon"; import { ListFilterModel } from "src/models/list-filter/filter"; import useFocus from "src/utils/focus"; import { useIntl } from "react-intl"; import { faCaretDown, faCaretUp, faCheck, faRandom, } from "@fortawesome/free-solid-svg-icons"; import { useDebounce } from "src/hooks/debounce"; import { ClearableInput } from "../Shared/ClearableInput"; import { useStopWheelScroll } from "src/utils/form"; import { ISortByOption } from "src/models/list-filter/filter-options"; import { useConfigurationContext } from "src/hooks/Config"; export function useDebouncedSearchInput( filter: ListFilterModel, setFilter: (filter: ListFilterModel) => void ) { const callback = useCallback( (value: string) => { const newFilter = filter.clone(); newFilter.searchTerm = value; newFilter.currentPage = 1; setFilter(newFilter); }, [filter, setFilter] ); const onClear = useCallback(() => callback(""), [callback]); const searchCallback = useDebounce(callback, 500); return { searchCallback, onClear }; } export const SearchTermInput: React.FC<{ filter: ListFilterModel; onFilterUpdate: (newFilter: ListFilterModel) => void; focus?: ReturnType; }> = ({ filter, onFilterUpdate, focus: providedFocus }) => { const intl = useIntl(); const [localInput, setLocalInput] = useState(filter.searchTerm); const localFocus = useFocus(); const focus = providedFocus ?? localFocus; const [, setQueryFocus] = focus; useEffect(() => { setLocalInput(filter.searchTerm); }, [filter.searchTerm]); const { searchCallback, onClear } = useDebouncedSearchInput( filter, onFilterUpdate ); useEffect(() => { Mousetrap.bind("/", (e) => { setQueryFocus(); e.preventDefault(); }); return () => { Mousetrap.unbind("/"); }; }); function onSetQuery(value: string) { setLocalInput(value); if (!value) { onClear(); } searchCallback(value); } return ( ); }; const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120", "250", "500", "1000"]; export const PageSizeSelector: React.FC<{ pageSize: number; setPageSize: (pageSize: number) => void; }> = ({ pageSize, setPageSize }) => { const intl = useIntl(); const perPageSelect = useRef(null); const [perPageInput, perPageFocus] = useFocus(); const [customPageSizeShowing, setCustomPageSizeShowing] = useState(false); useEffect(() => { if (customPageSizeShowing) { perPageFocus(); } }, [customPageSizeShowing, perPageFocus]); useStopWheelScroll(perPageInput); const pageSizeOptions = useMemo(() => { const ret = PAGE_SIZE_OPTIONS.map((o) => { return { label: o, value: o, }; }); const currentPerPage = pageSize.toString(); if (!ret.find((o) => o.value === currentPerPage)) { ret.push({ label: currentPerPage, value: currentPerPage }); ret.sort((a, b) => parseInt(a.value, 10) - parseInt(b.value, 10)); } ret.push({ label: `${intl.formatMessage({ id: "custom" })}...`, value: "custom", }); return ret; }, [intl, pageSize]); function onChangePageSize(val: string) { if (val === "custom") { // added timeout since Firefox seems to trigger the rootClose immediately // without it setTimeout(() => setCustomPageSizeShowing(true), 0); return; } setCustomPageSizeShowing(false); let pp = parseInt(val, 10); if (Number.isNaN(pp) || pp <= 0) { return; } setPageSize(pp); } return (
    onChangePageSize(e.target.value)} value={pageSize.toString()} className="btn-secondary" > {pageSizeOptions.map((s) => ( ))} setCustomPageSizeShowing(false)} >
    {/* can't use NumberField because of the ref */} ) => { if (e.key === "Enter") { onChangePageSize( (perPageInput.current as HTMLInputElement)?.value ?? "" ); e.preventDefault(); } }} />
    ); }; export const SortBySelect: React.FC<{ className?: string; sortBy: string | undefined; sortDirection: SortDirectionEnum; options: ISortByOption[]; onChangeSortBy: (eventKey: string | null) => void; onChangeSortDirection: () => void; onReshuffleRandomSort: () => void; }> = ({ className, sortBy, sortDirection, options, onChangeSortBy, onChangeSortDirection, onReshuffleRandomSort, }) => { const intl = useIntl(); const { configuration } = useConfigurationContext(); const { sfwContentMode } = configuration.interface; const currentSortBy = options.find((o) => o.value === sortBy); const currentSortByMessageID = currentSortBy ? !sfwContentMode ? currentSortBy.messageID : currentSortBy.sfwMessageID ?? currentSortBy.messageID : ""; function renderSortByOptions() { return options .map((o) => { const messageID = !sfwContentMode ? o.messageID : o.sfwMessageID ?? o.messageID; return { message: intl.formatMessage({ id: messageID }), value: o.value, }; }) .sort((a, b) => a.message.localeCompare(b.message)) .map((option) => ( {option.message} )); } return ( {currentSortBy ? intl.formatMessage({ id: currentSortByMessageID }) : ""} {renderSortByOptions()} {sortDirection === SortDirectionEnum.Asc ? intl.formatMessage({ id: "ascending" }) : intl.formatMessage({ id: "descending" })} } > {sortBy === "random" && ( {intl.formatMessage({ id: "actions.reshuffle" })} } > )} ); }; ================================================ FILE: ui/v2.5/src/components/List/ListOperationButtons.tsx ================================================ import React, { PropsWithChildren, useEffect, useMemo } from "react"; import { Button, ButtonGroup, Dropdown } from "react-bootstrap"; import Mousetrap from "mousetrap"; import { FormattedMessage, useIntl } from "react-intl"; import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; import { Icon } from "../Shared/Icon"; import { faEllipsisH, faPencil, faPencilAlt, faPlay, faTrash, } from "@fortawesome/free-solid-svg-icons"; import cx from "classnames"; import { createPortal } from "react-dom"; export const OperationDropdown: React.FC< PropsWithChildren<{ className?: string; menuPortalTarget?: HTMLElement; menuClassName?: string; }> > = ({ className, menuPortalTarget, menuClassName, children }) => { if (!children) return null; const menu = ( {children} ); return ( {menuPortalTarget ? createPortal(menu, menuPortalTarget) : menu} ); }; export const OperationDropdownItem: React.FC<{ text: string; onClick: () => void; className?: string; }> = ({ text, onClick, className }) => { return ( {text} ); }; export interface IListFilterOperation { text: string; onClick: () => void; isDisplayed?: () => boolean; icon?: IconDefinition; buttonVariant?: string; className?: string; } interface IListOperationButtonsProps { onSelectAll?: () => void; onSelectNone?: () => void; onInvertSelection?: () => void; onEdit?: () => void; onDelete?: () => void; itemsSelected?: boolean; otherOperations?: IListFilterOperation[]; } export const ListOperationButtons: React.FC = ({ onSelectAll, onSelectNone, onInvertSelection, onEdit, onDelete, itemsSelected, otherOperations, }) => { const intl = useIntl(); useEffect(() => { Mousetrap.bind("s a", () => onSelectAll?.()); Mousetrap.bind("s n", () => onSelectNone?.()); Mousetrap.bind("s i", () => onInvertSelection?.()); Mousetrap.bind("e", () => { if (itemsSelected) { onEdit?.(); } }); Mousetrap.bind("d d", () => { if (itemsSelected) { onDelete?.(); } }); return () => { Mousetrap.unbind("s a"); Mousetrap.unbind("s n"); Mousetrap.unbind("s i"); Mousetrap.unbind("e"); Mousetrap.unbind("d d"); }; }, [ onSelectAll, onSelectNone, onInvertSelection, itemsSelected, onEdit, onDelete, ]); const buttons = useMemo(() => { const ret = (otherOperations ?? []).filter((o) => { if (!o.icon) { return false; } if (!o.isDisplayed) { return true; } return o.isDisplayed(); }); if (itemsSelected) { if (onEdit) { ret.push({ icon: faPencilAlt, text: intl.formatMessage({ id: "actions.edit" }), onClick: onEdit, }); } if (onDelete) { ret.push({ icon: faTrash, text: intl.formatMessage({ id: "actions.delete" }), onClick: onDelete, buttonVariant: "danger", }); } } return ret; }, [otherOperations, itemsSelected, onEdit, onDelete, intl]); const operationButtons = useMemo(() => { return ( <> {buttons.map((button) => { return ( ); })} ); }, [buttons]); const moreDropdown = useMemo(() => { function renderSelectAll() { if (onSelectAll) { return ( onSelectAll?.()} > ); } } function renderSelectNone() { if (onSelectNone) { return ( onSelectNone?.()} > ); } } function renderInvertSelection() { if (onInvertSelection) { return ( onInvertSelection?.()} > ); } } const options = [ renderSelectAll(), renderSelectNone(), renderInvertSelection(), ].filter((o) => o); if (otherOperations) { otherOperations .filter((o) => { // buttons with icons are rendered in the button group if (o.icon) { return false; } if (!o.isDisplayed) { return true; } return o.isDisplayed(); }) .forEach((o) => { options.push( {o.text} ); }); } return ( {options.length > 0 ? options : undefined} ); }, [otherOperations, onSelectAll, onSelectNone, onInvertSelection]); // don't render anything if there are no buttons or operations if (buttons.length === 0 && !moreDropdown) { return null; } return ( <> {operationButtons} {moreDropdown} ); }; export const ListOperations: React.FC<{ items: number; hasSelection?: boolean; operations?: IListFilterOperation[]; onEdit?: () => void; onDelete?: () => void; onPlay?: () => void; operationsClassName?: string; operationsMenuClassName?: string; }> = ({ items, hasSelection = false, operations = [], onEdit, onDelete, onPlay, operationsClassName = "list-operations", operationsMenuClassName, }) => { const intl = useIntl(); const dropdownOperations = useMemo(() => { return operations.filter((o) => { if (o.icon) { return false; } if (!o.isDisplayed) { return true; } return o.isDisplayed(); }); }, [operations]); const buttons = useMemo(() => { const otherButtons = (operations ?? []).filter((o) => { if (!o.icon) { return false; } if (!o.isDisplayed) { return true; } return o.isDisplayed(); }); const ret: React.ReactNode[] = []; function addButton(b: React.ReactNode | null) { if (b) { ret.push(b); } } const playButton = !!items && onPlay ? ( ) : null; const editButton = hasSelection && onEdit ? ( ) : null; const deleteButton = hasSelection && onDelete ? ( ) : null; addButton(playButton); addButton(editButton); addButton(deleteButton); otherButtons.forEach((button) => { addButton( ); }); if (ret.length === 0) { return null; } return ret; }, [operations, hasSelection, onDelete, onEdit, onPlay, items, intl]); if (dropdownOperations.length === 0 && !buttons) { return null; } return (
    {buttons} {dropdownOperations.length > 0 && ( {dropdownOperations.map((o) => ( ))} )}
    ); }; ================================================ FILE: ui/v2.5/src/components/List/ListProvider.tsx ================================================ import React, { useMemo } from "react"; import { IListSelect, useCachedQueryResult, useListSelect } from "./util"; import { isFunction } from "lodash-es"; import { IHasID } from "src/utils/data"; import { useFilter } from "./FilterProvider"; import { ListFilterModel } from "src/models/list-filter/filter"; import { QueryResult } from "@apollo/client"; interface IListContextOptions { selectable?: boolean; items: T[]; } export type IListContextState = IListSelect & { selectable: boolean; items: T[]; }; export const ListStateContext = React.createContext( null ); export const ListContext = ( props: IListContextOptions & { children?: | ((props: IListContextState) => React.ReactNode) | React.ReactNode; } ) => { const { selectable = false, items, children } = props; const listSelect = useListSelect(items); const state: IListContextState = { selectable, items, ...listSelect, }; return ( {isFunction(children) ? (children as (props: IListContextState) => React.ReactNode)(state) : children} ); }; export function useListContext() { const context = React.useContext(ListStateContext); if (context === null) { throw new Error("useListContext must be used within a ListStateContext"); } return context as IListContextState; } const emptyState: IListContextState = { selectable: false, selectedIds: new Set(), getSelected: () => [], onSelectChange: () => {}, onSelectAll: () => {}, onSelectNone: () => {}, onInvertSelection: () => {}, items: [], hasSelection: false, selectedItems: [], }; export function useListContextOptional() { const context = React.useContext(ListStateContext); if (context === null) { return emptyState as IListContextState; } return context as IListContextState; } interface IQueryResultContextOptions< T extends QueryResult, E extends IHasID = IHasID, M = unknown > { filterHook?: (filter: ListFilterModel) => ListFilterModel; useResult: (filter: ListFilterModel) => T; useMetadataInfo?: (filter: ListFilterModel) => M; getCount: (data: T) => number; getItems: (data: T) => E[]; } export interface IQueryResultContextState< T extends QueryResult = QueryResult, E extends IHasID = IHasID, M = unknown > { effectiveFilter: ListFilterModel; result: T; cachedResult: T; metadataInfo?: M; items: E[]; totalCount: number; } export const QueryResultStateContext = React.createContext(null); export const QueryResultContext = < T extends QueryResult, E extends IHasID = IHasID, M = unknown >( props: IQueryResultContextOptions & { children?: | ((props: IQueryResultContextState) => React.ReactNode) | React.ReactNode; } ) => { const { filterHook, useResult, useMetadataInfo, getItems, getCount, children, } = props; const { filter } = useFilter(); const effectiveFilter = useMemo(() => { if (filterHook) { return filterHook(filter.clone()); } return filter; }, [filter, filterHook]); // metadata filter is the effective filter with the sort, page size and page number removed const metadataFilter = useMemo( () => effectiveFilter.metadataInfo(), [effectiveFilter] ); const result = useResult(effectiveFilter); const metadataInfo = useMetadataInfo?.(metadataFilter); // use cached query result for pagination const cachedResult = useCachedQueryResult(effectiveFilter, result); const items = useMemo(() => getItems(result), [getItems, result]); const totalCount = useMemo( () => getCount(cachedResult), [getCount, cachedResult] ); const state: IQueryResultContextState = { effectiveFilter, result, cachedResult, items, totalCount, metadataInfo, }; return ( {isFunction(children) ? (children as (props: IQueryResultContextState) => React.ReactNode)( state ) : children} ); }; export function useQueryResultContext< T extends QueryResult, E extends IHasID = IHasID, M = unknown >() { const context = React.useContext(QueryResultStateContext); if (context === null) { throw new Error( "useQueryResultContext must be used within a ListStateContext" ); } return context as IQueryResultContextState; } ================================================ FILE: ui/v2.5/src/components/List/ListTable.tsx ================================================ import React, { useMemo } from "react"; import { Table, Form } from "react-bootstrap"; import { CheckBoxSelect } from "../Shared/Select"; import cx from "classnames"; export interface IColumn { label: string; value: string; mandatory?: boolean; } export const ColumnSelector: React.FC<{ selected: string[]; allColumns: IColumn[]; setSelected: (selected: string[]) => void; }> = ({ selected, allColumns, setSelected }) => { const disableOptions = useMemo(() => { return allColumns.map((col) => { return { ...col, isDisabled: col.mandatory, }; }); }, [allColumns]); const selectedColumns = useMemo(() => { return disableOptions.filter((col) => selected.includes(col.value)); }, [selected, disableOptions]); return ( { setSelected(v.map((col) => col.value)); }} /> ); }; interface IListTableProps { className?: string; items: T[]; columns: string[]; setColumns: (columns: string[]) => void; allColumns: IColumn[]; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; renderCell: (column: IColumn, item: T, index: number) => React.ReactNode; } export const ListTable = ( props: IListTableProps ) => { const { className, items, columns, setColumns, allColumns, selectedIds, onSelectChange, renderCell, } = props; const visibleColumns = useMemo(() => { return allColumns.filter( (col) => col.mandatory || columns.includes(col.value) ); }, [columns, allColumns]); const renderObjectRow = (item: T, index: number) => { let shiftKey = false; return ( {visibleColumns.map((column) => ( {renderCell(column, item, index)} ))} ); }; const columnHeaders = useMemo(() => { return visibleColumns.map((column) => ( {column.label} )); }, [visibleColumns]); return (
    {columnHeaders} {items.map(renderObjectRow)}
    ); }; ================================================ FILE: ui/v2.5/src/components/List/ListViewOptions.tsx ================================================ import React, { useEffect, useRef, useState } from "react"; import Mousetrap from "mousetrap"; import { Button, ButtonGroup, Dropdown, Overlay, OverlayTrigger, Popover, Tooltip, } from "react-bootstrap"; import { DisplayMode } from "src/models/list-filter/types"; import { IntlShape, useIntl } from "react-intl"; import { Icon } from "../Shared/Icon"; import { faChevronDown, faList, faSquare, faTags, faThLarge, } from "@fortawesome/free-solid-svg-icons"; import { ZoomSelect } from "./ZoomSlider"; interface IListViewOptionsProps { zoomIndex?: number; onSetZoom?: (zoomIndex: number) => void; displayMode: DisplayMode; onSetDisplayMode: (m: DisplayMode) => void; displayModeOptions: DisplayMode[]; } function getIcon(option: DisplayMode) { switch (option) { case DisplayMode.Grid: return faThLarge; case DisplayMode.List: return faList; case DisplayMode.Wall: return faSquare; case DisplayMode.Tagger: return faTags; } } function getLabelId(option: DisplayMode) { let displayModeId = "unknown"; switch (option) { case DisplayMode.Grid: displayModeId = "grid"; break; case DisplayMode.List: displayModeId = "list"; break; case DisplayMode.Wall: displayModeId = "wall"; break; case DisplayMode.Tagger: displayModeId = "tagger"; break; } return `display_mode.${displayModeId}`; } function getLabel(intl: IntlShape, option: DisplayMode) { return intl.formatMessage({ id: getLabelId(option) }); } export const ListViewOptions: React.FC = ({ zoomIndex, onSetZoom, displayMode, onSetDisplayMode, displayModeOptions, }) => { const intl = useIntl(); const overlayTarget = useRef(null); const [showOptions, setShowOptions] = useState(false); useEffect(() => { Mousetrap.bind("v g", () => { if (displayModeOptions.includes(DisplayMode.Grid)) { onSetDisplayMode(DisplayMode.Grid); } }); Mousetrap.bind("v l", () => { if (displayModeOptions.includes(DisplayMode.List)) { onSetDisplayMode(DisplayMode.List); } }); Mousetrap.bind("v w", () => { if (displayModeOptions.includes(DisplayMode.Wall)) { onSetDisplayMode(DisplayMode.Wall); } }); Mousetrap.bind("v t", () => { if (displayModeOptions.includes(DisplayMode.Tagger)) { onSetDisplayMode(DisplayMode.Tagger); } }); return () => { Mousetrap.unbind("v g"); Mousetrap.unbind("v l"); Mousetrap.unbind("v w"); Mousetrap.unbind("v t"); }; }); function onChangeZoom(v: number) { if (onSetZoom) { onSetZoom(v); } } return ( <> setShowOptions(false)} > {({ placement, arrowProps, show: _show, ...props }) => (
    {onSetZoom && zoomIndex !== undefined && (displayMode === DisplayMode.Grid || displayMode === DisplayMode.Wall) ? (
    ) : null} {displayModeOptions.map((option) => ( { setShowOptions(false); onSetDisplayMode(option); }} > {getLabel(intl, option)} ))}
    )}
    ); }; export const ListViewButtonGroup: React.FC = ({ zoomIndex, onSetZoom, displayMode, onSetDisplayMode, displayModeOptions, }) => { const intl = useIntl(); return ( <> {displayModeOptions.length > 1 && ( {displayModeOptions.map((option) => ( {getLabel(intl, option)} } > ))} )}
    {onSetZoom && zoomIndex !== undefined && (displayMode === DisplayMode.Grid || displayMode === DisplayMode.Wall) ? ( ) : null}
    ); }; ================================================ FILE: ui/v2.5/src/components/List/ModifierSelect.tsx ================================================ import React from "react"; import { Button, Form } from "react-bootstrap"; import { CriterionModifier } from "src/core/generated-graphql"; import { ModifierCriterion } from "src/models/list-filter/criteria/criterion"; import cx from "classnames"; import { useIntl } from "react-intl"; const defaultOptions = [ CriterionModifier.IsNull, CriterionModifier.NotNull, CriterionModifier.Equals, CriterionModifier.NotEquals, CriterionModifier.Includes, CriterionModifier.Excludes, CriterionModifier.GreaterThan, CriterionModifier.LessThan, CriterionModifier.Between, CriterionModifier.NotBetween, ]; interface IModifierSelect { options?: CriterionModifier[]; value: CriterionModifier; onChanged: (m: CriterionModifier) => void; } export const ModifierSelectorButtons: React.FC = ({ options = defaultOptions, value, onChanged, }) => { const intl = useIntl(); return ( {options.map((m) => ( ))} ); }; export const ModifierSelect: React.FC = ({ options = defaultOptions, value, onChanged, }) => { const intl = useIntl(); return ( onChanged(e.target.value as CriterionModifier)} value={value} className="btn-secondary modifier-selector" > {options.map((m) => ( ))} ); }; ================================================ FILE: ui/v2.5/src/components/List/PagedList.tsx ================================================ import React, { PropsWithChildren, useMemo } from "react"; import { ApolloError, QueryResult } from "@apollo/client"; import { ListFilterModel } from "src/models/list-filter/filter"; import { Pagination, PaginationIndex } from "./Pagination"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { ErrorMessage } from "../Shared/ErrorMessage"; import { FormattedMessage } from "react-intl"; export const LoadedContent: React.FC< PropsWithChildren<{ loading?: boolean; error?: ApolloError; }> > = ({ loading, error, children }) => { if (loading) { return ; } if (error) { return ( } error={error.message} /> ); } return <>{children}; }; export const PagedList: React.FC< PropsWithChildren<{ result: QueryResult; cachedResult: QueryResult; filter: ListFilterModel; totalCount: number; onChangePage: (page: number) => void; metadataByline?: React.ReactNode; }> > = ({ result, cachedResult, filter, totalCount, onChangePage, metadataByline, children, }) => { const pages = Math.ceil(totalCount / filter.itemsPerPage); const pagination = useMemo(() => { return ( ); }, [ filter.itemsPerPage, filter.currentPage, totalCount, metadataByline, onChangePage, ]); const paginationIndex = useMemo(() => { if (cachedResult.loading) return; return ( ); }, [ cachedResult.loading, filter.itemsPerPage, filter.currentPage, totalCount, metadataByline, ]); const content = useMemo(() => { return ( {children} {!!pages && ( <> {paginationIndex} {pagination} )} ); }, [ result.loading, result.error, pages, children, pagination, paginationIndex, ]); return ( <> {pagination} {paginationIndex} {content} ); }; ================================================ FILE: ui/v2.5/src/components/List/Pagination.tsx ================================================ import React, { useEffect, useMemo, useRef, useState } from "react"; import { Button, ButtonGroup, Dropdown, Form, InputGroup, Overlay, Popover, } from "react-bootstrap"; import { FormattedMessage, FormattedNumber, useIntl } from "react-intl"; import useFocus from "src/utils/focus"; import { Icon } from "../Shared/Icon"; import { faCheck, faChevronDown } from "@fortawesome/free-solid-svg-icons"; import { useStopWheelScroll } from "src/utils/form"; import { Placement } from "react-bootstrap/esm/Overlay"; import { PatchComponent } from "src/patch"; const PageCount: React.FC<{ totalPages: number; currentPage: number; onChangePage: (page: number) => void; pagePopupPlacement?: Placement; }> = ({ totalPages, currentPage, onChangePage, pagePopupPlacement = "bottom", }) => { const intl = useIntl(); const currentPageCtrl = useRef(null); const [pageInput, pageFocus] = useFocus(); const [showSelectPage, setShowSelectPage] = useState(false); useEffect(() => { if (showSelectPage) { // delaying the focus to the next execution loop so that rendering takes place first and stops the page from resetting. setTimeout(() => { pageFocus(); }, 0); } }, [showSelectPage, pageFocus]); useStopWheelScroll(pageInput); const pageOptions = useMemo(() => { const maxPagesToShow = 1000; const min = Math.max(1, currentPage - maxPagesToShow / 2); const max = Math.min(min + maxPagesToShow, totalPages); const pages = []; for (let i = min; i <= max; i++) { pages.push(i); } return pages; }, [totalPages, currentPage]); function onCustomChangePage() { const newPage = Number.parseInt(pageInput.current?.value ?? "0"); if (newPage) { onChangePage(newPage); } setShowSelectPage(false); } return (
    {pageOptions.map((s) => ( onChangePage(s)} > {s} ))} setShowSelectPage(false)} >
    {/* can't use NumberField because of the ref */} ) => { if (e.key === "Enter") { onCustomChangePage(); e.preventDefault(); } }} onFocus={(e: React.FocusEvent) => e.target.select() } />
    ); }; interface IPaginationProps { itemsPerPage: number; currentPage: number; totalItems: number; metadataByline?: React.ReactNode; onChangePage: (page: number) => void; pagePopupPlacement?: Placement; } interface IPaginationIndexProps { loading?: boolean; itemsPerPage: number; currentPage: number; totalItems: number; metadataByline?: React.ReactNode; } const minPagesForCompact = 4; export const Pagination: React.FC = PatchComponent( "Pagination", ({ itemsPerPage, currentPage, totalItems, onChangePage, pagePopupPlacement, }) => { const intl = useIntl(); const totalPages = useMemo( () => Math.ceil(totalItems / itemsPerPage), [totalItems, itemsPerPage] ); const pageButtons = useMemo(() => { if (totalPages >= minPagesForCompact) return ( ); const pages = [...Array(totalPages).keys()].map((i) => i + 1); return pages.map((page: number) => ( )); }, [totalPages, currentPage, onChangePage, pagePopupPlacement]); if (totalPages <= 1) return
    ; return ( {pageButtons} ); } ); export const PaginationIndex: React.FC = PatchComponent( "PaginationIndex", ({ loading, itemsPerPage, currentPage, totalItems, metadataByline }) => { const intl = useIntl(); if (loading) return null; // Build the pagination index string const firstItemCount: number = Math.min( (currentPage - 1) * itemsPerPage + 1, totalItems ); const lastItemCount: number = Math.min( firstItemCount + (itemsPerPage - 1), totalItems ); const indexText: string = `${intl.formatNumber( firstItemCount )}-${intl.formatNumber(lastItemCount)} of ${intl.formatNumber(totalItems)}`; return ( {indexText}
    {metadataByline}
    ); } ); ================================================ FILE: ui/v2.5/src/components/List/SavedFilterList.tsx ================================================ import React, { HTMLAttributes, useEffect, useMemo, useState } from "react"; import { Button, ButtonGroup, Dropdown, Form, FormControl, InputGroup, Modal, OverlayTrigger, Tooltip, } from "react-bootstrap"; import { useConfigureUISetting, useFindSavedFilters, useSavedFilterDestroy, useSaveFilter, } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import { ListFilterModel } from "src/models/list-filter/filter"; import { FilterMode, SavedFilterDataFragment, } from "src/core/generated-graphql"; import { View } from "./views"; import { FormattedMessage, useIntl } from "react-intl"; import { Icon } from "../Shared/Icon"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { faBookmark, faSave, faTimes } from "@fortawesome/free-solid-svg-icons"; import { AlertModal } from "../Shared/Alert"; import cx from "classnames"; import { TruncatedInlineText } from "../Shared/TruncatedText"; import { OperationButton } from "../Shared/OperationButton"; import { createPortal } from "react-dom"; const ExistingSavedFilterList: React.FC<{ name: string; onSelect: (value: SavedFilterDataFragment) => void; savedFilters: SavedFilterDataFragment[]; disabled?: boolean; }> = ({ name, onSelect, savedFilters: existing, disabled = false }) => { const filtered = useMemo(() => { if (!name) return existing; return existing.filter((f) => f.name.toLowerCase().includes(name.toLowerCase()) ); }, [existing, name]); return (
      {filtered.map((f) => (
    • ))}
    ); }; export const SaveFilterDialog: React.FC<{ mode: FilterMode; onClose: (name?: string, id?: string) => void; isSaving?: boolean; }> = ({ mode, onClose, isSaving = false }) => { const intl = useIntl(); const [filterName, setFilterName] = useState(""); const { data } = useFindSavedFilters(mode); const overwritingFilter = useMemo(() => { const savedFilters = data?.findSavedFilters ?? []; return savedFilters.find( (f) => f.name.toLowerCase() === filterName.toLowerCase() ); }, [data?.findSavedFilters, filterName]); return ( setFilterName(e.target.value)} disabled={isSaving} /> setFilterName(f.name)} savedFilters={data?.findSavedFilters ?? []} /> {!!overwritingFilter && ( )} onClose(filterName, overwritingFilter?.id)} > {intl.formatMessage({ id: "actions.save" })} ); }; export const LoadFilterDialog: React.FC<{ mode: FilterMode; onClose: (filter?: SavedFilterDataFragment) => void; }> = ({ mode, onClose }) => { const intl = useIntl(); const [filterName, setFilterName] = useState(""); const { data } = useFindSavedFilters(mode); return ( setFilterName(e.target.value)} /> onClose(f)} savedFilters={data?.findSavedFilters ?? []} /> ); }; const DeleteAlert: React.FC<{ deletingFilter: SavedFilterDataFragment | undefined; onClose: (confirm?: boolean) => void; }> = ({ deletingFilter, onClose }) => { if (!deletingFilter) { return null; } return ( ); }; const OverwriteAlert: React.FC<{ overwritingFilter: SavedFilterDataFragment | undefined; onClose: (confirm?: boolean) => void; }> = ({ overwritingFilter, onClose }) => { if (!overwritingFilter) { return null; } return ( ); }; interface ISavedFilterListProps { filter: ListFilterModel; onSetFilter: (f: ListFilterModel) => void; view?: View; menuPortalTarget?: Element | DocumentFragment; } export const SavedFilterList: React.FC = ({ filter, onSetFilter, view, }) => { const Toast = useToast(); const intl = useIntl(); const { data, error, loading, refetch } = useFindSavedFilters(filter.mode); const [filterName, setFilterName] = useState(""); const [saving, setSaving] = useState(false); const [deletingFilter, setDeletingFilter] = useState< SavedFilterDataFragment | undefined >(); const [overwritingFilter, setOverwritingFilter] = useState< SavedFilterDataFragment | undefined >(); const saveFilter = useSaveFilter(); const [destroyFilter] = useSavedFilterDestroy(); const [saveUISetting] = useConfigureUISetting(); const savedFilters = data?.findSavedFilters ?? []; async function onSaveFilter(name: string, id?: string) { const filterCopy = filter.clone(); try { setSaving(true); await saveFilter(filterCopy, name, id); Toast.success( intl.formatMessage( { id: "toast.saved_entity", }, { entity: intl.formatMessage({ id: "filter" }).toLocaleLowerCase(), } ) ); setFilterName(""); setOverwritingFilter(undefined); refetch(); } catch (err) { Toast.error(err); } finally { setSaving(false); } } async function onDeleteFilter(f: SavedFilterDataFragment) { try { setSaving(true); await destroyFilter({ variables: { input: { id: f.id, }, }, }); Toast.success( intl.formatMessage( { id: "toast.delete_past_tense", }, { count: 1, singularEntity: intl.formatMessage({ id: "filter" }), pluralEntity: intl.formatMessage({ id: "filters" }), } ) ); refetch(); } catch (err) { Toast.error(err); } finally { setSaving(false); setDeletingFilter(undefined); } } async function onSetDefaultFilter() { if (!view) { return; } const filterCopy = filter.clone(); try { setSaving(true); await saveUISetting({ variables: { key: `defaultFilters.${view.toString()}`, value: { mode: filter.mode, find_filter: filterCopy.makeFindFilter(), object_filter: filterCopy.makeSavedFilter(), ui_options: filterCopy.makeSavedUIOptions(), }, }, }); Toast.success( intl.formatMessage({ id: "toast.default_filter_set", }) ); } catch (err) { Toast.error(err); } finally { setSaving(false); } } function filterClicked(f: SavedFilterDataFragment) { const newFilter = filter.clone(); newFilter.currentPage = 1; // #1795 - reset search term if not present in saved filter newFilter.searchTerm = ""; newFilter.configureFromSavedFilter(f); // #1507 - reset random seed when loaded newFilter.randomSeed = -1; onSetFilter(newFilter); } interface ISavedFilterItem { item: SavedFilterDataFragment; } const SavedFilterItem: React.FC = ({ item }) => { return (
    filterClicked(item)} title={item.name}> {item.name}
    ); }; function renderSavedFilters() { if (error) return
    {error.message}
    ; if (loading || saving) { return (
    ); } return (
      {savedFilters .filter( (f) => !filterName || f.name.toLowerCase().includes(filterName.toLowerCase()) ) .map((f) => ( ))}
    ); } function maybeRenderSetDefaultButton() { if (view) { return (
    onSetDefaultFilter()} > {intl.formatMessage({ id: "actions.set_as_default" })}
    ); } } return ( <> { if (confirm) { onDeleteFilter(deletingFilter!); } setDeletingFilter(undefined); }} /> { if (confirm) { onSaveFilter(overwritingFilter!.name, overwritingFilter!.id); } setOverwritingFilter(undefined); }} /> setFilterName(e.target.value)} /> } > {renderSavedFilters()} {maybeRenderSetDefaultButton()} ); }; interface ISavedFilterItem { item: SavedFilterDataFragment; onClick: () => void; onDelete: () => void; selected?: boolean; } const SavedFilterItem: React.FC = ({ item, onClick, onDelete, selected = false, }) => { const intl = useIntl(); return (
  • ); }; const SavedFilters: React.FC<{ error?: string; loading?: boolean; saving?: boolean; savedFilters: SavedFilterDataFragment[]; onFilterClicked: (f: SavedFilterDataFragment) => void; onDeleteClicked: (f: SavedFilterDataFragment) => void; currentFilterID?: string; }> = ({ error, loading, saving, savedFilters, onFilterClicked, onDeleteClicked, currentFilterID, }) => { if (error) return
    {error}
    ; if (loading || saving) { return (
    ); } return (
      {savedFilters.map((f) => ( onFilterClicked(f)} onDelete={() => onDeleteClicked(f)} selected={currentFilterID === f.id} /> ))}
    ); }; export const SidebarSavedFilterList: React.FC = ({ filter, onSetFilter, view, }) => { const Toast = useToast(); const intl = useIntl(); const [currentSavedFilter, setCurrentSavedFilter] = useState<{ id: string; set: boolean; }>(); const { data, error, loading, refetch } = useFindSavedFilters(filter.mode); const [filterName, setFilterName] = useState(""); const [saving, setSaving] = useState(false); const [deletingFilter, setDeletingFilter] = useState< SavedFilterDataFragment | undefined >(); const [showSaveDialog, setShowSaveDialog] = useState(false); const [settingDefault, setSettingDefault] = useState(false); const saveFilter = useSaveFilter(); const [destroyFilter] = useSavedFilterDestroy(); const [saveUISetting] = useConfigureUISetting(); const filteredFilters = useMemo(() => { const savedFilters = data?.findSavedFilters ?? []; if (!filterName) return savedFilters; return savedFilters.filter( (f) => !filterName || f.name.toLowerCase().includes(filterName.toLowerCase()) ); }, [data?.findSavedFilters, filterName]); // handle when filter is changed to de-select the current filter useEffect(() => { // HACK - first change will be from setting the filter // second change is likely from somewhere else setCurrentSavedFilter((v) => { if (!v) return v; if (v.set) { setCurrentSavedFilter({ id: v.id, set: false }); } else { setCurrentSavedFilter(undefined); } }); }, [filter]); async function onSaveFilter(name: string, id?: string) { try { setSaving(true); await saveFilter(filter, name, id); Toast.success( intl.formatMessage( { id: "toast.saved_entity", }, { entity: intl.formatMessage({ id: "filter" }).toLocaleLowerCase(), } ) ); setFilterName(""); setShowSaveDialog(false); refetch(); } catch (err) { Toast.error(err); } finally { setSaving(false); } } async function onDeleteFilter(f: SavedFilterDataFragment) { try { setSaving(true); await destroyFilter({ variables: { input: { id: f.id, }, }, }); Toast.success( intl.formatMessage( { id: "toast.delete_past_tense", }, { count: 1, singularEntity: intl.formatMessage({ id: "filter" }), pluralEntity: intl.formatMessage({ id: "filters" }), } ) ); refetch(); } catch (err) { Toast.error(err); } finally { setSaving(false); setDeletingFilter(undefined); } } async function onSetDefaultFilter() { if (!view) { return; } const filterCopy = filter.clone(); try { setSaving(true); await saveUISetting({ variables: { key: `defaultFilters.${view.toString()}`, value: { mode: filter.mode, find_filter: filterCopy.makeFindFilter(), object_filter: filterCopy.makeSavedFilter(), ui_options: filterCopy.makeSavedUIOptions(), }, }, }); Toast.success( intl.formatMessage({ id: "toast.default_filter_set", }) ); } catch (err) { Toast.error(err); } finally { setSaving(false); setSettingDefault(false); } } function filterClicked(f: SavedFilterDataFragment) { const newFilter = filter.clone(); newFilter.currentPage = 1; // #1795 - reset search term if not present in saved filter newFilter.searchTerm = ""; newFilter.configureFromSavedFilter(f); // #1507 - reset random seed when loaded newFilter.randomSeed = -1; setCurrentSavedFilter({ id: f.id, set: true }); onSetFilter(newFilter); } return (
    { if (confirm) { onDeleteFilter(deletingFilter!); } setDeletingFilter(undefined); }} /> {showSaveDialog && ( { setShowSaveDialog(false); if (name) { onSaveFilter(name, id); } }} /> )} } confirmVariant="primary" onConfirm={() => onSetDefaultFilter()} onCancel={() => setSettingDefault(false)} />
    setFilterName(e.target.value)} />
    ); }; export const SavedFilterDropdown: React.FC = (props) => { const SavedFilterDropdownRef = React.forwardRef< HTMLDivElement, HTMLAttributes >(({ style, className }: HTMLAttributes, ref) => (
    )); SavedFilterDropdownRef.displayName = "SavedFilterDropdown"; const menu = ( ); return ( } > {props.menuPortalTarget ? createPortal(menu, props.menuPortalTarget) : menu} ); }; ================================================ FILE: ui/v2.5/src/components/List/ZoomSlider.tsx ================================================ import React, { useEffect } from "react"; import Mousetrap from "mousetrap"; import { Form } from "react-bootstrap"; const minZoom = 0; const maxZoom = 3; export function useZoomKeybinds(props: { zoomIndex: number | undefined; onChangeZoom: (v: number) => void; }) { const { zoomIndex, onChangeZoom } = props; useEffect(() => { Mousetrap.bind("+", () => { if (zoomIndex !== undefined && zoomIndex < maxZoom) { onChangeZoom(zoomIndex + 1); } }); Mousetrap.bind("-", () => { if (zoomIndex !== undefined && zoomIndex > minZoom) { onChangeZoom(zoomIndex - 1); } }); return () => { Mousetrap.unbind("+"); Mousetrap.unbind("-"); }; }); } export interface IZoomSelectProps { zoomIndex: number; onChangeZoom: (v: number) => void; } export const ZoomSelect: React.FC = ({ zoomIndex, onChangeZoom, }) => { return ( ) => { onChangeZoom(Number.parseInt(e.currentTarget.value, 10)); e.preventDefault(); e.stopPropagation(); }} /> ); }; ================================================ FILE: ui/v2.5/src/components/List/styles.scss ================================================ .pagination { .btn { border-left: 1px solid $body-bg; border-right: 1px solid $body-bg; flex-grow: 0; padding-left: 15px; padding-right: 15px; transition: none; &.page-count { padding-right: 5px; } &.page-count-dropdown { padding-left: 5px; } &:first-child { border-left: none; border-right: none; } &:last-child { border-right: none; } } .page-count-container .btn { border-radius: 0; } } .center-text { text-align: center; } .display-mode-select { padding-left: 0.375rem; padding-right: 0.375rem; text-wrap: nowrap; > svg:first-child { margin-right: 0; } } .display-mode-menu { .dropdown-item { color: #f5f8fa; font-size: 1rem; &:hover { background-color: rgba(138, 155, 168, 0.15); cursor: pointer; } } .zoom-slider-container { display: flex; justify-content: center; margin-bottom: 0.5rem; min-height: 1rem; padding-bottom: 0.5rem; padding-top: 0.25rem; } .zoom-slider { &::-webkit-slider-thumb { background-color: $primary; } &::-webkit-slider-runnable-track { background-color: $body-bg; } &:focus::-webkit-slider-runnable-track { background-color: lighten($body-bg, 5%); } &::-moz-range-thumb { background-color: $primary; } &::-moz-range-track { background-color: $body-bg; } &:focus::-moz-range-track { background-color: lighten($body-bg, 5%); } } } // hide zoom slider in xs viewport @include media-breakpoint-down(xs) { .display-mode-menu .zoom-slider-container, .zoom-slider-container { display: none; } } .display-mode-popover { padding-left: 0; padding-right: 0; } input[type="range"].zoom-slider { height: 100%; margin: 0; max-width: 60px; padding-left: 0; padding-right: 0; // width is set to 100% by default, but in a flex container, it gets a very small width width: unset; } .query-text-field-group { align-items: stretch; display: flex; flex-wrap: wrap; position: relative; } .query-text-field { border: 0; width: 50%; } .query-text-field-clear { background-color: $secondary; color: $text-muted; font-size: $btn-font-size-sm; margin: $btn-padding-y $btn-padding-x; padding: 0; position: absolute; right: 0; z-index: 4; &:hover, &:focus, &:active, &:not(:disabled):not(.disabled):active, &:not(:disabled):not(.disabled):active:focus { background-color: $secondary; border-color: transparent; box-shadow: none; } } .saved-filter-list-menu { width: 300px; &.dropdown-menu.show { display: flex; flex-direction: column; } .set-as-default-button { float: right; margin-right: 0.5rem; padding: 0.25rem 0.5rem; width: auto; } .LoadingIndicator { height: auto; text-align: center; .spinner-border { height: 1.5rem; width: 1.5rem; } } } .saved-filter-list { list-style: none; margin-bottom: 0.25rem; max-height: 230px; overflow-y: auto; padding-left: 0; .dropdown-item-container { display: flex; .dropdown-item { align-items: center; color: $text-color; display: inline; overflow-x: hidden; padding-left: 1.25rem; padding-right: 0.25rem; text-overflow: ellipsis; &:focus, &:hover { background-color: #8a9ba826; cursor: pointer; } } .btn-group { margin-left: auto; .btn { border-radius: 0; } } .delete-button { color: $danger; } } } .sidebar-saved-filter-list-container .toolbar { align-items: center; display: flex; justify-content: space-between; padding: 0.5rem; .btn { font-weight: bold; } } .sidebar-saved-filter-list-container { .label-group { align-items: center; display: flex; overflow: hidden; } .saved-filter-item { cursor: pointer; margin-bottom: 0.25rem; min-height: 2em; a { align-items: center; display: flex; justify-content: space-between; min-height: 2em; outline: none; &:hover, &:focus-visible { background-color: rgba(138, 155, 168, 0.15); } .selected-object-label, .excluded-object-label { font-size: 16px; } } .label-group { align-items: center; display: flex; overflow: hidden; .selected { font-weight: bold; } .fa-icon { flex-shrink: 0; } } .delete-button { color: $danger; } } .saved-filter-search-input { margin-bottom: 0.5rem; } } .save-filter-dialog { .existing-filter-list { max-height: 300px; overflow-y: auto; } } .save-filter-button { color: $text-color; } .saved-filter-overwrite-warning { color: $danger; font-weight: bold; } .edit-filter-dialog .rating-stars { font-size: 1.3em; margin-left: 0.25em; } .rating-filter .and-divider { margin-left: 0.5em; } .edit-filter-dialog { .modal-header { align-items: center; padding: 0.5rem 1rem; .search-input { width: auto; } } .modal-body { max-height: min(550px, calc(100vh - 12rem)); padding-left: 0; padding-right: 0; } .modal-footer { justify-content: space-between; > div > :not(:first-child) { margin-left: 0.25rem; } } .search-term-row { align-items: center; display: flex; gap: 0.5rem; justify-content: space-between; margin-bottom: 0.5rem; margin-left: 1.5rem; margin-right: 1rem; .search-term-input { flex-basis: 75%; } @include media-breakpoint-down(xs) { flex-wrap: wrap; > span { width: 100%; } .search-term-input { flex-basis: 100%; } } } .filter-tags { border-top: 1px solid rgb(16 22 26 / 40%); padding: 1rem 1rem 0 1rem; } .criterion-list { flex-direction: column; flex-wrap: nowrap; .pinned-criterion-divider { padding-bottom: 2.5rem; } .card { border: 1px solid rgb(16 22 26 / 40%); box-shadow: none; margin: 0 0 -1px; padding: 0; .collapse-icon { margin-left: 0; } .filter-item-header { background-color: $card-cap-bg; border: none; border-bottom: 1px solid rgba(0, 0, 0, 0.125); color: inherit; cursor: pointer; display: flex; margin-bottom: 0; padding: 0.75rem 1.25rem; &:focus { border-color: $primary; border-radius: calc(0.375rem - 1px); box-shadow: inset 0 0 0 0.1rem rgba(19, 124, 189); outline: 0; } } } .filter-item-header .btn { border: 0; padding-bottom: 0; padding-top: 0; } .pin-criterion-button { color: $text_color; &:hover svg { transform: rotate(0); } } .remove-criterion-button { color: $danger; } } .edit-filter-right { display: flex; flex-direction: column; justify-content: space-between; padding-left: 1rem; padding-right: 1rem; width: 100%; } } .modifier-options { display: flex; flex-wrap: wrap; justify-content: center; } .modifier-options .modifier-option { background-color: $secondary; border: none; border-radius: 10rem; cursor: pointer; display: inline-block; font-size: 100%; font-weight: 700; line-height: 1; margin-bottom: 0.5rem; margin-right: 0.25rem; padding: 0.25em 0.6em; text-align: center; transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; vertical-align: baseline; white-space: nowrap; &.selected { background-color: $primary; } } .filter-tags { display: flex; justify-content: center; margin-bottom: 0.5rem; .more-tags { background-color: transparent; color: #fff; } .clear-all-button { color: $text-color; // to match filter pills line-height: 16px; padding: 0; } .tag-item.unsupported { background-color: $warning; } } .filter-button { position: relative; .fa-icon { margin: 0; } .badge { font-size: 60%; position: absolute; right: 0; // button group has a z-index of 1 z-index: 2; } } .filter-visible-button { padding-left: 0.3rem; padding-right: 0.3rem; &:focus:not(.active):not(:hover) { background: none; } &:focus, &.active:focus { box-shadow: none; } } .selectable-filter ul, ul.selectable-list { list-style-type: none; margin-top: 0.5rem; max-height: 300px; overflow-y: auto; // to prevent unnecessary vertical scrollbar padding-bottom: 0.15rem; padding-inline-start: 0; .modifier-object { font-style: italic; .selected-object-label, .unselected-object-label { opacity: 0.6; } } .unselected-object { opacity: 0.8; } .selected-object, .excluded-object, .unselected-object { cursor: pointer; margin-bottom: 0.25rem; min-height: 2em; a { align-items: center; display: flex; justify-content: space-between; min-height: 2em; outline: none; &:hover, &:focus-visible { background-color: rgba(138, 155, 168, 0.15); } .selected-object-label, .excluded-object-label { font-size: 16px; } } .include-button { color: $success; } .exclude-icon { color: $danger; } .exclude-button { align-items: center; display: flex; margin-left: 0.25rem; padding-left: 0.25rem; padding-right: 0.25rem; .exclude-button-text { color: $danger; display: none; font-size: 12px; font-weight: 600; } &:hover { background-color: inherit; } &:hover .exclude-button-text, &:focus .exclude-button-text { display: inline; } } .object-count { color: $text-muted; font-size: 12px; } } .selected-object:hover, .selected-object a:focus-visible, .excluded-object:hover, .excluded-object a:focus-visible { .include-button, .exclude-icon { color: #fff; } } } // used to align list text without icons to those that do .sidebar .no-icon-margin { // icon width is 17.5px + 5.6px margin each side margin-left: 28.7px; } .sidebar-list-filter .clearable-input-group { margin-bottom: 0.5rem; } .sidebar-list-filter ul, .folder-filter ul { list-style-type: none; margin-bottom: 0.25rem; max-height: 300px; overflow-y: auto; // to prevent unnecessary vertical scrollbar padding-bottom: 0.15rem; padding-inline-start: 0; .modifier-object { font-style: italic; .selected-object-label, .unselected-object-label { opacity: 0.6; } } .unselected-object { opacity: 0.8; } .selected-object, .excluded-object, .unselected-object { cursor: pointer; margin-bottom: 0.25rem; min-height: 2em; a { align-items: center; display: flex; justify-content: space-between; min-height: 2em; outline: none; &:hover, &:focus-visible { background-color: rgba(138, 155, 168, 0.15); } .selected-object-label, .excluded-object-label { font-size: 16px; } } .include-button { color: $success; &.single-value { visibility: hidden; } } .exclude-icon { color: $danger; } .exclude-button { align-items: center; display: flex; margin-left: 0.25rem; padding-left: 0.25rem; padding-right: 0.25rem; .exclude-button-text { color: $danger; display: none; font-size: 12px; font-weight: 600; } &:hover { background-color: transparent; } &:hover .exclude-button-text, &:focus .exclude-button-text { display: inline; } } .object-count { color: $text-muted; font-size: 12px; } } .selected-object:hover, .selected-object a:focus-visible, .excluded-object:hover, .excluded-object a:focus-visible { .include-button, .exclude-icon { color: #fff; } } .selected-object, .unselected-object { .label-group { align-items: center; display: flex; overflow: hidden; } } } .sidebar-list-filter > .extra { padding-top: 0.25rem; } .sidebar-list-filter .extra { min-height: 2em; } .duplicate-sub-options { margin-left: 2rem; padding-left: 0.5rem; .duplicate-sub-option { align-items: center; cursor: pointer; display: flex; height: 2em; opacity: 0.8; padding-left: 0.5rem; &:hover { background-color: rgba(138, 155, 168, 0.15); } } } .sidebar-folder-filter ul, .folder-filter ul, ul.selectable-list { margin-top: 0.25rem; .btn.expand-collapse { font-size: 0.8rem; padding-left: 0; padding-right: 0.25rem; text-align: left; } .empty .btn.expand-collapse { visibility: hidden; } .selected-object a .selected-object-label { font-size: 0.8em; overflow-wrap: break-word; white-space: normal; } } .tilted { transform: rotate(45deg); } .table-list { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); margin-bottom: 1rem; margin-left: 0; margin-right: 0; max-height: 78dvh; min-width: min-content; overflow-x: auto; position: relative; table { margin: 0; thead { background-color: #202b33; position: sticky; top: 0; z-index: 1; } td:first-child { padding: 0; } label { margin: 0; padding: 0.5rem; } } .column-select { margin: 0; padding: 7px; } .select-col { width: 20px; } .comma-list { list-style: none; margin: 0; padding: 4px 2px; li { display: inline; } li::after { content: ", "; } li:last-child::after { content: ""; } } .newline-list { list-style: none; margin: 0; padding: 4px 2px; li { display: inline; white-space: pre-wrap; } li::after { content: "\A"; } li:last-child::after { content: ""; } } .newline-list.overflowable, .comma-list.overflowable { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .comma-list.overflowable { width: 190px; } .newline-list.overflowable { -webkit-line-clamp: 1; width: 700px; } .newline-list.overflowable:hover, .comma-list.overflowable:hover { background: #28343c; border: 1px solid #414c53; box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.28); display: block; height: auto; margin-left: -0.4rem; margin-top: -0.9rem; overflow: hidden; padding: 0.1rem 0.5rem; position: absolute; top: auto; white-space: normal; width: max-content; z-index: 100; } .comma-list.overflowable:hover { max-width: 40rem; } .newline-list.overflowable li .ellips-data:hover, .comma-list.overflowable li .ellips-data:hover { max-width: fit-content; } td { color: hsla(0, 0%, 100%, 0.6); font-weight: 500; position: relative; text-align: left; white-space: nowrap; .ellips-data { display: block; max-width: 190px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .star-rating-number { display: none; } a { font-weight: 600; white-space: nowrap; } } td.select-col { text-align: center; } .table thead th { border: none; white-space: nowrap; } tr { border-collapse: collapse; } .date-head { width: 97px; } } .table-list tbody tr:hover { background-color: #2d3942; } .table-list a { color: $text-color; } .table-list .table-striped td, .table-list .table-striped th { font-size: 1rem; vertical-align: middle; h5, h6 { font-size: 1rem; } &:first-child { border-left: none; } } .filtered-list-toolbar { align-items: center; background-color: $body-bg; gap: 0.5rem; justify-content: center; // offset the main padding margin-top: -0.5rem; padding-bottom: 0.5rem; padding-top: 0.5rem; & > .btn-group { flex-wrap: wrap; justify-content: center; row-gap: 0.5rem; &:first-child { justify-content: flex-start; } &:last-child { justify-content: flex-end; } } // set the width of the zoom-slider-container to prevent buttons moving when // the slider appears/disappears .zoom-slider-container { min-width: 60px; } } .custom-field-filter { align-items: center; display: flex; > div:first-child { flex-grow: 1; } .custom-field-filter-buttons { display: flex; flex-direction: column; margin-left: 0.25rem; .btn { border-radius: 0.2rem; font-size: 0.875rem; line-height: 1.5; padding: 0.25rem 0.5rem; &:first-child { margin-bottom: 0.25rem; } } } } .item-list-container .sidebar-pane { width: 100%; } .sidebar { .sidebar-search-container { display: flex; margin-bottom: 0.5rem; } .search-term-input { flex-grow: 1; margin-right: 0; .clearable-text-field { height: 100%; } } .edit-filter-button { width: 100%; } .sidebar-footer { background-color: $body-bg; bottom: 0; display: none; padding: 0.5rem; position: sticky; @include media-breakpoint-down(xs) { display: flex; justify-content: center; } } } @include media-breakpoint-down(xs) { .sidebar .sidebar-search-container { margin-top: 0.25rem; } } .pagination-footer-container { background-color: transparent; bottom: $navbar-height; position: sticky; z-index: 10; @include media-breakpoint-up(sm) { bottom: 0; } } .pagination-footer { margin: auto; padding: 0.5rem 1rem 0.75rem; width: fit-content; .pagination.btn-group { box-shadow: 0 8px 10px 2px rgb(0 0 0 / 30%); } .pagination { margin-bottom: 0; .btn:disabled { color: #888; opacity: 1; } } } // on very large screens, offset the margins to center the pagination controls @media (min-width: 1800px) { .sidebar-pane:not(.hide-sidebar) { .filter-tags, .pagination-index-container, .pagination-footer-container { margin-left: -$sidebar-width; margin-right: 0; } } } // hide sidebar Edit Filter button on larger screens @include media-breakpoint-up(md) { .sidebar .edit-filter-button { display: none; } } // hide the search input field if the sidebar is open on smaller screens @media (min-width: 576px) and (max-width: 1400px) { .sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar .search-term-input { display: none; } } #more-criteria-popover { box-shadow: 0 8px 10px 2px rgb(0 0 0 / 30%); max-width: 400px; padding: 0.25rem; } // Duration slider styles .duration-slider, .age-slider-container { padding: 0.5rem 0 1rem; width: 100%; } .duration-label-input, .age-label-input { background: transparent; border: 1px solid transparent; border-radius: 0.25rem; color: $text-color; font-size: 0.875rem; font-weight: 500; padding: 0.125rem 0.25rem; width: 4rem; &:hover { border-color: $secondary; } &:focus { border-color: $primary; outline: none; } } .duration-preset { cursor: pointer; } .selected-items-info { align-items: center; border: 1px solid $secondary; display: flex; gap: 0.25rem; justify-content: flex-end; } .scene-list-toolbar .selected-items-info, .gallery-list-toolbar .selected-items-info { justify-content: flex-start; } // modify margins for toolbar within sidebar pane to accommodate toggle button .sidebar-pane .filtered-list-toolbar { margin-left: 40px; margin-right: 40px; } // on very large screens, offset the margins to center the toolbar @media (min-width: 1800px) { .sidebar-pane:not(.hide-sidebar) { .filtered-list-toolbar { margin-left: -$sidebar-width; margin-right: 0; } } } .item-list-container .filtered-list-toolbar.has-selection { border-radius: 0.5rem; margin-left: auto; margin-right: auto; padding-left: 0.5rem; padding-right: 0.5rem; position: sticky; top: $navbar-height; width: fit-content; z-index: 10; @include media-breakpoint-down(xs) { top: 0; } } .detail-body .filtered-list-toolbar.has-selection { top: calc($sticky-detail-header-height + $navbar-height); @include media-breakpoint-down(xs) { top: 0; } } ================================================ FILE: ui/v2.5/src/components/List/util.ts ================================================ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Mousetrap from "mousetrap"; import { ListFilterModel } from "src/models/list-filter/filter"; import { useHistory, useLocation } from "react-router-dom"; import { isEqual, isFunction } from "lodash-es"; import { QueryResult } from "@apollo/client"; import { IHasID } from "src/utils/data"; import { useConfigurationContext } from "src/hooks/Config"; import { View } from "./views"; import { usePrevious } from "src/hooks/state"; import * as GQL from "src/core/generated-graphql"; import { DisplayMode } from "src/models/list-filter/types"; import { Criterion } from "src/models/list-filter/criteria/criterion"; function locationEquals( loc1: ReturnType | undefined, loc2: ReturnType ) { return loc1 && loc1.pathname === loc2.pathname && loc1.search === loc2.search; } export function useFilterURL( filter: ListFilterModel, setFilter: React.Dispatch>, options?: { defaultFilter?: ListFilterModel; active?: boolean; } ) { const { defaultFilter, active = true } = options ?? {}; const history = useHistory(); const location = useLocation(); const prevLocation = usePrevious(location); // when the filter changes, update the URL const updateFilter = useCallback( ( value: ListFilterModel | ((prevState: ListFilterModel) => ListFilterModel) ) => { const newFilter = isFunction(value) ? value(filter) : value; if (active) { const newParams = newFilter.makeQueryParameters(); history.replace({ ...history.location, search: newParams }); } else { // set the filter without updating the URL setFilter(newFilter); } }, [history, active, setFilter, filter] ); // This hook runs on every page location change (ie navigation), // and updates the filter accordingly. useEffect(() => { // don't apply if active is false // also don't apply if location is unchanged if (!active || locationEquals(prevLocation, location)) return; // re-init to load default filter on empty new query params if (!location.search) { if (defaultFilter) updateFilter(defaultFilter.clone()); return; } // the query has changed, update filter if necessary setFilter((prevFilter) => { let newFilter = prevFilter.empty(); newFilter.configureFromQueryString(location.search); if (!isEqual(newFilter, prevFilter)) { // filter may have changed if random seed was set, update the URL const newParams = newFilter.makeQueryParameters(); if (newParams !== location.search) { history.replace({ ...history.location, search: newParams }); } return newFilter; } else { return prevFilter; } }); }, [ active, prevLocation, location, defaultFilter, setFilter, updateFilter, history, ]); return { setFilter: updateFilter }; } export function useDefaultFilter(emptyFilter: ListFilterModel, view?: View) { const { configuration: config } = useConfigurationContext(); const defaultFilter = useMemo(() => { if (view && config?.ui.defaultFilters?.[view]) { const savedFilter = config.ui.defaultFilters[view]!; const newFilter = emptyFilter.clone(); newFilter.currentPage = 1; try { newFilter.configureFromSavedFilter(savedFilter); } catch (err) { console.log(err); // ignore } // #1507 - reset random seed when loaded newFilter.randomSeed = -1; return newFilter; } }, [view, config?.ui.defaultFilters, emptyFilter]); const retFilter = defaultFilter ?? emptyFilter; return { defaultFilter: retFilter }; } function useEmptyFilter(props: { filterMode: GQL.FilterMode; defaultSort?: string; config?: GQL.ConfigDataFragment; }) { const { filterMode, defaultSort, config } = props; const emptyFilter = useMemo( () => new ListFilterModel(filterMode, config, { defaultSortBy: defaultSort, }), [config, filterMode, defaultSort] ); return emptyFilter; } export interface IFilterStateHook { filterMode: GQL.FilterMode; defaultFilter?: ListFilterModel; defaultSort?: string; view?: View; useURL?: boolean; } export function useFilterState( props: IFilterStateHook & { config?: GQL.ConfigDataFragment; } ) { const { filterMode, defaultSort, config, view, useURL, defaultFilter: propDefaultFilter, } = props; const [filter, setFilterState] = useState( () => new ListFilterModel(filterMode, config, { defaultSortBy: defaultSort }) ); const emptyFilter = useEmptyFilter({ filterMode, defaultSort, config }); const { defaultFilter: defaultFilterFromConfig } = useDefaultFilter( emptyFilter, view ); const { setFilter } = useFilterURL(filter, setFilterState, { defaultFilter: propDefaultFilter ?? defaultFilterFromConfig, active: useURL, }); return { filter, setFilter }; } export function useFilterOperations(props: { filter: ListFilterModel; setFilter: ( value: ListFilterModel | ((prevState: ListFilterModel) => ListFilterModel) ) => void; }) { const { setFilter } = props; const setPage = useCallback( (p: number) => { setFilter((cv) => cv.changePage(p)); }, [setFilter] ); const setDisplayMode = useCallback( (displayMode: DisplayMode) => { setFilter((cv) => cv.setDisplayMode(displayMode)); }, [setFilter] ); const setZoom = useCallback( (newZoomIndex: number) => { setFilter((cv) => cv.setZoom(newZoomIndex)); }, [setFilter] ); const removeCriterion = useCallback( (removedCriterion: Criterion) => { setFilter((cv) => cv.removeCriterion(removedCriterion.criterionOption.type) ); }, [setFilter] ); const clearAllCriteria = useCallback( (includeSearchTerm = false) => { setFilter((cv) => cv.clearCriteria(includeSearchTerm)); }, [setFilter] ); return { setPage, setDisplayMode, setZoom, removeCriterion, clearAllCriteria, }; } export function useListKeyboardShortcuts(props: { currentPage?: number; onChangePage?: (page: number) => void; showEditFilter?: () => void; pages?: number; onSelectAll?: () => void; onSelectNone?: () => void; onInvertSelection?: () => void; }) { const { currentPage, onChangePage, showEditFilter, pages = 0, onSelectAll, onSelectNone, onInvertSelection, } = props; // set up hotkeys useEffect(() => { if (showEditFilter) { Mousetrap.bind("f", (e) => { showEditFilter(); // prevent default behavior of typing f in a text field // otherwise the filter dialog closes, the query field is focused and // f is typed. e.preventDefault(); }); return () => { Mousetrap.unbind("f"); }; } }, [showEditFilter]); useEffect(() => { if (!currentPage || !changePage || !pages) return; function changePage(page: number) { if (!currentPage || !onChangePage || !pages) return; if (page >= 1 && page <= pages) { onChangePage(page); } } Mousetrap.bind("right", () => { changePage(currentPage + 1); }); Mousetrap.bind("left", () => { changePage(currentPage - 1); }); Mousetrap.bind("shift+right", () => { changePage(Math.min(pages, currentPage + 10)); }); Mousetrap.bind("shift+left", () => { changePage(Math.max(1, currentPage - 10)); }); Mousetrap.bind("ctrl+end", () => { changePage(pages); }); Mousetrap.bind("ctrl+home", () => { changePage(1); }); return () => { Mousetrap.unbind("right"); Mousetrap.unbind("left"); Mousetrap.unbind("shift+right"); Mousetrap.unbind("shift+left"); Mousetrap.unbind("ctrl+end"); Mousetrap.unbind("ctrl+home"); }; }, [currentPage, onChangePage, pages]); useEffect(() => { Mousetrap.bind("s a", () => onSelectAll?.()); Mousetrap.bind("s n", () => onSelectNone?.()); Mousetrap.bind("s i", () => onInvertSelection?.()); return () => { Mousetrap.unbind("s a"); Mousetrap.unbind("s n"); Mousetrap.unbind("s i"); }; }, [onSelectAll, onSelectNone, onInvertSelection]); } export function useListSelect(items: T[]) { const [itemsSelected, setItemsSelected] = useState([]); const [lastClickedId, setLastClickedId] = useState(); // TODO - this doesn't get updated when items changes const selectedIds = useMemo(() => { const newSelectedIds = new Set(); itemsSelected.forEach((item) => { newSelectedIds.add(item.id); }); return newSelectedIds; }, [itemsSelected]); // const prevItems = usePrevious(items); // #5341 - HACK/TODO: this is a regression of previous behaviour. I don't like the idea // of keeping selected items that are no longer in the list, since its not // clear to the user that the item is still selected, but there is now an expectation of // this behaviour. // useEffect(() => { // if (prevItems === items) { // return; // } // // filter out any selectedIds that are no longer in the list // const newSelectedIds = new Set(); // selectedIds.forEach((id) => { // if (items.some((item) => item.id === id)) { // newSelectedIds.add(id); // } // }); // setSelectedIds(newSelectedIds); // }, [prevItems, items, selectedIds]); function singleSelect(id: string, selected: boolean) { setLastClickedId(id); setItemsSelected((prevSelected) => { if (selected) { // prevent duplicates if (prevSelected.some((v) => v.id === id)) { return prevSelected; } const item = items.find((i) => i.id === id); if (item) { return [...prevSelected, item]; } return prevSelected; } else { return prevSelected.filter((item) => item.id !== id); } }); } function selectRange(startIndex: number, endIndex: number) { let start = startIndex; let end = endIndex; if (start > end) { const tmp = start; start = end; end = tmp; } const subset = items.slice(start, end + 1); // prevent duplicates const toAdd = subset.filter((item) => !selectedIds.has(item.id)); const newSelected = itemsSelected.concat(toAdd); setItemsSelected(newSelected); } function multiSelect(id: string) { let startIndex = 0; let thisIndex = -1; if (lastClickedId) { startIndex = items.findIndex((item) => { return item.id === lastClickedId; }); } thisIndex = items.findIndex((item) => { return item.id === id; }); selectRange(startIndex, thisIndex); } function onSelectChange(id: string, selected: boolean, shiftKey: boolean) { if (shiftKey) { multiSelect(id); } else { singleSelect(id, selected); } } function onSelectAll() { // #5341 - HACK/TODO: maintaining legacy behaviour of replacing selected items with // all items on the current page. To be consistent with the existing behaviour, it // should probably _add_ all items on the current page to the selected items. setItemsSelected([...items]); setLastClickedId(undefined); } function onSelectNone() { setItemsSelected([]); setLastClickedId(undefined); } function onInvertSelection() { setItemsSelected((prevSelected) => { const selectedSet = new Set(prevSelected.map((item) => item.id)); return items.filter((item) => !selectedSet.has(item.id)); }); setLastClickedId(undefined); } // TODO - this is for backwards compatibility const getSelected = useCallback(() => itemsSelected, [itemsSelected]); // convenience state const hasSelection = itemsSelected.length > 0; return { selectedItems: itemsSelected, selectedIds, getSelected, onSelectChange, onSelectAll, onSelectNone, onInvertSelection, hasSelection, }; } export type IListSelect = ReturnType< typeof useListSelect >; // returns true if the filter has changed in a way that impacts the total count function totalCountImpacted( oldFilter: ListFilterModel, newFilter: ListFilterModel ) { return ( oldFilter.criteria.length !== newFilter.criteria.length || oldFilter.criteria.some((c) => { const newCriterion = newFilter.criteria.find( (nc) => nc.getId() === c.getId() ); return !newCriterion || !isEqual(c, newCriterion); }) ); } // this hook caches a query result and count, and only updates it when the filter changes // in a way that would impact the result count // it is used to prevent the result count/pagination from flickering when changing pages or sorting export function useCachedQueryResult( filter: ListFilterModel, result: T ) { const [cachedResult, setCachedResult] = useState(result); const lastFilterRef = useRef(filter); // if we are only changing the page or sort, don't update the result count useEffect(() => { if (!result.loading) { setCachedResult(result); } else { if (totalCountImpacted(lastFilterRef.current, filter)) { setCachedResult(result); } } lastFilterRef.current = filter; }, [filter, result]); return cachedResult; } export interface IQueryResultHook< T extends QueryResult, E extends IHasID = IHasID, M = unknown > { filterHook?: (filter: ListFilterModel) => ListFilterModel; useResult: (filter: ListFilterModel) => T; useMetadataInfo?: (filter: ListFilterModel) => M; getCount: (data: T) => number; getItems: (data: T) => E[]; } export function useQueryResult< T extends QueryResult, E extends IHasID = IHasID, M = unknown >( props: IQueryResultHook & { filter: ListFilterModel; } ) { const { filter, filterHook, useResult, useMetadataInfo, getItems, getCount } = props; const effectiveFilter = useMemo(() => { if (filterHook) { return filterHook(filter.clone()); } return filter; }, [filter, filterHook]); // metadata filter is the effective filter with the sort, page size and page number removed const metadataFilter = useMemo( () => effectiveFilter.metadataInfo(), [effectiveFilter] ); const result = useResult(effectiveFilter); const metadataInfo = useMetadataInfo?.(metadataFilter); // use cached query result for pagination and metadata rendering const cachedResult = useCachedQueryResult(effectiveFilter, result); const items = useMemo(() => getItems(result), [getItems, result]); const totalCount = useMemo( () => getCount(cachedResult), [getCount, cachedResult] ); const pages = Math.ceil(totalCount / filter.itemsPerPage); return { effectiveFilter, metadataInfo, result, cachedResult, items, totalCount, pages, }; } // this hook collects the common logic when closing the edit/delete dialog // if applied is true, then the list should be refetched and selection cleared export function useCloseEditDelete(props: { onSelectNone: () => void; closeModal: () => void; result: QueryResult; }) { const { onSelectNone, closeModal, result } = props; const onCloseEditDelete = useCallback( (applied?: boolean) => { closeModal(); if (applied) { onSelectNone(); // refetch result.refetch(); } }, [onSelectNone, closeModal, result] ); return onCloseEditDelete; } export function useScrollToTopOnPageChange( currentPage: number, loading: boolean ) { const prevPage = usePrevious(currentPage); // scroll to the top of the page when the page changes // only scroll to top if the page has changed and is not loading useEffect(() => { if (loading || currentPage === prevPage || prevPage === undefined) { return; } // if the current page has a detail-header, then // scroll up relative to that rather than 0, 0 const detailHeader = document.querySelector(".detail-header"); if (detailHeader) { window.scrollTo(0, detailHeader.scrollHeight - 50); } else { window.scrollTo(0, 0); } }, [prevPage, currentPage, loading]); } // handle case where page is more than there are pages export function useEnsureValidPage( filter: ListFilterModel, totalCount: number, setFilter: React.Dispatch> ) { useEffect(() => { const totalPages = Math.ceil(totalCount / filter.itemsPerPage); if (totalPages > 0 && filter.currentPage > totalPages) { setFilter((prevFilter) => prevFilter.changePage(totalPages)); } }, [filter, totalCount, setFilter]); } ================================================ FILE: ui/v2.5/src/components/List/views.ts ================================================ export enum View { Galleries = "galleries", Images = "images", Scenes = "scenes", Groups = "groups", Performers = "performers", Tags = "tags", SceneMarkers = "scene_markers", Studios = "studios", TagMarkers = "tag_markers", TagGalleries = "tag_galleries", TagScenes = "tag_scenes", TagImages = "tag_images", TagPerformers = "tag_performers", TagGroups = "tag_groups", PerformerScenes = "performer_scenes", PerformerGalleries = "performer_galleries", PerformerImages = "performer_images", PerformerGroups = "performer_groups", PerformerAppearsWith = "performer_appears_with", StudioGalleries = "studio_galleries", StudioImages = "studio_images", GalleryImages = "gallery_images", StudioScenes = "studio_scenes", StudioGroups = "studio_groups", StudioPerformers = "studio_performers", StudioChildren = "studio_children", GroupScenes = "group_scenes", GroupSubGroups = "group_sub_groups", GroupPerformers = "group_performers", } ================================================ FILE: ui/v2.5/src/components/MainNavbar.tsx ================================================ import React, { useEffect, useRef, useState, useCallback, useMemo, } from "react"; import { defineMessages, FormattedMessage, MessageDescriptor, useIntl, } from "react-intl"; import { Nav, Navbar, Button } from "react-bootstrap"; import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; import { LinkContainer } from "react-router-bootstrap"; import { Link, NavLink, useLocation, useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import SessionUtils from "src/utils/session"; import { Icon } from "src/components/Shared/Icon"; import { useConfigurationContext } from "src/hooks/Config"; import { ManualStateContext } from "./Help/context"; import { SettingsButton } from "./SettingsButton"; import { faBars, faChartColumn, faFilm, faHeart, faImage, faImages, faMapMarkerAlt, faPlayCircle, faQuestionCircle, faSignOutAlt, faTag, faTimes, faUser, faVideo, } from "@fortawesome/free-solid-svg-icons"; import { baseURL } from "src/core/createClient"; import { PatchComponent } from "src/patch"; interface IMenuItem { name: string; message: MessageDescriptor; href: string; icon: IconDefinition; hotkey: string; userCreatable?: boolean; } const messages = defineMessages({ scenes: { id: "scenes", defaultMessage: "Scenes", }, images: { id: "images", defaultMessage: "Images", }, groups: { id: "groups", defaultMessage: "Groups", }, markers: { id: "markers", defaultMessage: "Markers", }, performers: { id: "performers", defaultMessage: "Performers", }, studios: { id: "studios", defaultMessage: "Studios", }, tags: { id: "tags", defaultMessage: "Tags", }, galleries: { id: "galleries", defaultMessage: "Galleries", }, sceneTagger: { id: "sceneTagger", defaultMessage: "Scene Tagger", }, donate: { id: "donate", defaultMessage: "Donate", }, statistics: { id: "statistics", defaultMessage: "Statistics", }, }); const allMenuItems: IMenuItem[] = [ { name: "scenes", message: messages.scenes, href: "/scenes", icon: faPlayCircle, hotkey: "g s", userCreatable: true, }, { name: "images", message: messages.images, href: "/images", icon: faImage, hotkey: "g i", }, { name: "groups", message: messages.groups, href: "/groups", icon: faFilm, hotkey: "g v", userCreatable: true, }, { name: "markers", message: messages.markers, href: "/scenes/markers", icon: faMapMarkerAlt, hotkey: "g k", }, { name: "galleries", message: messages.galleries, href: "/galleries", icon: faImages, hotkey: "g l", userCreatable: true, }, { name: "performers", message: messages.performers, href: "/performers", icon: faUser, hotkey: "g p", userCreatable: true, }, { name: "studios", message: messages.studios, href: "/studios", icon: faVideo, hotkey: "g u", userCreatable: true, }, { name: "tags", message: messages.tags, href: "/tags", icon: faTag, hotkey: "g t", userCreatable: true, }, ]; const newPathsList = allMenuItems .filter((item) => item.userCreatable) .map((item) => item.href); const MainNavbarMenuItems = PatchComponent( "MainNavBar.MenuItems", (props: React.PropsWithChildren<{}>) => { return ; } ); const MainNavbarUtilityItems = PatchComponent( "MainNavBar.UtilityItems", (props: React.PropsWithChildren<{}>) => { return <>{props.children}; } ); export const MainNavbar: React.FC = () => { const history = useHistory(); const location = useLocation(); const { configuration } = useConfigurationContext(); const { openManual } = React.useContext(ManualStateContext); const [expanded, setExpanded] = useState(false); // Show all menu items by default, unless config says otherwise const menuItems = useMemo(() => { let cfgMenuItems = configuration?.interface.menuItems; if (!cfgMenuItems) { return allMenuItems; } // translate old movies menu item to groups cfgMenuItems = cfgMenuItems.map((item) => { if (item === "movies") { return "groups"; } return item; }); return allMenuItems.filter((menuItem) => cfgMenuItems!.includes(menuItem.name) ); }, [configuration]); // react-bootstrap typing bug const navbarRef = useRef(null); const intl = useIntl(); const maybeCollapse = useCallback( (event: Event) => { if ( navbarRef.current && event.target instanceof Node && !navbarRef.current.contains(event.target) ) { setExpanded(false); } }, [setExpanded] ); useEffect(() => { if (expanded) { document.addEventListener("click", maybeCollapse); document.addEventListener("touchstart", maybeCollapse); } return () => { document.removeEventListener("click", maybeCollapse); document.removeEventListener("touchstart", maybeCollapse); }; }, [expanded, maybeCollapse]); const goto = useCallback( (page: string) => { history.push(page); if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } }, [history] ); const pathname = location.pathname.replace(/\/$/, ""); let newPath = newPathsList.includes(pathname) ? `${pathname}/new` : null; if (newPath !== null) { let queryParam = new URLSearchParams(location.search).get("q"); if (queryParam) { newPath += "?q=" + encodeURIComponent(queryParam); } } // set up hotkeys useEffect(() => { Mousetrap.bind("?", () => openManual()); Mousetrap.bind("g z", () => goto("/settings")); menuItems.forEach((item) => Mousetrap.bind(item.hotkey, () => goto(item.href)) ); if (newPath) { Mousetrap.bind("n", () => history.push(String(newPath))); } return () => { Mousetrap.unbind("?"); Mousetrap.unbind("g z"); menuItems.forEach((item) => Mousetrap.unbind(item.hotkey)); if (newPath) { Mousetrap.unbind("n"); } }; }); function maybeRenderLogout() { if (SessionUtils.isLoggedIn()) { return ( ); } } const handleDismiss = useCallback(() => setExpanded(false), [setExpanded]); function renderUtilityButtons() { return ( <> {maybeRenderLogout()} ); } return ( <> {menuItems.map(({ href, icon, message }) => ( ))} ); }; ================================================ FILE: ui/v2.5/src/components/PageNotFound.tsx ================================================ import React from "react"; export const PageNotFound: React.FC = () => { return

    Page not found.

    ; }; ================================================ FILE: ui/v2.5/src/components/Performers/EditPerformersDialog.tsx ================================================ import React, { useEffect, useState } from "react"; import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import { useBulkPerformerUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "../Shared/Modal"; import { useToast } from "src/hooks/Toast"; import { MultiSet } from "../Shared/MultiSet"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { getAggregateInputValue, getAggregateState, getAggregateStateObject, } from "src/utils/bulkUpdate"; import { genderStrings, genderToString, stringToGender, } from "src/utils/gender"; import { circumcisedStrings, circumcisedToString, stringToCircumcised, } from "src/utils/circumcised"; import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; import { CountrySelect } from "../Shared/CountrySelect"; import { useConfigurationContext } from "src/hooks/Config"; import cx from "classnames"; import { BulkUpdateDateInput } from "../Shared/DateInput"; import { getDateError } from "src/utils/yup"; interface IListOperationProps { selected: GQL.SlimPerformerDataFragment[]; onClose: (applied: boolean) => void; } const performerFields = [ "favorite", "disambiguation", "rating100", "gender", "birthdate", "death_date", "career_start", "career_end", "country", "ethnicity", "eye_color", // "height", // "weight", "measurements", "fake_tits", "penis_length", "circumcised", "hair_color", "tattoos", "piercings", "ignore_auto_tag", ]; export const EditPerformersDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); const { configuration } = useConfigurationContext(); const { sfwContentMode } = configuration.interface; const [tagIds, setTagIds] = useState({ mode: GQL.BulkUpdateIdMode.Add, }); const [existingTagIds, setExistingTagIds] = useState(); const [aggregateState, setAggregateState] = useState({}); // height and weight needs conversion to/from number const [height, setHeight] = useState(); const [weight, setWeight] = useState(); const [penis_length, setPenisLength] = useState(); const [updateInput, setUpdateInput] = useState( {} ); const genderOptions = [""].concat(genderStrings); const circumcisedOptions = [""].concat(circumcisedStrings); const unsetDisabled = props.selected.length < 2; const [updatePerformers] = useBulkPerformerUpdate(getPerformerInput()); const [birthdateError, setBirthdateError] = useState(); const [deathDateError, setDeathDateError] = useState(); const [careerStartError, setCareerStartError] = useState< string | undefined >(); const [careerEndError, setCareerEndError] = useState(); useEffect(() => { setBirthdateError(getDateError(updateInput.birthdate ?? "", intl)); }, [updateInput.birthdate, intl]); useEffect(() => { setDeathDateError(getDateError(updateInput.death_date ?? "", intl)); }, [updateInput.death_date, intl]); useEffect(() => { setCareerStartError(getDateError(updateInput.career_start ?? "", intl)); }, [updateInput.career_start, intl]); useEffect(() => { setCareerEndError(getDateError(updateInput.career_end ?? "", intl)); }, [updateInput.career_end, intl]); // Network state const [isUpdating, setIsUpdating] = useState(false); function setUpdateField(input: Partial) { setUpdateInput({ ...updateInput, ...input }); } function getPerformerInput(): GQL.BulkPerformerUpdateInput { const performerInput: GQL.BulkPerformerUpdateInput = { ids: props.selected.map((performer) => { return performer.id; }), ...updateInput, tag_ids: tagIds, }; // we don't have unset functionality for the rating star control // so need to determine if we are setting a rating or not performerInput.rating100 = getAggregateInputValue( updateInput.rating100, aggregateState.rating100 ); // gender dropdown doesn't have unset functionality // so need to determine what we are setting performerInput.gender = getAggregateInputValue( updateInput.gender, aggregateState.gender ); performerInput.circumcised = getAggregateInputValue( updateInput.circumcised, aggregateState.circumcised ); if (height !== undefined) { performerInput.height_cm = height === null ? null : parseFloat(height); } if (weight !== undefined) { performerInput.weight = weight === null ? null : parseFloat(weight); } if (penis_length !== undefined) { performerInput.penis_length = penis_length === null ? null : parseFloat(penis_length); } return performerInput; } async function onSave() { setIsUpdating(true); try { await updatePerformers(); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl .formatMessage({ id: "performers" }) .toLocaleLowerCase(), } ) ); props.onClose(true); } catch (e) { Toast.error(e); } setIsUpdating(false); } useEffect(() => { const updateState: GQL.BulkPerformerUpdateInput = {}; const state = props.selected; let updateTagIds: string[] = []; let updateHeight: string | undefined | null = undefined; let updateWeight: string | undefined | null = undefined; let updatePenisLength: string | undefined | null = undefined; let first = true; state.forEach((performer: GQL.SlimPerformerDataFragment) => { getAggregateStateObject(updateState, performer, performerFields, first); const performerTagIDs = (performer.tags ?? []).map((p) => p.id).sort(); updateTagIds = getAggregateState(updateTagIds, performerTagIDs, first) ?? []; const thisHeight = performer.height_cm !== undefined && performer.height_cm !== null ? performer.height_cm.toString() : performer.height_cm; updateHeight = getAggregateState(updateHeight, thisHeight, first); const thisWeight = performer.weight !== undefined && performer.weight !== null ? performer.weight.toString() : performer.weight; updateWeight = getAggregateState(updateWeight, thisWeight, first); const thisPenisLength = performer.penis_length !== undefined && performer.penis_length !== null ? performer.penis_length.toString() : performer.penis_length; updatePenisLength = getAggregateState( updatePenisLength, thisPenisLength, first ); first = false; }); setExistingTagIds(updateTagIds); setHeight(updateHeight); setWeight(updateWeight); setAggregateState(updateState); setUpdateInput(updateState); }, [props.selected]); function render() { // sfw class needs to be set because it is outside body return ( props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} isRunning={isUpdating} >
    setUpdateField({ rating100: value ?? undefined }) } disabled={isUpdating} /> setUpdateField({ favorite: checked })} checked={updateInput.favorite ?? undefined} label={intl.formatMessage({ id: "favourite" })} /> setUpdateField({ gender: stringToGender(event.currentTarget.value), }) } > {genderOptions.map((opt) => ( ))} setUpdateField({ disambiguation: newValue }) } unsetDisabled={unsetDisabled} /> setUpdateField({ birthdate: newValue }) } unsetDisabled={unsetDisabled} error={birthdateError} /> setUpdateField({ death_date: newValue }) } unsetDisabled={unsetDisabled} error={deathDateError} /> setUpdateField({ country: v })} showFlag /> setUpdateField({ ethnicity: newValue }) } unsetDisabled={unsetDisabled} /> setUpdateField({ hair_color: newValue }) } unsetDisabled={unsetDisabled} /> setUpdateField({ eye_color: newValue }) } unsetDisabled={unsetDisabled} /> setHeight(newValue)} unsetDisabled={unsetDisabled} /> setWeight(newValue)} unsetDisabled={unsetDisabled} /> setUpdateField({ measurements: newValue }) } unsetDisabled={unsetDisabled} /> setPenisLength(newValue)} unsetDisabled={unsetDisabled} /> setUpdateField({ circumcised: stringToCircumcised(event.currentTarget.value), }) } > {circumcisedOptions.map((opt) => ( ))} setUpdateField({ fake_tits: newValue }) } unsetDisabled={unsetDisabled} /> setUpdateField({ tattoos: newValue })} unsetDisabled={unsetDisabled} /> setUpdateField({ piercings: newValue }) } unsetDisabled={unsetDisabled} /> setUpdateField({ career_start: newValue }) } unsetDisabled={unsetDisabled} error={careerStartError} /> setUpdateField({ career_end: newValue }) } unsetDisabled={unsetDisabled} error={careerEndError} /> { setTagIds((c) => ({ ...c, ids: itemIDs })); }} onSetMode={(newMode) => { setTagIds((c) => ({ ...c, mode: newMode })); }} ids={tagIds.ids ?? []} existingIds={existingTagIds} mode={tagIds.mode} menuPortalTarget={document.body} /> setUpdateField({ ignore_auto_tag: checked }) } checked={updateInput.ignore_auto_tag ?? undefined} />
    ); } return render(); }; ================================================ FILE: ui/v2.5/src/components/Performers/GenderIcon.tsx ================================================ import React from "react"; import { faVenus, faTransgenderAlt, faMars, faNonBinary, } from "@fortawesome/free-solid-svg-icons"; import * as GQL from "src/core/generated-graphql"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useIntl } from "react-intl"; interface IIconProps { gender?: GQL.Maybe; className?: string; } function genderIcon(gender: GQL.GenderEnum) { switch (gender) { case GQL.GenderEnum.Male: return faMars; case GQL.GenderEnum.Female: return faVenus; case GQL.GenderEnum.NonBinary: return faNonBinary; default: return faTransgenderAlt; } } const GenderIcon: React.FC = ({ gender, className }) => { const intl = useIntl(); if (gender) { const icon = genderIcon(gender); // new version of fontawesome doesn't seem to support titles on icons, so adding it // to a span instead return ( ); } return null; }; export default GenderIcon; ================================================ FILE: ui/v2.5/src/components/Performers/PerformerCard.tsx ================================================ import React from "react"; import { Link } from "react-router-dom"; import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import NavUtils from "src/utils/navigation"; import TextUtils from "src/utils/text"; import { GridCard } from "../Shared/GridCard/GridCard"; import { CountryFlag } from "../Shared/CountryFlag"; import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; import { TagLink } from "../Shared/TagLink"; import { Button, ButtonGroup } from "react-bootstrap"; import { ModifierCriterion, CriterionValue, } from "src/models/list-filter/criteria/criterion"; import { PopoverCountButton } from "../Shared/PopoverCountButton"; import GenderIcon from "./GenderIcon"; import { faLink, faTag } from "@fortawesome/free-solid-svg-icons"; import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons"; import { RatingBanner } from "../Shared/RatingBanner"; import { usePerformerUpdate } from "src/core/StashService"; import { ILabeledId } from "src/models/list-filter/types"; import { FavoriteIcon } from "../Shared/FavoriteIcon"; import { PatchComponent } from "src/patch"; import { ExternalLinksButton } from "../Shared/ExternalLinksButton"; import { useConfigurationContext } from "src/hooks/Config"; import { OCounterButton } from "../Shared/CountButton"; export interface IPerformerCardExtraCriteria { scenes?: ModifierCriterion[]; images?: ModifierCriterion[]; galleries?: ModifierCriterion[]; groups?: ModifierCriterion[]; performer?: ILabeledId; } interface IPerformerCardProps { performer: GQL.PerformerDataFragment; cardWidth?: number; ageFromDate?: string; selecting?: boolean; selected?: boolean; zoomIndex?: number; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; extraCriteria?: IPerformerCardExtraCriteria; } const PerformerCardPopovers: React.FC = PatchComponent( "PerformerCard.Popovers", ({ performer, extraCriteria }) => { function maybeRenderScenesPopoverButton() { if (!performer.scene_count) return; return ( ); } function maybeRenderImagesPopoverButton() { if (!performer.image_count) return; return ( ); } function maybeRenderGalleriesPopoverButton() { if (!performer.gallery_count) return; return ( ); } function maybeRenderOCounter() { if (!performer.o_counter) return; return ; } function maybeRenderTagPopoverButton() { if (performer.tags.length <= 0) return; const popoverContent = performer.tags.map((tag) => ( )); return ( ); } function maybeRenderGroupsPopoverButton() { if (!performer.group_count) return; return ( ); } if ( performer.scene_count || performer.image_count || performer.gallery_count || performer.tags.length > 0 || performer.o_counter || performer.group_count ) { return ( <>
    {maybeRenderScenesPopoverButton()} {maybeRenderGroupsPopoverButton()} {maybeRenderImagesPopoverButton()} {maybeRenderGalleriesPopoverButton()} {maybeRenderTagPopoverButton()} {maybeRenderOCounter()} ); } return null; } ); const PerformerCardOverlays: React.FC = PatchComponent( "PerformerCard.Overlays", ({ performer }) => { const { configuration } = useConfigurationContext(); const uiConfig = configuration?.ui; const [updatePerformer] = usePerformerUpdate(); function onToggleFavorite(v: boolean) { if (performer.id) { updatePerformer({ variables: { input: { id: performer.id, favorite: v, }, }, }); } } function maybeRenderRatingBanner() { if (!performer.rating100) { return; } return ; } function maybeRenderFlag() { if (performer.country) { return ( {performer.country} ); } } function maybeRenderLinks() { if (!uiConfig?.showLinksOnPerformerCard) { return; } if (performer.urls && performer.urls.length > 0) { const twitter = performer.urls.filter((u) => u.match(/https?:\/\/(?:www\.)?(?:twitter|x).com\//) ); const instagram = performer.urls.filter((u) => u.match(/https?:\/\/(?:www\.)?instagram.com\//) ); const others = performer.urls.filter( (u) => !twitter.includes(u) && !instagram.includes(u) ); return (
    {twitter.length > 0 && ( )} {instagram.length > 0 && ( )} {others.length > 0 && ( )}
    ); } } return ( <> {maybeRenderRatingBanner()} {maybeRenderLinks()} {maybeRenderFlag()} ); } ); const PerformerCardDetails: React.FC = PatchComponent( "PerformerCard.Details", ({ performer, ageFromDate }) => { const intl = useIntl(); const age = TextUtils.age( performer.birthdate, ageFromDate ?? performer.death_date ); const ageL10nId = ageFromDate ? "media_info.performer_card.age_context" : "media_info.performer_card.age"; const ageL10String = intl.formatMessage({ id: "years_old", defaultMessage: "years old", }); const ageString = intl.formatMessage( { id: ageL10nId }, { age, years_old: ageL10String } ); return ( <> {age !== 0 ? (
    {ageString}
    ) : ( "" )} ); } ); const PerformerCardImage: React.FC = PatchComponent( "PerformerCard.Image", ({ performer }) => { return ( <> {performer.name ); } ); const PerformerCardTitle: React.FC = PatchComponent( "PerformerCard.Title", ({ performer }) => { return (
    {performer.name} {performer.disambiguation && ( {` (${performer.disambiguation})`} )}
    ); } ); export const PerformerCard: React.FC = PatchComponent( "PerformerCard", (props) => { const { performer, cardWidth, selecting, selected, onSelectedChanged, zoomIndex, } = props; return ( } title={} image={} overlays={} details={} popovers={} selected={selected} selecting={selecting} onSelectedChanged={onSelectedChanged} /> ); } ); ================================================ FILE: ui/v2.5/src/components/Performers/PerformerCardGrid.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { IPerformerCardExtraCriteria, PerformerCard } from "./PerformerCard"; import { useCardWidth, useContainerDimensions, } from "../Shared/GridCard/GridCard"; import { PatchComponent } from "src/patch"; interface IPerformerCardGrid { performers: GQL.PerformerDataFragment[]; selectedIds: Set; zoomIndex: number; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; extraCriteria?: IPerformerCardExtraCriteria; } const zoomWidths = [240, 300, 375, 470]; export const PerformerCardGrid: React.FC = PatchComponent( "PerformerCardGrid", ({ performers, selectedIds, zoomIndex, onSelectChange, extraCriteria }) => { const [componentRef, { width: containerWidth }] = useContainerDimensions(); const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); return (
    {performers.map((p) => ( 0} selected={selectedIds.has(p.id)} onSelectedChanged={(selected: boolean, shiftKey: boolean) => onSelectChange(p.id, selected, shiftKey) } extraCriteria={extraCriteria} /> ))}
    ); } ); ================================================ FILE: ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { Button, Tabs, Tab, Col, Row } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom"; import { Helmet } from "react-helmet"; import cx from "classnames"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { useFindPerformer, usePerformerUpdate, usePerformerDestroy, mutateMetadataAutoTag, } from "src/core/StashService"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useToast } from "src/hooks/Toast"; import { useConfigurationContext } from "src/hooks/Config"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { CompressedPerformerDetailsPanel, PerformerDetailsPanel, } from "./PerformerDetailsPanel"; import { PerformerScenesPanel } from "./PerformerScenesPanel"; import { PerformerGalleriesPanel } from "./PerformerGalleriesPanel"; import { PerformerGroupsPanel } from "./PerformerGroupsPanel"; import { PerformerImagesPanel } from "./PerformerImagesPanel"; import { PerformerAppearsWithPanel } from "./performerAppearsWithPanel"; import { PerformerEditPanel } from "./PerformerEditPanel"; import { PerformerMergeModal } from "../PerformerMergeDialog"; import { PerformerSubmitButton } from "./PerformerSubmitButton"; import { useRatingKeybinds } from "src/hooks/keybinds"; import { DetailImage } from "src/components/Shared/DetailImage"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { ExternalLinkButtons } from "src/components/Shared/ExternalLinksButton"; import { BackgroundImage } from "src/components/Shared/DetailsPage/BackgroundImage"; import { TabTitleCounter, useTabKey, } from "src/components/Shared/DetailsPage/Tabs"; import { DetailTitle } from "src/components/Shared/DetailsPage/DetailTitle"; import { ExpandCollapseButton } from "src/components/Shared/CollapseButton"; import { FavoriteIcon } from "src/components/Shared/FavoriteIcon"; import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; import { LightboxLink } from "src/hooks/Lightbox/LightboxLink"; import { PatchComponent } from "src/patch"; import { ILightboxImage } from "src/hooks/Lightbox/types"; import { goBackOrReplace } from "src/utils/history"; import { OCounterButton } from "src/components/Shared/CountButton"; interface IProps { performer: GQL.PerformerDataFragment; tabKey?: TabKey; } interface IPerformerParams { id: string; tab?: string; } const validTabs = [ "default", "scenes", "galleries", "images", "groups", "appearswith", ] as const; type TabKey = (typeof validTabs)[number]; function isTabKey(tab: string): tab is TabKey { return validTabs.includes(tab as TabKey); } const PerformerTabs: React.FC<{ tabKey?: TabKey; performer: GQL.PerformerDataFragment; abbreviateCounter: boolean; }> = ({ tabKey, performer, abbreviateCounter }) => { const populatedDefaultTab = useMemo(() => { let ret: TabKey = "scenes"; if (performer.scene_count == 0) { if (performer.gallery_count != 0) { ret = "galleries"; } else if (performer.image_count != 0) { ret = "images"; } else if (performer.group_count != 0) { ret = "groups"; } } return ret; }, [performer]); const { setTabKey } = useTabKey({ tabKey, validTabs, defaultTabKey: populatedDefaultTab, baseURL: `/performers/${performer.id}`, }); useEffect(() => { Mousetrap.bind("c", () => setTabKey("scenes")); Mousetrap.bind("g", () => setTabKey("galleries")); Mousetrap.bind("m", () => setTabKey("groups")); return () => { Mousetrap.unbind("c"); Mousetrap.unbind("g"); Mousetrap.unbind("m"); }; }); return ( } > } > } > } > } > ); }; interface IPerformerHeaderImageProps { activeImage: string | null | undefined; collapsed: boolean; encodingImage: boolean; lightboxImages: ILightboxImage[]; performer: GQL.PerformerDataFragment; } const PerformerHeaderImage: React.FC = PatchComponent( "PerformerHeaderImage", ({ encodingImage, activeImage, lightboxImages, performer }) => { return ( {!!activeImage && ( )} ); } ); const PerformerPage: React.FC = PatchComponent( "PerformerPage", ({ performer, tabKey }) => { const Toast = useToast(); const history = useHistory(); const intl = useIntl(); // Configuration settings const { configuration } = useConfigurationContext(); const uiConfig = configuration?.ui; const abbreviateCounter = uiConfig?.abbreviateCounters ?? false; const enableBackgroundImage = uiConfig?.enablePerformerBackgroundImage ?? false; const showAllDetails = uiConfig?.showAllDetails ?? true; const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false; const [collapsed, setCollapsed] = useState(!showAllDetails); const [isEditing, setIsEditing] = useState(false); const [isMerging, setIsMerging] = useState(false); const [image, setImage] = useState(); const [encodingImage, setEncodingImage] = useState(false); const loadStickyHeader = useLoadStickyHeader(); const activeImage = useMemo(() => { const performerImage = performer.image_path; if (isEditing) { if (image === null && performerImage) { const performerImageURL = new URL(performerImage); performerImageURL.searchParams.set("default", "true"); return performerImageURL.toString(); } else if (image) { return image; } } return performerImage; }, [image, isEditing, performer.image_path]); const lightboxImages = useMemo( () => [{ paths: { thumbnail: activeImage, image: activeImage } }], [activeImage] ); const [updatePerformer] = usePerformerUpdate(); const [deletePerformer, { loading: isDestroying }] = usePerformerDestroy(); async function onAutoTag() { try { await mutateMetadataAutoTag({ performers: [performer.id] }); Toast.success(intl.formatMessage({ id: "toast.started_auto_tagging" })); } catch (e) { Toast.error(e); } } function renderMergeButton() { return ( ); } function renderMergeDialog() { if (!performer.id) return; return ( { setIsMerging(false); if (mergedId !== undefined && mergedId !== performer.id) { // By default, the merge destination is the current performer, but // the user can change it, in which case we need to redirect. history.replace(`/performers/${mergedId}`); } }} performers={[performer]} /> ); } useRatingKeybinds( true, configuration?.ui.ratingSystemOptions?.type, setRating ); // set up hotkeys useEffect(() => { Mousetrap.bind("e", () => toggleEditing()); Mousetrap.bind("f", () => setFavorite(!performer.favorite)); Mousetrap.bind(",", () => setCollapsed(!collapsed)); return () => { Mousetrap.unbind("e"); Mousetrap.unbind("f"); Mousetrap.unbind(","); }; }); async function onSave(input: GQL.PerformerCreateInput) { await updatePerformer({ variables: { input: { id: performer.id, ...input, }, }, }); toggleEditing(false); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl.formatMessage({ id: "performer" }).toLocaleLowerCase(), } ) ); } async function onDelete() { try { await deletePerformer({ variables: { id: performer.id } }); } catch (e) { Toast.error(e); return; } goBackOrReplace(history, "/performers"); } function toggleEditing(value?: boolean) { if (value !== undefined) { setIsEditing(value); } else { setIsEditing((e) => !e); } setImage(undefined); } function setFavorite(v: boolean) { if (performer.id) { updatePerformer({ variables: { input: { id: performer.id, favorite: v, }, }, }); } } function setRating(v: number | null) { if (performer.id) { updatePerformer({ variables: { input: { id: performer.id, rating100: v, }, }, }); } } if (isDestroying) return ( ); const headerClassName = cx("detail-header", { edit: isEditing, collapsed, "full-width": !collapsed && !compactExpandedDetails, }); return (
    {performer.name}
    {!isEditing && ( setCollapsed(v)} /> )} setFavorite(v)} />
    setRating(value)} clickToRate withoutContext /> {!!performer.o_counter && ( )}
    {!isEditing && ( )} {isEditing ? ( toggleEditing()} setImage={setImage} setEncodingImage={setEncodingImage} /> ) : ( toggleEditing()} onDelete={onDelete} onAutoTag={onAutoTag} autoTagDisabled={performer.ignore_auto_tag} isNew={false} isEditing={false} onSave={() => {}} onImageChange={() => {}} classNames="mb-2" customButtons={ <> {renderMergeButton()}
    } >
    )}
    {!isEditing && loadStickyHeader && ( )}
    {!isEditing && ( )}
    {renderMergeDialog()}
    ); } ); const PerformerLoader: React.FC> = ({ location, match, }) => { const { id, tab } = match.params; const { data, loading, error } = useFindPerformer(id); useScrollToTopOnMount(); if (loading) return ; if (error) return ; if (!data?.findPerformer) return ; if (tab && !isTabKey(tab)) { return ( ); } return ( ); }; export default PerformerLoader; ================================================ FILE: ui/v2.5/src/components/Performers/PerformerDetails/PerformerCreate.tsx ================================================ import React, { useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { PerformerEditPanel } from "./PerformerEditPanel"; import { useHistory, useLocation } from "react-router-dom"; import { useToast } from "src/hooks/Toast"; import * as GQL from "src/core/generated-graphql"; import { usePerformerCreate } from "src/core/StashService"; const PerformerCreate: React.FC = () => { const Toast = useToast(); const history = useHistory(); const intl = useIntl(); const [image, setImage] = useState(); const [encodingImage, setEncodingImage] = useState(false); const location = useLocation(); const query = useMemo(() => new URLSearchParams(location.search), [location]); const performer = { name: query.get("q") ?? undefined, }; const [createPerformer] = usePerformerCreate(); async function onSave(input: GQL.PerformerCreateInput, andNew?: boolean) { const result = await createPerformer({ variables: { input }, }); if (result.data?.performerCreate) { if (!andNew) { history.push(`/performers/${result.data.performerCreate.id}`); } Toast.success( intl.formatMessage( { id: "toast.created_entity" }, { entity: intl.formatMessage({ id: "performer" }).toLocaleLowerCase(), } ) ); } } function renderPerformerImage() { if (encodingImage) { return ( ); } if (image) { return ( {intl.formatMessage({ ); } } return (
    {renderPerformerImage()}

    ); }; export default PerformerCreate; ================================================ FILE: ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx ================================================ import React, { PropsWithChildren } from "react"; import { useIntl } from "react-intl"; import { TagLink } from "src/components/Shared/TagLink"; import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; import { DetailItem } from "src/components/Shared/DetailItem"; import { CountryFlag } from "src/components/Shared/CountryFlag"; import { StashIDPill } from "src/components/Shared/StashID"; import { FormatAge, FormatCircumcised, FormatHeight, FormatPenisLength, FormatWeight, formatYearRange, } from "../PerformerList"; import { PatchComponent } from "src/patch"; import { CustomFields } from "src/components/Shared/CustomFields"; interface IPerformerDetails { performer: GQL.PerformerDataFragment; collapsed?: boolean; fullWidth?: boolean; } const PerformerDetailGroup: React.FC> = PatchComponent("PerformerDetailsPanel.DetailGroup", ({ children }) => { return
    {children}
    ; }); export const PerformerDetailsPanel: React.FC = PatchComponent("PerformerDetailsPanel", (props) => { const { performer, fullWidth, collapsed } = props; // Network state const intl = useIntl(); function renderTagsField() { if (!performer.tags.length) { return; } return (
      {(performer.tags ?? []).map((tag) => ( ))}
    ); } function renderStashIDs() { if (!performer.stash_ids.length) { return; } return (
      {performer.stash_ids.map((stashID) => (
    • ))}
    ); } let details = performer?.details ?.replace(/\[((?:http|www\.)[^\n\]]+)\]/gm, "") .trim(); return ( {performer.gender ? ( ) : ( "" )} {performer.country ? ( } fullWidth={fullWidth} /> ) : ( "" )} {(fullWidth || !collapsed) && ( )} ); }); export const CompressedPerformerDetailsPanel: React.FC = PatchComponent("CompressedPerformerDetailsPanel", ({ performer }) => { // Network state const intl = useIntl(); function scrollToTop() { window.scrollTo({ top: 0, behavior: "smooth" }); } return (
    scrollToTop()}> {performer.name} {performer.gender ? ( <> / {intl.formatMessage({ id: "gender_types." + performer.gender })} ) : ( "" )} {performer.birthdate ? ( <> / {TextUtils.age(performer.birthdate, performer.death_date)} ) : ( "" )} {performer.country ? ( <> / ) : ( "" )}
    ); }); ================================================ FILE: ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx ================================================ import React, { useEffect, useState } from "react"; import { Button, Form, Dropdown, SplitButton } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; import { useListPerformerScrapers, queryScrapePerformer, mutateReloadScrapers, queryScrapePerformerURL, } from "src/core/StashService"; import { Icon } from "src/components/Shared/Icon"; import { ImageInput } from "src/components/Shared/ImageInput"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { CountrySelect } from "src/components/Shared/CountrySelect"; import ImageUtils from "src/utils/image"; import { addUpdateStashID, getStashIDs } from "src/utils/stashIds"; import { stashboxDisplayName } from "src/utils/stashbox"; import { useToast } from "src/hooks/Toast"; import { Prompt } from "react-router-dom"; import { useFormik } from "formik"; import { genderToString, stringGenderMap, stringToGender, } from "src/utils/gender"; import { circumcisedToString, stringCircumMap, stringToCircumcised, } from "src/utils/circumcised"; import { useConfigurationContext } from "src/hooks/Config"; import { PerformerScrapeDialog } from "./PerformerScrapeDialog"; import PerformerScrapeModal from "./PerformerScrapeModal"; import PerformerStashBoxModal, { IStashBox } from "./PerformerStashBoxModal"; import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; import cx from "classnames"; import { faSyncAlt, faPlus } from "@fortawesome/free-solid-svg-icons"; import isEqual from "lodash-es/isEqual"; import { formikUtils } from "src/utils/form"; import { yupFormikValidate, yupInputNumber, yupInputEnum, yupDateString, yupRequiredStringArray, yupUniqueStringList, } from "src/utils/yup"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { CustomFieldsInput, formatCustomFieldInput, } from "src/components/Shared/CustomFields"; import { cloneDeep } from "@apollo/client/utilities"; const isScraper = ( scraper: GQL.Scraper | GQL.StashBox ): scraper is GQL.Scraper => (scraper as GQL.Scraper).id !== undefined; interface IPerformerDetails { performer: Partial; isVisible: boolean; onSubmit: ( performer: GQL.PerformerCreateInput, andNew?: boolean ) => Promise; onCancel?: () => void; setImage: (image?: string | null) => void; setEncodingImage: (loading: boolean) => void; } export const PerformerEditPanel: React.FC = ({ performer, isVisible, onSubmit, onCancel, setImage, setEncodingImage, }) => { const Toast = useToast(); const isNew = performer.id === undefined; // Editing state const [scraper, setScraper] = useState(); const [isScraperModalOpen, setIsScraperModalOpen] = useState(false); const [isStashIDSearchOpen, setIsStashIDSearchOpen] = useState(false); // Network state const [isLoading, setIsLoading] = useState(false); const Scrapers = useListPerformerScrapers(); const [queryableScrapers, setQueryableScrapers] = useState([]); const [scrapedPerformer, setScrapedPerformer] = useState(); const { configuration: stashConfig } = useConfigurationContext(); const intl = useIntl(); const schema = yup.object({ name: yup.string().required(), disambiguation: yup.string().ensure(), alias_list: yupRequiredStringArray(intl).defined(), gender: yupInputEnum(GQL.GenderEnum).nullable().defined(), birthdate: yupDateString(intl), death_date: yupDateString(intl), country: yup.string().ensure(), ethnicity: yup.string().ensure(), hair_color: yup.string().ensure(), eye_color: yup.string().ensure(), height_cm: yupInputNumber().positive().truncate().nullable().defined(), weight: yupInputNumber().positive().truncate().nullable().defined(), measurements: yup.string().ensure(), fake_tits: yup.string().ensure(), penis_length: yupInputNumber().positive().nullable().defined(), circumcised: yupInputEnum(GQL.CircumcisedEnum).nullable().defined(), tattoos: yup.string().ensure(), piercings: yup.string().ensure(), career_start: yupDateString(intl), career_end: yupDateString(intl), urls: yupUniqueStringList(intl), details: yup.string().ensure(), tag_ids: yup.array(yup.string().required()).defined(), ignore_auto_tag: yup.boolean().defined(), stash_ids: yup.mixed().defined(), image: yup.string().nullable().optional(), custom_fields: yup.object().required().defined(), }); const initialValues = { name: performer.name ?? "", disambiguation: performer.disambiguation ?? "", alias_list: performer.alias_list ?? [], gender: performer.gender ?? null, birthdate: performer.birthdate ?? "", death_date: performer.death_date ?? "", country: performer.country ?? "", ethnicity: performer.ethnicity ?? "", hair_color: performer.hair_color ?? "", eye_color: performer.eye_color ?? "", height_cm: performer.height_cm ?? null, weight: performer.weight ?? null, measurements: performer.measurements ?? "", fake_tits: performer.fake_tits ?? "", penis_length: performer.penis_length ?? null, circumcised: performer.circumcised ?? null, tattoos: performer.tattoos ?? "", piercings: performer.piercings ?? "", career_start: performer.career_start ?? "", career_end: performer.career_end ?? "", urls: performer.urls ?? [], details: performer.details ?? "", tag_ids: (performer.tags ?? []).map((t) => t.id), ignore_auto_tag: performer.ignore_auto_tag ?? false, stash_ids: getStashIDs(performer.stash_ids), custom_fields: cloneDeep(performer.custom_fields ?? {}), }; type InputValues = yup.InferType; const [customFieldsError, setCustomFieldsError] = useState(); function submit(values: InputValues) { const input = { ...schema.cast(values), custom_fields: formatCustomFieldInput(isNew, values.custom_fields), }; onSave(input); } const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), onSubmit: submit, }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( performer.tags, (ids) => formik.setFieldValue("tag_ids", ids) ); function translateScrapedGender(scrapedGender?: string) { if (!scrapedGender) { return; } // try to translate from enum values first const upperGender = scrapedGender.toUpperCase(); const asEnum = genderToString(upperGender); if (asEnum) { return stringToGender(asEnum); } else { // try to match against gender strings const caseInsensitive = true; return stringToGender(scrapedGender, caseInsensitive); } } function translateScrapedCircumcised(scrapedCircumcised?: string) { if (!scrapedCircumcised) { return; } const upperCircumcised = scrapedCircumcised.toUpperCase(); const asEnum = circumcisedToString(upperCircumcised); if (asEnum) { return stringToCircumcised(asEnum); } else { const caseInsensitive = true; return stringToCircumcised(scrapedCircumcised, caseInsensitive); } } function updatePerformerEditStateFromScraper( state: Partial ) { if (state.name) { formik.setFieldValue("name", state.name); } if (state.disambiguation) { formik.setFieldValue("disambiguation", state.disambiguation); } if (state.aliases) { formik.setFieldValue( "alias_list", state.aliases.split(",").map((a) => a.trim()) ); } if (state.birthdate) { formik.setFieldValue("birthdate", state.birthdate); } if (state.ethnicity) { formik.setFieldValue("ethnicity", state.ethnicity); } if (state.country) { formik.setFieldValue("country", state.country); } if (state.eye_color) { formik.setFieldValue("eye_color", state.eye_color); } if (state.height) { formik.setFieldValue("height_cm", parseInt(state.height, 10)); } if (state.measurements) { formik.setFieldValue("measurements", state.measurements); } if (state.fake_tits) { formik.setFieldValue("fake_tits", state.fake_tits); } if (state.career_start) { formik.setFieldValue("career_start", state.career_start); } if (state.career_end) { formik.setFieldValue("career_end", state.career_end); } if (state.tattoos) { formik.setFieldValue("tattoos", state.tattoos); } if (state.piercings) { formik.setFieldValue("piercings", state.piercings); } if (state.urls) { formik.setFieldValue("urls", state.urls); } if (state.gender) { // gender is a string in the scraper data const newGender = translateScrapedGender(state.gender); if (newGender) { formik.setFieldValue("gender", newGender); } } if (state.circumcised) { // circumcised is a string in the scraper data const newCircumcised = translateScrapedCircumcised(state.circumcised); if (newCircumcised) { formik.setFieldValue("circumcised", newCircumcised); } } updateTagsStateFromScraper(state.tags ?? undefined); // image is a base64 string // #404: don't overwrite image if it has been modified by the user // overwrite if not new since it came from a dialog // overwrite if image is unset if ( (!isNew || !formik.values.image) && state.images && state.images.length > 0 ) { const imageStr = state.images[0]; formik.setFieldValue("image", imageStr); } if (state.details) { formik.setFieldValue("details", state.details); } if (state.death_date) { formik.setFieldValue("death_date", state.death_date); } if (state.hair_color) { formik.setFieldValue("hair_color", state.hair_color); } if (state.weight) { formik.setFieldValue("weight", state.weight); } if (state.penis_length) { formik.setFieldValue("penis_length", state.penis_length); } updateStashIDs(state.remote_site_id); } function updateStashIDs(remoteSiteID: string | null | undefined) { if (remoteSiteID && (scraper as IStashBox).endpoint) { const newIDs = formik.values.stash_ids?.filter( (s) => s.endpoint !== (scraper as IStashBox).endpoint ) ?? []; newIDs?.push({ endpoint: (scraper as IStashBox).endpoint, stash_id: remoteSiteID, updated_at: new Date().toISOString(), }); formik.setFieldValue("stash_ids", newIDs); } } const encodingImage = ImageUtils.usePasteImage(onImageLoad); useEffect(() => { setImage(formik.values.image); }, [formik.values.image, setImage]); useEffect(() => { setEncodingImage(encodingImage); }, [setEncodingImage, encodingImage]); function onImageLoad(imageData: string | null) { formik.setFieldValue("image", imageData); } function onImageChange(event: React.FormEvent) { ImageUtils.onImageChange(event, onImageLoad); } async function onSave(input: InputValues, andNew?: boolean) { setIsLoading(true); try { await onSubmit(input, andNew); formik.resetForm(); } catch (e) { Toast.error(e); } setIsLoading(false); } async function onSaveAndNewClick() { const { values } = formik; const input = { ...schema.cast(values), custom_fields: formatCustomFieldInput(isNew, values.custom_fields), }; onSave(input, true); } // set up hotkeys useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { if (formik.dirty) { formik.submitForm(); } }); return () => { Mousetrap.unbind("s s"); if (!isNew) { Mousetrap.unbind("d d"); } }; } }); useEffect(() => { const newQueryableScrapers = (Scrapers?.data?.listScrapers ?? []).filter( (s) => s.performer?.supported_scrapes.includes(GQL.ScrapeType.Name) ); setQueryableScrapers(newQueryableScrapers); }, [Scrapers]); if (isLoading) return ; async function onReloadScrapers() { setIsLoading(true); try { await mutateReloadScrapers(); } catch (e) { Toast.error(e); } finally { setIsLoading(false); } } async function onScrapePerformer( selectedPerformer: GQL.ScrapedPerformerDataFragment, selectedScraper: GQL.Scraper ) { setIsScraperModalOpen(false); try { if (!scraper) return; setIsLoading(true); const { __typename, images: _image, tags: _tags, ...ret } = selectedPerformer; const result = await queryScrapePerformer(selectedScraper.id, ret); if (!result?.data?.scrapeSinglePerformer?.length) return; // assume one result // if this is a new performer, just dump the data if (isNew) { updatePerformerEditStateFromScraper( result.data.scrapeSinglePerformer[0] ); setScraper(undefined); } else { setScrapedPerformer(result.data.scrapeSinglePerformer[0]); } } catch (e) { Toast.error(e); } finally { setIsLoading(false); } } async function onScrapePerformerURL(url: string) { if (!url) return; setIsLoading(true); try { const result = await queryScrapePerformerURL(url); if (!result.data || !result.data.scrapePerformerURL) { return; } // if this is a new performer, just dump the data if (isNew) { updatePerformerEditStateFromScraper(result.data.scrapePerformerURL); } else { setScrapedPerformer(result.data.scrapePerformerURL); } } catch (e) { Toast.error(e); } finally { setIsLoading(false); } } async function onScrapeStashBox(performerResult: GQL.ScrapedPerformer) { setIsScraperModalOpen(false); const result: GQL.ScrapedPerformerDataFragment = { ...performerResult, images: performerResult.images ?? undefined, __typename: "ScrapedPerformer", }; // if this is a new performer, just dump the data if (isNew) { updatePerformerEditStateFromScraper(result); setScraper(undefined); } else { setScrapedPerformer(result); } } function onScraperSelected(s: GQL.Scraper | IStashBox | undefined) { setScraper(s); setIsScraperModalOpen(true); } function renderScraperMenu() { if (!performer) { return; } const stashBoxes = stashConfig?.general.stashBoxes ?? []; const popover = ( {stashBoxes.map((s, index) => ( onScraperSelected({ ...s, index })} > {stashboxDisplayName(s.name, index)} ))} {queryableScrapers ? queryableScrapers.map((s) => ( onScraperSelected(s)} > {s.name} )) : ""} onReloadScrapers()} > ); return ( {popover} ); } function urlScrapable(scrapedUrl?: string) { return ( !!scrapedUrl && (Scrapers?.data?.listScrapers ?? []).some((s) => (s?.performer?.urls ?? []).some((u) => scrapedUrl.includes(u)) ) ); } function maybeRenderScrapeDialog() { if (!scrapedPerformer) { return; } const currentPerformer = { ...formik.values, image: formik.values.image ?? performer.image_path, }; return ( { onScrapeDialogClosed(p); }} /> ); } function onScrapeDialogClosed(p?: GQL.ScrapedPerformerDataFragment) { if (p) { updatePerformerEditStateFromScraper(p); } setScrapedPerformer(undefined); setScraper(undefined); } function onStashIDSelected(item?: GQL.StashIdInput) { if (!item) return; formik.setFieldValue( "stash_ids", addUpdateStashID(formik.values.stash_ids, item) ); } function renderButtons(classNames: string) { return (
    {!isNew && onCancel ? ( ) : null} {renderScraperMenu()}
    {isNew ? ( formik.submitForm()} > onSaveAndNewClick()}> ) : ( )}
    ); } const renderScrapeModal = () => { if (!isScraperModalOpen) return; return scraper !== undefined && isScraper(scraper) ? ( setScraper(undefined)} onSelectPerformer={onScrapePerformer} name={formik.values.name || ""} /> ) : scraper !== undefined && !isScraper(scraper) ? ( setScraper(undefined)} onSelectPerformer={onScrapeStashBox} name={formik.values.name || ""} /> ) : undefined; }; const { renderField, renderInputField, renderSelectField, renderDateField, renderStringListField, renderStashIDsField, renderURLListField, } = formikUtils(intl, formik); function renderCountryField() { const title = intl.formatMessage({ id: "country" }); const control = ( formik.setFieldValue("country", v)} /> ); return renderField("country", title, control); } function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); return renderField("tag_ids", title, tagsControl()); } return ( <> {renderScrapeModal()} {maybeRenderScrapeDialog()} {isStashIDSearchOpen && ( s.endpoint )} onSelectItem={(item) => { onStashIDSelected(item); setIsStashIDSearchOpen(false); }} initialQuery={performer.name ?? ""} /> )} {renderButtons("mb-3")}
    {renderInputField("name")} {renderInputField("disambiguation")} {renderStringListField("alias_list", "aliases", { orderable: false })} {renderSelectField("gender", stringGenderMap)} {renderDateField("birthdate")} {renderDateField("death_date")} {renderCountryField()} {renderInputField("ethnicity")} {renderInputField("hair_color")} {renderInputField("eye_color")} {renderInputField("height_cm", "number")} {renderInputField("weight", "number", "weight_kg")} {renderInputField("penis_length", "number", "penis_length_cm")} {renderSelectField("circumcised", stringCircumMap)} {renderInputField("measurements")} {renderInputField("fake_tits")} {renderInputField("tattoos", "textarea")} {renderInputField("piercings", "textarea")} {renderDateField("career_start")} {renderDateField("career_end")} {renderURLListField("urls", onScrapePerformerURL, urlScrapable)} {renderInputField("details", "textarea")} {renderTagsField()} {renderStashIDsField( "stash_ids", "performers", "stash_ids", undefined, )}
    {renderInputField("ignore_auto_tag", "checkbox")}
    formik.setFieldValue("custom_fields", v)} error={customFieldsError} setError={(e) => setCustomFieldsError(e)} /> {renderButtons("mt-3")} ); }; ================================================ FILE: ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { FilteredGalleryList } from "src/components/Galleries/GalleryList"; import { usePerformerFilterHook } from "src/core/performers"; import { View } from "src/components/List/views"; import { PatchComponent } from "src/patch"; interface IPerformerDetailsProps { active: boolean; performer: GQL.PerformerDataFragment; } export const PerformerGalleriesPanel: React.FC = PatchComponent("PerformerGalleriesPanel", ({ active, performer }) => { const filterHook = usePerformerFilterHook(performer); return ( ); }); ================================================ FILE: ui/v2.5/src/components/Performers/PerformerDetails/PerformerGroupsPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { FilteredGroupList } from "src/components/Groups/GroupList"; import { usePerformerFilterHook } from "src/core/performers"; import { View } from "src/components/List/views"; import { PatchComponent } from "src/patch"; interface IPerformerDetailsProps { active: boolean; performer: GQL.PerformerDataFragment; } export const PerformerGroupsPanel: React.FC = PatchComponent("PerformerGroupsPanel", ({ active, performer }) => { const filterHook = usePerformerFilterHook(performer); return ( ); }); ================================================ FILE: ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { FilteredImageList } from "src/components/Images/ImageList"; import { usePerformerFilterHook } from "src/core/performers"; import { View } from "src/components/List/views"; import { PatchComponent } from "src/patch"; interface IPerformerImagesPanel { active: boolean; performer: GQL.PerformerDataFragment; } export const PerformerImagesPanel: React.FC = PatchComponent("PerformerImagesPanel", ({ active, performer }) => { const filterHook = usePerformerFilterHook(performer); return ( ); }); ================================================ FILE: ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { FilteredSceneList } from "src/components/Scenes/SceneList"; import { usePerformerFilterHook } from "src/core/performers"; import { View } from "src/components/List/views"; import { PatchComponent } from "src/patch"; interface IPerformerDetailsProps { active: boolean; performer: GQL.PerformerDataFragment; } export const PerformerScenesPanel: React.FC = PatchComponent("PerformerScenesPanel", ({ active, performer }) => { const filterHook = usePerformerFilterHook(performer); return ( ); }); ================================================ FILE: ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx ================================================ import React, { useState } from "react"; import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { ScrapedInputGroupRow, ScrapedImagesRow, ScrapeDialogRow, ScrapedTextAreaRow, ScrapedCountryRow, ScrapedStringListRow, } from "src/components/Shared/ScrapeDialog/ScrapeDialogRow"; import { ScrapeDialog } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; import { Form } from "react-bootstrap"; import { genderStrings, genderToString, stringToGender, } from "src/utils/gender"; import { circumcisedStrings, circumcisedToString, stringToCircumcised, } from "src/utils/circumcised"; import { IStashBox } from "./PerformerStashBoxModal"; import { ScrapeResult } from "src/components/Shared/ScrapeDialog/scrapeResult"; import { Tag } from "src/components/Tags/TagSelect"; import { uniq } from "lodash-es"; import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags"; function renderScrapedGender( result: ScrapeResult, isNew?: boolean, onChange?: (value: string) => void ) { const selectOptions = [""].concat(genderStrings); return ( { if (isNew && onChange) { onChange(e.currentTarget.value); } }} > {selectOptions.map((opt) => ( ))} ); } export function renderScrapedGenderRow( title: string, result: ScrapeResult, onChange: (value: ScrapeResult) => void ) { return ( onChange(result.cloneWithValue(value)) )} onChange={onChange} /> ); } function renderScrapedCircumcised( result: ScrapeResult, isNew?: boolean, onChange?: (value: string) => void ) { const selectOptions = [""].concat(circumcisedStrings); return ( { if (isNew && onChange) { onChange(e.currentTarget.value); } }} > {selectOptions.map((opt) => ( ))} ); } export function renderScrapedCircumcisedRow( title: string, result: ScrapeResult, onChange: (value: ScrapeResult) => void ) { return ( onChange(result.cloneWithValue(value)) )} onChange={onChange} /> ); } interface IPerformerScrapeDialogProps { performer: Partial; performerTags: Tag[]; scraped: GQL.ScrapedPerformer; scraper?: GQL.Scraper | IStashBox; onClose: (scrapedPerformer?: GQL.ScrapedPerformer) => void; } export const PerformerScrapeDialog: React.FC = ( props: IPerformerScrapeDialogProps ) => { const intl = useIntl(); const endpoint = (props.scraper as IStashBox)?.endpoint ?? undefined; function getCurrentRemoteSiteID() { if (!endpoint) { return; } // #6257 - it is possible (though unsupported) to have multiple stash IDs for the same // endpoint; in that case, we should prefer the one matching the scraped remote site ID // if it exists const stashIDs = (props.performer.stash_ids ?? []).filter( (s) => s.endpoint === endpoint ); if (stashIDs.length > 1 && props.scraped.remote_site_id) { const matchingID = stashIDs.find( (s) => s.stash_id === props.scraped.remote_site_id ); if (matchingID) { return matchingID.stash_id; } } // otherwise, return the first stash ID for the endpoint return props.performer.stash_ids?.find((s) => s.endpoint === endpoint) ?.stash_id; } function translateScrapedGender(scrapedGender?: string | null) { if (!scrapedGender) { return; } let retEnum: GQL.GenderEnum | undefined; // try to translate from enum values first const upperGender = scrapedGender.toUpperCase(); const asEnum = genderToString(upperGender); if (asEnum) { retEnum = stringToGender(asEnum); } else { // try to match against gender strings const caseInsensitive = true; retEnum = stringToGender(scrapedGender, caseInsensitive); } return genderToString(retEnum); } function translateScrapedCircumcised(scrapedCircumcised?: string | null) { if (!scrapedCircumcised) { return; } let retEnum: GQL.CircumcisedEnum | undefined; // try to translate from enum values first const upperCircumcised = scrapedCircumcised.toUpperCase(); const asEnum = circumcisedToString(upperCircumcised); if (asEnum) { retEnum = stringToCircumcised(asEnum); } else { // try to match against circumcised strings const caseInsensitive = true; retEnum = stringToCircumcised(scrapedCircumcised, caseInsensitive); } return circumcisedToString(retEnum); } const [name, setName] = useState>( new ScrapeResult(props.performer.name, props.scraped.name) ); const [disambiguation, setDisambiguation] = useState>( new ScrapeResult( props.performer.disambiguation, props.scraped.disambiguation ) ); const [aliases, setAliases] = useState>( new ScrapeResult( props.performer.alias_list?.join(", "), props.scraped.aliases ) ); const [birthdate, setBirthdate] = useState>( new ScrapeResult(props.performer.birthdate, props.scraped.birthdate) ); const [deathDate, setDeathDate] = useState>( new ScrapeResult( props.performer.death_date, props.scraped.death_date ) ); const [ethnicity, setEthnicity] = useState>( new ScrapeResult(props.performer.ethnicity, props.scraped.ethnicity) ); const [country, setCountry] = useState>( new ScrapeResult(props.performer.country, props.scraped.country) ); const [hairColor, setHairColor] = useState>( new ScrapeResult( props.performer.hair_color, props.scraped.hair_color ) ); const [eyeColor, setEyeColor] = useState>( new ScrapeResult(props.performer.eye_color, props.scraped.eye_color) ); const [height, setHeight] = useState>( new ScrapeResult( props.performer.height_cm?.toString(), props.scraped.height ) ); const [weight, setWeight] = useState>( new ScrapeResult( props.performer.weight?.toString(), props.scraped.weight ) ); const [penisLength, setPenisLength] = useState>( new ScrapeResult( props.performer.penis_length?.toString(), props.scraped.penis_length ) ); const [measurements, setMeasurements] = useState>( new ScrapeResult( props.performer.measurements, props.scraped.measurements ) ); const [fakeTits, setFakeTits] = useState>( new ScrapeResult(props.performer.fake_tits, props.scraped.fake_tits) ); const [careerStart, setCareerStart] = useState>( new ScrapeResult( props.performer.career_start, props.scraped.career_start ) ); const [careerEnd, setCareerEnd] = useState>( new ScrapeResult( props.performer.career_end, props.scraped.career_end ) ); const [tattoos, setTattoos] = useState>( new ScrapeResult(props.performer.tattoos, props.scraped.tattoos) ); const [piercings, setPiercings] = useState>( new ScrapeResult(props.performer.piercings, props.scraped.piercings) ); const [urls, setURLs] = useState>( new ScrapeResult( props.performer.urls, props.scraped.urls ? uniq((props.performer.urls ?? []).concat(props.scraped.urls ?? [])) : undefined ) ); const [gender, setGender] = useState>( new ScrapeResult( genderToString(props.performer.gender), translateScrapedGender(props.scraped.gender) ) ); const [circumcised, setCircumcised] = useState>( new ScrapeResult( circumcisedToString(props.performer.circumcised), translateScrapedCircumcised(props.scraped.circumcised) ) ); const [details, setDetails] = useState>( new ScrapeResult(props.performer.details, props.scraped.details) ); const [remoteSiteID, setRemoteSiteID] = useState>( new ScrapeResult( getCurrentRemoteSiteID(), props.scraped.remote_site_id ) ); const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags( props.performerTags, props.scraped.tags, endpoint ); const [image, setImage] = useState>( new ScrapeResult( props.performer.image, props.scraped.images && props.scraped.images.length > 0 ? props.scraped.images[0] : undefined ) ); const images = props.scraped.images && props.scraped.images.length > 0 ? props.scraped.images : []; const allFields = [ name, disambiguation, aliases, birthdate, ethnicity, country, eyeColor, height, measurements, fakeTits, penisLength, circumcised, careerStart, careerEnd, tattoos, piercings, urls, gender, image, tags, details, deathDate, hairColor, weight, remoteSiteID, ]; // don't show the dialog if nothing was scraped if (allFields.every((r) => !r.scraped) && newTags.length === 0) { props.onClose(); return <>; } function makeNewScrapedItem(): GQL.ScrapedPerformer { const newImage = image.getNewValue(); return { name: name.getNewValue() ?? "", disambiguation: disambiguation.getNewValue(), aliases: aliases.getNewValue(), birthdate: birthdate.getNewValue(), ethnicity: ethnicity.getNewValue(), country: country.getNewValue(), eye_color: eyeColor.getNewValue(), height: height.getNewValue(), measurements: measurements.getNewValue(), fake_tits: fakeTits.getNewValue(), career_start: careerStart.getNewValue(), career_end: careerEnd.getNewValue(), tattoos: tattoos.getNewValue(), piercings: piercings.getNewValue(), urls: urls.getNewValue(), gender: gender.getNewValue(), tags: tags.getNewValue(), images: newImage ? [newImage] : undefined, details: details.getNewValue(), death_date: deathDate.getNewValue(), hair_color: hairColor.getNewValue(), weight: weight.getNewValue(), penis_length: penisLength.getNewValue(), circumcised: circumcised.getNewValue(), remote_site_id: remoteSiteID.getNewValue(), }; } function renderScrapeRows() { return ( <> setName(value)} /> setDisambiguation(value)} /> setAliases(value)} /> {renderScrapedGenderRow( intl.formatMessage({ id: "gender" }), gender, (value) => setGender(value) )} setBirthdate(value)} /> setDeathDate(value)} /> setEthnicity(value)} /> setCountry(value)} /> setHairColor(value)} /> setEyeColor(value)} /> setWeight(value)} /> setHeight(value)} /> setPenisLength(value)} /> {renderScrapedCircumcisedRow( intl.formatMessage({ id: "circumcised" }), circumcised, (value) => setCircumcised(value) )} setMeasurements(value)} /> setFakeTits(value)} /> setCareerStart(value)} /> setCareerEnd(value)} /> setTattoos(value)} /> setPiercings(value)} /> setURLs(value)} /> setDetails(value)} /> {scrapedTagsRow} setImage(value)} /> setRemoteSiteID(value)} /> ); } if (linkDialog) { return linkDialog; } return ( { props.onClose(apply ? makeNewScrapedItem() : undefined); }} > {renderScrapeRows()} ); }; ================================================ FILE: ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeModal.tsx ================================================ import React, { useEffect, useRef, useState } from "react"; import { Button, Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "src/components/Shared/Modal"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useScrapePerformerList } from "src/core/StashService"; import { useDebounce } from "src/hooks/debounce"; const CLASSNAME = "PerformerScrapeModal"; const CLASSNAME_LIST = `${CLASSNAME}-list`; interface IProps { scraper: GQL.Scraper; onHide: () => void; onSelectPerformer: ( performer: GQL.ScrapedPerformerDataFragment, scraper: GQL.Scraper ) => void; name?: string; } const PerformerScrapeModal: React.FC = ({ scraper, name, onHide, onSelectPerformer, }) => { const intl = useIntl(); const inputRef = useRef(null); const [query, setQuery] = useState(name ?? ""); const { data, loading } = useScrapePerformerList(scraper.id, query); const performers = data?.scrapeSinglePerformer ?? []; const onInputChange = useDebounce(setQuery, 500); useEffect(() => inputRef.current?.focus(), []); return (
    onInputChange(e.currentTarget.value)} defaultValue={name ?? ""} placeholder="Performer name..." className="text-input mb-4" ref={inputRef} /> {loading ? (
    ) : (
      {performers.map((p, i) => (
    • ))}
    )}
    ); }; export default PerformerScrapeModal; ================================================ FILE: ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx ================================================ import React, { useEffect, useRef, useState } from "react"; import { Form, Row, Col, Badge } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "src/components/Shared/Modal"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { stashboxDisplayName } from "src/utils/stashbox"; import { useDebounce } from "src/hooks/debounce"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { stringToGender } from "src/utils/gender"; import TextUtils from "src/utils/text"; import GenderIcon from "src/components/Performers/GenderIcon"; import { CountryFlag } from "src/components/Shared/CountryFlag"; const CLASSNAME = "PerformerScrapeModal"; const CLASSNAME_LIST = `${CLASSNAME}-list`; const CLASSNAME_LIST_CONTAINER = `${CLASSNAME_LIST}-container`; interface IPerformerSearchResultDetailsProps { performer: GQL.ScrapedPerformerDataFragment; } const PerformerSearchResultDetails: React.FC< IPerformerSearchResultDetailsProps > = ({ performer }) => { function renderImage() { if (performer.images && performer.images.length > 0) { return (
    ); } } function calculateAge() { if (performer?.birthdate) { // calculate the age from birthdate. In future, this should probably be // provided by the server return TextUtils.age(performer.birthdate, performer.death_date); } } function renderTags() { if (performer.tags) { return ( {performer.tags?.map((tag) => ( {tag.name} ))} ); } } function renderCountry() { if (performer.country) { return ( ); } } let age = calculateAge(); return (
    {renderImage()}

    {performer.name} {performer.disambiguation && ( {` (${performer.disambiguation})`} )}

    {performer.gender && ( )} {age && ( {`${age} `} )}
    {renderCountry()}
    {renderTags()}
    ); }; export interface IPerformerSearchResult { performer: GQL.ScrapedPerformerDataFragment; } export const PerformerSearchResult: React.FC = ({ performer, }) => { return (
    ); }; export interface IStashBox extends GQL.StashBox { index: number; } interface IProps { instance: IStashBox; onHide: () => void; onSelectPerformer: (performer: GQL.ScrapedPerformer) => void; name?: string; } const PerformerStashBoxModal: React.FC = ({ instance, name, onHide, onSelectPerformer, }) => { const intl = useIntl(); const inputRef = useRef(null); const [query, setQuery] = useState(name ?? ""); const { data, loading } = GQL.useScrapeSinglePerformerQuery({ variables: { source: { stash_box_endpoint: instance.endpoint, }, input: { query, }, }, skip: query === "", }); const performers = data?.scrapeSinglePerformer ?? []; const onInputChange = useDebounce(setQuery, 500); useEffect(() => inputRef.current?.focus(), []); function renderResults() { if (!performers) { return; } return (
      {performers.map((p, i) => ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions, react/no-array-index-key
    • onSelectPerformer(p)}>
    • ))}
    ); } return (
    onInputChange(e.currentTarget.value)} defaultValue={name ?? ""} placeholder="Performer name..." className="text-input mb-4" ref={inputRef} /> {loading ? (
    ) : performers.length > 0 ? ( renderResults() ) : ( query !== "" &&
    No results found.
    )}
    ); }; export default PerformerStashBoxModal; ================================================ FILE: ui/v2.5/src/components/Performers/PerformerDetails/PerformerSubmitButton.tsx ================================================ import { Button } from "react-bootstrap"; import React, { useState } from "react"; import { FormattedMessage } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { SubmitStashBoxDraft } from "src/components/Dialogs/SubmitDraft"; interface IPerformerOperationsProps { performer: GQL.PerformerDataFragment; } export const PerformerSubmitButton: React.FC = ({ performer, }) => { const [showDraftModal, setShowDraftModal] = useState(false); const { data } = GQL.useConfigurationQuery(); const boxes = data?.configuration?.general?.stashBoxes ?? []; if (boxes.length === 0) return null; return ( <> setShowDraftModal(false)} /> ); }; ================================================ FILE: ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { FilteredPerformerList } from "src/components/Performers/PerformerList"; import { usePerformerFilterHook } from "src/core/performers"; import { View } from "src/components/List/views"; import { PatchComponent } from "src/patch"; interface IPerformerDetailsProps { active: boolean; performer: GQL.PerformerDataFragment; } export const PerformerAppearsWithPanel: React.FC = PatchComponent("PerformerAppearsWithPanel", ({ active, performer }) => { const performerValue = { id: performer.id, label: performer.name ?? `Performer ${performer.id}`, }; const extraCriteria = { performer: performerValue, }; const filterHook = usePerformerFilterHook(performer); return ( ); }); ================================================ FILE: ui/v2.5/src/components/Performers/PerformerList.tsx ================================================ import cloneDeep from "lodash-es/cloneDeep"; import React, { useCallback, useEffect } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { queryFindPerformers, useFindPerformers, usePerformersDestroy, } from "src/core/StashService"; import { useFilteredItemList } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { PerformerTagger } from "../Tagger/performers/PerformerTagger"; import { ExportDialog } from "../Shared/ExportDialog"; import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; import { IPerformerCardExtraCriteria } from "./PerformerCard"; import { PerformerListTable } from "./PerformerListTable"; import { EditPerformersDialog } from "./EditPerformersDialog"; import { cmToImperial, cmToInches, kgToLbs } from "src/utils/units"; import TextUtils from "src/utils/text"; import { PerformerCardGrid } from "./PerformerCardGrid"; import { PerformerMergeModal } from "./PerformerMergeDialog"; import { View } from "../List/views"; import { FilteredListToolbar, IItemListOperation, } from "../List/FilteredListToolbar"; import { PatchComponent, PatchContainerComponent } from "src/patch"; import useFocus from "src/utils/focus"; import { Sidebar, SidebarPane, SidebarPaneContent, SidebarStateContext, useSidebarState, } from "../Shared/Sidebar"; import { useCloseEditDelete, useFilterOperations } from "../List/util"; import { FilteredSidebarHeader, useFilteredSidebarKeybinds, } from "../List/Filters/FilterSidebar"; import { IListFilterOperation, ListOperations, } from "../List/ListOperationButtons"; import { FilterTags } from "../List/FilterTags"; import { Pagination, PaginationIndex } from "../List/Pagination"; import { LoadedContent } from "../List/PagedList"; import { SidebarTagsFilter } from "../List/Filters/TagsFilter"; import { SidebarRatingFilter } from "../List/Filters/RatingFilter"; import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter"; import { PerformerListFilterOptions } from "src/models/list-filter/performers"; import { Button } from "react-bootstrap"; import cx from "classnames"; import { FavoritePerformerCriterionOption } from "src/models/list-filter/criteria/favorite"; import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; import { SidebarOptionFilter } from "../List/Filters/OptionFilter"; import { GenderCriterionOption } from "src/models/list-filter/criteria/gender"; export const FormatHeight = (height?: number | null) => { const intl = useIntl(); if (!height) { return ""; } const [feet, inches] = cmToImperial(height); return ( {intl.formatNumber(height, { style: "unit", unit: "centimeter", unitDisplay: "short", })} {intl.formatNumber(feet, { style: "unit", unit: "foot", unitDisplay: "narrow", })} {intl.formatNumber(inches, { style: "unit", unit: "inch", unitDisplay: "narrow", })} ); }; export const FormatAge = ( birthdate?: string | null, deathdate?: string | null ) => { if (!birthdate) { return ""; } const age = TextUtils.age(birthdate, deathdate); return ( {age} ({birthdate}) ); }; export const FormatWeight = (weight?: number | null) => { const intl = useIntl(); if (!weight) { return ""; } const lbs = kgToLbs(weight); return ( {intl.formatNumber(weight, { style: "unit", unit: "kilogram", unitDisplay: "short", })} {intl.formatNumber(lbs, { style: "unit", unit: "pound", unitDisplay: "short", })} ); }; export function formatYearRange( start?: string | null, end?: string | null ): string | undefined { if (!start && !end) return undefined; return `${start ?? ""} - ${end ?? ""}`; } export const FormatCircumcised = (circumcised?: GQL.CircumcisedEnum | null) => { const intl = useIntl(); if (!circumcised) { return ""; } return ( {intl.formatMessage({ id: "circumcised_types." + circumcised, })} ); }; export const FormatPenisLength = (penis_length?: number | null) => { const intl = useIntl(); if (!penis_length) { return ""; } const inches = cmToInches(penis_length); return ( {intl.formatNumber(penis_length, { style: "unit", unit: "centimeter", unitDisplay: "short", maximumFractionDigits: 2, })} {intl.formatNumber(inches, { style: "unit", unit: "inch", unitDisplay: "narrow", maximumFractionDigits: 2, })} ); }; interface IPerformerList { filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; alterQuery?: boolean; extraCriteria?: IPerformerCardExtraCriteria; extraOperations?: IItemListOperation[]; } const PerformerList: React.FC<{ performers: GQL.PerformerDataFragment[]; filter: ListFilterModel; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; extraCriteria?: IPerformerCardExtraCriteria; }> = PatchComponent( "PerformerList", ({ performers, filter, selectedIds, onSelectChange, extraCriteria }) => { if (performers.length === 0 && filter.displayMode !== DisplayMode.Tagger) { return null; } if (filter.displayMode === DisplayMode.Grid) { return ( ); } if (filter.displayMode === DisplayMode.List) { return ( ); } if (filter.displayMode === DisplayMode.Tagger) { return ; } return null; } ); const PerformerFilterSidebarSections = PatchContainerComponent( "FilteredPerformerList.SidebarSections" ); const SidebarContent: React.FC<{ filter: ListFilterModel; setFilter: (filter: ListFilterModel) => void; filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; sidebarOpen: boolean; onClose?: () => void; showEditFilter: (editingCriterion?: string) => void; count?: number; focus?: ReturnType; }> = ({ filter, setFilter, filterHook, view, showEditFilter, sidebarOpen, onClose, count, focus, }) => { const showResultsId = count !== undefined ? "actions.show_count_results" : "actions.show_results"; const AgeCriterionOption = PerformerListFilterOptions.criterionOptions.find( (c) => c.type === "age" ); return ( <> } data-type={FavoritePerformerCriterionOption.type} option={FavoritePerformerCriterionOption} filter={filter} setFilter={setFilter} sectionID="favourite" /> } option={GenderCriterionOption} filter={filter} setFilter={setFilter} sectionID="gender" /> } option={AgeCriterionOption!} filter={filter} setFilter={setFilter} sectionID="age" />
    ); }; function useViewRandom(filter: ListFilterModel, count: number) { const history = useHistory(); const viewRandom = useCallback(async () => { // query for a random performer if (count === 0) { return; } const index = Math.floor(Math.random() * count); const filterCopy = cloneDeep(filter); filterCopy.itemsPerPage = 1; filterCopy.currentPage = index + 1; const singleResult = await queryFindPerformers(filterCopy); if (singleResult.data.findPerformers.performers.length === 1) { const { id } = singleResult.data.findPerformers.performers[0]; // navigate to the image player page history.push(`/performers/${id}`); } }, [history, filter, count]); return viewRandom; } function useAddKeybinds(filter: ListFilterModel, count: number) { const viewRandom = useViewRandom(filter, count); useEffect(() => { Mousetrap.bind("p r", () => { viewRandom(); }); return () => { Mousetrap.unbind("p r"); }; }, [viewRandom]); } export const FilteredPerformerList = PatchComponent( "FilteredPerformerList", (props: IPerformerList) => { const intl = useIntl(); const history = useHistory(); const searchFocus = useFocus(); const { filterHook, view, alterQuery, extraCriteria, extraOperations = [], } = props; // States const { showSidebar, setShowSidebar, sectionOpen, setSectionOpen, loading: sidebarStateLoading, } = useSidebarState(view); const { filterState, queryResult, modalState, listSelect, showEditFilter } = useFilteredItemList({ filterStateProps: { filterMode: GQL.FilterMode.Performers, view, useURL: alterQuery, }, queryResultProps: { useResult: useFindPerformers, getCount: (r) => r.data?.findPerformers.count ?? 0, getItems: (r) => r.data?.findPerformers.performers ?? [], filterHook, }, }); const { filter, setFilter } = filterState; const { effectiveFilter, result, cachedResult, items, totalCount } = queryResult; const { selectedIds, selectedItems, onSelectChange, onSelectAll, onSelectNone, onInvertSelection, hasSelection, } = listSelect; const { modal, showModal, closeModal } = modalState; // Utility hooks const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ filter, setFilter, }); useAddKeybinds(effectiveFilter, totalCount); useFilteredSidebarKeybinds({ showSidebar, setShowSidebar, }); useEffect(() => { Mousetrap.bind("e", () => { if (hasSelection) { onEdit?.(); } }); Mousetrap.bind("d d", () => { if (hasSelection) { onDelete?.(); } }); return () => { Mousetrap.unbind("e"); Mousetrap.unbind("d d"); }; }); const onCloseEditDelete = useCloseEditDelete({ closeModal, onSelectNone, result, }); const viewRandom = useViewRandom(effectiveFilter, totalCount); function onExport(all: boolean) { showModal( closeModal()} /> ); } function onEdit() { showModal( ); } function onDelete() { showModal( ); } function onMerge() { showModal( { closeModal(); if (mergedId) { history.push(`/performers/${mergedId}`); } }} show /> ); } const convertedExtraOperations: IListFilterOperation[] = extraOperations.map((o) => ({ ...o, isDisplayed: o.isDisplayed ? () => o.isDisplayed!(result, filter, selectedIds) : undefined, onClick: () => { o.onClick(result, filter, selectedIds); }, })); const otherOperations: IListFilterOperation[] = [ ...convertedExtraOperations, { text: intl.formatMessage({ id: "actions.select_all" }), onClick: () => onSelectAll(), isDisplayed: () => totalCount > 0, }, { text: intl.formatMessage({ id: "actions.select_none" }), onClick: () => onSelectNone(), isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.invert_selection" }), onClick: () => onInvertSelection(), isDisplayed: () => totalCount > 0, }, { text: intl.formatMessage({ id: "actions.open_random" }), onClick: viewRandom, }, { text: `${intl.formatMessage({ id: "actions.merge" })}…`, onClick: onMerge, isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export" }), onClick: () => onExport(false), isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export_all" }), onClick: () => onExport(true), }, ]; // render if (sidebarStateLoading) return null; const operations = ( ); return (
    {modal} setShowSidebar(false)}> setShowSidebar(false)} count={cachedResult.loading ? undefined : totalCount} focus={searchFocus} /> setShowSidebar(!showSidebar)} > showEditFilter(c.criterionOption.type)} onRemoveCriterion={removeCriterion} onRemoveAll={clearAllCriteria} />
    setFilter(filter.changePage(page))} />
    {totalCount > filter.itemsPerPage && (
    )}
    ); } ); ================================================ FILE: ui/v2.5/src/components/Performers/PerformerListTable.tsx ================================================ /* eslint-disable jsx-a11y/control-has-associated-label */ import React from "react"; import { useIntl } from "react-intl"; import { Button } from "react-bootstrap"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; import NavUtils from "src/utils/navigation"; import { faHeart } from "@fortawesome/free-solid-svg-icons"; import { usePerformerUpdate } from "src/core/StashService"; import { useTableColumns } from "src/hooks/useTableColumns"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import cx from "classnames"; import { FormatCircumcised, FormatHeight, FormatPenisLength, FormatWeight, formatYearRange, } from "./PerformerList"; import TextUtils from "src/utils/text"; import { getCountryByISO } from "src/utils/country"; import { IColumn, ListTable } from "../List/ListTable"; interface IPerformerListTableProps { performers: GQL.PerformerDataFragment[]; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; } const TABLE_NAME = "performers"; export const PerformerListTable: React.FC = ( props: IPerformerListTableProps ) => { const intl = useIntl(); const [updatePerformer] = usePerformerUpdate(); function setRating(v: number | null, performerId: string) { if (performerId) { updatePerformer({ variables: { input: { id: performerId, rating100: v, }, }, }); } } function setFavorite(v: boolean, performerId: string) { if (performerId) { updatePerformer({ variables: { input: { id: performerId, favorite: v, }, }, }); } } const ImageCell = (performer: GQL.PerformerDataFragment) => ( {performer.name ); const NameCell = (performer: GQL.PerformerDataFragment) => (
    {performer.name} {performer.disambiguation && ( {` (${performer.disambiguation})`} )}
    ); const AliasesCell = (performer: GQL.PerformerDataFragment) => { let aliases = performer.alias_list ? performer.alias_list.join(", ") : ""; return ( {aliases} ); }; const GenderCell = (performer: GQL.PerformerDataFragment) => ( <> {performer.gender ? intl.formatMessage({ id: "gender_types." + performer.gender }) : ""} ); const RatingCell = (performer: GQL.PerformerDataFragment) => ( setRating(value, performer.id)} clickToRate /> ); const AgeCell = (performer: GQL.PerformerDataFragment) => ( {performer.birthdate ? TextUtils.age(performer.birthdate, performer.death_date) : ""} ); const DeathdateCell = (performer: GQL.PerformerDataFragment) => ( <>{performer.death_date} ); const FavoriteCell = (performer: GQL.PerformerDataFragment) => ( ); const CountryCell = (performer: GQL.PerformerDataFragment) => { const { locale } = useIntl(); return ( {getCountryByISO(performer.country, locale)} ); }; const EthnicityCell = (performer: GQL.PerformerDataFragment) => ( <>{performer.ethnicity} ); const MeasurementsCell = (performer: GQL.PerformerDataFragment) => ( {performer.measurements} ); const FakeTitsCell = (performer: GQL.PerformerDataFragment) => ( <>{performer.fake_tits} ); const PenisLengthCell = (performer: GQL.PerformerDataFragment) => ( <>{FormatPenisLength(performer.penis_length)} ); const CircumcisedCell = (performer: GQL.PerformerDataFragment) => ( <>{FormatCircumcised(performer.circumcised)} ); const HairColorCell = (performer: GQL.PerformerDataFragment) => ( {performer.hair_color} ); const EyeColorCell = (performer: GQL.PerformerDataFragment) => ( <>{performer.eye_color} ); const HeightCell = (performer: GQL.PerformerDataFragment) => ( <>{FormatHeight(performer.height_cm)} ); const WeightCell = (performer: GQL.PerformerDataFragment) => ( <>{FormatWeight(performer.weight)} ); const CareerLengthCell = (performer: GQL.PerformerDataFragment) => ( <>{formatYearRange(performer.career_start, performer.career_end) ?? ""} ); const SceneCountCell = (performer: GQL.PerformerDataFragment) => ( {performer.scene_count} ); const GalleryCountCell = (performer: GQL.PerformerDataFragment) => ( {performer.gallery_count} ); const ImageCountCell = (performer: GQL.PerformerDataFragment) => ( {performer.image_count} ); const OCounterCell = (performer: GQL.PerformerDataFragment) => ( <>{performer.o_counter} ); interface IColumnSpec { value: string; label: string; defaultShow?: boolean; mandatory?: boolean; render?: ( scene: GQL.PerformerDataFragment, index: number ) => React.ReactNode; } const allColumns: IColumnSpec[] = [ { value: "image", label: intl.formatMessage({ id: "image" }), defaultShow: true, render: ImageCell, }, { value: "name", label: intl.formatMessage({ id: "name" }), mandatory: true, defaultShow: true, render: NameCell, }, { value: "aliases", label: intl.formatMessage({ id: "aliases" }), defaultShow: true, render: AliasesCell, }, { value: "gender", label: intl.formatMessage({ id: "gender" }), defaultShow: true, render: GenderCell, }, { value: "rating", label: intl.formatMessage({ id: "rating" }), defaultShow: true, render: RatingCell, }, { value: "age", label: intl.formatMessage({ id: "age" }), defaultShow: true, render: AgeCell, }, { value: "death_date", label: intl.formatMessage({ id: "death_date" }), render: DeathdateCell, }, { value: "favourite", label: intl.formatMessage({ id: "favourite" }), defaultShow: true, render: FavoriteCell, }, { value: "country", label: intl.formatMessage({ id: "country" }), defaultShow: true, render: CountryCell, }, { value: "ethnicity", label: intl.formatMessage({ id: "ethnicity" }), defaultShow: true, render: EthnicityCell, }, { value: "hair_color", label: intl.formatMessage({ id: "hair_color" }), render: HairColorCell, }, { value: "eye_color", label: intl.formatMessage({ id: "eye_color" }), render: EyeColorCell, }, { value: "height_cm", label: intl.formatMessage({ id: "height_cm" }), render: HeightCell, }, { value: "weight_kg", label: intl.formatMessage({ id: "weight_kg" }), render: WeightCell, }, { value: "penis_length_cm", label: intl.formatMessage({ id: "penis_length_cm" }), render: PenisLengthCell, }, { value: "circumcised", label: intl.formatMessage({ id: "circumcised" }), render: CircumcisedCell, }, { value: "measurements", label: intl.formatMessage({ id: "measurements" }), render: MeasurementsCell, }, { value: "fake_tits", label: intl.formatMessage({ id: "fake_tits" }), render: FakeTitsCell, }, { value: "career_length", label: intl.formatMessage({ id: "career_length" }), defaultShow: true, render: CareerLengthCell, }, { value: "scene_count", label: intl.formatMessage({ id: "scenes" }), defaultShow: true, render: SceneCountCell, }, { value: "gallery_count", label: intl.formatMessage({ id: "galleries" }), defaultShow: true, render: GalleryCountCell, }, { value: "image_count", label: intl.formatMessage({ id: "images" }), defaultShow: true, render: ImageCountCell, }, { value: "o_counter", label: intl.formatMessage({ id: "o_count" }), defaultShow: true, render: OCounterCell, }, ]; const defaultColumns = allColumns .filter((col) => col.defaultShow) .map((col) => col.value); const { selectedColumns, saveColumns } = useTableColumns( TABLE_NAME, defaultColumns ); const columnRenderFuncs: Record< string, (scene: GQL.PerformerDataFragment, index: number) => React.ReactNode > = {}; allColumns.forEach((col) => { if (col.render) { columnRenderFuncs[col.value] = col.render; } }); function renderCell( column: IColumn, performer: GQL.PerformerDataFragment, index: number ) { const render = columnRenderFuncs[column.value]; if (render) return render(performer, index); } return ( saveColumns(c)} selectedIds={props.selectedIds} onSelectChange={props.onSelectChange} renderCell={renderCell} /> ); }; ================================================ FILE: ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx ================================================ import { Form, Col, Row, Button } from "react-bootstrap"; import React, { useEffect, useMemo, useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { circumcisedToString, stringToCircumcised, } from "src/utils/circumcised"; import * as FormUtils from "src/utils/form"; import { genderToString, stringToGender } from "src/utils/gender"; import ImageUtils from "src/utils/image"; import { mutatePerformerMerge, queryFindPerformersByID, } from "src/core/StashService"; import { FormattedMessage, useIntl } from "react-intl"; import { useToast } from "src/hooks/Toast"; import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons"; import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog"; import { ScrapedCustomFieldRows, ScrapeDialogRow, ScrapedImageRow, ScrapedInputGroupRow, ScrapedStringListRow, ScrapedTextAreaRow, } from "../Shared/ScrapeDialog/ScrapeDialogRow"; import { ModalComponent } from "../Shared/Modal"; import { sortStoredIdObjects, uniqIDStoredIDs } from "src/utils/data"; import { CustomFieldScrapeResults, ObjectListScrapeResult, ScrapeResult, hasScrapedValues, } from "../Shared/ScrapeDialog/scrapeResult"; import { ScrapedTagsRow } from "../Shared/ScrapeDialog/ScrapedObjectsRow"; import { renderScrapedGenderRow, renderScrapedCircumcisedRow, } from "./PerformerDetails/PerformerScrapeDialog"; import { PerformerSelect } from "./PerformerSelect"; import { uniq } from "lodash-es"; import { StashIDsField } from "../Shared/StashID"; type MergeOptions = { values: GQL.PerformerUpdateInput; }; interface IPerformerMergeDetailsProps { sources: GQL.PerformerDataFragment[]; dest: GQL.PerformerDataFragment; onClose: (options?: MergeOptions) => void; } const PerformerMergeDetails: React.FC = ({ sources, dest, onClose, }) => { const intl = useIntl(); const [loading, setLoading] = useState(true); const [name, setName] = useState>( new ScrapeResult(dest.name) ); const [disambiguation, setDisambiguation] = useState>( new ScrapeResult(dest.disambiguation) ); const [aliases, setAliases] = useState>( new ScrapeResult(dest.alias_list) ); const [birthdate, setBirthdate] = useState>( new ScrapeResult(dest.birthdate) ); const [deathDate, setDeathDate] = useState>( new ScrapeResult(dest.death_date) ); const [ethnicity, setEthnicity] = useState>( new ScrapeResult(dest.ethnicity) ); const [country, setCountry] = useState>( new ScrapeResult(dest.country) ); const [hairColor, setHairColor] = useState>( new ScrapeResult(dest.hair_color) ); const [eyeColor, setEyeColor] = useState>( new ScrapeResult(dest.eye_color) ); const [height, setHeight] = useState>( new ScrapeResult(dest.height_cm?.toString()) ); const [weight, setWeight] = useState>( new ScrapeResult(dest.weight?.toString()) ); const [penisLength, setPenisLength] = useState>( new ScrapeResult(dest.penis_length?.toString()) ); const [measurements, setMeasurements] = useState>( new ScrapeResult(dest.measurements) ); const [fakeTits, setFakeTits] = useState>( new ScrapeResult(dest.fake_tits) ); const [careerStart, setCareerStart] = useState>( new ScrapeResult(dest.career_start?.toString()) ); const [careerEnd, setCareerEnd] = useState>( new ScrapeResult(dest.career_end?.toString()) ); const [tattoos, setTattoos] = useState>( new ScrapeResult(dest.tattoos) ); const [piercings, setPiercings] = useState>( new ScrapeResult(dest.piercings) ); const [urls, setURLs] = useState>( new ScrapeResult(dest.urls) ); const [gender, setGender] = useState>( new ScrapeResult(genderToString(dest.gender)) ); const [circumcised, setCircumcised] = useState>( new ScrapeResult(circumcisedToString(dest.circumcised)) ); const [details, setDetails] = useState>( new ScrapeResult(dest.details) ); const [tags, setTags] = useState>( new ObjectListScrapeResult( sortStoredIdObjects(dest.tags.map(idToStoredID)) ) ); const [stashIDs, setStashIDs] = useState(new ScrapeResult([])); const [image, setImage] = useState>( new ScrapeResult(dest.image_path) ); const [customFields, setCustomFields] = useState( new Map() ); function idToStoredID(o: { id: string; name: string }) { return { stored_id: o.id, name: o.name, }; } // calculate the values for everything // uses the first set value for single value fields, and combines all useEffect(() => { async function loadImages() { const src = sources.find((s) => s.image_path); if (!dest.image_path || !src) return; setLoading(true); const destData = await ImageUtils.imageToDataURL(dest.image_path); const srcData = await ImageUtils.imageToDataURL(src.image_path!); // keep destination image by default const useNewValue = false; setImage(new ScrapeResult(destData, srcData, useNewValue)); setLoading(false); } // append dest to all so that if dest has stash_ids with the same // endpoint, then it will be excluded first const all = sources.concat(dest); setName( new ScrapeResult(dest.name, sources.find((s) => s.name)?.name, !dest.name) ); setDisambiguation( new ScrapeResult( dest.disambiguation, sources.find((s) => s.disambiguation)?.disambiguation, !dest.disambiguation ) ); // default alias list should be the existing aliases, plus the names of all sources, // plus all source aliases, deduplicated const allAliases = uniq( dest.alias_list.concat( sources.map((s) => s.name), sources.flatMap((s) => s.alias_list) ) ); setAliases( new ScrapeResult(dest.alias_list, allAliases, !!allAliases.length) ); setBirthdate( new ScrapeResult( dest.birthdate, sources.find((s) => s.birthdate)?.birthdate, !dest.birthdate ) ); setDeathDate( new ScrapeResult( dest.death_date, sources.find((s) => s.death_date)?.death_date, !dest.death_date ) ); setEthnicity( new ScrapeResult( dest.ethnicity, sources.find((s) => s.ethnicity)?.ethnicity, !dest.ethnicity ) ); setCountry( new ScrapeResult( dest.country, sources.find((s) => s.country)?.country, !dest.country ) ); setHairColor( new ScrapeResult( dest.hair_color, sources.find((s) => s.hair_color)?.hair_color, !dest.hair_color ) ); setEyeColor( new ScrapeResult( dest.eye_color, sources.find((s) => s.eye_color)?.eye_color, !dest.eye_color ) ); setHeight( new ScrapeResult( dest.height_cm?.toString(), sources.find((s) => s.height_cm)?.height_cm?.toString(), !dest.height_cm ) ); setWeight( new ScrapeResult( dest.weight?.toString(), sources.find((s) => s.weight)?.weight?.toString(), !dest.weight ) ); setPenisLength( new ScrapeResult( dest.penis_length?.toString(), sources.find((s) => s.penis_length)?.penis_length?.toString(), !dest.penis_length ) ); setMeasurements( new ScrapeResult( dest.measurements, sources.find((s) => s.measurements)?.measurements, !dest.measurements ) ); setFakeTits( new ScrapeResult( dest.fake_tits, sources.find((s) => s.fake_tits)?.fake_tits, !dest.fake_tits ) ); setCareerStart( new ScrapeResult( dest.career_start?.toString(), sources.find((s) => s.career_start)?.career_start?.toString(), !dest.career_start ) ); setCareerEnd( new ScrapeResult( dest.career_end?.toString(), sources.find((s) => s.career_end)?.career_end?.toString(), !dest.career_end ) ); setTattoos( new ScrapeResult( dest.tattoos, sources.find((s) => s.tattoos)?.tattoos, !dest.tattoos ) ); setPiercings( new ScrapeResult( dest.piercings, sources.find((s) => s.piercings)?.piercings, !dest.piercings ) ); setURLs( new ScrapeResult( dest.urls ?? [], uniq(all.map((s) => s.urls ?? []).flat()) ) ); setGender( new ScrapeResult( genderToString(dest.gender), sources.find((s) => s.gender)?.gender ? genderToString(sources.find((s) => s.gender)?.gender) : undefined, !dest.gender ) ); setCircumcised( new ScrapeResult( circumcisedToString(dest.circumcised), sources.find((s) => s.circumcised)?.circumcised ? circumcisedToString(sources.find((s) => s.circumcised)?.circumcised) : undefined, !dest.circumcised ) ); setDetails( new ScrapeResult( dest.details, sources.find((s) => s.details)?.details, !dest.details ) ); setTags( new ObjectListScrapeResult( sortStoredIdObjects(dest.tags.map(idToStoredID)), uniqIDStoredIDs(all.map((s) => s.tags.map(idToStoredID)).flat()) ) ); setStashIDs( new ScrapeResult( dest.stash_ids, all .map((s) => s.stash_ids) .flat() .filter((s, index, a) => { // remove entries with duplicate endpoints return index === a.findIndex((ss) => ss.endpoint === s.endpoint); }) ) ); setImage( new ScrapeResult( dest.image_path, sources.find((s) => s.image_path)?.image_path, !dest.image_path ) ); const customFieldNames = new Set(Object.keys(dest.custom_fields)); for (const s of sources) { for (const n of Object.keys(s.custom_fields)) { customFieldNames.add(n); } } setCustomFields( new Map( Array.from(customFieldNames) .sort() .map((field) => { return [ field, new ScrapeResult( dest.custom_fields?.[field], sources.find((s) => s.custom_fields?.[field])?.custom_fields?.[ field ], dest.custom_fields?.[field] === undefined ), ]; }) ) ); loadImages(); }, [sources, dest]); const hasCustomFieldValues = useMemo(() => { return hasScrapedValues(Array.from(customFields.values())); }, [customFields]); // ensure this is updated if fields are changed const hasValues = useMemo(() => { return ( hasCustomFieldValues || hasScrapedValues([ name, disambiguation, aliases, birthdate, deathDate, ethnicity, country, hairColor, eyeColor, height, weight, penisLength, measurements, fakeTits, careerStart, careerEnd, tattoos, piercings, urls, gender, circumcised, details, tags, image, ]) ); }, [ name, disambiguation, aliases, birthdate, deathDate, ethnicity, country, hairColor, eyeColor, height, weight, penisLength, measurements, fakeTits, careerStart, careerEnd, tattoos, piercings, urls, gender, circumcised, details, tags, image, hasCustomFieldValues, ]); function renderScrapeRows() { if (loading) { return (
    ); } if (!hasValues) { return (
    ); } return ( <> setName(value)} /> setDisambiguation(value)} /> setAliases(value)} /> setBirthdate(value)} /> setDeathDate(value)} /> setEthnicity(value)} /> setCountry(value)} /> setHairColor(value)} /> setEyeColor(value)} /> setHeight(value)} /> setWeight(value)} /> setPenisLength(value)} /> setMeasurements(value)} /> setFakeTits(value)} /> setCareerStart(value)} /> setCareerEnd(value)} /> setTattoos(value)} /> setPiercings(value)} /> setURLs(value)} /> {renderScrapedGenderRow( intl.formatMessage({ id: "gender" }), gender, (value) => setGender(value) )} {renderScrapedCircumcisedRow( intl.formatMessage({ id: "circumcised" }), circumcised, (value) => setCircumcised(value) )} setTags(value)} /> setDetails(value)} /> } newField={} onChange={(value) => setStashIDs(value)} alwaysShow={ !!stashIDs.originalValue?.length || !!stashIDs.newValue?.length } /> setImage(value)} /> {hasCustomFieldValues && ( setCustomFields(newCustomFields)} /> )} ); } function createValues(): MergeOptions { // only set the cover image if it's different from the existing cover image const coverImage = image.useNewValue ? image.getNewValue() : undefined; return { values: { id: dest.id, name: name.getNewValue(), disambiguation: disambiguation.getNewValue(), alias_list: aliases .getNewValue() ?.map((s) => s.trim()) .filter((s) => s.length > 0), birthdate: birthdate.getNewValue(), death_date: deathDate.getNewValue(), ethnicity: ethnicity.getNewValue(), country: country.getNewValue(), hair_color: hairColor.getNewValue(), eye_color: eyeColor.getNewValue(), height_cm: height.getNewValue() ? parseFloat(height.getNewValue()!) : undefined, weight: weight.getNewValue() ? parseFloat(weight.getNewValue()!) : undefined, penis_length: penisLength.getNewValue() ? parseFloat(penisLength.getNewValue()!) : undefined, measurements: measurements.getNewValue(), fake_tits: fakeTits.getNewValue(), career_start: careerStart.getNewValue(), career_end: careerEnd.getNewValue(), tattoos: tattoos.getNewValue(), piercings: piercings.getNewValue(), urls: urls.getNewValue(), gender: stringToGender(gender.getNewValue()), circumcised: stringToCircumcised(circumcised.getNewValue()), tag_ids: tags.getNewValue()?.map((t) => t.stored_id!), details: details.getNewValue(), stash_ids: stashIDs.getNewValue(), image: coverImage, custom_fields: { partial: Object.fromEntries( Array.from(customFields.entries()).flatMap(([field, v]) => v.useNewValue ? [[field, v.getNewValue()]] : [] ) ), }, }, }; } const dialogTitle = intl.formatMessage({ id: "actions.merge", }); const destinationLabel = !hasValues ? "" : intl.formatMessage({ id: "dialogs.merge.destination" }); const sourceLabel = !hasValues ? "" : intl.formatMessage({ id: "dialogs.merge.combined" }); return ( { if (!apply) { onClose(); } else { onClose(createValues()); } }} > {renderScrapeRows()} ); }; interface IPerformerMergeModalProps { show: boolean; onClose: (mergedId?: string) => void; performers: GQL.SelectPerformerDataFragment[]; } export const PerformerMergeModal: React.FC = ({ show, onClose, performers, }) => { const [sourcePerformers, setSourcePerformers] = useState< GQL.SelectPerformerDataFragment[] >([]); const [destPerformer, setDestPerformer] = useState< GQL.SelectPerformerDataFragment[] >([]); const [loadedSources, setLoadedSources] = useState< GQL.PerformerDataFragment[] >([]); const [loadedDest, setLoadedDest] = useState(); const [running, setRunning] = useState(false); const [secondStep, setSecondStep] = useState(false); const intl = useIntl(); const Toast = useToast(); const title = intl.formatMessage({ id: "actions.merge", }); useEffect(() => { if (performers.length > 0) { // set the first performer as the destination, others as source setDestPerformer([performers[0]]); if (performers.length > 1) { setSourcePerformers(performers.slice(1)); } } }, [performers]); async function loadPerformers() { const performerIDs = sourcePerformers.map((s) => parseInt(s.id)); performerIDs.push(parseInt(destPerformer[0].id)); const query = await queryFindPerformersByID(performerIDs); const { performers: loadedPerformers } = query.data.findPerformers; setLoadedDest(loadedPerformers.find((s) => s.id === destPerformer[0].id)); setLoadedSources( loadedPerformers.filter((s) => s.id !== destPerformer[0].id) ); setSecondStep(true); } async function onMerge(options: MergeOptions) { const { values } = options; try { setRunning(true); const result = await mutatePerformerMerge( destPerformer[0].id, sourcePerformers.map((s) => s.id), values ); if (result.data?.performerMerge) { Toast.success(intl.formatMessage({ id: "toast.merged_performers" })); onClose(destPerformer[0].id); } onClose(); } catch (e) { Toast.error(e); } finally { setRunning(false); } } function canMerge() { return sourcePerformers.length > 0 && destPerformer.length !== 0; } function switchPerformers() { if (sourcePerformers.length && destPerformer.length) { const newDest = sourcePerformers[0]; setSourcePerformers([...sourcePerformers.slice(1), destPerformer[0]]); setDestPerformer([newDest]); } } if (secondStep && destPerformer.length > 0) { return ( { setSecondStep(false); if (values) { onMerge(values); } else { onClose(); } }} /> ); } return ( loadPerformers(), }} disabled={!canMerge()} cancel={{ variant: "secondary", onClick: () => onClose(), }} isRunning={running} >
    {FormUtils.renderLabel({ title: intl.formatMessage({ id: "dialogs.merge.source" }), labelProps: { column: true, sm: 3, xl: 12, }, })} setSourcePerformers(items)} values={sourcePerformers} menuPortalTarget={document.body} /> {FormUtils.renderLabel({ title: intl.formatMessage({ id: "dialogs.merge.destination", }), labelProps: { column: true, sm: 3, xl: 12, }, })} setDestPerformer(items)} values={destPerformer} menuPortalTarget={document.body} />
    ); }; ================================================ FILE: ui/v2.5/src/components/Performers/PerformerPopover.tsx ================================================ import React from "react"; import { ErrorMessage } from "../Shared/ErrorMessage"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { HoverPopover } from "../Shared/HoverPopover"; import { useFindPerformer } from "../../core/StashService"; import { PerformerCard } from "./PerformerCard"; import { useConfigurationContext } from "../../hooks/Config"; import { Placement } from "react-bootstrap/esm/Overlay"; interface IPeromerPopoverCardProps { id: string; } export const PerformerPopoverCard: React.FC = ({ id, }) => { const { data, loading, error } = useFindPerformer(id); if (loading) return (
    ); if (error) return ; if (!data?.findPerformer) return ; const performer = data.findPerformer; return (
    ); }; interface IPeroformerPopoverProps { id: string; hide?: boolean; placement?: Placement; target?: React.RefObject; } export const PerformerPopover: React.FC = ({ id, hide, children, placement = "top", target, }) => { const { configuration: config } = useConfigurationContext(); const showPerformerCardOnHover = config?.ui.showTagCardOnHover ?? true; if (hide || !showPerformerCardOnHover) { return <>{children}; } return ( } > {children} ); }; ================================================ FILE: ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx ================================================ import React from "react"; import { useFindPerformers } from "src/core/StashService"; import { PerformerCard } from "./PerformerCard"; import { ListFilterModel } from "src/models/list-filter/filter"; import { PatchComponent } from "src/patch"; import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow"; interface IProps { isTouch: boolean; filter: ListFilterModel; header: string; } export const PerformerRecommendationRow: React.FC = PatchComponent( "PerformerRecommendationRow", (props) => { const result = useFindPerformers(props.filter); const count = result.data?.findPerformers.count ?? 0; return ( {result.loading ? [...Array(props.filter.itemsPerPage)].map((i) => (
    )) : result.data?.findPerformers.performers.map((p) => ( ))}
    ); } ); ================================================ FILE: ui/v2.5/src/components/Performers/PerformerSelect.tsx ================================================ import React, { useEffect, useState } from "react"; import { OptionProps, components as reactSelectComponents, MultiValueGenericProps, SingleValueProps, } from "react-select"; import cx from "classnames"; import * as GQL from "src/core/generated-graphql"; import { usePerformerCreate, queryFindPerformersByIDForSelect, queryFindPerformersForSelect, } from "src/core/StashService"; import { useConfigurationContext } from "src/hooks/Config"; import { useIntl } from "react-intl"; import { defaultMaxOptionsShown } from "src/core/config"; import { ListFilterModel } from "src/models/list-filter/filter"; import { FilterSelectComponent, IFilterIDProps, IFilterProps, IFilterValueProps, Option as SelectOption, toOption, } from "../Shared/FilterSelect"; import { useCompare } from "src/hooks/state"; import { Link } from "react-router-dom"; import { sortByRelevance } from "src/utils/query"; import { PatchComponent, PatchFunction } from "src/patch"; import { TruncatedText } from "../Shared/TruncatedText"; import TextUtils from "src/utils/text"; import { PerformerPopover } from "./PerformerPopover"; import { Placement } from "react-bootstrap/esm/Overlay"; import { isUUID } from "src/utils/stashIds"; import { filterByStashID } from "src/models/list-filter/utils"; export type SelectObject = { id: string; name?: string | null; title?: string | null; }; export type Performer = Pick< GQL.Performer, | "id" | "name" | "alias_list" | "disambiguation" | "image_path" | "birthdate" | "death_date" >; type Option = SelectOption; type FindPerformersResult = Awaited< ReturnType >["data"]["findPerformers"]["performers"]; function sortPerformersByRelevance( input: string, performers: FindPerformersResult ) { return sortByRelevance( input, performers, (p) => p.name, (p) => p.alias_list ); } const performerSelectSort = PatchFunction( "PerformerSelect.sort", sortPerformersByRelevance ); const _PerformerSelect: React.FC< IFilterProps & IFilterValueProps & { ageFromDate?: string | null; hoverPlacementLabel?: Placement; hoverPlacementOptions?: Placement; } > = (props) => { const [createPerformer] = usePerformerCreate(); const { configuration } = useConfigurationContext(); const intl = useIntl(); const maxOptionsShown = configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown; const defaultCreatable = !configuration?.interface.disableDropdownCreate.performer; async function loadPerformers(input: string): Promise { const filter = new ListFilterModel(GQL.FilterMode.Performers); filter.currentPage = 1; filter.itemsPerPage = maxOptionsShown; filter.sortBy = "name"; filter.sortDirection = GQL.SortDirectionEnum.Asc; // If the input looks like a GUID, search for stash_id first and return match immediately if (isUUID(input)) { filterByStashID(filter, input); const query = await queryFindPerformersForSelect(filter); const matches = query.data.findPerformers.performers.slice(); if (matches.length > 0) { // Matches found, return them immediately. return matches.map(toOption); } // If no stash_id matches found, continue with standard name/alias search. filter.criteria = []; // Clear stash_id criterion to search by name/alias below. } filter.searchTerm = input; const query = await queryFindPerformersForSelect(filter); return performerSelectSort( input, query.data.findPerformers.performers.slice() ).map(toOption); } const PerformerOption: React.FC> = ( optionProps ) => { let thisOptionProps = optionProps; const { object } = optionProps.data; let { name } = object; // if name does not match the input value but an alias does, show the alias const { inputValue } = optionProps.selectProps; let alias: string | undefined = ""; if (!name.toLowerCase().includes(inputValue.toLowerCase())) { alias = object.alias_list?.find((a) => a.toLowerCase().includes(inputValue.toLowerCase()) ); } const sceneAge = TextUtils.age(object.birthdate, props.ageFromDate); const age = sceneAge < 18 ? TextUtils.age(object.birthdate, object.death_date) : sceneAge; const ageL10nId = !props.ageFromDate || sceneAge < 18 ? "media_info.performer_card.age" : "age_on_date"; const ageL10String = intl.formatMessage({ id: "years_old", defaultMessage: "years old", }); const ageString = intl.formatMessage( { id: ageL10nId }, { age, years_old: ageL10String } ); thisOptionProps = { ...optionProps, children: ( {name} {alias && (  ({alias}) )} } lineCount={1} /> {object.disambiguation && ( {object.disambiguation} )} {object.birthdate && ( {object.birthdate} {` (${ageString})`} )} ), }; return ; }; const PerformerMultiValueLabel: React.FC< MultiValueGenericProps > = (optionProps) => { let thisOptionProps = optionProps; const { object } = optionProps.data; thisOptionProps = { ...optionProps, children: ( {object.name} {object.disambiguation && ( {` (${object.disambiguation})`} )} ), }; return ; }; const PerformerValueLabel: React.FC> = ( optionProps ) => { let thisOptionProps = optionProps; const { object } = optionProps.data; thisOptionProps = { ...optionProps, children: ( {object.name} {object.disambiguation && ( {` (${object.disambiguation})`} )} ), }; return ; }; const onCreate = async (name: string) => { const result = await createPerformer({ variables: { input: { name } }, }); return { value: result.data!.performerCreate!.id, item: result.data!.performerCreate!, message: "Created performer", }; }; const getNamedObject = (id: string, name: string) => { return { id, name, alias_list: [], }; }; const isValidNewOption = (inputValue: string, options: Performer[]) => { if (!inputValue) { return false; } if ( options.some((o) => { return ( o.name.toLowerCase() === inputValue.toLowerCase() || o.alias_list?.some( (a) => a.toLowerCase() === inputValue.toLowerCase() ) ); }) ) { return false; } return true; }; return ( {...props} className={cx( "performer-select", { "performer-select-active": props.active, }, props.className )} loadOptions={loadPerformers} getNamedObject={getNamedObject} isValidNewOption={isValidNewOption} components={{ Option: PerformerOption, MultiValueLabel: PerformerMultiValueLabel, SingleValue: PerformerValueLabel, }} isMulti={props.isMulti ?? false} creatable={props.creatable ?? defaultCreatable} onCreate={onCreate} placeholder={ props.noSelectionString ?? intl.formatMessage( { id: "actions.select_entity" }, { entityType: intl.formatMessage({ id: props.isMulti ? "performers" : "performer", }), } ) } /> ); }; export const PerformerSelect = PatchComponent( "PerformerSelect", _PerformerSelect ); const _PerformerIDSelect: React.FC> = ( props ) => { const { ids, onSelect: onSelectValues } = props; const [values, setValues] = useState([]); const idsChanged = useCompare(ids); function onSelect(items: Performer[]) { setValues(items); onSelectValues?.(items); } async function loadObjectsByID(idsToLoad: string[]): Promise { const query = await queryFindPerformersByIDForSelect(idsToLoad); const { performers: loadedPerformers } = query.data.findPerformers; return loadedPerformers; } useEffect(() => { if (!idsChanged) { return; } if (!ids || ids?.length === 0) { setValues([]); return; } // load the values if we have ids and they haven't been loaded yet const filteredValues = values.filter((v) => ids.includes(v.id.toString())); if (filteredValues.length === ids.length) { return; } const load = async () => { const items = await loadObjectsByID(ids); setValues(items); }; load(); }, [ids, idsChanged, values]); return ; }; export const PerformerIDSelect = PatchComponent( "PerformerIDSelect", _PerformerIDSelect ); ================================================ FILE: ui/v2.5/src/components/Performers/Performers.tsx ================================================ import React from "react"; import { Route, Switch } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import Performer from "./PerformerDetails/Performer"; import PerformerCreate from "./PerformerDetails/PerformerCreate"; import { FilteredPerformerList } from "./PerformerList"; import { View } from "../List/views"; const Performers: React.FC = () => { return ; }; const PerformerRoutes: React.FC = () => { const titleProps = useTitleProps({ id: "performers" }); return ( <> ); }; export default PerformerRoutes; ================================================ FILE: ui/v2.5/src/components/Performers/styles.scss ================================================ #performer-page { .performer-image-container { .btn { box-shadow: none; } .performer { max-height: calc(100vh - 6rem); max-width: 100%; } } .content-container { padding-bottom: 10px; } .performer-head { display: inline-block; vertical-align: top; .name-icons { .not-favorite { color: rgba(191, 204, 214, 0.5); } .favorite { color: #ff7373; } .instagram { color: pink; } } .rating-number .form-control { width: inherit; } // The following min-width declarations prevent // the performer's O-Count from moving around // when hovering over rating stars .rating-stars-precision-full .star-rating-number { min-width: 0.75rem; } .rating-stars-precision-half .star-rating-number, .rating-stars-precision-tenth .star-rating-number { min-width: 1.45rem; } .rating-stars-precision-quarter .star-rating-number { min-width: 2rem; } } .alias { font-weight: bold; } .quality-group { display: inline-flex; margin-top: 0.25rem; } // the detail element ids are the same as field type name // which don't follow the correct convention /* stylelint-disable selector-class-pattern */ .collapsed { .detail-item.tattoos, .detail-item.piercings, .detail-item.career_start, .detail-item.career_end, .detail-item.details, .detail-item.tags, .detail-item.stash_ids { display: none; } } .detail-group .custom-fields .collapse-button { display: table-cell; font-weight: 700; padding-left: 0; } /* stylelint-enable selector-class-pattern */ } .new-view { margin-bottom: 2rem; .photo { padding: 1rem 1rem 1rem 2rem; width: 100%; } } .performer-card { width: 20rem; @media (max-width: 576px) { width: 100%; } .thumbnail-section { position: relative; .instagram { color: pink; } } &-image { aspect-ratio: 2/3; min-width: 11.25rem; object-fit: cover; object-position: top; width: 100%; } .fi { bottom: 1rem; filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9)); height: 2rem; position: absolute; right: 1rem; width: 3rem; } button.btn.favorite-button { padding: 0; position: absolute; right: 5px; top: 10px; svg.fa-icon { margin-left: 0.4rem; margin-right: 0.4rem; } } &:hover button.btn.favorite-button.not-favorite { opacity: 1; } &__age { color: $muted-gray; } // allow country string to be shown, but disable by default &__country-string { display: none; } } .card { &.performer-card { padding: 0 0 1rem 0; } } .scrape-dialog .performer-image { display: block; margin-bottom: 10px; margin-top: 10px; max-width: 100%; } #performer-scraper-popover { z-index: 1; } .PerformerScrapeModal { &-list { list-style: none; max-height: 50vh; overflow-x: hidden; overflow-y: auto; padding-inline-start: 0; li { cursor: pointer; } } } .flex-aligned { align-items: center; column-gap: 0.5rem; display: flex; } .gender-icon { &[data-gender="FEMALE"], &[data-gender="TRANSGENDER_FEMALE"] { color: #f38cac; } &[data-gender="MALE"], &[data-gender="TRANSGENDER_MALE"] { color: #89cff0; } &[data-gender="NON_BINARY"], &[data-gender="INTERSEX"] { color: #c8a2c8; } } .performer-height .height-imperial, .performer-weight .weight-imperial, .performer-penis-length .penis-length-imperial { &::before { content: " ("; } &::after { content: ")"; } } .penis-circumcised { &::before { content: " "; } } .favourite-data .favorite { color: #ff7373; } .performer-table .height-imperial, .performer-table .weight-imperial, .performer-table .penis-length-imperial, .performer-disambiguation { color: $text-muted; font-size: 0.875em; } .performer-table .age-data span { border-bottom: 1px dotted #f5f8fa; } .performer-result .performer-details > span { &::after { content: " • "; } &:last-child::after { content: ""; } } .performer-select-value .performer-disambiguation { color: initial; } .performer-select-option { .performer-select-row { align-items: center; display: flex; width: 100%; .performer-select-image { margin-right: 0.4em; max-height: 50px; max-width: 50px; } .performer-select-details { display: flex; flex-direction: column; justify-content: flex-start; max-height: 4.1rem; overflow: hidden; .performer-select-name { flex-shrink: 0; white-space: pre-wrap; word-break: break-all; .performer-select-alias { font-size: 0.8rem; font-weight: bold; } } .performer-select-disambiguation, .performer-select-birthdate { color: $text-muted; flex-shrink: 0; font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } } } } .edit-performers-dialog .modal-body { max-height: calc(100vh - 12rem); overflow-y: auto; padding-right: 1.5rem; } .performer-merge-dialog .custom-field { // ensure we don't catch the destination/source labels & > .form-label, .form-control { font-family: "Courier New", Courier, monospace; } } ================================================ FILE: ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx ================================================ import React, { useMemo, useState } from "react"; import { Button, ButtonGroup, Card, Col, Dropdown, Form, OverlayTrigger, Row, Table, Tooltip, } from "react-bootstrap"; import { Link, useHistory } from "react-router-dom"; import { FormattedMessage, FormattedNumber, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { ErrorMessage } from "../Shared/ErrorMessage"; import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; import { GalleryLink, GroupLink, SceneMarkerLink, TagLink, } from "../Shared/TagLink"; import { SweatDrops } from "../Shared/SweatDrops"; import { Pagination } from "src/components/List/Pagination"; import TextUtils from "src/utils/text"; import { DeleteScenesDialog } from "src/components/Scenes/DeleteScenesDialog"; import { EditScenesDialog } from "../Scenes/EditScenesDialog"; import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton"; import { faBox, faExclamationTriangle, faFileAlt, faFilm, faImages, faMapMarkerAlt, faPencilAlt, faTag, faTrash, } from "@fortawesome/free-solid-svg-icons"; import { SceneMergeModal } from "../Scenes/SceneMergeDialog"; import { objectTitle } from "src/core/files"; import { FileSize } from "../Shared/FileSize"; const CLASSNAME = "duplicate-checker"; const defaultDurationDiff = "1"; export const SceneDuplicateChecker: React.FC = () => { const intl = useIntl(); const history = useHistory(); const query = new URLSearchParams(history.location.search); const currentPage = Number.parseInt(query.get("page") ?? "1", 10); const pageSize = Number.parseInt(query.get("size") ?? "20", 10); const hashDistance = Number.parseInt(query.get("distance") ?? "0", 10); const durationDiff = Number.parseFloat( query.get("durationDiff") ?? defaultDurationDiff ); const [currentPageSize, setCurrentPageSize] = useState(pageSize); const [isMultiDelete, setIsMultiDelete] = useState(false); const [deletingScenes, setDeletingScenes] = useState(false); const [editingScenes, setEditingScenes] = useState(false); const [chkSafeSelect, setChkSafeSelect] = useState(true); const [checkedScenes, setCheckedScenes] = useState>( {} ); const { data, loading, refetch } = GQL.useFindDuplicateScenesQuery({ fetchPolicy: "no-cache", variables: { distance: hashDistance, duration_diff: durationDiff, }, }); const getGroupTotalSize = (group: GQL.SlimSceneDataFragment[]) => { // Sum all file sizes across all scenes in the group return group.reduce((groupTotal, scene) => { const sceneTotal = scene.files.reduce( (fileTotal, file) => fileTotal + file.size, 0 ); return groupTotal + sceneTotal; }, 0); }; const scenes = useMemo(() => { const groups = data?.findDuplicateScenes ?? []; // Sort by total file size descending (largest groups first) return [...groups].sort((a, b) => { return getGroupTotalSize(b) - getGroupTotalSize(a); }); }, [data?.findDuplicateScenes]); const { data: missingPhash } = GQL.useFindScenesQuery({ variables: { filter: { per_page: 0, }, scene_filter: { is_missing: "phash", file_count: { modifier: GQL.CriterionModifier.GreaterThan, value: 0, }, }, }, }); const [selectedScenes, setSelectedScenes] = useState< GQL.SlimSceneDataFragment[] | null >(null); const [mergeScenes, setMergeScenes] = useState<{ id: string; title: string }[]>(); const pageOptions = useMemo(() => { const pageSizes = [ 10, 20, 30, 40, 50, 100, 150, 200, 250, 500, 750, 1000, 1250, 1500, ]; const filteredSizes = pageSizes.filter((s, i) => { return scenes.length > s || i == 0 || scenes.length > pageSizes[i - 1]; }); return filteredSizes.map((size) => { return ( ); }); }, [scenes.length]); if (loading) return ; if (!data) return ; const filteredScenes = scenes.slice( (currentPage - 1) * pageSize, currentPage * pageSize ); const checkCount = Object.keys(checkedScenes).filter( (id) => checkedScenes[id] ).length; const setQuery = (q: Record) => { const newQuery = new URLSearchParams(query); for (const key of Object.keys(q)) { const value = q[key]; if (value !== undefined) { newQuery.set(key, String(value)); } else { newQuery.delete(key); } } history.push({ search: newQuery.toString() }); }; const resetCheckboxSelection = () => { const updatedScenes: Record = {}; Object.keys(checkedScenes).forEach((sceneKey) => { updatedScenes[sceneKey] = false; }); setCheckedScenes(updatedScenes); }; function onDeleteDialogClosed(deleted: boolean) { setDeletingScenes(false); if (deleted) { setSelectedScenes(null); refetch(); if (isMultiDelete) setCheckedScenes({}); } resetCheckboxSelection(); } const findLargestScene = (group: GQL.SlimSceneDataFragment[]) => { // Get maximum file size of a scene const totalSize = (scene: GQL.SlimSceneDataFragment) => { return scene.files.reduce((prev: number, f) => Math.max(prev, f.size), 0); }; // Find scene object with maximum total size return group.reduce((largest, scene) => { const largestSize = totalSize(largest); const currentSize = totalSize(scene); return currentSize > largestSize ? scene : largest; }); }; const findLargestResolutionScene = (group: GQL.SlimSceneDataFragment[]) => { // Get maximum resolution of a scene const sceneResolution = (scene: GQL.SlimSceneDataFragment) => { return scene.files.reduce( (prev: number, f) => Math.max(prev, f.height * f.width), 0 ); }; // Find scene object with maximum resolution return group.reduce((largest, scene) => { const largestSize = sceneResolution(largest); const currentSize = sceneResolution(scene); return currentSize > largestSize ? scene : largest; }); }; // Helper to get file date const findFirstFileByAge = ( oldest: boolean, compareScenes: GQL.SlimSceneDataFragment[] ) => { let selectedFile: GQL.VideoFileDataFragment; let oldestTimestamp: Date | undefined = undefined; // Loop through all files for (const file of compareScenes.flatMap((s) => s.files)) { // Get timestamp const timestamp: Date = new Date(file.mod_time); // Check if current file is oldest if (oldest) { if (oldestTimestamp === undefined || timestamp < oldestTimestamp) { oldestTimestamp = timestamp; selectedFile = file; } } else { if (oldestTimestamp === undefined || timestamp > oldestTimestamp) { oldestTimestamp = timestamp; selectedFile = file; } } } // Find scene with oldest file return compareScenes.find((s) => s.files.some((f) => f.id === selectedFile.id) ); }; function checkSameCodec(codecGroup: GQL.SlimSceneDataFragment[]) { const codecs = codecGroup.map((s) => s.files[0]?.video_codec); return new Set(codecs).size === 1; } function checkSameResolution(dataGroup: GQL.SlimSceneDataFragment[]) { const resolutions = dataGroup.map( (s) => s.files[0]?.width * s.files[0]?.height ); return new Set(resolutions).size === 1; } const onSelectLargestClick = () => { setSelectedScenes([]); const checkedArray: Record = {}; filteredScenes.forEach((group) => { if (chkSafeSelect && !checkSameCodec(group)) { return; } // Find largest scene in group a const largest = findLargestScene(group); group.forEach((scene) => { if (scene !== largest) { checkedArray[scene.id] = true; } }); }); setCheckedScenes(checkedArray); }; const onSelectLargestResolutionClick = () => { setSelectedScenes([]); const checkedArray: Record = {}; filteredScenes.forEach((group) => { if (chkSafeSelect && !checkSameCodec(group)) { return; } // Don't select scenes where resolution is identical. if (checkSameResolution(group)) { return; } // Find the highest resolution scene in group. const highest = findLargestResolutionScene(group); group.forEach((scene) => { if (scene !== highest) { checkedArray[scene.id] = true; } }); }); setCheckedScenes(checkedArray); }; const onSelectByAge = (oldest: boolean) => { setSelectedScenes([]); const checkedArray: Record = {}; filteredScenes.forEach((group) => { if (chkSafeSelect && !checkSameCodec(group)) { return; } const oldestScene = findFirstFileByAge(oldest, group); group.forEach((scene) => { if (scene !== oldestScene) { checkedArray[scene.id] = true; } }); }); setCheckedScenes(checkedArray); }; const handleCheck = (checked: boolean, sceneID: string) => { setCheckedScenes({ ...checkedScenes, [sceneID]: checked }); }; const handleDeleteChecked = () => { setSelectedScenes(scenes.flat().filter((s) => checkedScenes[s.id])); setDeletingScenes(true); setIsMultiDelete(true); }; const handleDeleteScene = (scene: GQL.SlimSceneDataFragment) => { setSelectedScenes([scene]); setDeletingScenes(true); setIsMultiDelete(false); }; function onEdit() { setSelectedScenes(scenes.flat().filter((s) => checkedScenes[s.id])); setEditingScenes(true); resetCheckboxSelection(); } function maybeRenderMissingPhashWarning() { const missingPhashes = missingPhash?.findScenes.count ?? 0; if (missingPhashes > 0) { return (

    Missing phashes for {missingPhashes} scenes. Please run the phash generation task.

    ); } } function maybeRenderEdit() { if (editingScenes && selectedScenes) { return ( setEditingScenes(false)} /> ); } } function maybeRenderTagPopoverButton(scene: GQL.SlimSceneDataFragment) { if (scene.tags.length <= 0) return; const popoverContent = scene.tags.map((tag) => ( )); return ( ); } function maybeRenderPerformerPopoverButton(scene: GQL.SlimSceneDataFragment) { if (scene.performers.length <= 0) return; return ; } function maybeRenderGroupPopoverButton(scene: GQL.SlimSceneDataFragment) { if (scene.groups.length <= 0) return; const popoverContent = scene.groups.map((sceneGroup) => (
    {sceneGroup.group.name
    )); return ( ); } function maybeRenderSceneMarkerPopoverButton( scene: GQL.SlimSceneDataFragment ) { if (scene.scene_markers.length <= 0) return; const popoverContent = scene.scene_markers.map((marker) => { const markerWithScene = { ...marker, scene: { id: scene.id } }; return ; }); return ( ); } function maybeRenderOCounter(scene: GQL.SlimSceneDataFragment) { if (scene.o_counter) { return (
    ); } } function maybeRenderGallery(scene: GQL.SlimSceneDataFragment) { if (scene.galleries.length <= 0) return; const popoverContent = scene.galleries.map((gallery) => ( )); return ( ); } function maybeRenderFileCount(scene: GQL.SlimSceneDataFragment) { if (scene.files.length <= 1) return; const popoverContent = ( ); return ( ); } function maybeRenderOrganized(scene: GQL.SlimSceneDataFragment) { if (scene.organized) { return (
    ); } } function maybeRenderPopoverButtonGroup(scene: GQL.SlimSceneDataFragment) { if ( scene.tags.length > 0 || scene.performers.length > 0 || scene.groups.length > 0 || scene.scene_markers.length > 0 || scene?.o_counter || scene.galleries.length > 0 || scene.files.length > 1 || scene.organized ) { return ( <> {maybeRenderTagPopoverButton(scene)} {maybeRenderPerformerPopoverButton(scene)} {maybeRenderGroupPopoverButton(scene)} {maybeRenderSceneMarkerPopoverButton(scene)} {maybeRenderOCounter(scene)} {maybeRenderGallery(scene)} {maybeRenderFileCount(scene)} {maybeRenderOrganized(scene)} ); } } function renderPagination() { return (
    {checkCount > 0 && ( {intl.formatMessage({ id: "actions.edit" })} } > {intl.formatMessage({ id: "actions.delete" })} } > )} { setQuery({ page: newPage === 1 ? undefined : newPage }); resetCheckboxSelection(); }} /> { setCurrentPageSize(parseInt(e.currentTarget.value, 10)); setQuery({ size: e.currentTarget.value === "20" ? undefined : e.currentTarget.value, }); resetCheckboxSelection(); }} > {pageOptions}
    ); } function renderMergeDialog() { if (mergeScenes) { return ( { setMergeScenes(undefined); if (mergedID) { // refresh refetch(); } }} show /> ); } } function onMergeClicked( sceneGroup: GQL.SlimSceneDataFragment[], scene: GQL.SlimSceneDataFragment ) { const selected = scenes.flat().filter((s) => checkedScenes[s.id]); // if scenes in this group other than this scene are selected, then only // the selected scenes will be selected as source. Otherwise all other // scenes will be source let srcScenes = selected.filter((s) => { if (s === scene) return false; return sceneGroup.includes(s); }) ?? []; if (!srcScenes.length) { srcScenes = sceneGroup.filter((s) => s !== scene); } // insert subject scene to the front so that it is considered the destination srcScenes.unshift(scene); setMergeScenes( srcScenes.map((s) => { return { id: s.id, title: objectTitle(s), }; }) ); } return (
    {deletingScenes && selectedScenes && ( )} {renderMergeDialog()} {maybeRenderEdit()}

    setQuery({ distance: e.currentTarget.value === "0" ? undefined : e.currentTarget.value, page: undefined, }) } defaultValue={hashDistance} className="input-control ml-4" > setQuery({ durationDiff: e.currentTarget.value === defaultDurationDiff ? undefined : e.currentTarget.value, page: undefined, }) } defaultValue={durationDiff} className="input-control ml-4" > resetCheckboxSelection()}> {intl.formatMessage({ id: "dupe_check.select_none" })} onSelectLargestResolutionClick()} > {intl.formatMessage({ id: "dupe_check.select_all_but_largest_resolution", })} onSelectLargestClick()}> {intl.formatMessage({ id: "dupe_check.select_all_but_largest_file", })} onSelectByAge(true)}> {intl.formatMessage({ id: "dupe_check.select_oldest", })} onSelectByAge(false)}> {intl.formatMessage({ id: "dupe_check.select_youngest", })} { setChkSafeSelect(e.target.checked); resetCheckboxSelection(); }} />
    {maybeRenderMissingPhashWarning()} {renderPagination()} {filteredScenes.map((group, groupIndex) => group.map((scene, i) => { const file = scene.files.length > 0 ? scene.files[0] : undefined; return ( <> {i === 0 && groupIndex !== 0 ? ( ) : undefined} ); }) )}
    {intl.formatMessage({ id: "details" })} {intl.formatMessage({ id: "duration" })} {intl.formatMessage({ id: "filesize" })} {intl.formatMessage({ id: "resolution" })} {intl.formatMessage({ id: "bitrate" })} {intl.formatMessage({ id: "media_info.video_codec" })} {intl.formatMessage({ id: "actions.delete" })}
    handleCheck(e.currentTarget.checked, scene.id) } /> } placement="right" >

    {" "} {scene.title ? scene.title : TextUtils.fileNameFromPath( file?.path ?? "" )}{" "}

    {file?.path ?? ""}

    {maybeRenderPopoverButtonGroup(scene)} {file?.duration && TextUtils.secondsToTimestamp(file.duration)} {`${file?.width ?? 0}x${file?.height ?? 0}`}  mbps {file?.video_codec ?? ""}
    {scenes.length === 0 && (

    No duplicates found.

    )} {renderPagination()}
    ); }; export default SceneDuplicateChecker; ================================================ FILE: ui/v2.5/src/components/SceneDuplicateChecker/styles.scss ================================================ #scene-duplicate-checker { .scene-path { font-size: 0.88em; } .filter-container { margin: 0; } .separator { border-top: 1px solid white; height: 10px; } .form-group .row { align-items: center; } } ================================================ FILE: ui/v2.5/src/components/SceneFilenameParser/ParserField.ts ================================================ export class ParserField { public field: string; public helperText?: string; constructor(field: string, helperText?: string) { this.field = field; this.helperText = helperText; } public getFieldPattern() { return `{${this.field}}`; } static Title = new ParserField("title"); static Ext = new ParserField("ext", "File extension"); static Rating = new ParserField("rating100"); static I = new ParserField("i", "Matches any ignored word"); static D = new ParserField("d", "Matches any delimiter (.-_)"); static Performer = new ParserField("performer"); static Studio = new ParserField("studio"); static Tag = new ParserField("tag"); // date fields static Date = new ParserField("date", "YYYY-MM-DD"); static YYYY = new ParserField("yyyy", "Year"); static YY = new ParserField("yy", "Year (20YY)"); static MM = new ParserField("mm", "Two digit month"); static MMM = new ParserField("mmm", "Three letter month (eg Jan)"); static DD = new ParserField("dd", "Two digit date"); static YYYYMMDD = new ParserField("yyyymmdd"); static YYMMDD = new ParserField("yymmdd"); static DDMMYYYY = new ParserField("ddmmyyyy"); static DDMMYY = new ParserField("ddmmyy"); static MMDDYYYY = new ParserField("mmddyyyy"); static MMDDYY = new ParserField("mmddyy"); static validFields = [ ParserField.Title, ParserField.Ext, ParserField.D, ParserField.I, ParserField.Rating, ParserField.Performer, ParserField.Studio, ParserField.Tag, ParserField.Date, ParserField.YYYY, ParserField.YY, ParserField.MM, ParserField.MMM, ParserField.DD, ParserField.YYYYMMDD, ParserField.YYMMDD, ParserField.DDMMYYYY, ParserField.DDMMYY, ParserField.MMDDYYYY, ParserField.MMDDYY, ]; static fullDateFields = [ ParserField.YYYYMMDD, ParserField.YYMMDD, ParserField.DDMMYYYY, ParserField.DDMMYY, ParserField.MMDDYYYY, ParserField.MMDDYY, ]; } ================================================ FILE: ui/v2.5/src/components/SceneFilenameParser/ParserInput.tsx ================================================ import React, { useState } from "react"; import { Button, Dropdown, DropdownButton, Form, InputGroup, } from "react-bootstrap"; import { useIntl } from "react-intl"; import { ParserField } from "./ParserField"; import { ShowFields } from "./ShowFields"; const builtInRecipes = [ { pattern: "{title}", ignoreWords: [], whitespaceCharacters: "", capitalizeTitle: false, description: "Filename", }, { pattern: "{title}.{ext}", ignoreWords: [], whitespaceCharacters: "", capitalizeTitle: false, description: "Without extension", }, { pattern: "{}.{yy}.{mm}.{dd}.{title}.XXX.{}.{ext}", ignoreWords: [], whitespaceCharacters: ".", capitalizeTitle: true, description: "", }, { pattern: "{}.{yy}.{mm}.{dd}.{title}.{ext}", ignoreWords: [], whitespaceCharacters: ".", capitalizeTitle: true, description: "", }, { pattern: "{title}.XXX.{}.{ext}", ignoreWords: [], whitespaceCharacters: ".", capitalizeTitle: true, description: "", }, { pattern: "{}.{yy}.{mm}.{dd}.{title}.{i}.{ext}", ignoreWords: ["cz", "fr"], whitespaceCharacters: ".", capitalizeTitle: true, description: "Foreign language", }, ]; export interface IParserInput { pattern: string; ignoreWords: string[]; whitespaceCharacters: string; capitalizeTitle: boolean; page: number; pageSize: number; findClicked: boolean; ignoreOrganized: boolean; } interface IParserRecipe { pattern: string; ignoreWords: string[]; whitespaceCharacters: string; capitalizeTitle: boolean; description: string; } interface IParserInputProps { input: IParserInput; onFind: (input: IParserInput) => void; onPageSizeChanged: (newSize: number) => void; showFields: Map; setShowFields: (fields: Map) => void; } export const ParserInput: React.FC = ( props: IParserInputProps ) => { const intl = useIntl(); const [pattern, setPattern] = useState(props.input.pattern); const [ignoreWords, setIgnoreWords] = useState( props.input.ignoreWords.join(" ") ); const [whitespaceCharacters, setWhitespaceCharacters] = useState( props.input.whitespaceCharacters ); const [capitalizeTitle, setCapitalizeTitle] = useState( props.input.capitalizeTitle ); const [ignoreOrganized, setIgnoreOrganized] = useState( props.input.ignoreOrganized ); function onFind() { props.onFind({ pattern, ignoreWords: ignoreWords.split(" "), whitespaceCharacters, capitalizeTitle, page: 1, pageSize: props.input.pageSize, findClicked: props.input.findClicked, ignoreOrganized, }); } function setParserRecipe(recipe: IParserRecipe) { setPattern(recipe.pattern); setIgnoreWords(recipe.ignoreWords.join(" ")); setWhitespaceCharacters(recipe.whitespaceCharacters); setCapitalizeTitle(recipe.capitalizeTitle); } const validFields = [new ParserField("", "Wildcard")].concat( ParserField.validFields ); function addParserField(field: ParserField) { setPattern(pattern + field.getFieldPattern()); } const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120", "250", "500", "1000"]; return ( {intl.formatMessage({ id: "config.tools.scene_filename_parser.filename_pattern", })} ) => setPattern(e.currentTarget.value) } value={pattern} /> {validFields.map((item) => ( addParserField(item)} > {item.field || "{}"} {item.helperText} ))} {intl.formatMessage({ id: "config.tools.scene_filename_parser.escape_chars", })} {intl.formatMessage({ id: "config.tools.scene_filename_parser.ignored_words", })} ) => setIgnoreWords(e.currentTarget.value) } value={ignoreWords} /> {intl.formatMessage({ id: "config.tools.scene_filename_parser.matches_with", })}
    {intl.formatMessage({ id: "title" })}
    {intl.formatMessage({ id: "config.tools.scene_filename_parser.whitespace_chars", })} ) => setWhitespaceCharacters(e.currentTarget.value) } value={whitespaceCharacters} /> {intl.formatMessage({ id: "config.tools.scene_filename_parser.whitespace_chars_desc", })} setCapitalizeTitle(!capitalizeTitle)} /> {intl.formatMessage({ id: "config.tools.scene_filename_parser.capitalize_title", })} setIgnoreOrganized(!ignoreOrganized)} /> {intl.formatMessage({ id: "config.tools.scene_filename_parser.ignore_organized", })} {/* TODO - mapping stuff will go here */} {builtInRecipes.map((item) => ( setParserRecipe(item)} > {item.pattern} {item.description} ))} props.setShowFields(fields)} /> ) => props.onPageSizeChanged(parseInt(e.currentTarget.value, 10)) } defaultValue={props.input.pageSize} className="col-1 input-control filter-item" > {PAGE_SIZE_OPTIONS.map((val) => ( ))}
    ); }; ================================================ FILE: ui/v2.5/src/components/SceneFilenameParser/SceneFilenameParser.tsx ================================================ /* eslint-disable no-param-reassign, jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */ import React, { useEffect, useState, useCallback, useRef } from "react"; import { Button, Card, Form, Table } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import clone from "lodash-es/clone"; import { queryParseSceneFilenames, useScenesUpdate, } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useToast } from "src/hooks/Toast"; import { Pagination } from "src/components/List/Pagination"; import { IParserInput, ParserInput } from "./ParserInput"; import { ParserField } from "./ParserField"; import { SceneParserResult, SceneParserRow } from "./SceneParserRow"; const initialParserInput = { pattern: "{title}.{ext}", ignoreWords: [], whitespaceCharacters: "._", capitalizeTitle: true, page: 1, pageSize: 20, findClicked: false, ignoreOrganized: true, }; const initialShowFieldsState = new Map([ ["Title", true], ["Date", true], ["Rating", true], ["Performers", true], ["Tags", true], ["Studio", true], ]); export const SceneFilenameParser: React.FC = () => { const intl = useIntl(); const Toast = useToast(); const [parserResult, setParserResult] = useState([]); const [parserInput, setParserInput] = useState(initialParserInput); const prevParserInputRef = useRef(); const prevParserInput = prevParserInputRef.current; const [allTitleSet, setAllTitleSet] = useState(false); const [allDateSet, setAllDateSet] = useState(false); const [allRatingSet, setAllRatingSet] = useState(false); const [allPerformerSet, setAllPerformerSet] = useState(false); const [allTagSet, setAllTagSet] = useState(false); const [allStudioSet, setAllStudioSet] = useState(false); const [showFields, setShowFields] = useState>( initialShowFieldsState ); const [totalItems, setTotalItems] = useState(0); // Network state const [isLoading, setIsLoading] = useState(false); const [updateScenes] = useScenesUpdate(getScenesUpdateData()); useEffect(() => { prevParserInputRef.current = parserInput; }, [parserInput]); const determineFieldsToHide = useCallback(() => { const { pattern } = parserInput; const titleSet = pattern.includes("{title}"); const dateSet = pattern.includes("{date}") || pattern.includes("{dd}") || // don't worry about other partial date fields since this should be implied ParserField.fullDateFields.some((f) => { return pattern.includes(`{${f.field}}`); }); const ratingSet = pattern.includes("{rating100}"); const performerSet = pattern.includes("{performer}"); const tagSet = pattern.includes("{tag}"); const studioSet = pattern.includes("{studio}"); const newShowFields = new Map([ ["Title", titleSet], ["Date", dateSet], ["Rating", ratingSet], ["Performers", performerSet], ["Tags", tagSet], ["Studio", studioSet], ]); setShowFields(newShowFields); }, [parserInput]); const parseResults = useCallback( ( results: GQL.ParseSceneFilenamesQuery["parseSceneFilenames"]["results"] ) => { if (results) { const result = results .map((r) => { return new SceneParserResult(r); }) .filter((r) => !!r) as SceneParserResult[]; setParserResult(result); determineFieldsToHide(); } }, [determineFieldsToHide] ); const parseSceneFilenames = useCallback(() => { setParserResult([]); setIsLoading(true); const parserFilter = { q: parserInput.pattern, page: parserInput.page, per_page: parserInput.pageSize, sort: "path", direction: GQL.SortDirectionEnum.Asc, }; const parserInputData = { ignoreWords: parserInput.ignoreWords, whitespaceCharacters: parserInput.whitespaceCharacters, capitalizeTitle: parserInput.capitalizeTitle, ignoreOrganized: parserInput.ignoreOrganized, }; queryParseSceneFilenames(parserFilter, parserInputData) .then((response) => { const result = response?.data?.parseSceneFilenames; if (result) { parseResults(result.results); setTotalItems(result.count); } }) .catch((err) => Toast.error(err)) .finally(() => setIsLoading(false)); }, [parserInput, parseResults, Toast]); useEffect(() => { // only refresh if parserInput actually changed if (prevParserInput === parserInput) { return; } if (parserInput.findClicked) { parseSceneFilenames(); } }, [parserInput, parseSceneFilenames, prevParserInput]); function onPageSizeChanged(newSize: number) { const newInput = clone(parserInput); newInput.page = 1; newInput.pageSize = newSize; setParserInput(newInput); } function onPageChanged(newPage: number) { if (newPage !== parserInput.page) { const newInput = clone(parserInput); newInput.page = newPage; setParserInput(newInput); } } function onFindClicked(input: IParserInput) { const newInput = clone(input); newInput.page = 1; newInput.findClicked = true; setParserInput(newInput); setTotalItems(0); } function getScenesUpdateData() { return parserResult .filter((result) => result.isChanged()) .map((result) => result.toSceneUpdateInput()); } async function onApply() { setIsLoading(true); try { await updateScenes(); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl.formatMessage({ id: "scenes" }).toLocaleLowerCase() } ) ); } catch (e) { Toast.error(e); } setIsLoading(false); // trigger a refresh of the results onFindClicked(parserInput); } useEffect(() => { const newAllTitleSet = !parserResult.some((r) => { return !r.title.isSet; }); const newAllDateSet = !parserResult.some((r) => { return !r.date.isSet; }); const newAllRatingSet = !parserResult.some((r) => { return !r.rating.isSet; }); const newAllPerformerSet = !parserResult.some((r) => { return !r.performers.isSet; }); const newAllTagSet = !parserResult.some((r) => { return !r.tags.isSet; }); const newAllStudioSet = !parserResult.some((r) => { return !r.studio.isSet; }); setAllTitleSet(newAllTitleSet); setAllDateSet(newAllDateSet); setAllRatingSet(newAllRatingSet); setAllTagSet(newAllPerformerSet); setAllTagSet(newAllTagSet); setAllStudioSet(newAllStudioSet); }, [parserResult]); function onSelectAllTitleSet(selected: boolean) { const newResult = [...parserResult]; newResult.forEach((r) => { r.title.isSet = selected; }); setParserResult(newResult); setAllTitleSet(selected); } function onSelectAllDateSet(selected: boolean) { const newResult = [...parserResult]; newResult.forEach((r) => { r.date.isSet = selected; }); setParserResult(newResult); setAllDateSet(selected); } function onSelectAllRatingSet(selected: boolean) { const newResult = [...parserResult]; newResult.forEach((r) => { r.rating.isSet = selected; }); setParserResult(newResult); setAllRatingSet(selected); } function onSelectAllPerformerSet(selected: boolean) { const newResult = [...parserResult]; newResult.forEach((r) => { r.performers.isSet = selected; }); setParserResult(newResult); setAllPerformerSet(selected); } function onSelectAllTagSet(selected: boolean) { const newResult = [...parserResult]; newResult.forEach((r) => { r.tags.isSet = selected; }); setParserResult(newResult); setAllTagSet(selected); } function onSelectAllStudioSet(selected: boolean) { const newResult = [...parserResult]; newResult.forEach((r) => { r.studio.isSet = selected; }); setParserResult(newResult); setAllStudioSet(selected); } function onChange(scene: SceneParserResult, changedScene: SceneParserResult) { const newResult = [...parserResult]; const index = newResult.indexOf(scene); newResult[index] = changedScene; setParserResult(newResult); } function renderHeader( fieldName: string, allSet: boolean, onAllSet: (set: boolean) => void ) { if (!showFields.get(fieldName)) { return null; } return ( <> { onAllSet(!allSet); }} /> {fieldName} ); } function renderTable() { if (parserResult.length === 0) { return undefined; } return ( <>
    {renderHeader( intl.formatMessage({ id: "title" }), allTitleSet, onSelectAllTitleSet )} {renderHeader( intl.formatMessage({ id: "date" }), allDateSet, onSelectAllDateSet )} {renderHeader( intl.formatMessage({ id: "rating" }), allRatingSet, onSelectAllRatingSet )} {renderHeader( intl.formatMessage({ id: "performers" }), allPerformerSet, onSelectAllPerformerSet )} {renderHeader( intl.formatMessage({ id: "tags" }), allTagSet, onSelectAllTagSet )} {renderHeader( intl.formatMessage({ id: "studio" }), allStudioSet, onSelectAllStudioSet )} {parserResult.map((scene) => ( onChange(scene, changedScene)} showFields={showFields} /> ))}
    {intl.formatMessage({ id: "config.tools.scene_filename_parser.filename", })}
    onPageChanged(page)} /> ); } return (

    {intl.formatMessage({ id: "config.tools.scene_filename_parser.title" })}

    onFindClicked(input)} onPageSizeChanged={onPageSizeChanged} showFields={showFields} setShowFields={setShowFields} /> {isLoading && } {renderTable()}
    ); }; export default SceneFilenameParser; ================================================ FILE: ui/v2.5/src/components/SceneFilenameParser/SceneParserRow.tsx ================================================ import React from "react"; import isEqual from "lodash-es/isEqual"; import clone from "lodash-es/clone"; import { Form } from "react-bootstrap"; import { ParseSceneFilenamesQuery, SlimSceneDataFragment, } from "src/core/generated-graphql"; import { PerformerSelect, TagSelect, StudioSelect, } from "src/components/Shared/Select"; import cx from "classnames"; import { objectTitle } from "src/core/files"; class ParserResult { public value?: T; public originalValue?: T; public isSet: boolean = false; public setOriginalValue(value?: T) { this.originalValue = value; this.value = value; } public setValue(value?: T) { if (value) { this.value = value; this.isSet = !isEqual(this.value, this.originalValue); } } } export class SceneParserResult { public id: string; public filename: string; public title: ParserResult = new ParserResult(); public date: ParserResult = new ParserResult(); public rating: ParserResult = new ParserResult(); public studio: ParserResult = new ParserResult(); public tags: ParserResult = new ParserResult(); public performers: ParserResult = new ParserResult(); public scene: SlimSceneDataFragment; constructor( result: ParseSceneFilenamesQuery["parseSceneFilenames"]["results"][0] ) { this.scene = result.scene; this.id = this.scene.id; this.filename = objectTitle(this.scene); this.title.setOriginalValue(this.scene.title ?? undefined); this.date.setOriginalValue(this.scene.date ?? undefined); this.rating.setOriginalValue(this.scene.rating100 ?? undefined); this.performers.setOriginalValue(this.scene.performers.map((p) => p.id)); this.tags.setOriginalValue(this.scene.tags.map((t) => t.id)); this.studio.setOriginalValue(this.scene.studio?.id); this.title.setValue(result.title ?? undefined); this.date.setValue(result.date ?? undefined); this.rating.setValue(result.rating ?? undefined); this.performers.setValue(result.performer_ids ?? undefined); this.tags.setValue(result.tag_ids ?? undefined); this.studio.setValue(result.studio_id ?? undefined); } // returns true if any of its fields have set == true public isChanged() { return ( this.title.isSet || this.date.isSet || this.rating.isSet || this.performers.isSet || this.studio.isSet || this.tags.isSet ); } public toSceneUpdateInput() { return { id: this.id, rating: this.rating.isSet ? this.rating.value : undefined, title: this.title.isSet ? this.title.value : undefined, date: this.date.isSet ? this.date.value : undefined, studio_id: this.studio.isSet ? this.studio.value : undefined, performer_ids: this.performers.isSet ? this.performers.value : undefined, tag_ids: this.tags.isSet ? this.tags.value : undefined, }; } } interface ISceneParserFieldProps { parserResult: ParserResult; className?: string; onSetChanged: (isSet: boolean) => void; onValueChanged: (value: T) => void; originalParserResult?: ParserResult; } function SceneParserStringField(props: ISceneParserFieldProps) { function maybeValueChanged(value: string) { if (value !== props.parserResult.value) { props.onValueChanged(value); } } const result = props.originalParserResult || props.parserResult; return ( <> { props.onSetChanged(!props.parserResult.isSet); }} /> ) => maybeValueChanged(event.currentTarget.value) } /> ); } function SceneParserRatingField( props: ISceneParserFieldProps ) { function maybeValueChanged(value?: number) { if (value !== props.parserResult.value) { props.onValueChanged(value); } } const result = props.originalParserResult || props.parserResult; const options = ["", 1, 2, 3, 4, 5]; return ( <> { props.onSetChanged(!props.parserResult.isSet); }} /> ) => maybeValueChanged( event.currentTarget.value === "" ? undefined : Number.parseInt(event.currentTarget.value, 10) ) } > {options.map((opt) => ( ))} ); } function SceneParserPerformerField(props: ISceneParserFieldProps) { function maybeValueChanged(value: string[]) { if (value !== props.parserResult.value) { props.onValueChanged(value); } } const originalPerformers = (props.originalParserResult?.originalValue ?? []) as string[]; const newPerformers = props.parserResult.value ?? []; return ( <> { props.onSetChanged(!props.parserResult.isSet); }} /> { maybeValueChanged(items.map((i) => i.id)); }} ids={newPerformers} /> ); } function SceneParserTagField(props: ISceneParserFieldProps) { function maybeValueChanged(value: string[]) { if (value !== props.parserResult.value) { props.onValueChanged(value); } } const originalTags = props.originalParserResult?.originalValue ?? []; const newTags = props.parserResult.value ?? []; return ( <> { props.onSetChanged(!props.parserResult.isSet); }} /> { maybeValueChanged(items.map((i) => i.id)); }} ids={newTags} /> ); } function SceneParserStudioField(props: ISceneParserFieldProps) { function maybeValueChanged(value: string) { if (value !== props.parserResult.value) { props.onValueChanged(value); } } const originalStudio = props.originalParserResult?.originalValue ? [props.originalParserResult?.originalValue] : []; const newStudio = props.parserResult.value ? [props.parserResult.value] : []; return ( <> { props.onSetChanged(!props.parserResult.isSet); }} /> { maybeValueChanged(items[0].id); }} ids={newStudio} /> ); } interface ISceneParserRowProps { scene: SceneParserResult; onChange: (changedScene: SceneParserResult) => void; showFields: Map; } export const SceneParserRow = (props: ISceneParserRowProps) => { function changeParser(result: ParserResult, isSet: boolean, value?: T) { const newParser = clone(result); newParser.isSet = isSet; newParser.value = value; return newParser; } function onTitleChanged(set: boolean, value: string) { const newResult = clone(props.scene); newResult.title = changeParser(newResult.title, set, value); props.onChange(newResult); } function onDateChanged(set: boolean, value: string) { const newResult = clone(props.scene); newResult.date = changeParser(newResult.date, set, value); props.onChange(newResult); } function onRatingChanged(set: boolean, value?: number) { const newResult = clone(props.scene); newResult.rating = changeParser(newResult.rating, set, value); props.onChange(newResult); } function onPerformerIdsChanged(set: boolean, value: string[]) { const newResult = clone(props.scene); newResult.performers = changeParser(newResult.performers, set, value); props.onChange(newResult); } function onTagIdsChanged(set: boolean, value: string[]) { const newResult = clone(props.scene); newResult.tags = changeParser(newResult.tags, set, value); props.onChange(newResult); } function onStudioIdChanged(set: boolean, value: string) { const newResult = clone(props.scene); newResult.studio = changeParser(newResult.studio, set, value); props.onChange(newResult); } return ( {props.scene.filename} {props.showFields.get("Title") && ( onTitleChanged(isSet, props.scene.title.value ?? "") } onValueChanged={(value) => onTitleChanged(props.scene.title.isSet, value) } /> )} {props.showFields.get("Date") && ( onDateChanged(isSet, props.scene.date.value ?? "") } onValueChanged={(value) => onDateChanged(props.scene.date.isSet, value) } /> )} {props.showFields.get("Rating") && ( onRatingChanged(isSet, props.scene.rating.value ?? undefined) } onValueChanged={(value) => onRatingChanged(props.scene.rating.isSet, value) } /> )} {props.showFields.get("Performers") && ( onPerformerIdsChanged(set, props.scene.performers.value ?? []) } onValueChanged={(value) => onPerformerIdsChanged(props.scene.performers.isSet, value) } /> )} {props.showFields.get("Tags") && ( onTagIdsChanged(isSet, props.scene.tags.value ?? []) } onValueChanged={(value) => onTagIdsChanged(props.scene.tags.isSet, value) } /> )} {props.showFields.get("Studio") && ( onStudioIdChanged(set, props.scene.studio.value ?? "") } onValueChanged={(value) => onStudioIdChanged(props.scene.studio.isSet, value) } /> )} ); }; ================================================ FILE: ui/v2.5/src/components/SceneFilenameParser/ShowFields.tsx ================================================ import { faCheck, faChevronDown, faChevronRight, faTimes, } from "@fortawesome/free-solid-svg-icons"; import React, { useState } from "react"; import { Button, Collapse } from "react-bootstrap"; import { useIntl } from "react-intl"; import { Icon } from "src/components/Shared/Icon"; interface IShowFieldsProps { fields: Map; onShowFieldsChanged: (fields: Map) => void; } export const ShowFields: React.FC = (props) => { const intl = useIntl(); const [open, setOpen] = useState(false); function handleClick(label: string) { const copy = new Map(props.fields); copy.set(label, !props.fields.get(label)); props.onShowFieldsChanged(copy); } const fieldRows = [...props.fields.entries()].map(([label, enabled]) => ( )); return (
    {fieldRows}
    ); }; ================================================ FILE: ui/v2.5/src/components/SceneFilenameParser/styles.scss ================================================ #recipe-select::after { content: none; } .scene-parser-results { margin-left: 31ch; overflow-x: auto; } .scene-parser-row { .parser-field-filename { left: 1ch; position: absolute; width: 30ch; } .parser-field-title { width: 40ch; } .parser-field-date { width: 13ch; } .parser-field-performers { width: 30ch; } .parser-field-performers-select, .parser-field-tags-select, .parser-field-studio-select { margin-bottom: 0.5rem; } .parser-field-tags { width: 30ch; } .parser-field-studio { width: 20ch; } .form-control { min-width: 10ch; } .form-control + .form-control { margin-top: 0.5rem; } .badge-items { background-color: #e9ecef; margin-bottom: 0.25rem; } } ================================================ FILE: ui/v2.5/src/components/ScenePlayer/PlaylistButtons.ts ================================================ /* eslint-disable @typescript-eslint/naming-convention */ import videojs, { VideoJsPlayer } from "video.js"; interface ControlOptions extends videojs.ComponentOptions { direction: "forward" | "back"; parent: SkipButtonPlugin; } class SkipButtonPlugin extends videojs.getPlugin("plugin") { onNext?: () => void; onPrevious?: () => void; constructor(player: VideoJsPlayer) { super(player); player.ready(() => { this.ready(); }); } public setForwardHandler(handler?: () => void) { this.onNext = handler; if (handler !== undefined) this.player.addClass("vjs-skip-buttons-next"); else this.player.removeClass("vjs-skip-buttons-next"); } public setBackwardHandler(handler?: () => void) { this.onPrevious = handler; if (handler !== undefined) this.player.addClass("vjs-skip-buttons-prev"); else this.player.removeClass("vjs-skip-buttons-prev"); } handleForward() { this.onNext?.(); } handleBackward() { this.onPrevious?.(); } ready() { this.player.addClass("vjs-skip-buttons"); this.player.controlBar.addChild( "skipButton", { direction: "forward", parent: this, }, 1 ); this.player.controlBar.addChild( "skipButton", { direction: "back", parent: this, }, 0 ); } } class SkipButton extends videojs.getComponent("button") { private parentPlugin: SkipButtonPlugin; private direction: "forward" | "back"; constructor(player: VideoJsPlayer, options: ControlOptions) { super(player, options); this.parentPlugin = options.parent; this.direction = options.direction; if (options.direction === "forward") { this.controlText(this.localize("Skip to next video")); this.addClass(`vjs-icon-next-item`); } else if (options.direction === "back") { this.controlText(this.localize("Skip to previous video")); this.addClass(`vjs-icon-previous-item`); } } /** * Return button class names */ buildCSSClass() { return `vjs-skip-button ${super.buildCSSClass()}`; } /** * Seek with the button's configured offset */ handleClick() { if (this.direction === "forward") this.parentPlugin.handleForward(); else this.parentPlugin.handleBackward(); } } videojs.registerComponent("SkipButton", SkipButton); videojs.registerPlugin("skipButtons", SkipButtonPlugin); declare module "video.js" { interface VideoJsPlayer { skipButtons: () => SkipButtonPlugin; } interface VideoJsPlayerPluginOptions { skipButtons?: {}; } } export default SkipButtonPlugin; ================================================ FILE: ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx ================================================ import React, { KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import videojs, { VideoJsPlayer, VideoJsPlayerOptions } from "video.js"; import useScript from "src/hooks/useScript"; import "videojs-contrib-dash"; import "videojs-mobile-ui"; import "videojs-seek-buttons"; import { UAParser } from "ua-parser-js"; import "./live"; import "./PlaylistButtons"; import "./source-selector"; import "./persist-volume"; import "./autostart-button"; import MarkersPlugin, { type IMarker } from "./markers"; void MarkersPlugin; import "./vtt-thumbnails"; import "./big-buttons"; import "./track-activity"; import "./vrmode"; import "./media-session"; import "./wake-sentinel"; import cx from "classnames"; import { useSceneSaveActivity, useSceneIncrementPlayCount, useConfigureInterface, } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { ScenePlayerScrubber } from "./ScenePlayerScrubber"; import { useConfigurationContext } from "src/hooks/Config"; import { ConnectionState, InteractiveContext, } from "src/hooks/Interactive/context"; import { SceneInteractiveStatus } from "src/hooks/Interactive/status"; import { languageMap } from "src/utils/caption"; import { VIDEO_PLAYER_ID } from "./util"; // @ts-ignore import airplay from "@silvermine/videojs-airplay"; // @ts-ignore import chromecast from "@silvermine/videojs-chromecast"; import abLoopPlugin from "videojs-abloop"; import ScreenUtils from "src/utils/screen"; import { PatchComponent } from "src/patch"; // register videojs plugins airplay(videojs); chromecast(videojs); abLoopPlugin(window, videojs); function handleHotkeys(player: VideoJsPlayer, event: videojs.KeyboardEvent) { function seekStep(step: number) { const time = player.currentTime() + step; const duration = player.duration(); if (time < 0) { player.currentTime(0); } else if (time < duration) { player.currentTime(time); } else { player.currentTime(duration); } } function seekPercent(percent: number) { const duration = player.duration(); const time = duration * percent; player.currentTime(time); } function seekPercentRelative(percent: number) { const duration = player.duration(); const currentTime = player.currentTime(); const time = currentTime + duration * percent; if (time > duration) return; player.currentTime(time); } function toggleABLooping() { const opts = player.abLoopPlugin.getOptions(); if (!opts.start) { opts.start = player.currentTime(); } else if (!opts.end) { opts.end = player.currentTime(); opts.enabled = true; } else { opts.start = 0; opts.end = 0; opts.enabled = false; } player.abLoopPlugin.setOptions(opts); } let seekFactor = 10; if (event.shiftKey) { seekFactor = 5; } else if (event.ctrlKey || event.altKey) { seekFactor = 60; } switch (event.which) { case 39: // right arrow seekStep(seekFactor); break; case 37: // left arrow seekStep(-seekFactor); break; } // toggle player looping with shift+l if (event.shiftKey && event.which === 76) { player.loop(!player.loop()); return; } if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { return; } const skipButtons = player.skipButtons(); if (skipButtons) { // handle multimedia keys switch (event.key) { case "MediaTrackNext": if (!skipButtons.onNext) return; skipButtons.onNext(); break; case "MediaTrackPrevious": if (!skipButtons.onPrevious) return; skipButtons.onPrevious(); break; // MediaPlayPause handled by videojs } } switch (event.which) { case 32: // space case 13: // enter if (player.paused()) player.play(); else player.pause(); break; case 77: // m player.muted(!player.muted()); break; case 70: // f if (player.isFullscreen()) player.exitFullscreen(); else player.requestFullscreen(); break; case 76: // l toggleABLooping(); break; case 38: // up arrow player.volume(player.volume() + 0.1); break; case 40: // down arrow player.volume(player.volume() - 0.1); break; case 48: // 0 player.currentTime(0); break; case 49: // 1 seekPercent(0.1); break; case 50: // 2 seekPercent(0.2); break; case 51: // 3 seekPercent(0.3); break; case 52: // 4 seekPercent(0.4); break; case 53: // 5 seekPercent(0.5); break; case 54: // 6 seekPercent(0.6); break; case 55: // 7 seekPercent(0.7); break; case 56: // 8 seekPercent(0.8); break; case 57: // 9 seekPercent(0.9); break; case 221: // ] seekPercentRelative(0.1); break; case 219: // [ seekPercentRelative(-0.1); break; } } type MarkerFragment = Pick & { primary_tag: Pick; tags: Array>; }; function getMarkerTitle(marker: MarkerFragment) { if (marker.title) { return marker.title; } let ret = marker.primary_tag.name; if (marker.tags.length) { ret += `, ${marker.tags.map((t) => t.name).join(", ")}`; } return ret; } interface IScenePlayerProps { scene: GQL.SceneDataFragment; hideScrubberOverride: boolean; autoplay?: boolean; permitLoop?: boolean; initialTimestamp: number; sendSetTimestamp: (setTimestamp: (value: number) => void) => void; onComplete: () => void; onNext: () => void; onPrevious: () => void; } export const ScenePlayer: React.FC = PatchComponent( "ScenePlayer", ({ scene, hideScrubberOverride, autoplay, permitLoop = true, initialTimestamp: _initialTimestamp, sendSetTimestamp, onComplete, onNext, onPrevious, }) => { const { configuration } = useConfigurationContext(); const interfaceConfig = configuration?.interface; const uiConfig = configuration?.ui; const videoRef = useRef(null); const [_player, setPlayer] = useState(); const sceneId = useRef(); const [sceneSaveActivity] = useSceneSaveActivity(); const [sceneIncrementPlayCount] = useSceneIncrementPlayCount(); const [updateInterfaceConfig] = useConfigureInterface(); const [time, setTime] = useState(0); const [ready, setReady] = useState(false); const { interactive: interactiveClient, uploadScript, currentScript, initialised: interactiveInitialised, state: interactiveState, } = React.useContext(InteractiveContext); const [fullscreen, setFullscreen] = useState(false); const [showScrubber, setShowScrubber] = useState(false); const started = useRef(false); const auto = useRef(false); const interactiveReady = useRef(false); const minimumPlayPercent = uiConfig?.minimumPlayPercent ?? 0; const trackActivity = uiConfig?.trackActivity ?? true; const vrTag = uiConfig?.vrTag ?? undefined; useScript( "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1", uiConfig?.enableChromecast ); const file = useMemo( () => (scene.files.length > 0 ? scene.files[0] : undefined), [scene] ); const maxLoopDuration = interfaceConfig?.maximumLoopDuration ?? 0; const looping = useMemo( () => !!file?.duration && permitLoop && maxLoopDuration !== 0 && file.duration < maxLoopDuration, [file, permitLoop, maxLoopDuration] ); const getPlayer = useCallback(() => { if (!_player) return null; if (_player.isDisposed()) return null; return _player; }, [_player]); useEffect(() => { if (hideScrubberOverride || fullscreen) { setShowScrubber(false); return; } const onResize = () => { const show = window.innerHeight >= 450 && !ScreenUtils.isMobile(); setShowScrubber(show); }; onResize(); window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, [hideScrubberOverride, fullscreen]); useEffect(() => { sendSetTimestamp((value: number) => { const player = getPlayer(); if (player && value >= 0) { if (player.hasStarted() && player.paused()) { player.currentTime(value); } else { player.play()?.then(() => { player.currentTime(value); }); } } }); }, [sendSetTimestamp, getPlayer]); // Initialize VideoJS player useEffect(() => { const options: VideoJsPlayerOptions = { id: VIDEO_PLAYER_ID, controls: true, controlBar: { pictureInPictureToggle: false, volumePanel: { inline: false, }, chaptersButton: false, }, html5: { dash: { updateSettings: [ { streaming: { buffer: { bufferTimeAtTopQuality: 30, bufferTimeAtTopQualityLongForm: 30, }, gaps: { jumpGaps: false, jumpLargeGaps: false, }, }, }, ], }, }, nativeControlsForTouch: false, playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], inactivityTimeout: 700, preload: "none", playsinline: true, techOrder: ["chromecast", "html5"], userActions: { hotkeys: function (this: VideoJsPlayer, event) { handleHotkeys(this, event); }, }, plugins: { airPlay: { addButtonToControlBar: uiConfig?.enableChromecast ?? false, }, chromecast: {}, vttThumbnails: { showTimestamp: true, }, markers: {}, sourceSelector: {}, persistVolume: {}, bigButtons: {}, seekButtons: { forward: 10, back: 10, }, skipButtons: {}, trackActivity: {}, vrMenu: {}, autostartButton: { enabled: interfaceConfig?.autostartVideo ?? false, }, abLoopPlugin: { start: 0, end: false, enabled: false, loopIfBeforeStart: true, loopIfAfterEnd: true, pauseAfterLooping: false, pauseBeforeLooping: false, createButtons: uiConfig?.showAbLoopControls ?? false, }, mediaSession: {}, wakeSentinel: {}, }, }; const videoEl = document.createElement("video-js"); videoEl.setAttribute("data-vjs-player", "true"); videoEl.setAttribute("crossorigin", "anonymous"); videoEl.classList.add("vjs-big-play-centered"); videoRef.current!.appendChild(videoEl); const vjs = videojs(videoEl, options); /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ const settings = (vjs as any).textTrackSettings; settings.setValues({ backgroundColor: "#000", backgroundOpacity: "0.5", }); settings.updateDisplay(); vjs.focus(); setPlayer(vjs); // Video player destructor return () => { vjs.dispose(); videoEl.remove(); setPlayer(undefined); // reset sceneId to force reload sources sceneId.current = undefined; }; // empty deps - only init once // showAbLoopControls is necessary to re-init the player when the config changes // Note: interfaceConfig?.autostartVideo is intentionally excluded to prevent // player re-initialization when toggling autostart (which would interrupt playback) // eslint-disable-next-line react-hooks/exhaustive-deps }, [uiConfig?.showAbLoopControls, uiConfig?.enableChromecast]); useEffect(() => { const player = getPlayer(); if (!player) return; const skipButtons = player.skipButtons(); skipButtons.setForwardHandler(onNext); skipButtons.setBackwardHandler(onPrevious); }, [getPlayer, onNext, onPrevious]); useEffect(() => { if (scene.interactive && interactiveInitialised) { interactiveReady.current = false; uploadScript(scene.paths.funscript || "").then(() => { interactiveReady.current = true; }); } }, [ uploadScript, interactiveInitialised, scene.interactive, scene.paths.funscript, ]); // play the script if video started before script upload finished useEffect(() => { if (interactiveState !== ConnectionState.Ready) return; const player = getPlayer(); if (!player || player.paused()) return; interactiveClient.ensurePlaying(player.currentTime()); }, [interactiveState, getPlayer, interactiveClient]); useEffect(() => { const player = getPlayer(); if (!player) return; const vrMenu = player.vrMenu(); let showButton = false; if (vrTag) { showButton = scene.tags.some((tag) => vrTag === tag.name); } vrMenu.setShowButton(showButton); }, [getPlayer, scene, vrTag]); // Player event handlers useEffect(() => { const player = getPlayer(); if (!player) return; function canplay(this: VideoJsPlayer) { // if we're seeking before starting, don't set the initial timestamp // when starting from the beginning, there is a small delay before the event // is triggered, so we can't just check if the time is 0 if (this.currentTime() >= 0.1) { return; } } function playing(this: VideoJsPlayer) { // This still runs even if autoplay failed on Safari, // only set flag if actually playing if (!started.current && !this.paused()) { started.current = true; } } function loadstart(this: VideoJsPlayer) { setReady(true); } function fullscreenchange(this: VideoJsPlayer) { setFullscreen(this.isFullscreen()); } player.on("canplay", canplay); player.on("playing", playing); player.on("loadstart", loadstart); player.on("fullscreenchange", fullscreenchange); return () => { player.off("canplay", canplay); player.off("playing", playing); player.off("loadstart", loadstart); player.off("fullscreenchange", fullscreenchange); }; }, [getPlayer]); // delay before second play event after a play event to adjust for video player issues const DELAY_FOR_SECOND_PLAY_MS = 1000; const playingTimer = useRef(); useEffect(() => { const player = getPlayer(); if (!player) return; function playing(this: VideoJsPlayer) { if (scene.interactive && interactiveReady.current) { interactiveClient.play(this.currentTime()); // trigger a second script play event to adjust for video player issues clearTimeout(playingTimer.current); playingTimer.current = setTimeout(() => { if (this.paused()) return; interactiveClient.play(this.currentTime()); }, DELAY_FOR_SECOND_PLAY_MS); } } function pause(this: VideoJsPlayer) { interactiveClient.pause(); } function timeupdate(this: VideoJsPlayer) { if (this.paused()) return; setTime(this.currentTime()); } player.on("playing", playing); player.on("pause", pause); player.on("timeupdate", timeupdate); return () => { player.off("playing", playing); player.off("pause", pause); player.off("timeupdate", timeupdate); clearTimeout(playingTimer.current); }; }, [getPlayer, interactiveClient, scene]); useEffect(() => { const player = getPlayer(); if (!player) return; // don't re-initialise the player unless the scene has changed if (!file || scene.id === sceneId.current) return; sceneId.current = scene.id; setReady(false); // reset on new scene player.trackActivity().reset(); // always stop the interactive client on initialisation interactiveClient.pause(); const isSafari = UAParser().browser.name?.includes("Safari"); const isLandscape = file.height && file.width && file.width > file.height; const mobileUiOptions = { fullscreen: { enterOnRotate: true, exitOnRotate: true, lockOnRotate: true, lockToLandscapeOnEnter: uiConfig?.disableMobileMediaAutoRotateEnabled ? false : isLandscape, }, touchControls: { disabled: true, }, }; if (!isSafari) { player.mobileUi(mobileUiOptions); } function isDirect(src: URL) { return ( src.pathname.endsWith("/stream") || src.pathname.endsWith("/stream.mpd") || src.pathname.endsWith("/stream.m3u8") ); } const { duration } = file; const sourceSelector = player.sourceSelector(); sourceSelector.setSources( scene.sceneStreams .filter((stream) => { const src = new URL(stream.url); const isFileTranscode = !isDirect(src); return !(isFileTranscode && isSafari); }) .map((stream) => { const src = new URL(stream.url); return { src: stream.url, type: stream.mime_type ?? undefined, label: stream.label ?? undefined, offset: !isDirect(src), duration, }; }) ); function getDefaultLanguageCode() { let languageCode = window.navigator.language; if (languageCode.indexOf("-") !== -1) { languageCode = languageCode.split("-")[0]; } if (languageCode.indexOf("_") !== -1) { languageCode = languageCode.split("_")[0]; } return languageCode; } if (scene.captions && scene.captions.length > 0) { const languageCode = getDefaultLanguageCode(); let hasDefault = false; for (let caption of scene.captions) { const lang = caption.language_code; let label = lang; if (languageMap.has(lang)) { label = languageMap.get(lang)!; } label = label + " (" + caption.caption_type + ")"; const setAsDefault = !hasDefault && languageCode == lang; if (setAsDefault) { hasDefault = true; } sourceSelector.addTextTrack( { src: `${scene.paths.caption}?lang=${lang}&type=${caption.caption_type}`, kind: "captions", srclang: lang, label: label, default: setAsDefault, }, false ); } } const alwaysStartFromBeginning = uiConfig?.alwaysStartFromBeginning ?? false; const resumeTime = scene.resume_time ?? 0; let startPosition = _initialTimestamp; if ( !startPosition && !alwaysStartFromBeginning && file.duration > resumeTime ) { startPosition = resumeTime; } setTime(startPosition); player.load(); player.focus(); // Check the autostart button plugin for user preference const autostartButton = player.autostartButton(); const buttonEnabled = autostartButton.getEnabled(); auto.current = autoplay || buttonEnabled || (interfaceConfig?.autostartVideo ?? false) || _initialTimestamp > 0; player.ready(() => { player.vttThumbnails().src(scene.paths.vtt ?? null); if (startPosition) { player.currentTime(startPosition); } }); started.current = false; }, [ getPlayer, file, scene, interactiveClient, autoplay, interfaceConfig?.autostartVideo, uiConfig?.alwaysStartFromBeginning, uiConfig?.disableMobileMediaAutoRotateEnabled, _initialTimestamp, ]); useEffect(() => { return () => { // stop the interactive client on unmount interactiveClient.pause(); }; }, [interactiveClient]); const loadMarkers = useCallback(() => { const player = getPlayer(); if (!player) return; const markerData = scene.scene_markers.map((marker) => ({ title: getMarkerTitle(marker), seconds: marker.seconds, end_seconds: marker.end_seconds ?? null, primaryTag: marker.primary_tag, })); const markers = player!.markers(); const uniqueTagNames = markerData .map((marker) => marker.primaryTag.name) .filter((value, index, self) => self.indexOf(value) === index); // Wait for colors markers.findColors(uniqueTagNames); const showRangeTags = !ScreenUtils.isMobile() && (uiConfig?.showRangeMarkers ?? true); const timestampMarkers: IMarker[] = []; const rangeMarkers: IMarker[] = []; if (!showRangeTags) { for (const marker of markerData) { timestampMarkers.push(marker); } } else { for (const marker of markerData) { if (marker.end_seconds === null) { timestampMarkers.push(marker); } else { rangeMarkers.push(marker); } } } requestAnimationFrame(() => { markers.addDotMarkers(timestampMarkers); markers.addRangeMarkers(rangeMarkers); }); }, [getPlayer, scene, uiConfig]); useEffect(() => { const player = getPlayer(); if (!player) return; if (scene.paths.screenshot) { player.poster(scene.paths.screenshot); } else { player.poster(""); } // Define the event handler outside the useEffect const handleLoadMetadata = () => { loadMarkers(); }; // Ensure markers are added after player is fully ready and sources are loaded if (player.readyState() >= 1) { loadMarkers(); } else { player.on("loadedmetadata", handleLoadMetadata); } return () => { player.off("loadedmetadata", handleLoadMetadata); const markers = player!.markers(); markers.clearMarkers(); }; }, [getPlayer, scene, loadMarkers]); useEffect(() => { const player = getPlayer(); if (!player) return; async function saveActivity(resumeTime: number, playDuration: number) { if (!scene.id) return; await sceneSaveActivity({ variables: { id: scene.id, playDuration, resume_time: resumeTime, }, }); } async function incrementPlayCount() { if (!scene.id) return; await sceneIncrementPlayCount({ variables: { id: scene.id, }, }); } const activity = player.trackActivity(); activity.saveActivity = saveActivity; activity.incrementPlayCount = incrementPlayCount; activity.minimumPlayPercent = minimumPlayPercent; activity.setEnabled(trackActivity); }, [ getPlayer, scene, vrTag, trackActivity, minimumPlayPercent, sceneIncrementPlayCount, sceneSaveActivity, ]); // Sync autostart button with config changes useEffect(() => { const player = getPlayer(); if (!player) return; async function updateAutoStart(enabled: boolean) { await updateInterfaceConfig({ variables: { input: { autostartVideo: enabled, }, }, }); } const autostartButton = player.autostartButton(); if (autostartButton) { autostartButton.syncWithConfig( interfaceConfig?.autostartVideo ?? false ); autostartButton.updateAutoStart = updateAutoStart; } }, [getPlayer, updateInterfaceConfig, interfaceConfig?.autostartVideo]); useEffect(() => { const player = getPlayer(); if (!player) return; player.loop(looping); interactiveClient.setLooping(looping); }, [getPlayer, interactiveClient, looping]); useEffect(() => { const player = getPlayer(); if (!player || !ready || !auto.current) { return; } // check if we're waiting for the interactive client if ( scene.interactive && interactiveClient.handyKey && currentScript !== scene.paths.funscript ) { return; } player.play(); auto.current = false; }, [getPlayer, scene, ready, interactiveClient, currentScript]); // Attach handler for onComplete event useEffect(() => { const player = getPlayer(); if (!player) return; player.on("ended", onComplete); return () => player.off("ended"); }, [getPlayer, onComplete]); // set up mediaSession plugin useEffect(() => { const player = getPlayer(); if (!player) return; // set up mediasession plugin // get performer names as array const performers = scene?.performers.map((p) => p.name).join(", "); player .mediaSession() .setMetadata( scene?.title ?? "Stash", scene?.studio?.name ?? performers ?? "Stash", scene.paths.screenshot || "" ); }, [getPlayer, scene]); const pausedBeforeScrubber = useRef(true); function onScrubberScroll() { const player = getPlayer(); if (started.current && player) { pausedBeforeScrubber.current = player.paused(); player.pause(); } } function onScrubberSeek(seconds: number) { const player = getPlayer(); if (started.current && player) { player.currentTime(seconds); if (!pausedBeforeScrubber.current) { player.play(); } } else { setTime(seconds); } } // Override spacebar to always pause/play function onKeyDown(this: HTMLDivElement, event: KeyboardEvent) { const player = getPlayer(); if (!player) return; if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { return; } if (event.key == " ") { event.preventDefault(); event.stopPropagation(); if (player.paused()) { player.play(); } else { player.pause(); } } } const isPortrait = file && file.height && file.width && file.height > file.width; return (
    {scene.interactive && (interactiveState !== ConnectionState.Ready || getPlayer()?.paused()) && } {file && showScrubber && ( )}
    ); } ); export default ScenePlayer; ================================================ FILE: ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx ================================================ import React, { CSSProperties, useEffect, useRef, useState, useCallback, } from "react"; import { Button } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; import { Icon } from "src/components/Shared/Icon"; import { faChevronRight, faChevronLeft, } from "@fortawesome/free-solid-svg-icons"; import { useSpriteInfo } from "src/hooks/sprite"; interface IScenePlayerScrubberProps { file: GQL.VideoFileDataFragment; scene: GQL.SceneDataFragment; time: number; onSeek: (seconds: number) => void; onScroll: () => void; } interface ISceneSpriteItem { style: CSSProperties; time: string; } const scrubberViewportHeight = 120; const scrubberTagsHeight = 30; const scrubberSpriteHeight = scrubberViewportHeight - scrubberTagsHeight; export const ScenePlayerScrubber: React.FC = ({ file, scene, time, onSeek, onScroll, }) => { const contentEl = useRef(null); const indicatorEl = useRef(null); const sliderEl = useRef(null); const mouseDown = useRef(false); const lastMouseEvent = useRef(null); const startMouseEvent = useRef(null); const velocity = useRef(0); const prevTime = useRef(NaN); const _width = useRef(0); const [width, setWidth] = useState(0); const [scrubWidth, setScrubWidth] = useState(0); const position = useRef(0); const setPosition = useCallback( (value: number, seek: boolean) => { if (!scrubWidth) return; const slider = sliderEl.current!; const indicator = indicatorEl.current!; const midpointOffset = slider.clientWidth / 2; let newPosition: number; let percentage: number; if (value >= midpointOffset) { percentage = 0; newPosition = midpointOffset; } else if (value <= midpointOffset - scrubWidth) { percentage = 1; newPosition = midpointOffset - scrubWidth; } else { percentage = (midpointOffset - value) / scrubWidth; newPosition = value; } slider.style.transform = `translateX(${newPosition}px)`; indicator.style.transform = `translateX(${percentage * 100}%)`; position.current = newPosition; if (seek) { onSeek(percentage * (file.duration || 0)); } }, [onSeek, file.duration, scrubWidth] ); const spriteInfo = useSpriteInfo(scene.paths.vtt ?? undefined); const [spriteItems, setSpriteItems] = useState(); useEffect(() => { if (!spriteInfo || spriteInfo.length === 0) return; let totalWidth = 0; // calculate total width/height of scrubber image so we can scale it const maxX = Math.max(...spriteInfo.map((sprite) => sprite.x + sprite.w)); const maxY = Math.max(...spriteInfo.map((sprite) => sprite.y + sprite.h)); const spriteWidth = spriteInfo[0].w; const spriteHeight = spriteInfo[0].h; const scale = scrubberSpriteHeight / spriteHeight; const w = spriteWidth * scale; const h = scrubberSpriteHeight; const sizeX = maxX * scale; const sizeY = maxY * scale; // scale sprite dimensions to fit scrubber height, and calculate background position for each sprite const newSprites = spriteInfo?.map((sprite, index) => { totalWidth += w; const left = w * index; const spriteX = sprite.x * scale; const spriteY = sprite.y * scale; const style = { width: `${w}px`, height: `${h}px`, backgroundPosition: `${-spriteX}px ${-spriteY}px`, backgroundImage: `url(${sprite.url})`, backgroundSize: `${sizeX}px ${sizeY}px`, left: `${left}px`, }; const start = TextUtils.secondsToTimestamp(sprite.start); const end = TextUtils.secondsToTimestamp(sprite.end); return { style, time: `${start} - ${end}`, }; }); setScrubWidth(totalWidth); setSpriteItems(newSprites); }, [spriteInfo]); useEffect(() => { const onResize = (entries: ResizeObserverEntry[]) => { const newWidth = entries[0].target.clientWidth; if (_width.current != newWidth) { // set prevTime to NaN to not use a transition when updating the slider position prevTime.current = NaN; _width.current = newWidth; setWidth(newWidth); } }; const content = contentEl.current!; const resizeObserver = new ResizeObserver(onResize); resizeObserver.observe(content); return () => { resizeObserver.unobserve(content); }; }, []); function setLinearTransition() { const slider = sliderEl.current!; slider.style.transition = "500ms linear"; } function setEaseOutTransition() { const slider = sliderEl.current!; slider.style.transition = "333ms ease-out"; } function clearTransition() { const slider = sliderEl.current!; slider.style.transition = ""; } // Update slider position when player time changes useEffect(() => { if (!scrubWidth || !width) return; const duration = Number(file.duration); const percentage = time / duration; const newPosition = width / 2 - percentage * scrubWidth; // Ignore position changes of < 1px if (Math.abs(newPosition - position.current) < 1) return; const delta = Math.abs(time - prevTime.current); if (isNaN(delta)) { // Don't use a transition on initial time change or after resize clearTransition(); } else if (delta <= 1) { // If time changed by < 1s, use linear transition instead of ease-out setLinearTransition(); } else { setEaseOutTransition(); } prevTime.current = time; setPosition(newPosition, false); }, [file.duration, setPosition, time, width, scrubWidth]); const onMouseUp = useCallback( (event: MouseEvent) => { if (!mouseDown.current) return; const slider = sliderEl.current!; mouseDown.current = false; contentEl.current!.classList.remove("dragging"); let newPosition = position.current; const midpointOffset = slider.clientWidth / 2; const delta = Math.abs(event.clientX - startMouseEvent.current!.clientX); if (delta < 1 && event.target instanceof HTMLDivElement) { const { target } = event; if (target.hasAttribute("data-sprite-item-id")) { newPosition = midpointOffset - (target.offsetLeft + event.offsetX); } if (target.hasAttribute("data-marker-id")) { newPosition = midpointOffset - target.offsetLeft; } } if (Math.abs(velocity.current) > 25) { newPosition = position.current + velocity.current * 10; velocity.current = 0; } setEaseOutTransition(); setPosition(newPosition, true); }, [setPosition] ); const onMouseDown = useCallback((event: MouseEvent) => { // Only if left mouse button pressed if (event.button !== 0) return; event.preventDefault(); mouseDown.current = true; lastMouseEvent.current = event; startMouseEvent.current = event; velocity.current = 0; }, []); const onMouseMove = useCallback( (event: MouseEvent) => { if (!mouseDown.current) return; // negative dragging right (past), positive left (future) const delta = event.clientX - lastMouseEvent.current!.clientX; if (lastMouseEvent.current === startMouseEvent.current) { // this is the first mousemove event after mousedown // #4295: a mousemove with delta 0 can be sent when just clicking // ignore such an event to prevent pausing the player if (delta === 0) return; onScroll(); } contentEl.current!.classList.add("dragging"); const movement = event.movementX; velocity.current = movement; clearTransition(); setPosition(position.current + delta, false); lastMouseEvent.current = event; }, [onScroll, setPosition] ); useEffect(() => { const content = contentEl.current!; content.addEventListener("mousedown", onMouseDown, false); content.addEventListener("mousemove", onMouseMove, false); window.addEventListener("mouseup", onMouseUp, false); return () => { content.removeEventListener("mousedown", onMouseDown); content.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); }; }, [onMouseDown, onMouseMove, onMouseUp]); function goBack() { const slider = sliderEl.current!; const newPosition = position.current + slider.clientWidth; setEaseOutTransition(); setPosition(newPosition, true); } function goForward() { const slider = sliderEl.current!; const newPosition = position.current - slider.clientWidth; setEaseOutTransition(); setPosition(newPosition, true); } function renderTags() { if (!spriteItems) return; return scene.scene_markers.map((marker, index) => { const { duration } = file; const left = (scrubWidth * marker.seconds) / duration; const style = { left: `${left}px` }; return (
    {marker.title || marker.primary_tag.name}
    ); }); } function renderSprites() { if (!scene.paths.vtt) return; return spriteItems?.map((sprite, index) => { return (
    {sprite.time}
    ); }); } return (
    {renderTags()}
    {renderSprites()}
    ); }; ================================================ FILE: ui/v2.5/src/components/ScenePlayer/autostart-button.ts ================================================ /* eslint-disable @typescript-eslint/naming-convention */ import videojs, { VideoJsPlayer } from "video.js"; interface IAutostartButtonOptions { enabled?: boolean; } interface AutostartButtonOptions extends videojs.ComponentOptions { autostartEnabled: boolean; } class AutostartButton extends videojs.getComponent("Button") { private autostartEnabled: boolean; constructor(player: VideoJsPlayer, options: AutostartButtonOptions) { super(player, options); this.autostartEnabled = options.autostartEnabled; this.updateIcon(); } buildCSSClass() { return `vjs-autostart-button ${super.buildCSSClass()}`; } private updateIcon() { this.removeClass("vjs-icon-play-circle"); this.removeClass("vjs-icon-cancel"); if (this.autostartEnabled) { this.addClass("vjs-icon-play-circle"); this.controlText(this.localize("Auto-start enabled (click to disable)")); } else { this.addClass("vjs-icon-cancel"); this.controlText(this.localize("Auto-start disabled (click to enable)")); } } handleClick(event: Event) { // Prevent the click from bubbling up and affecting the video player event.stopPropagation(); this.autostartEnabled = !this.autostartEnabled; this.updateIcon(); this.trigger("autostartchanged", { enabled: this.autostartEnabled }); } public setEnabled(enabled: boolean) { this.autostartEnabled = enabled; this.updateIcon(); } } class AutostartButtonPlugin extends videojs.getPlugin("plugin") { private button: AutostartButton; private autostartEnabled: boolean; updateAutoStart: (enabled: boolean) => Promise = () => { return Promise.resolve(); }; constructor(player: VideoJsPlayer, options?: IAutostartButtonOptions) { super(player, options); this.autostartEnabled = options?.enabled ?? false; this.button = new AutostartButton(player, { autostartEnabled: this.autostartEnabled, }); player.ready(() => { this.ready(); }); } private ready() { // Add button to control bar, before the fullscreen button const { controlBar } = this.player; const fullscreenToggle = controlBar.getChild("fullscreenToggle"); if (fullscreenToggle) { controlBar.addChild(this.button); controlBar.el().insertBefore(this.button.el(), fullscreenToggle.el()); } else { controlBar.addChild(this.button); } // Listen for changes this.button.on("autostartchanged", (_, data: { enabled: boolean }) => { this.autostartEnabled = data.enabled; this.updateAutoStart(this.autostartEnabled); }); } public isEnabled(): boolean { return this.autostartEnabled; } public getEnabled(): boolean { return this.autostartEnabled; } public setEnabled(enabled: boolean) { this.autostartEnabled = enabled; this.button.setEnabled(enabled); } public syncWithConfig(configEnabled: boolean) { // Sync button state with external config changes if (this.autostartEnabled !== configEnabled) { this.setEnabled(configEnabled); } } } // Register the plugin with video.js. videojs.registerComponent("AutostartButton", AutostartButton); videojs.registerPlugin("autostartButton", AutostartButtonPlugin); declare module "video.js" { interface VideoJsPlayer { autostartButton: () => AutostartButtonPlugin; } interface VideoJsPlayerPluginOptions { autostartButton?: IAutostartButtonOptions; } } export default AutostartButtonPlugin; ================================================ FILE: ui/v2.5/src/components/ScenePlayer/big-buttons.ts ================================================ import videojs, { VideoJsPlayer } from "video.js"; // prettier-ignore const BigPlayButton = videojs.getComponent("BigPlayButton") as unknown as typeof videojs.BigPlayButton; class BigPlayPauseButton extends BigPlayButton { handleClick(event: videojs.EventTarget.Event) { if (this.player().paused()) { super.handleClick(event); } else { this.player().pause(); } } buildCSSClass() { return "vjs-control vjs-button vjs-big-play-pause-button"; } } class BigButtonGroup extends videojs.getComponent("Component") { constructor(player: VideoJsPlayer) { super(player); this.addChild("seekButton", { direction: "back", seconds: 10, }); this.addChild("BigPlayPauseButton"); this.addChild("seekButton", { direction: "forward", seconds: 10, }); } createEl() { return super.createEl("div", { className: "vjs-big-button-group", }); } } class BigButtonsPlugin extends videojs.getPlugin("plugin") { constructor(player: VideoJsPlayer) { super(player); player.ready(() => { player.addChild("BigButtonGroup"); }); } } // Register the plugin with video.js. videojs.registerComponent("BigButtonGroup", BigButtonGroup); videojs.registerComponent("BigPlayPauseButton", BigPlayPauseButton); videojs.registerPlugin("bigButtons", BigButtonsPlugin); /* eslint-disable @typescript-eslint/naming-convention */ declare module "video.js" { interface VideoJsPlayer { bigButtons: () => BigButtonsPlugin; } interface VideoJsPlayerPluginOptions { bigButtons?: {}; } } export default BigButtonsPlugin; ================================================ FILE: ui/v2.5/src/components/ScenePlayer/live.ts ================================================ import { debounce } from "lodash-es"; import videojs, { VideoJsPlayer } from "video.js"; export interface ISource extends videojs.Tech.SourceObject { offset?: boolean; duration?: number; } interface ICue extends TextTrackCue { _startTime?: number; _endTime?: number; } // delay before loading new source after setting currentTime const loadDelay = 200; function offsetMiddleware(player: VideoJsPlayer) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- allow access to private tech methods let tech: any; let source: ISource; let offsetStart: number | undefined; let seeking = 0; function initCues(cues: TextTrackCueList) { const offset = offsetStart ?? 0; for (let j = 0; j < cues.length; j++) { const cue = cues[j] as ICue; cue._startTime = cue.startTime; cue.startTime = cue._startTime - offset; cue._endTime = cue.endTime; cue.endTime = cue._endTime - offset; } } function updateOffsetStart(offset: number | undefined) { offsetStart = offset; if (!tech) return; offset = offset ?? 0; const tracks = tech.remoteTextTracks(); for (let i = 0; i < tracks.length; i++) { const { cues } = tracks[i]; if (cues) { for (let j = 0; j < cues.length; j++) { const cue = cues[j] as ICue; if (cue._startTime === undefined || cue._endTime === undefined) { continue; } cue.startTime = cue._startTime - offset; cue.endTime = cue._endTime - offset; } } } } const loadSource = debounce( (seconds: number) => { const srcUrl = new URL(source.src); srcUrl.searchParams.set("start", seconds.toString()); source.src = srcUrl.toString(); const poster = player.poster(); const playbackRate = tech.playbackRate(); seeking = tech.paused() ? 1 : 2; player.poster(""); tech.setSource(source); tech.setPlaybackRate(playbackRate); tech.one("canplay", () => { player.poster(poster); if (seeking === 1 || tech.scrubbing()) { tech.pause(); } seeking = 0; }); tech.trigger("timeupdate"); tech.trigger("pause"); tech.trigger("seeking"); tech.play(); }, loadDelay, { leading: true } ); return { setTech(newTech: videojs.Tech) { tech = newTech; const _addRemoteTextTrack = tech.addRemoteTextTrack.bind(tech); function addRemoteTextTrack( this: VideoJsPlayer, options: videojs.TextTrackOptions, manualCleanup: boolean ) { const textTrack = _addRemoteTextTrack(options, manualCleanup); textTrack.addEventListener("load", () => { const { cues } = textTrack.track; if (cues) { initCues(cues); } }); return textTrack; } tech.addRemoteTextTrack = addRemoteTextTrack; const trackEls: HTMLTrackElement[] = tech.remoteTextTrackEls(); for (let i = 0; i < trackEls.length; i++) { const trackEl = trackEls[i]; const { track } = trackEl; if (track.cues) { initCues(track.cues); } else { trackEl.addEventListener("load", () => { if (track.cues) { initCues(track.cues); } }); } } }, setSource( srcObj: ISource, next: (err: unknown, src: videojs.Tech.SourceObject) => void ) { if (srcObj.offset && srcObj.duration) { updateOffsetStart(0); } else { updateOffsetStart(undefined); } source = srcObj; next(null, srcObj); }, duration(seconds: number) { if (source.duration) { return source.duration; } else { return seconds; } }, buffered(buffers: TimeRanges) { if (offsetStart === undefined) { return buffers; } const timeRanges: number[][] = []; for (let i = 0; i < buffers.length; i++) { const start = buffers.start(i) + offsetStart; const end = buffers.end(i) + offsetStart; timeRanges.push([start, end]); } // types for createTimeRanges are incorrect, should be number[][] not TimeRange[] // eslint-disable-next-line @typescript-eslint/no-explicit-any return videojs.createTimeRanges(timeRanges as any); }, currentTime(seconds: number) { return (offsetStart ?? 0) + seconds; }, setCurrentTime(seconds: number) { if (offsetStart === undefined) { return seconds; } const offsetSeconds = seconds - offsetStart; const buffers = tech.buffered() as TimeRanges; for (let i = 0; i < buffers.length; i++) { const start = buffers.start(i); const end = buffers.end(i); // seek point is in buffer, just seek normally if (start <= offsetSeconds && offsetSeconds <= end) { return offsetSeconds; } } updateOffsetStart(seconds); loadSource(seconds); return 0; }, callPlay() { if (seeking) { seeking = 2; return videojs.middleware.TERMINATOR; } }, }; } videojs.use("*", offsetMiddleware); ================================================ FILE: ui/v2.5/src/components/ScenePlayer/markers.ts ================================================ import videojs, { VideoJsPlayer } from "video.js"; import CryptoJS from "crypto-js"; export interface IMarker { title: string; seconds: number; end_seconds?: number | null; primaryTag: { name: string }; } interface IMarkersOptions { markers?: IMarker[]; } class MarkersPlugin extends videojs.getPlugin("plugin") { private markers: IMarker[] = []; private markerDivs: { dot?: HTMLDivElement; range?: HTMLDivElement; containedRanges?: HTMLDivElement[]; }[] = []; private markerTooltip: HTMLElement | null = null; private defaultTooltip: HTMLElement | null = null; private layerHeight: number = 9; private tagColors: { [tag: string]: string } = {}; constructor(player: VideoJsPlayer) { super(player); player.ready(() => { const tooltip = videojs.dom.createEl("div") as HTMLElement; tooltip.className = "vjs-marker-tooltip"; tooltip.style.visibility = "hidden"; const parent = player .el() .querySelector(".vjs-progress-holder .vjs-mouse-display"); if (parent) parent.appendChild(tooltip); this.markerTooltip = tooltip; this.defaultTooltip = player .el() .querySelector( ".vjs-progress-holder .vjs-mouse-display .vjs-time-tooltip" ); }); } private showMarkerTooltip(title: string, layer: number = 0) { if (!this.markerTooltip) return; this.markerTooltip.innerText = title; this.markerTooltip.style.right = `${-this.markerTooltip.clientWidth / 2}px`; this.markerTooltip.style.top = `-${this.layerHeight * layer + 50}px`; this.markerTooltip.style.visibility = "visible"; if (this.defaultTooltip) this.defaultTooltip.style.visibility = "hidden"; } private hideMarkerTooltip() { if (this.markerTooltip) this.markerTooltip.style.visibility = "hidden"; if (this.defaultTooltip) this.defaultTooltip.style.visibility = "visible"; } addDotMarker(marker: IMarker) { const duration = this.player.duration(); const markerSet: { dot?: HTMLDivElement; range?: HTMLDivElement; } = {}; const seekBar = this.player.el().querySelector(".vjs-progress-holder"); markerSet.dot = videojs.dom.createEl("div") as HTMLDivElement; markerSet.dot.className = "vjs-marker"; if (duration) { // marker is 6px wide - adjust by 3px to align to center not left side markerSet.dot.style.left = `calc(${ (marker.seconds / duration) * 100 }% - 3px)`; markerSet.dot.style.visibility = "visible"; } // Add event listeners to dot markerSet.dot.addEventListener("click", () => this.player.currentTime(marker.seconds) ); markerSet.dot.toggleAttribute("marker-tooltip-shown", true); // Set background color based on tag (if available) if ( marker.primaryTag && marker.primaryTag.name && this.tagColors[marker.primaryTag.name] ) { markerSet.dot.style.backgroundColor = this.tagColors[marker.primaryTag.name]; } markerSet.dot.addEventListener("mouseenter", () => { this.showMarkerTooltip(marker.title); markerSet.dot?.toggleAttribute("marker-tooltip-shown", true); }); markerSet.dot.addEventListener("mouseout", () => { this.hideMarkerTooltip(); markerSet.dot?.toggleAttribute("marker-tooltip-shown", false); }); if (seekBar) { seekBar.appendChild(markerSet.dot); } this.markers.push(marker); this.markerDivs.push(markerSet); } addDotMarkers(markers: IMarker[]) { markers.forEach(this.addDotMarker, this); } private renderRangeMarkers(markers: IMarker[], layer: number) { const duration = this.player.duration(); const parent = this.player.el().querySelector(".vjs-progress-control"); const seekBar = this.player.el().querySelector(".vjs-progress-holder"); if (!seekBar || !parent || !duration) return; markers.forEach((marker) => { this.renderRangeMarker(marker, layer, duration, seekBar, parent); }); } private renderRangeMarker( marker: IMarker, layer: number, duration: number, seekBar: Element, parent: Element ) { if (!marker.end_seconds) return; const markerSet: { dot?: HTMLDivElement; range?: HTMLDivElement; } = {}; const rangeDiv = videojs.dom.createEl("div") as HTMLDivElement; rangeDiv.className = "vjs-marker-range"; // Use percentage-based positioning for proper scaling in fullscreen mode // The range marker is inside vjs-progress-control, but needs to align with // vjs-progress-holder which has 15px margins on each side. // We use calc() to combine percentage positioning with the fixed margin offset. const startPercent = (marker.seconds / duration) * 100; const widthPercent = ((marker.end_seconds - marker.seconds) / duration) * 100; // left: 15px margin + percentage of the progress holder width // Since progress-holder has margin: 0 15px, we need calc(15px + X% of remaining width) // The progress-holder width is (100% - 30px), so the actual left position is: // 15px + startPercent% * (100% - 30px) = 15px + startPercent% * 100% - startPercent% * 30px rangeDiv.style.left = `calc(15px + ${startPercent}% - ${ startPercent * 0.3 }px)`; rangeDiv.style.width = `calc(${widthPercent}% - ${widthPercent * 0.3}px)`; rangeDiv.style.bottom = `${layer * this.layerHeight}px`; // Adjust height based on layer rangeDiv.style.display = "none"; // Initially hidden // Set background color based on tag (if available) if ( marker.primaryTag && marker.primaryTag.name && this.tagColors[marker.primaryTag.name] ) { rangeDiv.style.backgroundColor = this.tagColors[marker.primaryTag.name]; } markerSet.range = rangeDiv; markerSet.range.style.display = "block"; markerSet.range.addEventListener("pointermove", (e) => { e.stopPropagation(); }); markerSet.range.addEventListener("pointerover", (e) => { e.stopPropagation(); }); markerSet.range.addEventListener("pointerout", (e) => { e.stopPropagation(); }); markerSet.range.addEventListener("mouseenter", () => { this.showMarkerTooltip(marker.title, layer); markerSet.range?.toggleAttribute("marker-tooltip-shown", true); }); markerSet.range.addEventListener("mouseout", () => { this.hideMarkerTooltip(); markerSet.range?.toggleAttribute("marker-tooltip-shown", false); }); parent.appendChild(rangeDiv); this.markers.push(marker); this.markerDivs.push(markerSet); } addRangeMarkers(markers: IMarker[]) { let remainingMarkers = [...markers]; let layerNum = 0; while (remainingMarkers.length > 0) { // Get the set of markers that currently have the highest total duration that don't overlap. We do this layer by layer to prioritize filling // the lower layers when possible const mwis = this.findMWIS(remainingMarkers); if (!mwis.length) break; this.renderRangeMarkers(mwis, layerNum); remainingMarkers = remainingMarkers.filter( (marker) => !mwis.includes(marker) ); layerNum++; } } // Use dynamic programming to find maximum weight independent set (ie the set of markers that have the highest total duration that don't overlap) private findMWIS(markers: IMarker[]): IMarker[] { if (!markers.length) return []; // Sort markers by end time markers = markers .slice() .sort((a, b) => (a.end_seconds || 0) - (b.end_seconds || 0)); const n = markers.length; // Compute p(j) for each marker. This is the index of the marker that has the highest end time that doesn't overlap with marker j const p: number[] = new Array(n).fill(-1); for (let j = 0; j < n; j++) { for (let i = j - 1; i >= 0; i--) { if ((markers[i].end_seconds || 0) <= markers[j].seconds) { p[j] = i; break; } } } // Initialize M[j] // Compute M[j] for each marker. This is the maximum total duration of markers that don't overlap with marker j const M: number[] = new Array(n).fill(0); for (let j = 0; j < n; j++) { const include = (markers[j].end_seconds || 0) - markers[j].seconds + (M[p[j]] || 0); const exclude = j > 0 ? M[j - 1] : 0; M[j] = Math.max(include, exclude); } // Reconstruct optimal solution const findSolution = (j: number): IMarker[] => { if (j < 0) return []; const include = (markers[j].end_seconds || 0) - markers[j].seconds + (M[p[j]] || 0); const exclude = j > 0 ? M[j - 1] : 0; if (include >= exclude) { return [...findSolution(p[j]), markers[j]]; } else { return findSolution(j - 1); } }; return findSolution(n - 1); } removeMarker(marker: IMarker) { const i = this.markers.indexOf(marker); if (i === -1) return; this.markers.splice(i, 1); const markerSet = this.markerDivs.splice(i, 1)[0]; if (markerSet.dot?.hasAttribute("marker-tooltip-shown")) { this.hideMarkerTooltip(); } markerSet.dot?.remove(); if (markerSet.range) markerSet.range.remove(); } removeMarkers(markers: IMarker[]) { markers.forEach(this.removeMarker, this); } clearMarkers() { for (const markerSet of this.markerDivs) { if (markerSet.dot?.hasAttribute("marker-tooltip-shown")) { this.hideMarkerTooltip(); } markerSet.dot?.remove(); if (markerSet.range) markerSet.range.remove(); } this.markers = []; this.markerDivs = []; } // Implementing the findColors method findColors(tagNames: string[]) { // Compute base hues for each tag const baseHues: { [tag: string]: number } = {}; for (const tag of tagNames) { baseHues[tag] = this.computeBaseHue(tag); } // Adjust hues to avoid similar colors const adjustedHues = this.adjustHues(baseHues); // Convert adjusted hues to colors and store in tagColors dictionary for (const tag of tagNames) { this.tagColors[tag] = this.hueToColor(adjustedHues[tag]); } } // Helper methods translated from Python // Compute base hue from tag name private computeBaseHue(tag: string): number { const hash = CryptoJS.SHA256(tag); const hashHex = hash.toString(CryptoJS.enc.Hex); const hashInt = BigInt(`0x${hashHex}`); const baseHue = Number(hashInt % BigInt(360)); // Map to [0, 360) return baseHue; } // Calculate minimum acceptable hue difference based on number of tags private calculateDeltaMin(N: number): number { const maxDeltaNeeded = 35; let scalingFactor: number; if (N <= 4) { scalingFactor = 0.8; } else if (N <= 10) { scalingFactor = 0.6; } else { scalingFactor = 0.4; } const deltaMin = Math.min((360 / N) * scalingFactor, maxDeltaNeeded); return deltaMin; } // Adjust hues to ensure minimum difference private adjustHues(baseHues: { [tag: string]: number }): { [tag: string]: number; } { const adjustedHues: { [tag: string]: number } = {}; const tags = Object.keys(baseHues); const N = tags.length; const deltaMin = this.calculateDeltaMin(N); // Sort the tags by base hue const sortedTags = tags.sort((a, b) => baseHues[a] - baseHues[b]); // Get sorted base hues const baseHuesSorted = sortedTags.map((tag) => baseHues[tag]); // Unwrap hues to handle circular nature const unwrappedHues = [...baseHuesSorted]; for (let i = 1; i < N; i++) { if (unwrappedHues[i] <= unwrappedHues[i - 1]) { unwrappedHues[i] += 360; // Unwrap by adding 360 degrees } } // Adjust hues to ensure minimum difference for (let i = 1; i < N; i++) { const requiredHue = unwrappedHues[i - 1] + deltaMin; if (unwrappedHues[i] < requiredHue) { unwrappedHues[i] = requiredHue; // Adjust hue minimally } } // Handle wrap-around difference const endGap = unwrappedHues[0] + 360 - unwrappedHues[N - 1]; if (endGap < deltaMin) { // Adjust first and last hues minimally to increase end gap const adjustmentNeeded = (deltaMin - endGap) / 2; // Adjust the first hue backward, ensure it doesn't go below other hues unwrappedHues[0] = Math.max( unwrappedHues[0] - adjustmentNeeded, unwrappedHues[1] - 360 + deltaMin ); // Adjust the last hue forward unwrappedHues[N - 1] += adjustmentNeeded; } // Wrap adjusted hues back to [0, 360) const adjustedHuesList = unwrappedHues.map((hue) => hue % 360); // Map adjusted hues back to tags for (let i = 0; i < N; i++) { adjustedHues[sortedTags[i]] = adjustedHuesList[i]; } return adjustedHues; } // Convert hue to RGB color in hex format private hueToColor(hue: number): string { // Convert hue from degrees to [0, 1) const hueNormalized = hue / 360.0; const saturation = 0.65; const value = 0.95; const rgb = this.hsvToRgb(hueNormalized, saturation, value); const alpha = 0.6; // Set the desired alpha value here const rgbColor = `#${this.toHex(rgb[0])}${this.toHex(rgb[1])}${this.toHex( rgb[2] )}${this.toHex(Math.round(alpha * 255))}`; return rgbColor; } // Convert HSV to RGB private hsvToRgb(h: number, s: number, v: number): [number, number, number] { const i = Math.floor(h * 6); const f = h * 6 - i; const p = v * (1 - s); const q = v * (1 - f * s); const t = v * (1 - (1 - f) * s); let r, g, b; switch (i % 6) { case 0: r = v; g = t; b = p; break; case 1: r = q; g = v; b = p; break; case 2: r = p; g = v; b = t; break; case 3: r = p; g = q; b = v; break; case 4: r = t; g = p; b = v; break; case 5: r = v; g = p; b = q; break; default: r = v; g = t; b = p; break; } return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; } // Convert a number to two-digit hex string private toHex(value: number): string { return value.toString(16).padStart(2, "0"); } } videojs.registerPlugin("markers", MarkersPlugin); /* eslint-disable @typescript-eslint/naming-convention */ declare module "video.js" { interface VideoJsPlayer { markers: () => MarkersPlugin; } interface VideoJsPlayerPluginOptions { markers?: IMarkersOptions; } } export default MarkersPlugin; ================================================ FILE: ui/v2.5/src/components/ScenePlayer/media-session.ts ================================================ import videojs, { VideoJsPlayer } from "video.js"; class MediaSessionPlugin extends videojs.getPlugin("plugin") { constructor(player: VideoJsPlayer) { super(player); player.ready(() => { player.addClass("vjs-media-session"); this.setActionHandlers(); }); player.on("play", () => { this.updatePlaybackState(); }); player.on("pause", () => { this.updatePlaybackState(); }); this.updatePlaybackState(); } // manually set poster since it's only set on useEffect public setMetadata(title: string, artist: string, poster: string): void { if ("mediaSession" in navigator) { navigator.mediaSession.metadata = new MediaMetadata({ title, artist, artwork: [ { src: poster || this.player.poster() || "", type: "image/jpeg", }, ], }); } } private updatePlaybackState(): void { if ("mediaSession" in navigator) { const playbackState = this.player.paused() ? "paused" : "playing"; navigator.mediaSession.playbackState = playbackState; } } private setActionHandlers(): void { // method initialization navigator.mediaSession.setActionHandler("play", () => { this.player.play(); }); navigator.mediaSession.setActionHandler("pause", () => { this.player.pause(); }); navigator.mediaSession.setActionHandler("nexttrack", () => { this.player.skipButtons()?.handleForward(); }); navigator.mediaSession.setActionHandler("previoustrack", () => { this.player.skipButtons()?.handleBackward(); }); } } videojs.registerPlugin("mediaSession", MediaSessionPlugin); /* eslint-disable @typescript-eslint/naming-convention */ declare module "video.js" { interface VideoJsPlayer { mediaSession: () => MediaSessionPlugin; } } export default MediaSessionPlugin; ================================================ FILE: ui/v2.5/src/components/ScenePlayer/persist-volume.ts ================================================ import videojs, { VideoJsPlayer } from "video.js"; import localForage from "localforage"; const levelKey = "volume-level"; const mutedKey = "volume-muted"; interface IPersistVolumeOptions { enabled?: boolean; } class PersistVolumePlugin extends videojs.getPlugin("plugin") { enabled: boolean; constructor(player: VideoJsPlayer, options?: IPersistVolumeOptions) { super(player, options); this.enabled = options?.enabled ?? true; player.on("volumechange", () => { if (this.enabled) { localForage.setItem(levelKey, player.volume()); localForage.setItem(mutedKey, player.muted()); } }); player.ready(() => { this.ready(); }); } private ready() { localForage.getItem(levelKey).then((value) => { if (value !== null) { this.player.volume(value); } }); localForage.getItem(mutedKey).then((value) => { if (value !== null) { this.player.muted(value); } }); } } // Register the plugin with video.js. videojs.registerPlugin("persistVolume", PersistVolumePlugin); /* eslint-disable @typescript-eslint/naming-convention */ declare module "video.js" { interface VideoJsPlayer { persistVolume: () => PersistVolumePlugin; } interface VideoJsPlayerPluginOptions { persistVolume?: IPersistVolumeOptions; } } export default PersistVolumePlugin; ================================================ FILE: ui/v2.5/src/components/ScenePlayer/source-selector.ts ================================================ import videojs, { VideoJsPlayer } from "video.js"; export interface ISource extends videojs.Tech.SourceObject { label?: string; errored?: boolean; } class SourceMenuItem extends videojs.getComponent("MenuItem") { public source: ISource; public isSelected = false; constructor(parent: SourceMenuButton, source: ISource) { const options = {} as videojs.MenuItemOptions; options.selectable = true; options.multiSelectable = false; options.label = source.label || source.type; super(parent.player(), options); this.source = source; this.addClass("vjs-source-menu-item"); } selected(selected: boolean): void { super.selected(selected); this.isSelected = selected; } handleClick() { if (this.isSelected) return; this.trigger("selected"); } } class SourceMenuButton extends videojs.getComponent("MenuButton") { private items: SourceMenuItem[] = []; private selectedSource: ISource | null = null; constructor(player: VideoJsPlayer) { super(player); player.on("loadstart", () => { this.update(); }); } public setSources(sources: ISource[]) { this.selectedSource = null; this.items = sources.map((source, i) => { if (i === 0) { this.selectedSource = source; } const item = new SourceMenuItem(this, source); item.on("selected", () => { this.selectedSource = source; this.trigger("sourceselected", source); }); return item; }); } createEl() { return videojs.dom.createEl("div", { className: "vjs-source-selector vjs-menu-button vjs-menu-button-popup vjs-control vjs-button", }); } createItems() { if (this.items === undefined) return []; for (const item of this.items) { item.selected(item.source === this.selectedSource); } return this.items; } setSelectedSource(source: ISource) { this.selectedSource = source; if (this.items === undefined) return; for (const item of this.items) { item.selected(item.source === this.selectedSource); } } markSourceErrored(source: ISource) { const item = this.items.find((i) => i.source.src === source.src); if (item === undefined) return; item.addClass("vjs-source-menu-item-error"); } } class SourceSelectorPlugin extends videojs.getPlugin("plugin") { private menu: SourceMenuButton; private sources: ISource[] = []; private selectedIndex = -1; private cleanupTextTracks: HTMLTrackElement[] = []; private manualTextTracks: HTMLTrackElement[] = []; // don't auto play next source if user manually selected a source private manuallySelected = false; constructor(player: VideoJsPlayer) { super(player); this.menu = new SourceMenuButton(player); this.menu.on("sourceselected", (_, source: ISource) => { this.selectedIndex = this.sources.findIndex((src) => src === source); if (this.selectedIndex === -1) return; this.manuallySelected = true; const loadSrc = this.sources[this.selectedIndex]; const currentTime = player.currentTime(); const paused = player.paused(); player.src(loadSrc); player.one("canplay", () => { if (paused) { player.pause(); } player.currentTime(currentTime); }); player.play(); }); player.on("ready", () => { const { controlBar } = player; const fullscreenToggle = controlBar.getChild("fullscreenToggle")!.el(); controlBar.addChild(this.menu); controlBar.el().insertBefore(this.menu.el(), fullscreenToggle); }); player.on("loadedmetadata", () => { if (!player.videoWidth() && !player.videoHeight()) { // Occurs during preload when videos with supported audio/unsupported video are preloaded. // Treat this as a decoding error and try the next source without playing. // However on Safari we get an media event when m3u8 or mpd is loaded which needs to be ignored. if (player.error() !== null) return; const currentSrc = player.currentSrc(); if (currentSrc === null) return; if (currentSrc.includes(".m3u8") || currentSrc.includes(".mpd")) { player.play(); } else { player.error(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED); return; } } }); player.on("error", () => { const error = player.error(); if (!error) return; // Only try next source if media was unsupported if ( error.code !== MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED && error.code !== MediaError.MEDIA_ERR_DECODE ) return; const currentSource = player.currentSource() as ISource; console.log(`Source '${currentSource.label}' is unsupported`); // mark current source as errored currentSource.errored = true; this.menu.markSourceErrored(currentSource); // don't auto play next source if user manually selected a source if (this.manuallySelected) { return; } // TODO - make auto play next source configurable // try the next source in the list if ( this.selectedIndex !== -1 && this.selectedIndex + 1 < this.sources.length ) { this.selectedIndex += 1; const newSource = this.sources[this.selectedIndex]; console.log(`Trying next source in playlist: '${newSource.label}'`); this.menu.setSelectedSource(newSource); const currentTime = player.currentTime(); player.src(newSource); player.load(); player.one("canplay", () => { player.currentTime(currentTime); }); player.play(); } else { console.log("No more sources in playlist"); } }); } setSources(sources: ISource[]) { const cleanupTracks = this.cleanupTextTracks.splice(0); for (const track of cleanupTracks) { this.player.removeRemoteTextTrack(track); } this.menu.setSources(sources); if (sources.length !== 0) { this.selectedIndex = 0; } else { this.selectedIndex = -1; } this.sources = sources; this.player.src(sources[0]); } get textTracks(): HTMLTrackElement[] { return [...this.cleanupTextTracks, ...this.manualTextTracks]; } addTextTrack(options: videojs.TextTrackOptions, manualCleanup: boolean) { const track = this.player.addRemoteTextTrack(options, true); if (manualCleanup) { this.manualTextTracks.push(track); } else { this.cleanupTextTracks.push(track); } return track; } removeTextTrack(track: HTMLTrackElement) { this.player.removeRemoteTextTrack(track); let index = this.manualTextTracks.indexOf(track); if (index != -1) { this.manualTextTracks.splice(index, 1); } index = this.cleanupTextTracks.indexOf(track); if (index != -1) { this.cleanupTextTracks.splice(index, 1); } } } // Register the plugin with video.js. videojs.registerComponent("SourceMenuButton", SourceMenuButton); videojs.registerPlugin("sourceSelector", SourceSelectorPlugin); /* eslint-disable @typescript-eslint/naming-convention */ declare module "video.js" { interface VideoJsPlayer { sourceSelector: () => SourceSelectorPlugin; } interface VideoJsPlayerPluginOptions { sourceSelector?: {}; } } export default SourceSelectorPlugin; ================================================ FILE: ui/v2.5/src/components/ScenePlayer/styles.scss ================================================ @import "video.js/dist/video-js.css"; @import "videojs-mobile-ui/dist/videojs-mobile-ui.css"; @import "videojs-seek-buttons/dist/videojs-seek-buttons.css"; @import "@silvermine/videojs-chromecast/dist/silvermine-videojs-chromecast.css"; @import "@silvermine/videojs-airplay/dist/silvermine-videojs-airplay.css"; $scrubberHeight: 120px; $menuHeight: 4rem; $sceneTabWidth: 450px; .VideoPlayer { display: flex; flex-direction: column; max-height: calc(100vh - #{$menuHeight}); padding-bottom: 0.25rem; @media (min-width: 1200px) { height: 100vh; } &.portrait .video-wrapper { height: 177.78vw; } } .video-wrapper { height: 56.25vw; overflow: hidden; position: relative; width: 100%; @media (min-width: 1200px) { height: 100%; } } .VideoPlayer.no-file .video-js { .vjs-big-play-button, .vjs-control-bar { display: none; } } .video-js { height: 100%; position: absolute; width: 100%; &:not(.vjs-has-started) .vjs-control-bar { display: flex; } // show controls even when an error is displayed /* stylelint-disable declaration-no-important */ &.vjs-error .vjs-control-bar { display: flex !important; } /* stylelint-enable declaration-no-important */ // allow interaction with the controls when error is displayed .vjs-error-display, .vjs-error-display .vjs-modal-dialog-content { position: static; } // hide spinner when error is displayed &.vjs-error .vjs-loading-spinner { display: none; } .vjs-button { outline: none; } .vjs-big-button-group { display: none; height: 80px; justify-content: space-around; opacity: 0; position: absolute; top: calc(50% - 40px); width: 100%; z-index: 1; .vjs-button { font-size: 4em; height: 100%; width: 80px; .vjs-icon-placeholder::before { height: 100%; line-height: 80px; } } } .vjs-airplay-button .vjs-icon-placeholder, .vjs-chromecast-button .vjs-icon-placeholder { height: 1.6em; width: 1.6em; } .vjs-autostart-button { cursor: pointer; &.vjs-icon-play-circle::before { align-items: center; background-color: rgba(255, 255, 255, 0.9); border-radius: 50%; color: rgba(80, 80, 80, 0.9); content: "\f101"; font-size: 1em; line-height: 1; margin-left: 1rem; padding: 0.3em; position: relative; z-index: 2; } &.vjs-icon-cancel::before { align-items: center; background-color: rgba(80, 80, 80, 0.9); border-radius: 50%; color: #fff; content: "\f103"; font-size: 1em; line-height: 1; margin-right: 1rem; padding: 0.3em; position: relative; z-index: 2; } &.vjs-icon-play-circle::after, &.vjs-icon-cancel::after { background-color: rgb(255 255 255 / 70%); border-radius: 8px; content: ""; height: 2.5rem; left: 50%; opacity: 0.7; position: absolute; top: 50%; transform: translate(-50%, -50%) rotate(90deg); width: 1rem; z-index: 1; } &:hover { text-shadow: 0 0 1em rgba(255, 255, 255, 0.75); } } .vjs-touch-overlay .vjs-play-control { z-index: 1; } .vjs-control-bar { background: none; /* Scales control size */ font-size: 15px; &::before { background: linear-gradient( 0deg, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0) 100% ); bottom: 0; content: ""; height: 10rem; pointer-events: none; position: absolute; width: 100%; } } .vjs-time-control { align-items: center; display: flex; justify-content: center; min-width: 0; padding: 0 4px; pointer-events: none; .vjs-control-text { display: none; } } .vjs-duration { margin-right: auto; } .vjs-remaining-time { display: none; } .vjs-progress-control { bottom: 2.5em; height: 3em; position: absolute; width: 100%; .vjs-progress-holder { margin: 0 15px; } } /* stylelint-disable declaration-no-important */ .vjs-play-progress .vjs-time-tooltip { display: none !important; } /* stylelint-enable declaration-no-important */ .vjs-volume-control { z-index: 1; } /* stylelint-disable declaration-no-important */ .vjs-slider { box-shadow: none !important; text-shadow: none !important; } /* stylelint-enable declaration-no-important */ .vjs-vtt-thumbnail-display { border: 2px solid white; border-radius: 2px; bottom: 6em; box-shadow: 0 0 7px rgba(0, 0, 0, 0.6); opacity: 0; pointer-events: none; position: absolute; transition: opacity 0.2s; z-index: 100; } .vjs-big-play-button, .vjs-big-play-button:hover, .vjs-big-play-button:focus, &:hover .vjs-big-play-button { background: none; border: none; font-size: 10em; } .vjs-skip-button { &::before { font-size: 1.8em; line-height: 1.67; } } &.vjs-skip-buttons { .vjs-icon-next-item, .vjs-icon-previous-item { display: none; } &-prev .vjs-icon-previous-item, &-next .vjs-icon-next-item { display: inline-block; } } .vjs-source-selector { &.vjs-hover .vjs-menu { display: none; } .vjs-menu li { font-size: 0.8em; } .vjs-button > .vjs-icon-placeholder::before { content: "\f110"; font-family: VideoJS; } .vjs-menu-item.vjs-source-menu-item-error:not(.vjs-selected) { color: $text-muted; } .vjs-menu-item.vjs-source-menu-item-error { font-style: italic; } } .vjs-vr-selector { .vjs-menu li { font-size: 0.8em; } .vjs-button { background: url("/vr.svg") center center no-repeat; width: 50%; } } .vjs-marker { background-color: rgba(33, 33, 33, 0.8); bottom: 0; height: 100%; left: 0; opacity: 1; position: absolute; transition: opacity 0.2s ease; visibility: hidden; width: 6px; z-index: 100; &:hover { cursor: pointer; transform: scale(1.3, 1.3); } } .vjs-marker-range { background-color: rgba(255, 255, 255, 0.4); border-radius: 2px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); height: 8px; min-width: 8px; position: absolute; transform: translateY(-28px); transition: none; } .vjs-marker-tooltip { background-color: #fff; background-color: rgba(255, 255, 255, 0.8); border-radius: 0.3em; color: #000; float: right; font-family: Arial, Helvetica, sans-serif; font-size: 0.6em; padding: 6px 8px 8px 8px; pointer-events: none; position: absolute; top: -3.4em; visibility: hidden; white-space: nowrap; z-index: 1; } .vjs-text-track-settings select { background: #fff; } .vjs-seek-button.skip-back span.vjs-icon-placeholder::before { -ms-transform: none; -webkit-transform: none; transform: none; } .vjs-seek-button.skip-forward span.vjs-icon-placeholder::before { -ms-transform: scale(-1, 1); -webkit-transform: scale(-1, 1); transform: scale(-1, 1); } @media (pointer: coarse) { &.vjs-touch-enabled { &.vjs-has-started .vjs-big-button-group { display: flex; opacity: 1; visibility: visible; } &.vjs-has-started.vjs-user-inactive.vjs-playing .vjs-big-button-group { opacity: 0; pointer-events: none; transition: visibility 1s, opacity 1s; visibility: visible; } .vjs-big-play-pause-button .vjs-icon-placeholder::before { content: "\f101"; font-family: VideoJS; } &.vjs-playing .vjs-big-play-pause-button .vjs-icon-placeholder::before { content: "\f103"; } .vjs-vtt-thumbnail-display { bottom: 2.8em; } // hide the regular seek buttons on touch screens .vjs-control-bar .vjs-seek-button { display: none; } } } @media (max-width: 576px) { .vjs-control-bar { .vjs-autostart-button { display: none; } } } // make controls a little more compact on smaller screens @media (max-width: 768px) { .vjs-control-bar { .vjs-control { width: 2.5em; } .vjs-progress-control { height: 2em; width: 100%; } .vjs-playback-rate { width: 3em; } .vjs-button > .vjs-icon-placeholder::before, .vjs-skip-button::before { font-size: 1.5em; line-height: 2; } .vjs-airplay-button .vjs-icon-placeholder, .vjs-chromecast-button .vjs-icon-placeholder { height: 1.4em; width: 1.4em; } .vjs-source-selector .vjs-menu { z-index: 9999; } } .vjs-menu-button-popup .vjs-menu { width: 8em; .vjs-menu-content { max-height: 10em; } } .vjs-playback-rate .vjs-playback-rate-value { font-size: 1em; line-height: 2.97; } .vjs-source-selector { .vjs-menu li { font-size: 10px; } } .vjs-time-control { font-size: 12px; } .vjs-big-button-group .vjs-button { font-size: 2em; width: 50px; } .vjs-current-time { margin-left: 1em; } } } .scene-tabs, .scene-player-container { padding-left: 15px; position: relative; width: 100%; } .scene-player-container { padding-right: 15px; } .scrubber-wrapper { display: flex; flex-shrink: 0; margin: 5px 0; overflow: hidden; position: relative; } #scrubber-back { float: left; } #scrubber-forward { float: right; } .scrubber-button { background-color: transparent; border: 1px solid #555; color: $link-color; cursor: pointer; font-size: 1.1rem; font-weight: 800; height: 100%; line-height: $scrubberHeight; padding: 0; text-align: center; width: 1.8rem; } .scrubber-content { cursor: pointer; display: inline-block; flex-grow: 1; height: $scrubberHeight; margin: 0 7px; overflow: hidden; -webkit-overflow-scrolling: touch; position: relative; -webkit-user-select: none; user-select: none; &.dragging { cursor: grabbing; } } #scrubber-position-indicator { background-color: rgba(255, 255, 255, 0.7); height: 20px; left: -100%; position: absolute; width: 100%; z-index: 0; } #scrubber-current-position { background-color: #fff; height: 30px; left: 50%; position: absolute; width: 2px; z-index: 1; } .scrubber-viewport { height: 100%; overflow: hidden; position: static; } .scrubber-slider { height: 100%; left: 0; position: absolute; width: 100%; } .scrubber-tags { height: 20px; margin-bottom: 10px; position: relative; &-background { background-color: #555; height: 20px; left: 0; position: absolute; right: 0; } } .scrubber-heatmap { background-size: 100% 100%; height: 20px; left: 0; position: absolute; right: 0; } .scrubber-tag { background-color: #000; cursor: pointer; font-size: 10px; height: 20px; padding: 0 10px; position: absolute; transform: translateX(-50%); white-space: nowrap; &:hover { background-color: #444; z-index: 1; } &:hover::after { border-top: solid 5px #444; z-index: 1; } &::after { border-left: solid 5px transparent; border-right: solid 5px transparent; border-top: solid 5px #000; bottom: -5px; content: ""; left: 50%; margin-left: -5px; position: absolute; } } .scrubber-item { color: white; display: flex; font-size: 10px; margin: 0 auto; position: absolute; text-align: center; text-shadow: 1px 1px black; &-time { align-self: flex-end; display: inline-block; width: 100%; } } @media (max-width: 1199px) { .scene-tabs { padding-right: 15px; } .scene-player-container { padding-left: 0; padding-right: 0; } .scrubber-wrapper { margin-left: 5px; margin-right: 5px; } } @media (min-width: 1200px) { .scene-tabs { flex: 0 0 $sceneTabWidth; max-width: $sceneTabWidth; overflow: auto; &.collapsed { display: none; } .tab-content { flex: 1 1 auto; min-height: 15rem; overflow-x: hidden; overflow-y: auto; } } .scene-divider { flex: 0 0 15px; max-width: 15px; button { background-color: transparent; border: 0; color: $link-color; cursor: pointer; font-size: 10px; font-weight: 800; height: 100%; line-height: 100%; padding: 0; text-align: center; width: 100%; &:active:not(:hover), &:focus:not(:hover) { background-color: transparent; border: 0; box-shadow: none; } } } .scene-player-container { flex: 0 0 calc(100% - #{$sceneTabWidth} - 15px); max-width: calc(100% - #{$sceneTabWidth} - 15px); padding-left: 0; &.expanded { flex: 0 0 calc(100% - 15px); max-width: calc(100% - 15px); } } } ================================================ FILE: ui/v2.5/src/components/ScenePlayer/track-activity.ts ================================================ import videojs, { VideoJsPlayer } from "video.js"; const intervalSeconds = 1; // check every second const sendInterval = 10; // send every 10 seconds class TrackActivityPlugin extends videojs.getPlugin("plugin") { totalPlayDuration = 0; currentPlayDuration = 0; minimumPlayPercent = 0; incrementPlayCount: () => Promise = () => { return Promise.resolve(); }; saveActivity: (resumeTime: number, playDuration: number) => Promise = () => { return Promise.resolve(); }; private enabled = false; private playCountIncremented = false; private intervalID: number | undefined; private lastResumeTime = 0; private lastDuration = 0; constructor(player: VideoJsPlayer) { super(player); player.on("playing", () => { this.start(); }); player.on("waiting", () => { this.stop(); }); player.on("stalled", () => { this.stop(); }); player.on("pause", () => { this.stop(); }); player.on("dispose", () => { this.stop(); }); player.on("ended", () => { this.stop(); }); } private start() { if (this.enabled && !this.intervalID) { this.intervalID = window.setInterval(() => { this.intervalHandler(); }, intervalSeconds * 1000); this.lastResumeTime = this.player.currentTime(); this.lastDuration = this.player.duration(); } } private stop() { if (this.intervalID) { window.clearInterval(this.intervalID); this.intervalID = undefined; this.sendActivity(); } } reset() { this.stop(); this.totalPlayDuration = 0; this.currentPlayDuration = 0; this.playCountIncremented = false; } setEnabled(enabled: boolean) { this.enabled = enabled; if (!enabled) { this.stop(); } else if (!this.player.paused()) { this.start(); } } private intervalHandler() { if (!this.enabled || !this.player) return; this.lastResumeTime = this.player.currentTime(); this.lastDuration = this.player.duration(); this.totalPlayDuration += intervalSeconds; this.currentPlayDuration += intervalSeconds; if (this.totalPlayDuration % sendInterval === 0) { this.sendActivity(); } } private sendActivity() { if (!this.enabled) return; if (this.totalPlayDuration > 0) { let resumeTime = this.player?.currentTime() ?? this.lastResumeTime; const videoDuration = this.player?.duration() ?? this.lastDuration; const percentCompleted = (100 / videoDuration) * resumeTime; const percentPlayed = (100 / videoDuration) * this.totalPlayDuration; if ( !this.playCountIncremented && percentPlayed >= this.minimumPlayPercent ) { this.incrementPlayCount(); this.playCountIncremented = true; } // if video is 98% or more complete then reset resume_time if (percentCompleted >= 98) { resumeTime = 0; } this.saveActivity(resumeTime, this.currentPlayDuration); this.currentPlayDuration = 0; } } } // Register the plugin with video.js. videojs.registerPlugin("trackActivity", TrackActivityPlugin); /* eslint-disable @typescript-eslint/naming-convention */ declare module "video.js" { interface VideoJsPlayer { trackActivity: () => TrackActivityPlugin; } interface VideoJsPlayerPluginOptions { trackActivity?: {}; } } export default TrackActivityPlugin; ================================================ FILE: ui/v2.5/src/components/ScenePlayer/util.ts ================================================ import videojs, { VideoJsPlayer } from "video.js"; export const VIDEO_PLAYER_ID = "VideoJsPlayer"; export const getPlayer = () => videojs.getPlayer(VIDEO_PLAYER_ID); export const getPlayerPosition = () => getPlayer()?.currentTime(); export type AbLoopOptions = { start: number; end: number | false; enabled?: boolean; }; export type AbLoopPluginApi = { getOptions: () => AbLoopOptions; setOptions: (options: AbLoopOptions) => void; }; export const getAbLoopPlugin = () => { const player = getPlayer(); if (!player) return null; const { abLoopPlugin } = player as VideoJsPlayer & { abLoopPlugin?: AbLoopPluginApi; }; return abLoopPlugin ?? null; }; ================================================ FILE: ui/v2.5/src/components/ScenePlayer/vrmode.ts ================================================ /* eslint-disable @typescript-eslint/naming-convention */ import videojs, { VideoJsPlayer } from "video.js"; import "videojs-vr"; // separate type import, otherwise typescript elides the above import // and the plugin does not get initialized import type { ProjectionType, Plugin as VideoJsVRPlugin } from "videojs-vr"; export interface VRMenuOptions { /** * Whether to show the vr button. * @default false */ showButton?: boolean; } enum VRType { LR180 = "180 LR", TB360 = "360 TB", Mono360 = "360 Mono", Off = "Off", } const vrTypeProjection: Record = { [VRType.LR180]: "180_LR", [VRType.TB360]: "360_TB", [VRType.Mono360]: "360", [VRType.Off]: "NONE", }; function isVrDevice() { return navigator.userAgent.match(/oculusbrowser|\svr\s/i); } class VRMenuItem extends videojs.getComponent("MenuItem") { public type: VRType; public isSelected = false; constructor(parent: VRMenuButton, type: VRType) { const options: videojs.MenuItemOptions = {}; options.selectable = true; options.multiSelectable = false; options.label = type; super(parent.player(), options); this.type = type; this.addClass("vjs-source-menu-item"); } selected(selected: boolean): void { super.selected(selected); this.isSelected = selected; } handleClick() { if (this.isSelected) return; this.trigger("selected"); } } class VRMenuButton extends videojs.getComponent("MenuButton") { private items: VRMenuItem[] = []; private selectedType: VRType = VRType.Off; constructor(player: VideoJsPlayer) { super(player); this.setTypes(); } private onSelected(item: VRMenuItem) { this.selectedType = item.type; this.items.forEach((i) => { i.selected(i.type === this.selectedType); }); this.trigger("typeselected", item.type); } public setTypes() { this.items = Object.values(VRType).map((type) => { const item = new VRMenuItem(this, type); item.on("selected", () => { this.onSelected(item); }); return item; }); this.update(); } createEl() { return videojs.dom.createEl("div", { className: "vjs-vr-selector vjs-menu-button vjs-menu-button-popup vjs-control vjs-button", }); } createItems() { if (this.items === undefined) return []; for (const item of this.items) { item.selected(item.type === this.selectedType); } return this.items; } } class VRMenuPlugin extends videojs.getPlugin("plugin") { private menu: VRMenuButton; private showButton: boolean; private vr?: VideoJsVRPlugin; constructor(player: VideoJsPlayer, options: VRMenuOptions) { super(player); this.menu = new VRMenuButton(player); this.showButton = options.showButton ?? false; if (isVrDevice()) return; this.vr = this.player.vr(); this.menu.on("typeselected", (_, type: VRType) => { this.loadVR(type); }); player.on("ready", () => { if (this.showButton) { this.addButton(); } }); } private loadVR(type: VRType) { const projection = vrTypeProjection[type]; this.vr?.setProjection(projection); this.vr?.init(); } private addButton() { const { controlBar } = this.player; const fullscreenToggle = controlBar.getChild("fullscreenToggle")!.el(); controlBar.addChild(this.menu); controlBar.el().insertBefore(this.menu.el(), fullscreenToggle); } private removeButton() { const { controlBar } = this.player; controlBar.removeChild(this.menu); } public setShowButton(showButton: boolean) { if (isVrDevice()) return; if (showButton === this.showButton) return; this.showButton = showButton; if (showButton) { this.addButton(); } else { this.removeButton(); this.loadVR(VRType.Off); } } } // Register the plugin with video.js. videojs.registerComponent("VRMenuButton", VRMenuButton); videojs.registerPlugin("vrMenu", VRMenuPlugin); /* eslint-disable @typescript-eslint/naming-convention */ declare module "video.js" { interface VideoJsPlayer { vrMenu: () => VRMenuPlugin; } interface VideoJsPlayerPluginOptions { vrMenu?: VRMenuOptions; } } export default VRMenuPlugin; ================================================ FILE: ui/v2.5/src/components/ScenePlayer/vtt-thumbnails.ts ================================================ import videojs, { VideoJsPlayer } from "video.js"; import { WebVTT } from "videojs-vtt.js"; export interface IVTTThumbnailsOptions { /** * Source URL to use for thumbnails. */ src?: string; /** * Whether to show the timestamp on hover. * @default false */ showTimestamp?: boolean; } interface IVTTData { start: number; end: number; style: IVTTStyle | null; } interface IVTTStyle { background: string; width: string; height: string; } class VTTThumbnailsPlugin extends videojs.getPlugin("plugin") { private source: string | null; private showTimestamp: boolean; private progressBar?: HTMLElement; private thumbnailHolder?: HTMLDivElement; private showing = false; private vttData?: IVTTData[]; private lastStyle?: IVTTStyle; constructor(player: VideoJsPlayer, options: IVTTThumbnailsOptions) { super(player, options); this.source = options.src ?? null; this.showTimestamp = options.showTimestamp ?? false; player.ready(() => { player.addClass("vjs-vtt-thumbnails"); this.initializeThumbnails(); }); } src(source: string | null): void { this.resetPlugin(); this.source = source; this.initializeThumbnails(); } detach(): void { this.resetPlugin(); } private resetPlugin() { this.showing = false; if (this.thumbnailHolder) { this.thumbnailHolder.remove(); delete this.thumbnailHolder; } if (this.progressBar) { this.progressBar.removeEventListener( "pointerenter", this.onBarPointerEnter ); this.progressBar.removeEventListener( "pointermove", this.onBarPointerMove ); this.progressBar.removeEventListener( "pointerleave", this.onBarPointerLeave ); delete this.progressBar; } delete this.vttData; delete this.lastStyle; } /** * Bootstrap the plugin. */ private initializeThumbnails() { if (!this.source) { return; } const baseUrl = this.getBaseUrl(); const url = this.getFullyQualifiedUrl(this.source, baseUrl); this.getVttFile(url).then((data) => { this.vttData = this.processVtt(data); this.setupThumbnailElement(); }); } /** * Builds a base URL should we require one. */ private getBaseUrl() { return [ window.location.protocol, "//", window.location.hostname, window.location.port ? ":" + window.location.port : "", window.location.pathname, ] .join("") .split(/([^\/]*)$/gi)[0]; } /** * Grabs the contents of the VTT file. */ private getVttFile(url: string): Promise { return new Promise((resolve, reject) => { const req = new XMLHttpRequest(); req.addEventListener("load", () => { resolve(req.responseText); }); req.addEventListener("error", (e) => { reject(e); }); req.open("GET", url); req.send(); }); } private setupThumbnailElement() { const progressBar = this.player.$(".vjs-progress-control") as HTMLElement; if (!progressBar) return; this.progressBar = progressBar; const thumbHolder = document.createElement("div"); thumbHolder.setAttribute("class", "vjs-vtt-thumbnail-display"); progressBar.appendChild(thumbHolder); this.thumbnailHolder = thumbHolder; if (!this.showTimestamp) { this.player.$(".vjs-mouse-display")?.classList.add("vjs-hidden"); } progressBar.addEventListener("pointerover", this.onBarPointerEnter); progressBar.addEventListener("pointerout", this.onBarPointerLeave); } private onBarPointerEnter = () => { this.showThumbnailHolder(); this.progressBar?.addEventListener("pointermove", this.onBarPointerMove); }; private onBarPointerMove = (e: Event) => { const { progressBar } = this; if (!progressBar) return; this.showThumbnailHolder(); this.updateThumbnailStyle( videojs.dom.getPointerPosition(progressBar, e).x, progressBar.offsetWidth ); }; private onBarPointerLeave = () => { this.hideThumbnailHolder(); this.progressBar?.removeEventListener("pointermove", this.onBarPointerMove); }; private getStyleForTime(time: number) { if (!this.vttData) return null; for (const element of this.vttData) { const item = element; if (time >= item.start && time < item.end) { return item.style; } } return null; } private showThumbnailHolder() { if (this.thumbnailHolder && !this.showing) { this.showing = true; this.thumbnailHolder.style.opacity = "1"; } } private hideThumbnailHolder() { if (this.thumbnailHolder && this.showing) { this.showing = false; this.thumbnailHolder.style.opacity = "0"; } } private updateThumbnailStyle(percent: number, width: number) { if (!this.thumbnailHolder) return; const duration = this.player.duration(); const time = percent * duration; const currentStyle = this.getStyleForTime(time); if (!currentStyle) { this.hideThumbnailHolder(); return; } const xPos = percent * width; const thumbnailWidth = parseInt(currentStyle.width, 10); const halfThumbnailWidth = thumbnailWidth >> 1; const marginRight = width - (xPos + halfThumbnailWidth); const marginLeft = xPos - halfThumbnailWidth; if (marginLeft > 0 && marginRight > 0) { this.thumbnailHolder.style.transform = "translateX(" + (xPos - halfThumbnailWidth) + "px)"; } else if (marginLeft <= 0) { this.thumbnailHolder.style.transform = "translateX(" + 0 + "px)"; } else if (marginRight <= 0) { this.thumbnailHolder.style.transform = "translateX(" + (width - thumbnailWidth) + "px)"; } if (this.lastStyle && this.lastStyle === currentStyle) { return; } this.lastStyle = currentStyle; Object.assign(this.thumbnailHolder.style, currentStyle); } private processVtt(data: string) { const processedVtts: IVTTData[] = []; const parser = new WebVTT.Parser(window, WebVTT.StringDecoder()); parser.oncue = (cue: VTTCue) => { processedVtts.push({ start: cue.startTime, end: cue.endTime, style: this.getVttStyle(cue.text), }); }; parser.parse(data); parser.flush(); return processedVtts; } private getFullyQualifiedUrl(path: string, base: string) { if (path.indexOf("//") >= 0) { // We have a fully qualified path. return path; } if (base.indexOf("//") === 0) { // We don't have a fully qualified path, but need to // be careful with trimming. return [base.replace(/\/$/gi, ""), this.trim(path, "/")].join("/"); } if (base.indexOf("//") > 0) { // We don't have a fully qualified path, and should // trim both sides of base and path. return [this.trim(base, "/"), this.trim(path, "/")].join("/"); } // If all else fails. return path; } private getPropsFromDef(def: string) { const match = def.match(/^([^#]*)#xywh=(\d+),(\d+),(\d+),(\d+)$/i); if (!match) return null; return { image: match[1], x: match[2], y: match[3], w: match[4], h: match[5], }; } private getVttStyle(vttImageDef: string) { // If there isn't a protocol, use the VTT source URL. let baseSplit: string; if (this.source === null) { baseSplit = this.getBaseUrl(); } else if (this.source.indexOf("//") >= 0) { baseSplit = this.source.split(/([^\/]*)$/gi)[0]; } else { baseSplit = this.getBaseUrl() + this.source.split(/([^\/]*)$/gi)[0]; } vttImageDef = this.getFullyQualifiedUrl(vttImageDef, baseSplit); const imageProps = this.getPropsFromDef(vttImageDef); if (!imageProps) return null; return { background: 'url("' + imageProps.image + '") no-repeat -' + imageProps.x + "px -" + imageProps.y + "px", width: imageProps.w + "px", height: imageProps.h + "px", }; } /** * trim * * @param str source string * @param charlist characters to trim from text * @return trimmed string */ private trim(str: string, charlist: string) { let whitespace = [ " ", "\n", "\r", "\t", "\f", "\x0b", "\xa0", "\u2000", "\u2001", "\u2002", "\u2003", "\u2004", "\u2005", "\u2006", "\u2007", "\u2008", "\u2009", "\u200a", "\u200b", "\u2028", "\u2029", "\u3000", ].join(""); let l = 0; str += ""; if (charlist) { whitespace = (charlist + "").replace(/([[\]().?/*{}+$^:])/g, "$1"); } l = str.length; for (let i = 0; i < l; i++) { if (whitespace.indexOf(str.charAt(i)) === -1) { str = str.substring(i); break; } } l = str.length; for (let i = l - 1; i >= 0; i--) { if (whitespace.indexOf(str.charAt(i)) === -1) { str = str.substring(0, i + 1); break; } } return whitespace.indexOf(str.charAt(0)) === -1 ? str : ""; } } // Register the plugin with video.js. videojs.registerPlugin("vttThumbnails", VTTThumbnailsPlugin); /* eslint-disable @typescript-eslint/naming-convention */ declare module "video.js" { interface VideoJsPlayer { vttThumbnails: () => VTTThumbnailsPlugin; } interface VideoJsPlayerPluginOptions { vttThumbnails?: IVTTThumbnailsOptions; } } export default VTTThumbnailsPlugin; ================================================ FILE: ui/v2.5/src/components/ScenePlayer/wake-sentinel.ts ================================================ import videojs, { VideoJsPlayer } from "video.js"; class WakeSentinelPlugin extends videojs.getPlugin("plugin") { public wakeLock: WakeLockSentinel | null = null; public wakeLockFail: boolean = false; constructor(player: VideoJsPlayer) { super(player); // listen for visibility change events document.addEventListener("visibilitychange", async () => { if (document.visibilityState === "visible") { // reacquire the wake lock when the page becomes visible await this.acquireWakeLock(); } }); // acquire wake lock on ready and play player.ready(async () => { player.addClass("vjs-wake-sentinel"); await this.acquireWakeLock(true); }); player.on("play", () => this.acquireWakeLock()); // release wake lock on pause, dispose and end player.on("pause", () => this.releaseWakeLock()); player.on("dispose", () => this.releaseWakeLock()); player.on("ended", () => this.releaseWakeLock()); } private async releaseWakeLock(): Promise { this.wakeLock?.release().then(() => (this.wakeLock = null)); } private async acquireWakeLock(log = false): Promise { // if wake lock failed, don't even try if (this.wakeLockFail) return; // check for wake lock on startup if ("wakeLock" in navigator) { try { this.wakeLock = await navigator.wakeLock.request("screen"); } catch (err) { if (log) console.error("Failed to obtain Screen Wake Lock:", err); this.wakeLockFail = true; } } else { if (log) { console.warn( "Screen Wake Lock API not supported. Secure context (https or localhost) and modern browser required." ); } this.wakeLockFail = true; } } } videojs.registerPlugin("wakeSentinel", WakeSentinelPlugin); /* eslint-disable @typescript-eslint/naming-convention */ declare module "video.js" { interface VideoJsPlayer { wakeSentinel: () => WakeSentinelPlugin; } } export default WakeSentinelPlugin; ================================================ FILE: ui/v2.5/src/components/Scenes/DeleteSceneMarkersDialog.tsx ================================================ import React, { useState } from "react"; import { useSceneMarkersDestroy } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "src/components/Shared/Modal"; import { useToast } from "src/hooks/Toast"; import { useIntl } from "react-intl"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; interface IDeleteSceneMarkersDialogProps { selected: GQL.SceneMarkerDataFragment[]; onClose: (confirmed: boolean) => void; } export const DeleteSceneMarkersDialog: React.FC< IDeleteSceneMarkersDialogProps > = (props: IDeleteSceneMarkersDialogProps) => { const intl = useIntl(); const singularEntity = intl.formatMessage({ id: "marker" }); const pluralEntity = intl.formatMessage({ id: "markers" }); const header = intl.formatMessage( { id: "dialogs.delete_object_title" }, { count: props.selected.length, singularEntity, pluralEntity } ); const toastMessage = intl.formatMessage( { id: "toast.delete_past_tense" }, { count: props.selected.length, singularEntity, pluralEntity } ); const message = intl.formatMessage( { id: "dialogs.delete_object_desc" }, { count: props.selected.length, singularEntity, pluralEntity } ); const Toast = useToast(); const [deleteSceneMarkers] = useSceneMarkersDestroy( getSceneMarkersDeleteInput() ); // Network state const [isDeleting, setIsDeleting] = useState(false); function getSceneMarkersDeleteInput(): GQL.SceneMarkersDestroyMutationVariables { return { ids: props.selected.map((marker) => marker.id), }; } async function onDelete() { setIsDeleting(true); try { await deleteSceneMarkers(); Toast.success(toastMessage); props.onClose(true); } catch (e) { Toast.error(e); props.onClose(false); } setIsDeleting(false); } return ( props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} isRunning={isDeleting} >

    {message}

    ); }; export default DeleteSceneMarkersDialog; ================================================ FILE: ui/v2.5/src/components/Scenes/DeleteScenesDialog.tsx ================================================ import React, { useState } from "react"; import { Form } from "react-bootstrap"; import { useScenesDestroy } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "src/components/Shared/Modal"; import { useToast } from "src/hooks/Toast"; import { useConfigurationContext } from "src/hooks/Config"; import { FormattedMessage, useIntl } from "react-intl"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; import { objectPath } from "src/core/files"; interface IDeleteSceneDialogProps { selected: GQL.SlimSceneDataFragment[]; onClose: (confirmed: boolean) => void; } export const DeleteScenesDialog: React.FC = ( props: IDeleteSceneDialogProps ) => { const intl = useIntl(); const singularEntity = intl.formatMessage({ id: "scene" }); const pluralEntity = intl.formatMessage({ id: "scenes" }); const header = intl.formatMessage( { id: "dialogs.delete_entity_title" }, { count: props.selected.length, singularEntity, pluralEntity } ); const toastMessage = intl.formatMessage( { id: "toast.delete_past_tense" }, { count: props.selected.length, singularEntity, pluralEntity } ); const message = intl.formatMessage( { id: "dialogs.delete_entity_desc" }, { count: props.selected.length, singularEntity, pluralEntity } ); const { configuration: config } = useConfigurationContext(); const [deleteFile, setDeleteFile] = useState( config?.defaults.deleteFile ?? false ); const [deleteGenerated, setDeleteGenerated] = useState( config?.defaults.deleteGenerated ?? true ); const Toast = useToast(); const [deleteScene] = useScenesDestroy(getScenesDeleteInput()); // Network state const [isDeleting, setIsDeleting] = useState(false); function getScenesDeleteInput(): GQL.ScenesDestroyInput { return { ids: props.selected.map((scene) => scene.id), delete_file: deleteFile, delete_generated: deleteGenerated, }; } async function onDelete() { setIsDeleting(true); try { await deleteScene(); Toast.success(toastMessage); props.onClose(true); } catch (e) { Toast.error(e); props.onClose(false); } setIsDeleting(false); } function funscriptPath(sp: string) { const extIndex = sp.lastIndexOf("."); if (extIndex !== -1) { return sp.substring(0, extIndex + 1) + "funscript"; } return sp; } function maybeRenderDeleteFileAlert() { if (!deleteFile) { return; } const deletedFiles: string[] = []; props.selected.forEach((s) => { const paths = s.files.map((f) => f.path); deletedFiles.push(...paths); if (s.interactive && s.files.length) { deletedFiles.push(funscriptPath(objectPath(s))); } }); const deleteTrashPath = config?.general.deleteTrashPath; const deleteAlertId = deleteTrashPath ? "dialogs.delete_alert_to_trash" : "dialogs.delete_alert"; return (

      {deletedFiles.slice(0, 5).map((s) => (
    • {s}
    • ))} {deletedFiles.length > 5 && ( )}
    ); } return ( props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} isRunning={isDeleting} >

    {message}

    {maybeRenderDeleteFileAlert()}
    setDeleteFile(!deleteFile)} /> setDeleteGenerated(!deleteGenerated)} />
    ); }; export default DeleteScenesDialog; ================================================ FILE: ui/v2.5/src/components/Scenes/EditSceneMarkersDialog.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import { useBulkSceneMarkerUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "../Shared/Modal"; import { useToast } from "src/hooks/Toast"; import { MultiSet } from "../Shared/MultiSet"; import { getAggregateState, getAggregateStateObject, } from "src/utils/bulkUpdate"; import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; import { TagSelect } from "../Shared/Select"; interface IListOperationProps { selected: GQL.SceneMarkerDataFragment[]; onClose: (applied: boolean) => void; } const scenemarkerFields = ["title"]; export const EditSceneMarkersDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); const [updateInput, setUpdateInput] = useState({ ids: props.selected.map((scenemarker) => { return scenemarker.id; }), }); const [tagIds, setTagIds] = useState({ mode: GQL.BulkUpdateIdMode.Add, }); const unsetDisabled = props.selected.length < 2; const [updateSceneMarkers] = useBulkSceneMarkerUpdate(); // Network state const [isUpdating, setIsUpdating] = useState(false); const aggregateState = useMemo(() => { const updateState: Partial = {}; const state = props.selected; let updateTagIds: string[] = []; let first = true; state.forEach((scenemarker: GQL.SceneMarkerDataFragment) => { getAggregateStateObject( updateState, scenemarker, scenemarkerFields, first ); // sceneMarker data fragment doesn't have primary_tag_id, so handle separately updateState.primary_tag_id = getAggregateState( updateState.primary_tag_id, scenemarker.primary_tag.id, first ); const thisTagIDs = (scenemarker.tags ?? []).map((p) => p.id).sort(); updateTagIds = getAggregateState(updateTagIds, thisTagIDs, first) ?? []; first = false; }); return { state: updateState, tagIds: updateTagIds }; }, [props.selected]); // update initial state from aggregate useEffect(() => { setUpdateInput((current) => ({ ...current, ...aggregateState.state })); }, [aggregateState]); function setUpdateField(input: Partial) { setUpdateInput((current) => ({ ...current, ...input })); } function getSceneMarkerInput(): GQL.BulkSceneMarkerUpdateInput { const sceneMarkerInput: GQL.BulkSceneMarkerUpdateInput = { ...updateInput, tag_ids: tagIds, }; return sceneMarkerInput; } async function onSave() { setIsUpdating(true); try { await updateSceneMarkers({ variables: { input: getSceneMarkerInput(), }, }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl.formatMessage({ id: "markers" }).toLocaleLowerCase(), } ) ); props.onClose(true); } catch (e) { Toast.error(e); } setIsUpdating(false); } function render() { return ( props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} isRunning={isUpdating} >
    setUpdateField({ title: newValue })} unsetDisabled={unsetDisabled} /> setUpdateField({ primary_tag_id: t[0]?.id })} ids={ updateInput.primary_tag_id ? [updateInput.primary_tag_id] : [] } /> { setTagIds((c) => ({ ...c, ids: itemIDs })); }} onSetMode={(newMode) => { setTagIds((c) => ({ ...c, mode: newMode })); }} ids={tagIds.ids ?? []} existingIds={aggregateState.tagIds ?? []} mode={tagIds.mode} menuPortalTarget={document.body} />
    ); } return render(); }; ================================================ FILE: ui/v2.5/src/components/Scenes/EditScenesDialog.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import { useBulkSceneUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { StudioSelect } from "../Shared/Select"; import { ModalComponent } from "../Shared/Modal"; import { MultiSet } from "../Shared/MultiSet"; import { useToast } from "src/hooks/Toast"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { getAggregateInputValue, getAggregateGroupIds, getAggregatePerformerIds, getAggregateStateObject, getAggregateTagIds, getAggregateStudioId, } from "src/utils/bulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; import { BulkUpdateDateInput } from "../Shared/DateInput"; import { getDateError } from "src/utils/yup"; interface IListOperationProps { selected: GQL.SlimSceneDataFragment[]; onClose: (applied: boolean) => void; } const sceneFields = [ "code", "rating100", "details", "organized", "director", "date", ]; export const EditScenesDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); const [updateInput, setUpdateInput] = useState({ ids: props.selected.map((scene) => { return scene.id; }), }); const [dateError, setDateError] = useState(); const [performerIds, setPerformerIds] = useState({ mode: GQL.BulkUpdateIdMode.Add, }); const [tagIds, setTagIds] = useState({ mode: GQL.BulkUpdateIdMode.Add, }); const [groupIds, setGroupIds] = useState({ mode: GQL.BulkUpdateIdMode.Add, }); const unsetDisabled = props.selected.length < 2; const [updateScenes] = useBulkSceneUpdate(); // Network state const [isUpdating, setIsUpdating] = useState(false); const aggregateState = useMemo(() => { const updateState: Partial = {}; const state = props.selected; updateState.studio_id = getAggregateStudioId(props.selected); const updateTagIds = getAggregateTagIds(props.selected); const updatePerformerIds = getAggregatePerformerIds(props.selected); const updateGroupIds = getAggregateGroupIds(props.selected); let first = true; state.forEach((scene: GQL.SlimSceneDataFragment) => { getAggregateStateObject(updateState, scene, sceneFields, first); first = false; }); return { state: updateState, tagIds: updateTagIds, performerIds: updatePerformerIds, groupIds: updateGroupIds, }; }, [props.selected]); // update initial state from aggregate useEffect(() => { setUpdateInput((current) => ({ ...current, ...aggregateState.state })); }, [aggregateState]); useEffect(() => { setDateError(getDateError(updateInput.date ?? "", intl)); }, [updateInput.date, intl]); function setUpdateField(input: Partial) { setUpdateInput((current) => ({ ...current, ...input })); } function getSceneInput(): GQL.BulkSceneUpdateInput { const sceneInput: GQL.BulkSceneUpdateInput = { ...updateInput, tag_ids: tagIds, performer_ids: performerIds, group_ids: groupIds, }; // we don't have unset functionality for the rating star control // so need to determine if we are setting a rating or not sceneInput.rating100 = getAggregateInputValue( updateInput.rating100, aggregateState.state.rating100 ); return sceneInput; } async function onSave() { setIsUpdating(true); try { await updateScenes({ variables: { input: getSceneInput() } }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl.formatMessage({ id: "scenes" }).toLocaleLowerCase() } ) ); props.onClose(true); } catch (e) { Toast.error(e); } setIsUpdating(false); } function render() { return ( props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} isRunning={isUpdating} >
    setUpdateField({ rating100: value ?? undefined }) } disabled={isUpdating} /> setUpdateField({ code: newValue })} unsetDisabled={unsetDisabled} /> setUpdateField({ date: newValue })} unsetDisabled={unsetDisabled} error={dateError} /> setUpdateField({ director: newValue }) } unsetDisabled={unsetDisabled} /> setUpdateField({ studio_id: items.length > 0 ? items[0]?.id : undefined, }) } ids={updateInput.studio_id ? [updateInput.studio_id] : []} isDisabled={isUpdating} menuPortalTarget={document.body} /> { setPerformerIds((c) => ({ ...c, ids: itemIDs })); }} onSetMode={(newMode) => { setPerformerIds((c) => ({ ...c, mode: newMode })); }} ids={performerIds.ids ?? []} existingIds={aggregateState.performerIds} mode={performerIds.mode} menuPortalTarget={document.body} /> { setGroupIds((c) => ({ ...c, ids: itemIDs })); }} onSetMode={(newMode) => { setGroupIds((c) => ({ ...c, mode: newMode })); }} ids={groupIds.ids ?? []} existingIds={aggregateState.groupIds} mode={groupIds.mode} menuPortalTarget={document.body} /> { setTagIds((c) => ({ ...c, ids: itemIDs })); }} onSetMode={(newMode) => { setTagIds((c) => ({ ...c, mode: newMode })); }} ids={tagIds.ids ?? []} existingIds={aggregateState.tagIds} mode={tagIds.mode} menuPortalTarget={document.body} /> setUpdateField({ details: newValue })} unsetDisabled={unsetDisabled} as="textarea" /> setUpdateField({ organized: checked })} checked={updateInput.organized ?? undefined} />
    ); } return render(); }; ================================================ FILE: ui/v2.5/src/components/Scenes/PreviewScrubber.tsx ================================================ import React, { useRef, useMemo, useState, useLayoutEffect, useEffect, } from "react"; import { useSpriteInfo } from "src/hooks/sprite"; import { useThrottle } from "src/hooks/throttle"; import TextUtils from "src/utils/text"; import { HoverScrubber } from "../Shared/HoverScrubber"; interface IScenePreviewProps { vttPath: string | undefined; onClick?: (timestamp: number) => void; disabled?: boolean; } function scaleToFit(dimensions: { w: number; h: number }, bounds: DOMRect) { const rw = bounds.width / dimensions.w; const rh = bounds.height / dimensions.h; // for consistency, use max by default and min for portrait if (dimensions.w > dimensions.h) { return Math.max(rw, rh); } return Math.min(rw, rh); } const defaultSprites = 81; // 9x9 grid by default export const PreviewScrubber: React.FC = ({ vttPath, onClick, disabled, }) => { const imageParentRef = useRef(null); const [style, setStyle] = useState({}); const [activeIndex, setActiveIndex] = useState(); const debounceSetActiveIndex = useThrottle(setActiveIndex, 50); // hold off on loading vtt until first mouse over const [hasLoaded, setHasLoaded] = useState(false); const spriteInfo = useSpriteInfo(hasLoaded ? vttPath : undefined); const spriteSheetSize = useMemo(() => { if (!spriteInfo) { return { x: 0, y: 0 }; } // calculate total width/height of scrubber image so we can scale it const maxX = Math.max(...spriteInfo.map((sprite) => sprite.x + sprite.w)); const maxY = Math.max(...spriteInfo.map((sprite) => sprite.y + sprite.h)); return { x: maxX, y: maxY }; }, [spriteInfo]); const sprite = useMemo(() => { if (!spriteInfo || activeIndex === undefined) { return undefined; } return spriteInfo[activeIndex]; }, [activeIndex, spriteInfo]); // mark as loaded on the first hover useEffect(() => { if (activeIndex !== undefined) { setHasLoaded(true); } }, [activeIndex]); useLayoutEffect(() => { const imageParent = imageParentRef.current; if (!sprite || !imageParent) { return setStyle({}); } const clientRect = imageParent.getBoundingClientRect(); const scale = scaleToFit(sprite, clientRect); setStyle({ backgroundPosition: `${-sprite.x * scale}px ${-sprite.y * scale}px`, backgroundImage: `url(${sprite.url})`, backgroundSize: `${spriteSheetSize.x * scale}px ${ spriteSheetSize.y * scale }px`, width: `${sprite.w * scale}px`, height: `${sprite.h * scale}px`, }); }, [sprite, spriteSheetSize]); const currentTime = useMemo(() => { if (!sprite) return undefined; const start = TextUtils.secondsToTimestamp(sprite.start); return start; }, [sprite]); function onScrubberClick(index: number) { if (!onClick || !spriteInfo) { return; } const s = spriteInfo[index]; onClick(s.start); } if (spriteInfo === null || !vttPath) return null; return (
    {sprite && (
    {currentTime !== undefined && (
    {currentTime}
    )}
    )} debounceSetActiveIndex(i)} onClick={onScrubberClick} disabled={disabled} />
    ); }; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneCard.tsx ================================================ import React, { useEffect, useMemo, useRef } from "react"; import { Button, ButtonGroup, OverlayTrigger, Tooltip } from "react-bootstrap"; import { useHistory } from "react-router-dom"; import cx from "classnames"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; import { GalleryLink, TagLink, SceneMarkerLink } from "../Shared/TagLink"; import { HoverPopover } from "../Shared/HoverPopover"; import { TruncatedText } from "../Shared/TruncatedText"; import NavUtils from "src/utils/navigation"; import TextUtils from "src/utils/text"; import { SceneQueue } from "src/models/sceneQueue"; import { useConfigurationContext } from "src/hooks/Config"; import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton"; import { GridCard } from "../Shared/GridCard/GridCard"; import { RatingBanner } from "../Shared/RatingBanner"; import { FormattedMessage } from "react-intl"; import { faBox, faCopy, faFilm, faImages, faMapMarkerAlt, faTag, } from "@fortawesome/free-solid-svg-icons"; import { objectPath, objectTitle } from "src/core/files"; import { PreviewScrubber } from "./PreviewScrubber"; import { PatchComponent } from "src/patch"; import { StudioOverlay } from "../Shared/GridCard/StudioOverlay"; import { GroupTag } from "../Groups/GroupTag"; import { FileSize } from "../Shared/FileSize"; import { OCounterButton } from "../Shared/CountButton"; import { defaultPreviewVolume } from "src/core/config"; interface IScenePreviewProps { isPortrait: boolean; image?: string; video?: string; soundActive: boolean; volume?: number; vttPath?: string; onScrubberClick?: (timestamp: number) => void; disabled?: boolean; } export const ScenePreview: React.FC = ({ image, video, isPortrait, soundActive, vttPath, onScrubberClick, disabled, volume, }) => { const videoEl = useRef(null); useEffect(() => { const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.intersectionRatio > 0) // Catch is necessary due to DOMException if user hovers before clicking on page videoEl.current?.play()?.catch(() => {}); else videoEl.current?.pause(); }); }); if (videoEl.current) observer.observe(videoEl.current); }); useEffect(() => { if (videoEl?.current?.volume) videoEl.current.volume = soundActive ? (volume ?? 0) / 100 : 0; }, [volume, soundActive]); return (
    ); }; interface ISceneCardProps { scene: GQL.SlimSceneDataFragment; width?: number; previewHeight?: number; index?: number; queue?: SceneQueue; compact?: boolean; selecting?: boolean; selected?: boolean | undefined; zoomIndex?: number; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; fromGroupId?: string; } const Description: React.FC<{ sceneNumber?: number; }> = ({ sceneNumber }) => { if (!sceneNumber) return null; return ( <>
    {sceneNumber !== undefined && ( #{sceneNumber} )} ); }; const SceneCardPopovers = PatchComponent( "SceneCard.Popovers", (props: ISceneCardProps) => { const file = useMemo( () => (props.scene.files.length > 0 ? props.scene.files[0] : undefined), [props.scene] ); const sceneNumber = useMemo(() => { if (!props.fromGroupId) { return undefined; } const group = props.scene.groups.find( (g) => g.group.id === props.fromGroupId ); return group?.scene_index ?? undefined; }, [props.fromGroupId, props.scene.groups]); function maybeRenderTagPopoverButton() { if (props.scene.tags.length <= 0) return; const popoverContent = props.scene.tags.map((tag) => ( )); return ( ); } function maybeRenderPerformerPopoverButton() { if (props.scene.performers.length <= 0) return; return ( ); } function maybeRenderGroupPopoverButton() { if (props.scene.groups.length <= 0) return; const popoverContent = props.scene.groups.map((sceneGroup) => ( )); return ( ); } function maybeRenderSceneMarkerPopoverButton() { if (props.scene.scene_markers.length <= 0) return; const popoverContent = props.scene.scene_markers.map((marker) => { const markerWithScene = { ...marker, scene: { id: props.scene.id } }; return ; }); return ( ); } function maybeRenderOCounter() { if (props.scene.o_counter) { return ; } } function maybeRenderGallery() { if (props.scene.galleries.length <= 0) return; const popoverContent = props.scene.galleries.map((gallery) => ( )); return ( ); } function maybeRenderOrganized() { if (props.scene.organized) { return ( {"Organized"}} placement="bottom" >
    ); } } function maybeRenderDupeCopies() { const phash = file ? file.fingerprints.find((fp) => fp.type === "phash") : undefined; if (phash) { return (
    ); } } function maybeRenderPopoverButtonGroup() { if ( !props.compact && (props.scene.tags.length > 0 || props.scene.performers.length > 0 || props.scene.groups.length > 0 || props.scene.scene_markers.length > 0 || props.scene?.o_counter || props.scene.galleries.length > 0 || props.scene.organized || sceneNumber !== undefined) ) { return ( <>
    {maybeRenderTagPopoverButton()} {maybeRenderPerformerPopoverButton()} {maybeRenderGroupPopoverButton()} {maybeRenderSceneMarkerPopoverButton()} {maybeRenderOCounter()} {maybeRenderGallery()} {maybeRenderOrganized()} {maybeRenderDupeCopies()} ); } } return <>{maybeRenderPopoverButtonGroup()}; } ); const SceneCardDetails = PatchComponent( "SceneCard.Details", (props: ISceneCardProps) => { return (
    {props.scene.date} {objectPath(props.scene)}
    ); } ); const SceneCardOverlays = PatchComponent( "SceneCard.Overlays", (props: ISceneCardProps) => { const ret = useMemo(() => { return ( ); }, [props.scene.studio, props.selecting]); return ret; } ); interface ISceneSpecsOverlay { scene: GQL.SlimSceneDataFragment; } export const SceneSpecsOverlay: React.FC = PatchComponent( "SceneCard.SceneSpecs", ({ scene }) => { const file = scene.files?.[0]; if (!file) return null; return (
    {file.width && file.height ? ( {TextUtils.resolution(file.width, file.height)} ) : ( "" )} {file.duration > 0 ? ( {TextUtils.secondsToTimestamp(file.duration)} ) : ( "" )}
    ); } ); const SceneCardImage = PatchComponent( "SceneCard.Image", (props: ISceneCardProps) => { const history = useHistory(); const { configuration } = useConfigurationContext(); const cont = configuration?.interface.continuePlaylistDefault ?? false; const file = useMemo( () => (props.scene.files.length > 0 ? props.scene.files[0] : undefined), [props.scene] ); function maybeRenderInteractiveSpeedOverlay() { return (
    {props.scene.interactive_speed ?? ""}
    ); } function onScrubberClick(timestamp: number) { if (props.selecting) return; const link = props.queue ? props.queue.makeLink(props.scene.id, { sceneIndex: props.index, continue: cont, start: timestamp, }) : `/scenes/${props.scene.id}?t=${timestamp}`; history.push(link); } function isPortrait() { const width = file?.width ? file.width : 0; const height = file?.height ? file.height : 0; return height > width; } return ( <> {maybeRenderInteractiveSpeedOverlay()} ); } ); export const SceneCard = PatchComponent( "SceneCard", (props: ISceneCardProps) => { const { configuration } = useConfigurationContext(); const file = useMemo( () => (props.scene.files.length > 0 ? props.scene.files[0] : undefined), [props.scene] ); function zoomIndex() { if (!props.compact && props.zoomIndex !== undefined) { return `zoom-${props.zoomIndex}`; } return ""; } function filelessClass() { if (!props.scene.files.length) { return "fileless"; } return ""; } const cont = configuration?.interface.continuePlaylistDefault ?? false; const sceneLink = props.queue ? props.queue.makeLink(props.scene.id, { sceneIndex: props.index, continue: cont, }) : `/scenes/${props.scene.id}`; return ( } overlays={} details={} popovers={} selected={props.selected} selecting={props.selecting} onSelectedChanged={props.onSelectedChanged} /> ); } ); ================================================ FILE: ui/v2.5/src/components/Scenes/SceneCardGrid.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { SceneQueue } from "src/models/sceneQueue"; import { SceneCard } from "./SceneCard"; import { useCardWidth, useContainerDimensions, } from "../Shared/GridCard/GridCard"; import { PatchComponent } from "src/patch"; interface ISceneCardGrid { scenes: GQL.SlimSceneDataFragment[]; queue?: SceneQueue; selectedIds: Set; zoomIndex: number; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; fromGroupId?: string; } const zoomWidths = [280, 340, 480, 640]; export const SceneCardGrid: React.FC = PatchComponent( "SceneCardGrid", ({ scenes, queue, selectedIds, zoomIndex, onSelectChange, fromGroupId }) => { const [componentRef, { width: containerWidth }] = useContainerDimensions(); const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); return (
    {scenes.map((scene, index) => ( 0} selected={selectedIds.has(scene.id)} onSelectedChanged={(selected: boolean, shiftKey: boolean) => onSelectChange(scene.id, selected, shiftKey) } fromGroupId={fromGroupId} /> ))}
    ); } ); ================================================ FILE: ui/v2.5/src/components/Scenes/SceneDetails/ExternalPlayerButton.tsx ================================================ import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons"; import React from "react"; import { Button } from "react-bootstrap"; import { useIntl } from "react-intl"; import { Icon } from "src/components/Shared/Icon"; import { objectTitle } from "src/core/files"; import { SceneDataFragment } from "src/core/generated-graphql"; export interface IExternalPlayerButtonProps { scene: SceneDataFragment; } export const ExternalPlayerButton: React.FC = ({ scene, }) => { const isAndroid = /(android)/i.test(navigator.userAgent); const isAppleDevice = /(ipod|iphone|ipad)/i.test(navigator.userAgent); const intl = useIntl(); const { paths } = scene; if (!paths || !paths.stream || (!isAndroid && !isAppleDevice)) return ; const { stream } = paths; const title = objectTitle(scene); let url; const streamURL = new URL(stream); if (isAndroid) { const scheme = streamURL.protocol.slice(0, -1); streamURL.hash = `Intent;action=android.intent.action.VIEW;scheme=${scheme};type=video/mp4;S.title=${encodeURIComponent( title )};end`; // #4401 - not allowed to set the protocol from a "special" protocol to a non-special protocol url = streamURL .toString() .replace(new RegExp(`^${streamURL.protocol}`), "intent:"); } else if (isAppleDevice) { streamURL.host = "x-callback-url"; streamURL.port = ""; streamURL.pathname = "stream"; streamURL.search = `url=${encodeURIComponent(stream)}`; // #4401 - not allowed to set the protocol from a "special" protocol to a non-special protocol url = streamURL .toString() .replace(new RegExp(`^${streamURL.protocol}`), "vlc-x-callback:"); } return ( ); }; export default ExternalPlayerButton; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneDetails/OCounterButton.tsx ================================================ import { faBan, faMinus, faThumbsUp } from "@fortawesome/free-solid-svg-icons"; import React, { useState } from "react"; import { Button, ButtonGroup, Dropdown, DropdownButton } from "react-bootstrap"; import { useIntl } from "react-intl"; import { Icon } from "src/components/Shared/Icon"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { SweatDrops } from "src/components/Shared/SweatDrops"; import { useConfigurationContext } from "src/hooks/Config"; export interface IOCounterButtonProps { value: number; onIncrement: () => Promise; onDecrement: () => Promise; onReset: () => Promise; } export const OCounterButton: React.FC = ( props: IOCounterButtonProps ) => { const intl = useIntl(); const { configuration } = useConfigurationContext(); const { sfwContentMode } = configuration.interface; const icon = !sfwContentMode ? : ; const messageID = !sfwContentMode ? "o_count" : "o_count_sfw"; const [loading, setLoading] = useState(false); async function increment() { setLoading(true); await props.onIncrement(); setLoading(false); } async function decrement() { setLoading(true); await props.onDecrement(); setLoading(false); } async function reset() { setLoading(true); await props.onReset(); setLoading(false); } if (loading) return ; const renderButton = () => ( ); const maybeRenderDropdown = () => { if (props.value) { return ( Decrement Reset ); } }; return ( {renderButton()} {maybeRenderDropdown()} ); }; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneDetails/OrganizedButton.tsx ================================================ import React from "react"; import cx from "classnames"; import { Button, Spinner } from "react-bootstrap"; import { Icon } from "src/components/Shared/Icon"; import { defineMessages, useIntl } from "react-intl"; import { faBox } from "@fortawesome/free-solid-svg-icons"; export interface IOrganizedButtonProps { loading: boolean; organized: boolean; onClick: () => void; } export const OrganizedButton: React.FC = ( props: IOrganizedButtonProps ) => { const intl = useIntl(); const messages = defineMessages({ organized: { id: "organized", defaultMessage: "Organized", }, }); if (props.loading) return ; return ( ); }; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx ================================================ import React from "react"; import { FormattedMessage } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { Button, Badge, Card } from "react-bootstrap"; import TextUtils from "src/utils/text"; import { markerTitle } from "src/core/markers"; import { useConfigurationContext } from "src/hooks/Config"; interface IPrimaryTags { sceneMarkers: GQL.SceneMarkerDataFragment[]; onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void; onLoopMarker: (marker: GQL.SceneMarkerDataFragment) => void; onEdit: (marker: GQL.SceneMarkerDataFragment) => void; } export const PrimaryTags: React.FC = ({ sceneMarkers, onClickMarker, onLoopMarker, onEdit, }) => { const { configuration } = useConfigurationContext(); const showAbLoopControls = configuration?.ui?.showAbLoopControls; if (!sceneMarkers?.length) return
    ; const primaryTagNames: Record = {}; const markersByTag: Record = {}; sceneMarkers.forEach((m) => { if (primaryTagNames[m.primary_tag.id]) { markersByTag[m.primary_tag.id].push(m); } else { primaryTagNames[m.primary_tag.id] = m.primary_tag.name; markersByTag[m.primary_tag.id] = [m]; } }); const primaryCards = Object.keys(markersByTag).map((id) => { const markers = markersByTag[id].map((marker) => { const tags = marker.tags.map((tag) => ( {tag.name} )); return (

    {TextUtils.formatTimestampRange( marker.seconds, marker.end_seconds ?? undefined )}
    {showAbLoopControls && marker.end_seconds != null && ( )}
    {tags}
    ); }); return (

    {primaryTagNames[id]}

    {markers}
    ); }); return
    {primaryCards}
    ; }; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneDetails/QueueViewer.tsx ================================================ import React, { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import cx from "classnames"; import { Button, Form, Spinner } from "react-bootstrap"; import { Icon } from "src/components/Shared/Icon"; import { useIntl } from "react-intl"; import { faChevronDown, faChevronUp, faRandom, faStepBackward, faStepForward, } from "@fortawesome/free-solid-svg-icons"; import { objectTitle } from "src/core/files"; import { QueuedScene } from "src/models/sceneQueue"; export interface IPlaylistViewer { scenes: QueuedScene[]; currentID?: string; start?: number; continue?: boolean; hasMoreScenes: boolean; setContinue: (v: boolean) => void; onSceneClicked: (id: string) => void; onNext: () => void; onPrevious: () => void; onRandom: () => void; onMoreScenes: () => void; onLessScenes: () => void; } export const QueueViewer: React.FC = ({ scenes, currentID, start = 0, continue: continuePlaylist = false, hasMoreScenes, setContinue, onNext, onPrevious, onRandom, onSceneClicked, onMoreScenes, onLessScenes, }) => { const intl = useIntl(); const [lessLoading, setLessLoading] = useState(false); const [moreLoading, setMoreLoading] = useState(false); const currentIndex = scenes.findIndex((s) => s.id === currentID); useEffect(() => { setLessLoading(false); setMoreLoading(false); }, [scenes]); function isCurrentScene(scene: QueuedScene) { return scene.id === currentID; } function handleSceneClick( event: React.MouseEvent, id: string ) { onSceneClicked(id); event.preventDefault(); } function lessClicked() { setLessLoading(true); onLessScenes(); } function moreClicked() { setMoreLoading(true); onMoreScenes(); } function renderPlaylistEntry(scene: QueuedScene) { return (
  • handleSceneClick(e, scene.id)} >
    {scene.title
    {objectTitle(scene)} {scene?.studio?.name} {scene?.performers ?.map(function (performer) { return performer.name; }) .join(", ")} {scene?.date}
  • ); } return (
    { setContinue(!continuePlaylist); }} />
    {currentIndex > 0 || start > 1 ? ( ) : ( "" )} {currentIndex < scenes.length - 1 || hasMoreScenes ? ( ) : ( "" )}
    {start > 1 ? (
    ) : undefined}
      {scenes.map(renderPlaylistEntry)}
    {hasMoreScenes ? (
    ) : undefined}
    ); }; export default QueueViewer; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx ================================================ import { Tab, Nav, Dropdown, Button } from "react-bootstrap"; import React, { useEffect, useState, useMemo, useRef, useLayoutEffect, } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { useHistory, Link, RouteComponentProps } from "react-router-dom"; import { Helmet } from "react-helmet"; import * as GQL from "src/core/generated-graphql"; import { mutateMetadataScan, useFindScene, useSceneIncrementO, useSceneGenerateScreenshot, useSceneUpdate, queryFindScenes, queryFindScenesByID, useSceneIncrementPlayCount, } from "src/core/StashService"; import { SceneEditPanel } from "./SceneEditPanel"; import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { Icon } from "src/components/Shared/Icon"; import { Counter } from "src/components/Shared/Counter"; import { useToast } from "src/hooks/Toast"; import SceneQueue, { QueuedScene } from "src/models/sceneQueue"; import { ListFilterModel } from "src/models/list-filter/filter"; import Mousetrap from "mousetrap"; import { OrganizedButton } from "./OrganizedButton"; import { useConfigurationContext } from "src/hooks/Config"; import { getAbLoopPlugin, getPlayerPosition, } from "src/components/ScenePlayer/util"; import { faEllipsisV, faChevronRight, faChevronLeft, } from "@fortawesome/free-solid-svg-icons"; import { objectPath, objectTitle } from "src/core/files"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import TextUtils from "src/utils/text"; import { OCounterButton, ViewCountButton, } from "src/components/Shared/CountButton"; import { useRatingKeybinds } from "src/hooks/keybinds"; import { lazyComponent } from "src/utils/lazyComponent"; import cx from "classnames"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { PatchComponent, PatchContainerComponent } from "src/patch"; import { SceneMergeModal } from "../SceneMergeDialog"; import { goBackOrReplace } from "src/utils/history"; import { FormattedDate } from "src/components/Shared/Date"; const SubmitStashBoxDraft = lazyComponent( () => import("src/components/Dialogs/SubmitDraft") ); const ScenePlayer = lazyComponent( () => import("src/components/ScenePlayer/ScenePlayer") ); const GalleryViewer = lazyComponent( () => import("src/components/Galleries/GalleryViewer") ); const ExternalPlayerButton = lazyComponent( () => import("./ExternalPlayerButton") ); const QueueViewer = lazyComponent(() => import("./QueueViewer")); const SceneMarkersPanel = lazyComponent(() => import("./SceneMarkersPanel")); const SceneFileInfoPanel = lazyComponent(() => import("./SceneFileInfoPanel")); const SceneDetailPanel = lazyComponent(() => import("./SceneDetailPanel")); const SceneHistoryPanel = lazyComponent(() => import("./SceneHistoryPanel")); const SceneGroupPanel = lazyComponent(() => import("./SceneGroupPanel")); const SceneGalleriesPanel = lazyComponent( () => import("./SceneGalleriesPanel") ); const DeleteScenesDialog = lazyComponent(() => import("../DeleteScenesDialog")); const GenerateDialog = lazyComponent( () => import("../../Dialogs/GenerateDialog") ); const SceneVideoFilterPanel = lazyComponent( () => import("./SceneVideoFilterPanel") ); const VideoFrameRateResolution: React.FC<{ width?: number; height?: number; frameRate?: number; }> = ({ width, height, frameRate }) => { const intl = useIntl(); const resolution = useMemo(() => { if (width && height) { const r = TextUtils.resolution(width, height); return ( {r} ); } return undefined; }, [width, height]); const frameRateDisplay = useMemo(() => { if (frameRate) { return ( ); } return undefined; }, [intl, frameRate]); const divider = useMemo(() => { return resolution && frameRateDisplay ? ( | ) : undefined; }, [resolution, frameRateDisplay]); return ( {frameRateDisplay} {divider} {resolution} ); }; interface IProps { scene: GQL.SceneDataFragment; setTimestamp: (num: number) => void; queueScenes: QueuedScene[]; onQueueNext: () => void; onQueuePrevious: () => void; onQueueRandom: () => void; onQueueSceneClicked: (sceneID: string) => void; onDelete: () => void; continuePlaylist: boolean; queueHasMoreScenes: boolean; onQueueMoreScenes: () => void; onQueueLessScenes: () => void; queueStart: number; collapsed: boolean; setCollapsed: (state: boolean) => void; setContinuePlaylist: (value: boolean) => void; } interface ISceneParams { id: string; } const ScenePageTabs = PatchContainerComponent("ScenePage.Tabs"); const ScenePageTabContent = PatchContainerComponent( "ScenePage.TabContent" ); const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { const { scene, setTimestamp, queueScenes, onQueueNext, onQueuePrevious, onQueueRandom, onQueueSceneClicked, onDelete, continuePlaylist, queueHasMoreScenes, onQueueMoreScenes, onQueueLessScenes, queueStart, collapsed, setCollapsed, setContinuePlaylist, } = props; const Toast = useToast(); const intl = useIntl(); const history = useHistory(); const [updateScene] = useSceneUpdate(); const [generateScreenshot] = useSceneGenerateScreenshot(); const { configuration } = useConfigurationContext(); const [showDraftModal, setShowDraftModal] = useState(false); const boxes = configuration?.general?.stashBoxes ?? []; const [incrementO] = useSceneIncrementO(scene.id); const [incrementPlay] = useSceneIncrementPlayCount(); function incrementPlayCount() { incrementPlay({ variables: { id: scene.id, }, }); } const [organizedLoading, setOrganizedLoading] = useState(false); const [activeTabKey, setActiveTabKey] = useState("scene-details-panel"); const [isMerging, setIsMerging] = useState(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false); const onIncrementOClick = async () => { try { await incrementO(); } catch (e) { Toast.error(e); } }; function setRating(v: number | null) { updateScene({ variables: { input: { id: scene.id, rating100: v, }, }, }); } useRatingKeybinds( true, configuration?.ui.ratingSystemOptions?.type, setRating ); // set up hotkeys useEffect(() => { Mousetrap.bind("a", () => setActiveTabKey("scene-details-panel")); Mousetrap.bind("q", () => setActiveTabKey("scene-queue-panel")); Mousetrap.bind("e", () => setActiveTabKey("scene-edit-panel")); Mousetrap.bind("k", () => setActiveTabKey("scene-markers-panel")); Mousetrap.bind("i", () => setActiveTabKey("scene-file-info-panel")); Mousetrap.bind("h", () => setActiveTabKey("scene-history-panel")); Mousetrap.bind("o", () => { onIncrementOClick(); }); Mousetrap.bind("p n", () => onQueueNext()); Mousetrap.bind("p p", () => onQueuePrevious()); Mousetrap.bind("p r", () => onQueueRandom()); Mousetrap.bind(",", () => setCollapsed(!collapsed)); Mousetrap.bind("c c", () => { onGenerateScreenshot(getPlayerPosition()); }); Mousetrap.bind("c d", () => { onGenerateScreenshot(); }); return () => { Mousetrap.unbind("a"); Mousetrap.unbind("q"); Mousetrap.unbind("e"); Mousetrap.unbind("k"); Mousetrap.unbind("i"); Mousetrap.unbind("h"); Mousetrap.unbind("o"); Mousetrap.unbind("p n"); Mousetrap.unbind("p p"); Mousetrap.unbind("p r"); Mousetrap.unbind(","); Mousetrap.unbind("c c"); Mousetrap.unbind("c d"); }; }); async function onSave(input: GQL.SceneCreateInput) { await updateScene({ variables: { input: { id: scene.id, ...input, }, }, }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase() } ) ); } const onOrganizedClick = async () => { try { setOrganizedLoading(true); await updateScene({ variables: { input: { id: scene.id, organized: !scene.organized, }, }, }); } catch (e) { Toast.error(e); } finally { setOrganizedLoading(false); } }; function onClickMarker(marker: GQL.SceneMarkerDataFragment) { const abLoopPlugin = getAbLoopPlugin(); const opts = abLoopPlugin?.getOptions(); const start = opts?.start; const end = opts?.end; const hasLoopRange = opts?.enabled && typeof start === "number" && typeof end === "number" && Number.isFinite(start) && Number.isFinite(end); if ( abLoopPlugin && opts && hasLoopRange && (marker.seconds < Math.min(start as number, end as number) || marker.seconds > Math.max(start as number, end as number)) ) { abLoopPlugin.setOptions({ ...opts, enabled: false, }); } setTimestamp(marker.seconds); } function onLoopMarker(marker: GQL.SceneMarkerDataFragment) { if (marker.end_seconds == null) return; setTimestamp(marker.seconds); const start = Math.min(marker.seconds, marker.end_seconds); const end = Math.max(marker.seconds, marker.end_seconds); const abLoopPlugin = getAbLoopPlugin(); const opts = abLoopPlugin?.getOptions(); if (opts && abLoopPlugin) { abLoopPlugin.setOptions({ ...opts, start, end, enabled: true, }); } } async function onRescan() { await mutateMetadataScan({ paths: [objectPath(scene)], rescan: true, }); Toast.success( intl.formatMessage( { id: "toast.rescanning_entity" }, { count: 1, singularEntity: intl .formatMessage({ id: "scene" }) .toLocaleLowerCase(), } ) ); } async function onGenerateScreenshot(at?: number) { await generateScreenshot({ variables: { id: scene.id, at, }, }); Toast.success(intl.formatMessage({ id: "toast.generating_screenshot" })); } function onDeleteDialogClosed(deleted: boolean) { setIsDeleteAlertOpen(false); if (deleted) { onDelete(); } } function maybeRenderMergeDialog() { if (!scene.id) return; return ( { setIsMerging(false); if (mergedId !== undefined && mergedId !== scene.id) { // By default, the merge destination is the current scene, but // the user can change it, in which case we need to redirect. history.replace(`/scenes/${mergedId}`); } }} scenes={[{ id: scene.id, title: objectTitle(scene) }]} /> ); } function maybeRenderDeleteDialog() { if (isDeleteAlertOpen) { return ( ); } } function maybeRenderSceneGenerateDialog() { if (isGenerateDialogOpen) { return ( { setIsGenerateDialogOpen(false); }} type="scene" /> ); } } const renderOperations = () => ( {!!scene.files.length && ( onRescan()} > )} setIsGenerateDialogOpen(true)} > onGenerateScreenshot(getPlayerPosition())} > onGenerateScreenshot()} > {boxes.length > 0 && ( setShowDraftModal(true)} > )} setIsMerging(true)} > ... setIsDeleteAlertOpen(true)} > ); const renderTabs = () => ( k && setActiveTabKey(k)} >
    {scene.galleries.length >= 1 && ( {scene.galleries.length === 1 && ( )} )} setIsDeleteAlertOpen(true)} />
    ); function getCollapseButtonIcon() { return collapsed ? faChevronRight : faChevronLeft; } const title = objectTitle(scene); const file = useMemo( () => (scene.files.length > 0 ? scene.files[0] : undefined), [scene] ); return ( <> {title} {maybeRenderSceneGenerateDialog()} {maybeRenderMergeDialog()} {maybeRenderDeleteDialog()}
    {scene.studio && (

    {`${scene.studio.name}

    )}

    {!!scene.date && }
    incrementPlayCount()} /> onIncrementOClick()} /> {renderOperations()}
    {renderTabs()}
    setShowDraftModal(false)} /> ); }); const SceneLoader: React.FC> = ({ location, history, match, }) => { const { id } = match.params; const { configuration } = useConfigurationContext(); const { data, loading, error } = useFindScene(id); const [scene, setScene] = useState(); // useLayoutEffect to update before paint useLayoutEffect(() => { // only update scene when loading is done if (!loading) { setScene(data?.findScene ?? undefined); } }, [data, loading]); const queryParams = useMemo( () => new URLSearchParams(location.search), [location.search] ); const sceneQueue = useMemo( () => SceneQueue.fromQueryParameters(queryParams), [queryParams] ); const queryContinue = useMemo(() => { let cont = queryParams.get("continue"); if (cont) { return cont === "true"; } else { return !!configuration?.interface.continuePlaylistDefault; } }, [configuration?.interface.continuePlaylistDefault, queryParams]); const [queueScenes, setQueueScenes] = useState([]); const [collapsed, setCollapsed] = useState(false); const [continuePlaylist, setContinuePlaylist] = useState(queryContinue); const [hideScrubber, setHideScrubber] = useState( !(configuration?.interface.showScrubber ?? true) ); const _setTimestamp = useRef<(value: number) => void>(); const initialTimestamp = useMemo(() => { const t = queryParams.get("t"); if (!t) return 0; const n = Number(t); if (Number.isNaN(n)) return 0; return n; }, [queryParams]); const [queueTotal, setQueueTotal] = useState(0); const [queueStart, setQueueStart] = useState(1); const autoplay = queryParams.get("autoplay") === "true"; const autoPlayOnSelected = configuration?.interface.autostartVideoOnPlaySelected ?? false; const currentQueueIndex = useMemo( () => queueScenes.findIndex((s) => s.id === id), [queueScenes, id] ); function getSetTimestamp(fn: (value: number) => void) { _setTimestamp.current = fn; } function setTimestamp(value: number) { if (_setTimestamp.current) { _setTimestamp.current(value); } } // set up hotkeys useEffect(() => { Mousetrap.bind(".", () => setHideScrubber((value) => !value)); return () => { Mousetrap.unbind("."); }; }, []); async function getQueueFilterScenes(filter: ListFilterModel) { const query = await queryFindScenes(filter); const { scenes, count } = query.data.findScenes; setQueueScenes(scenes); setQueueTotal(count); setQueueStart((filter.currentPage - 1) * filter.itemsPerPage + 1); } async function getQueueScenes(sceneIDs: number[]) { const query = await queryFindScenesByID(sceneIDs); const { scenes, count } = query.data.findScenes; setQueueScenes(scenes); setQueueTotal(count); setQueueStart(1); } useEffect(() => { if (sceneQueue.query) { getQueueFilterScenes(sceneQueue.query); } else if (sceneQueue.sceneIDs) { getQueueScenes(sceneQueue.sceneIDs); } }, [sceneQueue]); async function onQueueLessScenes() { if (!sceneQueue.query || queueStart <= 1) { return; } const filterCopy = sceneQueue.query.clone(); const newStart = queueStart - filterCopy.itemsPerPage; filterCopy.currentPage = Math.ceil(newStart / filterCopy.itemsPerPage); const query = await queryFindScenes(filterCopy); const { scenes } = query.data.findScenes; // prepend scenes to scene list const newScenes = (scenes as QueuedScene[]).concat(queueScenes); setQueueScenes(newScenes); setQueueStart(newStart); return scenes; } const queueHasMoreScenes = useMemo(() => { return queueStart + queueScenes.length - 1 < queueTotal; }, [queueStart, queueScenes, queueTotal]); async function onQueueMoreScenes() { if (!sceneQueue.query || !queueHasMoreScenes) { return; } const filterCopy = sceneQueue.query.clone(); const newStart = queueStart + queueScenes.length; filterCopy.currentPage = Math.ceil(newStart / filterCopy.itemsPerPage); const query = await queryFindScenes(filterCopy); const { scenes } = query.data.findScenes; // append scenes to scene list const newScenes = queueScenes.concat(scenes); setQueueScenes(newScenes); // don't change queue start return scenes; } function loadScene(sceneID: string, autoPlay?: boolean, newPage?: number) { const sceneLink = sceneQueue.makeLink(sceneID, { newPage, autoPlay, continue: continuePlaylist, }); history.replace(sceneLink); } async function queueNext(autoPlay: boolean) { if (currentQueueIndex === -1) return; if (currentQueueIndex < queueScenes.length - 1) { loadScene(queueScenes[currentQueueIndex + 1].id, autoPlay); } else { // if we're at the end of the queue, load more scenes if (currentQueueIndex === queueScenes.length - 1 && queueHasMoreScenes) { const loadedScenes = await onQueueMoreScenes(); if (loadedScenes && loadedScenes.length > 0) { // set the page to the next page const newPage = (sceneQueue.query?.currentPage ?? 0) + 1; loadScene(loadedScenes[0].id, autoPlay, newPage); } } } } async function queuePrevious(autoPlay: boolean) { if (currentQueueIndex === -1) return; if (currentQueueIndex > 0) { loadScene(queueScenes[currentQueueIndex - 1].id, autoPlay); } else { // if we're at the beginning of the queue, load the previous page if (queueStart > 1) { const loadedScenes = await onQueueLessScenes(); if (loadedScenes && loadedScenes.length > 0) { const newPage = (sceneQueue.query?.currentPage ?? 0) - 1; loadScene( loadedScenes[loadedScenes.length - 1].id, autoPlay, newPage ); } } } } async function queueRandom(autoPlay: boolean) { if (sceneQueue.query) { const { query } = sceneQueue; const pages = Math.ceil(queueTotal / query.itemsPerPage); const page = Math.floor(Math.random() * pages) + 1; const index = Math.floor( Math.random() * Math.min(query.itemsPerPage, queueTotal) ); const filterCopy = sceneQueue.query.clone(); filterCopy.currentPage = page; const queryResults = await queryFindScenes(filterCopy); if (queryResults.data.findScenes.scenes.length > index) { const { id: sceneID } = queryResults.data.findScenes.scenes[index]; // navigate to the image player page loadScene(sceneID, autoPlay, page); } } else if (queueTotal !== 0) { const index = Math.floor(Math.random() * queueTotal); loadScene(queueScenes[index].id, autoPlay); } } function onComplete() { // load the next scene if we're continuing if (continuePlaylist) { queueNext(true); } } function onDelete() { if ( continuePlaylist && currentQueueIndex >= 0 && currentQueueIndex < queueScenes.length - 1 ) { loadScene(queueScenes[currentQueueIndex + 1].id); } else { goBackOrReplace(history, "/scenes"); } } function getScenePage(sceneID: string) { if (!sceneQueue.query) return; // find the page that the scene is on const index = queueScenes.findIndex((s) => s.id === sceneID); if (index === -1) return; const perPage = sceneQueue.query.itemsPerPage; return Math.floor((index + queueStart - 1) / perPage) + 1; } function onQueueSceneClicked(sceneID: string) { loadScene(sceneID, autoPlayOnSelected, getScenePage(sceneID)); } if (!scene) { if (loading) return ; if (error) return ; return ; } return (
    queueNext(autoPlayOnSelected)} onQueuePrevious={() => queuePrevious(autoPlayOnSelected)} onQueueRandom={() => queueRandom(autoPlayOnSelected)} onQueueSceneClicked={onQueueSceneClicked} continuePlaylist={continuePlaylist} queueHasMoreScenes={queueHasMoreScenes} onQueueLessScenes={onQueueLessScenes} onQueueMoreScenes={onQueueMoreScenes} collapsed={collapsed} setCollapsed={setCollapsed} setContinuePlaylist={setContinuePlaylist} />
    queueNext(true)} onPrevious={() => queuePrevious(true)} />
    ); }; export default SceneLoader; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneDetails/SceneCreate.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { useHistory, useLocation } from "react-router-dom"; import { SceneEditPanel } from "./SceneEditPanel"; import * as GQL from "src/core/generated-graphql"; import { mutateCreateScene, useFindScene } from "src/core/StashService"; import ImageUtils from "src/utils/image"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useToast } from "src/hooks/Toast"; const SceneCreate: React.FC = () => { const history = useHistory(); const intl = useIntl(); const Toast = useToast(); const location = useLocation(); const query = useMemo(() => new URLSearchParams(location.search), [location]); // create scene from provided scene id if applicable const { data, loading } = useFindScene(query.get("from_scene_id") ?? "new"); const [loadingCoverImage, setLoadingCoverImage] = useState(false); const [coverImage, setCoverImage] = useState(); const scene = useMemo(() => { if (data?.findScene) { return { ...data.findScene, paths: undefined, id: undefined, }; } return { title: query.get("q") ?? undefined, }; }, [data?.findScene, query]); useEffect(() => { async function fetchCoverImage() { const srcScene = data?.findScene; if (srcScene?.paths.screenshot) { setLoadingCoverImage(true); const imageData = await ImageUtils.imageToDataURL( srcScene.paths.screenshot ); setCoverImage(imageData); setLoadingCoverImage(false); } else { setCoverImage(undefined); } } fetchCoverImage(); }, [data?.findScene]); if (loading || loadingCoverImage) { return ; } async function onSave(input: GQL.SceneCreateInput, andNew?: boolean) { const fileID = query.get("file_id") ?? undefined; const result = await mutateCreateScene({ ...input, file_ids: fileID ? [fileID] : undefined, }); if (result.data?.sceneCreate?.id) { if (!andNew) { history.push(`/scenes/${result.data.sceneCreate.id}`); } Toast.success( intl.formatMessage( { id: "toast.created_entity" }, { entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase() } ) ); } } return (

    ); }; export default SceneCreate; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx ================================================ import React from "react"; import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; import { TagLink } from "src/components/Shared/TagLink"; import { PerformerCard } from "src/components/Performers/PerformerCard"; import { sortPerformers } from "src/core/performers"; import { DirectorLink } from "src/components/Shared/Link"; import { CustomFields } from "src/components/Shared/CustomFields"; interface ISceneDetailProps { scene: GQL.SceneDataFragment; } export const SceneDetailPanel: React.FC = (props) => { const intl = useIntl(); function renderDetails() { if (!props.scene.details || props.scene.details === "") return; return ( <>
    :{" "}

    {props.scene.details}

    ); } function renderTags() { if (props.scene.tags.length === 0) return; const tags = props.scene.tags.map((tag) => ( )); return ( <>
    {tags} ); } function renderPerformers() { if (props.scene.performers.length === 0) return; const performers = sortPerformers(props.scene.performers); const cards = performers.map((performer) => ( )); return ( <>
    {cards}
    ); } // filename should use entire row if there is no studio const sceneDetailsWidth = props.scene.studio ? "col-9" : "col-12"; return ( <>
    :{" "} {TextUtils.formatDateTime(intl, props.scene.created_at)}{" "}
    :{" "} {TextUtils.formatDateTime(intl, props.scene.updated_at)}{" "}
    {props.scene.code && (
    : {props.scene.code}{" "}
    )} {props.scene.director && (
    :{" "}
    )}
    {renderDetails()} {renderTags()} {renderPerformers()}
    ); }; export default SceneDetailPanel; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx ================================================ import React, { useEffect, useState, useMemo } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { Button, Dropdown, Form, Col, Row, ButtonGroup, SplitButton, } from "react-bootstrap"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; import { queryScrapeScene, queryScrapeSceneURL, useListSceneScrapers, mutateReloadScrapers, queryScrapeSceneQueryFragment, } from "src/core/StashService"; import { Icon } from "src/components/Shared/Icon"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { ImageInput } from "src/components/Shared/ImageInput"; import { useToast } from "src/hooks/Toast"; import ImageUtils from "src/utils/image"; import { addUpdateStashID, getStashIDs } from "src/utils/stashIds"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; import { useConfigurationContext } from "src/hooks/Config"; import { IGroupEntry, SceneGroupTable } from "./SceneGroupTable"; import { faSearch, faPlus } from "@fortawesome/free-solid-svg-icons"; import { objectTitle } from "src/core/files"; import { galleryTitle } from "src/core/galleries"; import { lazyComponent } from "src/utils/lazyComponent"; import isEqual from "lodash-es/isEqual"; import { yupDateString, yupFormikValidate, yupUniqueStringList, } from "src/utils/yup"; import { Performer, PerformerSelect, } from "src/components/Performers/PerformerSelect"; import { formikUtils } from "src/utils/form"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { Gallery, GallerySelect } from "src/components/Galleries/GallerySelect"; import { Group } from "src/components/Groups/GroupSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; import { CustomFieldsInput, formatCustomFieldInput, } from "src/components/Shared/CustomFields"; import { cloneDeep } from "@apollo/client/utilities"; const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); interface IProps { scene: Partial; initialCoverImage?: string; isNew?: boolean; isVisible: boolean; onSubmit: (input: GQL.SceneCreateInput, andNew?: boolean) => Promise; onDelete?: () => void; } export const SceneEditPanel: React.FC = ({ scene, initialCoverImage, isNew = false, isVisible, onSubmit, onDelete, }) => { const intl = useIntl(); const Toast = useToast(); const [galleries, setGalleries] = useState([]); const [performers, setPerformers] = useState([]); const [groups, setGroups] = useState([]); const [studio, setStudio] = useState(null); const Scrapers = useListSceneScrapers(); const [fragmentScrapers, setFragmentScrapers] = useState([]); const [queryableScrapers, setQueryableScrapers] = useState([]); const [scraper, setScraper] = useState(); const [isScraperQueryModalOpen, setIsScraperQueryModalOpen] = useState(false); const [isStashIDSearchOpen, setIsStashIDSearchOpen] = useState(false); const [scrapedScene, setScrapedScene] = useState(); const [endpoint, setEndpoint] = useState(); useEffect(() => { setGalleries( scene.galleries?.map((g) => ({ id: g.id, title: galleryTitle(g), files: g.files, folder: g.folder, })) ?? [] ); }, [scene.galleries]); useEffect(() => { setPerformers(scene.performers ?? []); }, [scene.performers]); useEffect(() => { setGroups(scene.groups?.map((m) => m.group) ?? []); }, [scene.groups]); useEffect(() => { setStudio(scene.studio ?? null); }, [scene.studio]); const { configuration: stashConfig } = useConfigurationContext(); // Network state const [isLoading, setIsLoading] = useState(false); const schema = yup.object({ title: yup.string().ensure(), code: yup.string().ensure(), urls: yupUniqueStringList(intl), date: yupDateString(intl), director: yup.string().ensure(), gallery_ids: yup.array(yup.string().required()).defined(), studio_id: yup.string().required().nullable(), performer_ids: yup.array(yup.string().required()).defined(), groups: yup .array( yup.object({ group_id: yup.string().required(), scene_index: yup.number().integer().nullable().defined(), }) ) .defined(), tag_ids: yup.array(yup.string().required()).defined(), stash_ids: yup.mixed().defined(), details: yup.string().ensure(), cover_image: yup.string().nullable().optional(), custom_fields: yup.object().required().defined(), }); const initialValues = useMemo( () => ({ title: scene.title ?? "", code: scene.code ?? "", urls: scene.urls ?? [], date: scene.date ?? "", director: scene.director ?? "", gallery_ids: (scene.galleries ?? []).map((g) => g.id), studio_id: scene.studio?.id ?? null, performer_ids: (scene.performers ?? []).map((p) => p.id), groups: (scene.groups ?? []).map((m) => { return { group_id: m.group.id, scene_index: m.scene_index ?? null }; }), tag_ids: (scene.tags ?? []).map((t) => t.id), stash_ids: getStashIDs(scene.stash_ids), details: scene.details ?? "", cover_image: initialCoverImage, custom_fields: cloneDeep(scene.custom_fields ?? {}), }), [scene, initialCoverImage] ); type InputValues = yup.InferType; const [customFieldsError, setCustomFieldsError] = useState(); function submit(values: InputValues) { const input = { ...schema.cast(values), custom_fields: formatCustomFieldInput(isNew, values.custom_fields), }; onSave(input); } const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), onSubmit: submit, }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( scene.tags, (ids) => formik.setFieldValue("tag_ids", ids) ); const coverImagePreview = useMemo(() => { const sceneImage = scene.paths?.screenshot; const formImage = formik.values.cover_image; if (formImage === null && sceneImage) { const sceneImageURL = new URL(sceneImage); sceneImageURL.searchParams.set("default", "true"); return sceneImageURL.toString(); } else if (formImage) { return formImage; } return sceneImage; }, [formik.values.cover_image, scene.paths?.screenshot]); const groupEntries = useMemo(() => { return formik.values.groups .map((m) => { return { group: groups.find((mm) => mm.id === m.group_id), scene_index: m.scene_index, }; }) .filter((m) => m.group !== undefined) as IGroupEntry[]; }, [formik.values.groups, groups]); function onSetGalleries(items: Gallery[]) { setGalleries(items); formik.setFieldValue( "gallery_ids", items.map((i) => i.id) ); } function onSetPerformers(items: Performer[]) { setPerformers(items); formik.setFieldValue( "performer_ids", items.map((item) => item.id) ); } function onSetStudio(item: Studio | null) { setStudio(item); formik.setFieldValue("studio_id", item ? item.id : null); } useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { if (formik.dirty) { formik.submitForm(); } }); Mousetrap.bind("d d", () => { if (onDelete) { onDelete(); } }); return () => { Mousetrap.unbind("s s"); Mousetrap.unbind("d d"); }; } }); useEffect(() => { const toFilter = Scrapers?.data?.listScrapers ?? []; const newFragmentScrapers = toFilter.filter((s) => s.scene?.supported_scrapes.includes(GQL.ScrapeType.Fragment) ); const newQueryableScrapers = toFilter.filter((s) => s.scene?.supported_scrapes.includes(GQL.ScrapeType.Name) ); setFragmentScrapers(newFragmentScrapers); setQueryableScrapers(newQueryableScrapers); }, [Scrapers, stashConfig]); function onSetGroups(items: Group[]) { setGroups(items); const existingGroups = formik.values.groups; const newGroups = items.map((m) => { const existing = existingGroups.find((mm) => mm.group_id === m.id); if (existing) { return existing; } return { group_id: m.id, scene_index: null, }; }); formik.setFieldValue("groups", newGroups); } async function onSave(input: InputValues, andNew?: boolean) { setIsLoading(true); try { await onSubmit(input, andNew); formik.resetForm(); } catch (e) { Toast.error(e); } setIsLoading(false); } async function onSaveAndNewClick() { const input = { ...schema.cast(formik.values), custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), }; onSave(input, true); } const encodingImage = ImageUtils.usePasteImage(onImageLoad); function onImageLoad(imageData: string) { formik.setFieldValue("cover_image", imageData); } function onCoverImageChange(event: React.FormEvent) { ImageUtils.onImageChange(event, onImageLoad); } function onResetCover() { formik.setFieldValue("cover_image", null); } async function onScrapeClicked(s: GQL.ScraperSourceInput) { setIsLoading(true); try { const result = await queryScrapeScene(s, scene.id!); if (!result.data || !result.data.scrapeSingleScene?.length) { Toast.success("No scenes found"); return; } // assume one returned scene setScrapedScene(result.data.scrapeSingleScene[0]); setEndpoint(s.stash_box_endpoint ?? undefined); } catch (e) { Toast.error(e); } finally { setIsLoading(false); } } async function scrapeFromQuery( s: GQL.ScraperSourceInput, fragment: GQL.ScrapedSceneDataFragment ) { setIsLoading(true); try { const input: GQL.ScrapedSceneInput = { date: fragment.date, code: fragment.code, details: fragment.details, director: fragment.director, remote_site_id: fragment.remote_site_id, title: fragment.title, urls: fragment.urls, }; const result = await queryScrapeSceneQueryFragment(s, input); if (!result.data || !result.data.scrapeSingleScene?.length) { Toast.success("No scenes found"); return; } // assume one returned scene setScrapedScene(result.data.scrapeSingleScene[0]); } catch (e) { Toast.error(e); } finally { setIsLoading(false); } } function onScrapeQueryClicked(s: GQL.ScraperSourceInput) { setScraper(s); setEndpoint(s.stash_box_endpoint ?? undefined); setIsScraperQueryModalOpen(true); } async function onReloadScrapers() { setIsLoading(true); try { await mutateReloadScrapers(); } catch (e) { Toast.error(e); } finally { setIsLoading(false); } } function onScrapeDialogClosed(sceneData?: GQL.ScrapedSceneDataFragment) { if (sceneData) { updateSceneFromScrapedScene(sceneData); } setScrapedScene(undefined); } function maybeRenderScrapeDialog() { if (!scrapedScene) { return; } const currentScene = { id: scene.id!, ...formik.values, }; if (!currentScene.cover_image) { currentScene.cover_image = scene.paths?.screenshot; } return ( onScrapeDialogClosed(s)} /> ); } function onSceneSelected(s: GQL.ScrapedSceneDataFragment) { if (!scraper) return; if (scraper?.stash_box_endpoint !== undefined) { // must be stash-box - assume full scene setScrapedScene(s); } else { // must be scraper scrapeFromQuery(scraper, s); } } const renderScrapeQueryModal = () => { if (!isScraperQueryModalOpen || !scraper) return; return ( setScraper(undefined)} onSelectScene={(s) => { setIsScraperQueryModalOpen(false); setScraper(undefined); onSceneSelected(s); }} name={formik.values.title || objectTitle(scene) || ""} /> ); }; function urlScrapable(scrapedUrl: string): boolean { return (Scrapers?.data?.listScrapers ?? []).some((s) => (s?.scene?.urls ?? []).some((u) => scrapedUrl.includes(u)) ); } function updateSceneFromScrapedScene( updatedScene: GQL.ScrapedSceneDataFragment ) { if (updatedScene.title) { formik.setFieldValue("title", updatedScene.title); } if (updatedScene.code) { formik.setFieldValue("code", updatedScene.code); } if (updatedScene.details) { formik.setFieldValue("details", updatedScene.details); } if (updatedScene.director) { formik.setFieldValue("director", updatedScene.director); } if (updatedScene.date) { formik.setFieldValue("date", updatedScene.date); } if (updatedScene.urls) { formik.setFieldValue("urls", updatedScene.urls); } if (updatedScene.studio && updatedScene.studio.stored_id) { onSetStudio({ id: updatedScene.studio.stored_id, name: updatedScene.studio.name ?? "", aliases: [], }); } if (updatedScene.performers && updatedScene.performers.length > 0) { const idPerfs = updatedScene.performers.filter((p) => { return p.stored_id !== undefined && p.stored_id !== null; }); if (idPerfs.length > 0) { onSetPerformers( idPerfs.map((p) => { return { id: p.stored_id!, name: p.name ?? "", alias_list: [], }; }) ); } } if (updatedScene.groups && updatedScene.groups.length > 0) { const idMovis = updatedScene.groups.filter((p) => { return p.stored_id !== undefined && p.stored_id !== null; }); if (idMovis.length > 0) { onSetGroups( idMovis.map((p) => { return { id: p.stored_id!, name: p.name ?? "", }; }) ); } } updateTagsStateFromScraper(updatedScene.tags ?? undefined); if (updatedScene.image) { // image is a base64 string formik.setFieldValue("cover_image", updatedScene.image); } if (updatedScene.remote_site_id && endpoint) { let found = false; formik.setFieldValue( "stash_ids", formik.values.stash_ids.map((s) => { if (s.endpoint === endpoint) { found = true; return { endpoint, stash_id: updatedScene.remote_site_id, updated_at: new Date().toISOString(), }; } return s; }) ); if (!found) { formik.setFieldValue( "stash_ids", formik.values.stash_ids.concat({ endpoint, stash_id: updatedScene.remote_site_id, updated_at: new Date().toISOString(), }) ); } } } async function onScrapeSceneURL(url: string) { if (!url) { return; } setIsLoading(true); try { const result = await queryScrapeSceneURL(url); if (!result.data || !result.data.scrapeSceneURL) { return; } setScrapedScene(result.data.scrapeSceneURL); } catch (e) { Toast.error(e); } finally { setIsLoading(false); } } function onStashIDSelected(item?: GQL.StashIdInput) { if (!item) return; formik.setFieldValue( "stash_ids", addUpdateStashID(formik.values.stash_ids, item) ); } const image = useMemo(() => { if (encodingImage) { return ( ); } if (coverImagePreview) { return ( {intl.formatMessage({ ); } return
    ; }, [encodingImage, coverImagePreview, intl]); if (isLoading) return ; const splitProps = { labelProps: { column: true, sm: 3, }, fieldProps: { sm: 9, }, }; const fullWidthProps = { labelProps: { column: true, sm: 3, xl: 12, }, fieldProps: { sm: 9, xl: 12, }, }; const urlProps = isNew ? splitProps : { labelProps: { column: true, md: 3, lg: 12, }, fieldProps: { md: 9, lg: 12, }, }; const { renderField, renderInputField, renderDateField, renderURLListField, renderStashIDsField, } = formikUtils(intl, formik, splitProps); function renderGalleriesField() { const title = intl.formatMessage({ id: "galleries" }); const control = ( onSetGalleries(items)} isMulti /> ); return renderField("gallery_ids", title, control); } function renderStudioField() { const title = intl.formatMessage({ id: "studio" }); const control = ( onSetStudio(items.length > 0 ? items[0] : null)} values={studio ? [studio] : []} /> ); return renderField("studio_id", title, control); } function renderPerformersField() { const date = (() => { try { return schema.validateSyncAt("date", formik.values); } catch (e) { return undefined; } })(); const title = intl.formatMessage({ id: "performers" }); const control = ( ); return renderField("performer_ids", title, control, fullWidthProps); } function onSetGroupEntries(input: IGroupEntry[]) { setGroups(input.map((m) => m.group)); const newGroups = input.map((m) => ({ group_id: m.group.id, scene_index: m.scene_index, })); formik.setFieldValue("groups", newGroups); } function renderGroupsField() { const title = intl.formatMessage({ id: "groups" }); const control = ( ); return renderField("groups", title, control, fullWidthProps); } function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); return renderField("tag_ids", title, tagsControl(), fullWidthProps); } function renderDetailsField() { const props = { labelProps: { column: true, sm: 3, lg: 12, }, fieldProps: { sm: 9, lg: 12, }, }; return renderInputField("details", "textarea", "details", props); } return (
    {renderScrapeQueryModal()} {maybeRenderScrapeDialog()} {isStashIDSearchOpen && ( s.endpoint )} onSelectItem={(item) => { onStashIDSelected(item); setIsStashIDSearchOpen(false); }} initialQuery={scene.title ?? ""} /> )}
    {isNew ? ( formik.submitForm()} > onSaveAndNewClick()}> ) : ( )} {onDelete && ( )}
    {!isNew && (
    } stashBoxes={stashConfig?.general.stashBoxes ?? []} scrapers={queryableScrapers} onScraperClicked={onScrapeQueryClicked} onReloadScrapers={onReloadScrapers} />
    )}
    {renderInputField("title")} {renderInputField("code", "text", "scene_code")} {renderURLListField( "urls", onScrapeSceneURL, urlScrapable, "urls", urlProps )} {renderDateField("date")} {renderInputField("director")} {renderGalleriesField()} {renderStudioField()} {renderPerformersField()} {renderGroupsField()} {renderTagsField()} {renderStashIDsField( "stash_ids", "scenes", "stash_ids", fullWidthProps, )} {renderDetailsField()} {image} formik.setFieldValue("custom_fields", v)} error={customFieldsError} setError={(e) => setCustomFieldsError(e)} />
    ); }; export default SceneEditPanel; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx ================================================ import React, { useMemo, useState } from "react"; import { Accordion, Button, Card } from "react-bootstrap"; import { FormattedMessage, FormattedNumber, FormattedTime, useIntl, } from "react-intl"; import { useHistory } from "react-router-dom"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { DeleteFilesDialog } from "src/components/Shared/DeleteFilesDialog"; import { RevealInFilesystemButton } from "src/components/Shared/RevealInFilesystemButton"; import { ReassignFilesDialog } from "src/components/Shared/ReassignFilesDialog"; import * as GQL from "src/core/generated-graphql"; import { mutateSceneSetPrimaryFile } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import NavUtils from "src/utils/navigation"; import TextUtils from "src/utils/text"; import { TextField, URLField, URLsField } from "src/utils/field"; import { StashIDPill } from "src/components/Shared/StashID"; import { PatchComponent } from "../../../patch"; import { FileSize } from "src/components/Shared/FileSize"; interface IFileInfoPanelProps { sceneID: string; file: GQL.VideoFileDataFragment; primary?: boolean; ofMany?: boolean; onSetPrimaryFile?: () => void; onDeleteFile?: () => void; onReassign?: () => void; loading?: boolean; } const FileInfoPanel: React.FC = ( props: IFileInfoPanelProps ) => { const intl = useIntl(); const history = useHistory(); // TODO - generalise fingerprints const oshash = props.file.fingerprints.find((f) => f.type === "oshash"); const phash = props.file.fingerprints.find((f) => f.type === "phash"); const checksum = props.file.fingerprints.find((f) => f.type === "md5"); function onSplit() { history.push( `/scenes/new?from_scene_id=${props.sceneID}&file_id=${props.file.id}` ); } return (
    {props.primary && ( <>
    )}
    {props.ofMany && props.onSetPrimaryFile && !props.primary && (
    )}
    ); }; interface ISceneFileInfoPanelProps { scene: GQL.SceneDataFragment; } const _SceneFileInfoPanel: React.FC = ( props: ISceneFileInfoPanelProps ) => { const Toast = useToast(); const [loading, setLoading] = useState(false); const [deletingFile, setDeletingFile] = useState(); const [reassigningFile, setReassigningFile] = useState(); function renderStashIDs() { if (!props.scene.stash_ids.length) { return; } return ( <>
    {props.scene.stash_ids.map((stashID) => { return (
    ); })}
    ); } function renderFunscript() { if (props.scene.interactive) { return ( ); } } function renderInteractiveSpeed() { if (props.scene.interactive_speed) { return ( ); } } const filesPanel = useMemo(() => { if (props.scene.files.length === 0) { return; } if (props.scene.files.length === 1) { return ( ); } async function onSetPrimaryFile(fileID: string) { try { setLoading(true); await mutateSceneSetPrimaryFile(props.scene.id, fileID); } catch (e) { Toast.error(e); } finally { setLoading(false); } } return ( {deletingFile && ( setDeletingFile(undefined)} selected={[deletingFile]} /> )} {reassigningFile && ( setReassigningFile(undefined)} selected={reassigningFile} /> )} {props.scene.files.map((file, index) => ( onSetPrimaryFile(file.id)} onDeleteFile={() => setDeletingFile(file)} onReassign={() => setReassigningFile(file)} loading={loading} /> ))} ); }, [props.scene, loading, Toast, deletingFile, reassigningFile]); return ( <>
    {props.scene.files.length > 0 && ( )} {renderFunscript()} {renderInteractiveSpeed()} {renderStashIDs()}
    {filesPanel} ); }; export const SceneFileInfoPanel = PatchComponent( "SceneFileInfoPanel", _SceneFileInfoPanel ); export default SceneFileInfoPanel; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneDetails/SceneGalleriesPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { GalleryCard } from "src/components/Galleries/GalleryCard"; interface ISceneGalleriesPanelProps { galleries: GQL.SlimGalleryDataFragment[]; } export const SceneGalleriesPanel: React.FC = ({ galleries, }) => { const cards = galleries.map((gallery) => ( )); return
    {cards}
    ; }; export default SceneGalleriesPanel; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneDetails/SceneGroupPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { GroupCard } from "src/components/Groups/GroupCard"; interface ISceneGroupPanelProps { scene: GQL.SceneDataFragment; } export const SceneGroupPanel: React.FC = ( props: ISceneGroupPanelProps ) => { const cards = props.scene.groups.map((sceneGroup) => ( )); return ( <>
    {cards}
    ); }; export default SceneGroupPanel; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneDetails/SceneGroupTable.tsx ================================================ import React, { useMemo } from "react"; import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { Form, Row, Col } from "react-bootstrap"; import { Group, GroupSelect } from "src/components/Groups/GroupSelect"; import cx from "classnames"; import { NumberField } from "src/utils/form"; export type GroupSceneIndexMap = Map; export interface IGroupEntry { group: Group; scene_index?: GQL.InputMaybe | undefined; } export interface IProps { value: IGroupEntry[]; onUpdate: (input: IGroupEntry[]) => void; } export const SceneGroupTable: React.FC = (props) => { const { value, onUpdate } = props; const intl = useIntl(); const groupIDs = useMemo(() => value.map((m) => m.group.id), [value]); const updateFieldChanged = (index: number, sceneIndex: number | null) => { const newValues = value.map((existing, i) => { if (i === index) { return { ...existing, scene_index: sceneIndex, }; } return existing; }); onUpdate(newValues); }; function onGroupSet(index: number, groups: Group[]) { if (!groups.length) { // remove this entry const newValues = value.filter((_, i) => i !== index); onUpdate(newValues); return; } const group = groups[0]; const newValues = value.map((existing, i) => { if (i === index) { return { ...existing, group: group, }; } return existing; }); onUpdate(newValues); } function onNewGroupSet(groups: Group[]) { if (!groups.length) { return; } const group = groups[0]; const newValues = [ ...value, { group: group, scene_index: null, }, ]; onUpdate(newValues); } function renderTableData() { return ( <> {value.map((m, i) => ( onGroupSet(i, items)} values={[m.group!]} excludeIds={groupIDs} /> ) => { updateFieldChanged( i, e.currentTarget.value === "" ? null : Number.parseInt(e.currentTarget.value, 10) ); }} /> ))} onNewGroupSet(items)} values={[]} excludeIds={groupIDs} /> ); } return (
    {intl.formatMessage({ id: "group_scene_number" })} {renderTableData()}
    ); }; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneDetails/SceneHistoryPanel.tsx ================================================ import { faEllipsisV, faPlus, faTrash, } from "@fortawesome/free-solid-svg-icons"; import React from "react"; import { Button, Dropdown } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { AlertModal } from "src/components/Shared/Alert"; import { Counter } from "src/components/Shared/Counter"; import { DateInput } from "src/components/Shared/DateInput"; import { Icon } from "src/components/Shared/Icon"; import { ModalComponent } from "src/components/Shared/Modal"; import { useSceneDecrementO, useSceneDecrementPlayCount, useSceneIncrementO, useSceneIncrementPlayCount, useSceneResetO, useSceneResetPlayCount, useSceneResetActivity, } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { useConfigurationContext } from "src/hooks/Config"; import { useToast } from "src/hooks/Toast"; import { TextField } from "src/utils/field"; import TextUtils from "src/utils/text"; const History: React.FC<{ className?: string; history: string[]; unknownDate?: string; onRemove: (date: string) => void; noneID: string; }> = ({ className, history, unknownDate, noneID, onRemove }) => { const intl = useIntl(); if (history.length === 0) { return (
    ); } function renderDate(date: string) { if (date === unknownDate) { return intl.formatMessage({ id: "unknown_date" }); } return TextUtils.formatDateTime(intl, date); } return (
      {history.map((playdate, index) => (
    • {renderDate(playdate)}
    • ))}
    ); }; const HistoryMenu: React.FC<{ hasHistory: boolean; showResetResumeDuration: boolean; onAddDate: () => void; onClearDates: () => void; resetResume: () => void; resetDuration: () => void; }> = ({ hasHistory, showResetResumeDuration, onAddDate, onClearDates, resetResume, resetDuration, }) => { const intl = useIntl(); return ( onAddDate()} > {hasHistory && ( onClearDates()} > )} {showResetResumeDuration && ( resetResume()} > )} {showResetResumeDuration && ( resetDuration()} > )} ); }; const DatePickerModal: React.FC<{ show: boolean; onClose: (t?: string) => void; }> = ({ show, onClose }) => { const intl = useIntl(); const [date, setDate] = React.useState( TextUtils.dateTimeToString(new Date()) ); return ( } accept={{ onClick: () => onClose(date), text: intl.formatMessage({ id: "actions.confirm" }), }} cancel={{ variant: "secondary", onClick: () => onClose(), text: intl.formatMessage({ id: "actions.cancel" }), }} >
    setDate(d)} isTime />
    ); }; interface ISceneHistoryProps { scene: GQL.SceneDataFragment; } export const SceneHistoryPanel: React.FC = ({ scene }) => { const intl = useIntl(); const Toast = useToast(); const { configuration } = useConfigurationContext(); const { sfwContentMode } = configuration.interface; const [dialogs, setDialogs] = React.useState({ playHistory: false, oHistory: false, addPlay: false, addO: false, }); function setDialogPartial(partial: Partial) { setDialogs({ ...dialogs, ...partial }); } const [incrementPlayCount] = useSceneIncrementPlayCount(); const [decrementPlayCount] = useSceneDecrementPlayCount(); const [clearPlayCount] = useSceneResetPlayCount(); const [incrementOCount] = useSceneIncrementO(scene.id); const [decrementOCount] = useSceneDecrementO(scene.id); const [resetO] = useSceneResetO(scene.id); const [resetResume] = useSceneResetActivity(scene.id, true, false); const [resetDuration] = useSceneResetActivity(scene.id, false, true); function dateStringToISOString(time: string) { const date = TextUtils.stringToFuzzyDateTime(time); if (!date) return null; return date.toISOString(); } function handleAddPlayDate(time?: string) { incrementPlayCount({ variables: { id: scene.id, times: time ? [time] : undefined, }, }); } function handleDeletePlayDate(time: string) { decrementPlayCount({ variables: { id: scene.id, times: time ? [time] : undefined, }, }); } function handleClearPlayDates() { setDialogPartial({ playHistory: false }); clearPlayCount({ variables: { id: scene.id, }, }); } function handleAddODate(time?: string) { incrementOCount({ variables: { id: scene.id, times: time ? [time] : undefined, }, }); } function handleDeleteODate(time: string) { decrementOCount({ variables: { id: scene.id, times: time ? [time] : undefined, }, }); } function handleClearODates() { setDialogPartial({ oHistory: false }); resetO({ variables: { id: scene.id, }, }); } async function handleResetResume() { try { await resetResume({ variables: { id: scene.id, reset_resume: true, reset_duration: false, }, }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase(), } ) ); } catch (e) { Toast.error(e); } } async function handleResetDuration() { try { await resetDuration({ variables: { id: scene.id, reset_resume: false, reset_duration: true, }, }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase(), } ) ); } catch (e) { Toast.error(e); } } function maybeRenderDialogs() { const clearHistoryMessageID = sfwContentMode ? "dialogs.clear_o_history_confirm_sfw" : "dialogs.clear_play_history_confirm"; return ( <> handleClearPlayDates()} onCancel={() => setDialogPartial({ playHistory: false })} /> handleClearODates()} onCancel={() => setDialogPartial({ oHistory: false })} /> {/* add conditions here so that date is generated correctly */} {dialogs.addPlay && ( { const tt = t ? dateStringToISOString(t) : null; if (tt) { handleAddPlayDate(tt); } setDialogPartial({ addPlay: false }); }} /> )} {dialogs.addO && ( { const tt = t ? dateStringToISOString(t) : null; if (tt) { handleAddODate(tt); } setDialogPartial({ addO: false }); }} /> )} ); } const playHistory = (scene.play_history ?? []).filter( (h) => h != null ) as string[]; const oHistory = (scene.o_history ?? []).filter((h) => h != null) as string[]; const oHistoryMessageID = sfwContentMode ? "o_history_sfw" : "o_history"; const noneMessageID = sfwContentMode ? "odate_recorded_no_sfw" : "odate_recorded_no"; return (
    {maybeRenderDialogs()}
    0} showResetResumeDuration={true} onAddDate={() => setDialogPartial({ addPlay: true })} onClearDates={() => setDialogPartial({ playHistory: true })} resetResume={() => handleResetResume()} resetDuration={() => handleResetDuration()} />
    handleDeletePlayDate(t)} />
    0} showResetResumeDuration={false} onAddDate={() => setDialogPartial({ addO: true })} onClearDates={() => setDialogPartial({ oHistory: true })} resetResume={() => handleResetResume()} resetDuration={() => handleResetDuration()} />
    handleDeleteODate(t)} />
    ); }; export default SceneHistoryPanel; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { Button, Form } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { useFormik } from "formik"; import * as yup from "yup"; import * as GQL from "src/core/generated-graphql"; import { useSceneMarkerCreate, useSceneMarkerUpdate, useSceneMarkerDestroy, } from "src/core/StashService"; import { DurationInput } from "src/components/Shared/DurationInput"; import { MarkerTitleSuggest } from "src/components/Shared/Select"; import { getAbLoopPlugin, getPlayerPosition, } from "src/components/ScenePlayer/util"; import { useToast } from "src/hooks/Toast"; import isEqual from "lodash-es/isEqual"; import { formikUtils } from "src/utils/form"; import { yupFormikValidate } from "src/utils/yup"; import { Tag, TagSelect } from "src/components/Tags/TagSelect"; interface ISceneMarkerForm { sceneID: string; marker?: GQL.SceneMarkerDataFragment; onClose: () => void; } export const SceneMarkerForm: React.FC = ({ sceneID, marker, onClose, }) => { const intl = useIntl(); const [sceneMarkerCreate] = useSceneMarkerCreate(); const [sceneMarkerUpdate] = useSceneMarkerUpdate(); const [sceneMarkerDestroy] = useSceneMarkerDestroy(); const Toast = useToast(); const [primaryTag, setPrimaryTag] = useState(); const [tags, setTags] = useState([]); const isNew = marker === undefined; const schema = yup.object({ title: yup.string().ensure(), seconds: yup.number().min(0).required(), end_seconds: yup .number() .min(0) .nullable() .defined() .test( "is-greater-than-seconds", intl.formatMessage({ id: "validation.end_time_before_start_time" }), function (value) { return value === null || value >= this.parent.seconds; } ), primary_tag_id: yup.string().required(), tag_ids: yup.array(yup.string().required()).defined(), }); // useMemo to only run getPlayerPosition when the input marker actually changes const initialValues = useMemo(() => { if (!marker) { const abLoopPlugin = getAbLoopPlugin(); const opts = abLoopPlugin?.getOptions(); const start = opts?.start; const end = opts?.end; const hasAbLoop = Number.isFinite(start); if (opts?.enabled && hasAbLoop) { const current = Math.round(getPlayerPosition() ?? 0); const rawEnd = Number.isFinite(end) && (end as number) > 0 ? (end as number) : null; const endSeconds = rawEnd !== null ? rawEnd : Math.max(start as number, current); return { title: "", seconds: start as number, end_seconds: endSeconds, primary_tag_id: "", tag_ids: [], }; } } return { title: marker?.title ?? "", seconds: marker?.seconds ?? Math.round(getPlayerPosition() ?? 0), end_seconds: marker?.end_seconds ?? null, primary_tag_id: marker?.primary_tag.id ?? "", tag_ids: marker?.tags.map((tag) => tag.id) ?? [], }; }, [marker]); type InputValues = yup.InferType; const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), onSubmit: (values) => onSave(schema.cast(values)), }); function onSetPrimaryTag(item: Tag) { setPrimaryTag(item); formik.setFieldValue("primary_tag_id", item.id); } function onSetTags(items: Tag[]) { setTags(items); formik.setFieldValue( "tag_ids", items.map((item) => item.id) ); } useEffect(() => { setPrimaryTag( marker?.primary_tag ? { ...marker.primary_tag, aliases: [], stash_ids: [] } : undefined ); }, [marker?.primary_tag]); useEffect(() => { setTags( marker?.tags.map((t) => ({ ...t, aliases: [], stash_ids: [], })) ?? [] ); }, [marker?.tags]); async function onSave(input: InputValues) { try { if (isNew) { await sceneMarkerCreate({ variables: { scene_id: sceneID, ...input, // undefined means setting to null, not omitting the field end_seconds: input.end_seconds ?? null, }, }); } else { await sceneMarkerUpdate({ variables: { id: marker.id, scene_id: sceneID, ...input, // undefined means setting to null, not omitting the field end_seconds: input.end_seconds ?? null, }, }); } } catch (e) { Toast.error(e); } finally { onClose(); } } async function onDelete() { if (isNew) return; try { await sceneMarkerDestroy({ variables: { id: marker.id } }); } catch (e) { Toast.error(e); } finally { onClose(); } } const splitProps = { labelProps: { column: true, sm: 3, }, fieldProps: { sm: 9, }, }; const fullWidthProps = { labelProps: { column: true, sm: 3, xl: 12, }, fieldProps: { sm: 9, xl: 12, }, }; const { renderField } = formikUtils(intl, formik, splitProps); function renderTitleField() { const title = intl.formatMessage({ id: "title" }); const control = ( formik.setFieldValue("title", v)} /> ); return renderField("title", title, control); } function renderPrimaryTagField() { const title = intl.formatMessage({ id: "primary_tag" }); const control = ( <> onSetPrimaryTag(t[0])} values={primaryTag ? [primaryTag] : []} hoverPlacement="right" /> {formik.touched.primary_tag_id && ( {formik.errors.primary_tag_id} )} ); return renderField("primary_tag_id", title, control); } function renderTimeField() { const { error } = formik.getFieldMeta("seconds"); const title = intl.formatMessage({ id: "time" }); const control = ( formik.setFieldValue("seconds", v)} onReset={() => formik.setFieldValue("seconds", getPlayerPosition() ?? 0) } error={error} /> ); return renderField("seconds", title, control); } function renderEndTimeField() { const { error } = formik.getFieldMeta("end_seconds"); const title = intl.formatMessage({ id: "time_end" }); const control = ( <> formik.setFieldValue("end_seconds", v ?? null)} onReset={() => formik.setFieldValue("end_seconds", getPlayerPosition() ?? 0) } error={error} /> {formik.touched.end_seconds && formik.errors.end_seconds && ( {formik.errors.end_seconds} )} ); return renderField("end_seconds", title, control); } function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); const control = ( ); return renderField("tag_ids", title, control, fullWidthProps); } return (
    {renderTitleField()} {renderPrimaryTagField()} {renderTimeField()} {renderEndTimeField()} {renderTagsField()}
    {!isNew && ( )}
    ); }; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx ================================================ import React, { useState, useEffect } from "react"; import { Button } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { MarkerWallPanel } from "src/components/Wall/WallPanel"; import { PrimaryTags } from "./PrimaryTags"; import { SceneMarkerForm } from "./SceneMarkerForm"; interface ISceneMarkersPanelProps { sceneId: string; isVisible: boolean; onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void; onLoopMarker: (marker: GQL.SceneMarkerDataFragment) => void; } export const SceneMarkersPanel: React.FC = ({ sceneId, isVisible, onClickMarker, onLoopMarker, }) => { const { data, loading } = GQL.useFindSceneMarkerTagsQuery({ variables: { id: sceneId }, }); const [isEditorOpen, setIsEditorOpen] = useState(false); const [editingMarker, setEditingMarker] = useState(); // set up hotkeys useEffect(() => { if (!isVisible) return; Mousetrap.bind("n", () => onOpenEditor()); return () => { Mousetrap.unbind("n"); }; }); if (loading) return null; function onOpenEditor(marker?: GQL.SceneMarkerDataFragment) { setIsEditorOpen(true); setEditingMarker(marker ?? undefined); } const closeEditor = () => { setEditingMarker(undefined); setIsEditorOpen(false); }; if (isEditorOpen) return ( ); const sceneMarkers = ( data?.sceneMarkerTags.map((tag) => tag.scene_markers) ?? [] ).reduce((prev, current) => [...prev, ...current], []); return (
    { e.preventDefault(); window.scrollTo(0, 0); onClickMarker(marker); }} />
    ); }; export default SceneMarkersPanel; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneDetails/SceneQueryModal.tsx ================================================ import React, { useCallback, useEffect, useRef, useState } from "react"; import { Badge, Button, Col, Form, InputGroup, Row } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "src/components/Shared/Modal"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { Icon } from "src/components/Shared/Icon"; import { queryScrapeSceneQuery } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import { faSearch } from "@fortawesome/free-solid-svg-icons"; interface ISceneSearchResultDetailsProps { scene: GQL.ScrapedSceneDataFragment; } const SceneSearchResultDetails: React.FC = ({ scene, }) => { function renderPerformers() { if (scene.performers) { return ( {scene.performers?.map((performer) => ( {performer.name} ))} ); } } function renderTags() { if (scene.tags) { return ( {scene.tags?.map((tag) => ( {tag.name} ))} ); } } function renderImage() { if (scene.image) { return (
    ); } } return (
    {renderImage()}

    {scene.title}

    {scene.studio?.name} {scene.studio?.name && scene.date && ` • `} {scene.date}
    {renderPerformers()} {renderTags()}
    ); }; export interface ISceneSearchResult { scene: GQL.ScrapedSceneDataFragment; } export const SceneSearchResult: React.FC = ({ scene }) => { return (
    ); }; interface IProps { scraper: GQL.ScraperSourceInput; onHide: () => void; onSelectScene: (scene: GQL.ScrapedSceneDataFragment) => void; name?: string; } export const SceneQueryModal: React.FC = ({ scraper, name, onHide, onSelectScene, }) => { const CLASSNAME = "SceneScrapeModal"; const CLASSNAME_LIST = `${CLASSNAME}-list`; const CLASSNAME_LIST_CONTAINER = `${CLASSNAME_LIST}-container`; const intl = useIntl(); const Toast = useToast(); const inputRef = useRef(null); const [loading, setLoading] = useState(false); const [scenes, setScenes] = useState(); const [error, setError] = useState(); const doQuery = useCallback( async (input: string) => { if (!input) return; setLoading(true); try { const r = await queryScrapeSceneQuery(scraper, input); setScenes(r.data.scrapeSingleScene); } catch (err) { if (err instanceof Error) setError(err); } finally { setLoading(false); } }, [scraper] ); useEffect(() => inputRef.current?.focus(), []); useEffect(() => { if (error) { Toast.error(error); setError(undefined); } }, [error, Toast]); function renderResults() { if (!scenes) { return; } return (
      {scenes.map((s, i) => ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions, react/no-array-index-key
    • onSelectScene(s)}>
    • ))}
    ); } return (
    ) => e.key === "Enter" && doQuery(inputRef.current?.value ?? "") } /> {loading ? (
    ) : ( renderResults() )}
    ); }; export default SceneQueryModal; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx ================================================ import React, { useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { ScrapedInputGroupRow, ScrapedTextAreaRow, ScrapedImageRow, ScrapedStringListRow, } from "src/components/Shared/ScrapeDialog/ScrapeDialogRow"; import { ScrapeDialog } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; import { useIntl } from "react-intl"; import { uniq } from "lodash-es"; import { Performer } from "src/components/Performers/PerformerSelect"; import { sortStoredIdObjects } from "src/utils/data"; import { ObjectListScrapeResult, ObjectScrapeResult, ScrapeResult, } from "src/components/Shared/ScrapeDialog/scrapeResult"; import { ScrapedGroupsRow, ScrapedPerformersRow, ScrapedStudioRow, } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow"; import { useCreateScrapedGroup, useCreateScrapedPerformer, useCreateScrapedStudio, } from "src/components/Shared/ScrapeDialog/createObjects"; import { Tag } from "src/components/Tags/TagSelect"; import { Studio } from "src/components/Studios/StudioSelect"; import { Group } from "src/components/Groups/GroupSelect"; import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags"; interface ISceneScrapeDialogProps { scene: Partial; sceneStudio: Studio | null; scenePerformers: Performer[]; sceneTags: Tag[]; sceneGroups: Group[]; scraped: GQL.ScrapedScene; endpoint?: string; onClose: (scrapedScene?: GQL.ScrapedScene) => void; } export const SceneScrapeDialog: React.FC = ({ scene, sceneStudio, scenePerformers, sceneTags, sceneGroups, scraped, onClose, endpoint, }) => { const [title, setTitle] = useState>( new ScrapeResult(scene.title, scraped.title) ); const [code, setCode] = useState>( new ScrapeResult(scene.code, scraped.code) ); const [urls, setURLs] = useState>( new ScrapeResult( scene.urls, scraped.urls ? uniq((scene.urls ?? []).concat(scraped.urls ?? [])) : undefined ) ); const [date, setDate] = useState>( new ScrapeResult(scene.date, scraped.date) ); const [director, setDirector] = useState>( new ScrapeResult(scene.director, scraped.director) ); const [studio, setStudio] = useState>( new ObjectScrapeResult( sceneStudio ? { stored_id: sceneStudio.id, name: sceneStudio.name, } : undefined, scraped.studio?.stored_id ? scraped.studio : undefined ) ); const [newStudio, setNewStudio] = useState( scraped.studio && !scraped.studio.stored_id ? scraped.studio : undefined ); const [stashID, setStashID] = useState( new ScrapeResult( scene.stash_ids?.find((s) => s.endpoint === endpoint)?.stash_id, scraped.remote_site_id ) ); const [performers, setPerformers] = useState< ObjectListScrapeResult >( new ObjectListScrapeResult( sortStoredIdObjects( scenePerformers.map((p) => ({ stored_id: p.id, name: p.name, })) ), sortStoredIdObjects(scraped.performers ?? undefined) ) ); const [newPerformers, setNewPerformers] = useState( scraped.performers?.filter((t) => !t.stored_id) ?? [] ); const [groups, setGroups] = useState< ObjectListScrapeResult >( new ObjectListScrapeResult( sortStoredIdObjects( sceneGroups.map((p) => ({ stored_id: p.id, name: p.name, })) ), sortStoredIdObjects(scraped.groups ?? undefined) ) ); const [newGroups, setNewGroups] = useState( scraped.groups?.filter((t) => !t.stored_id) ?? [] ); const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags( sceneTags, scraped.tags, endpoint ); const [details, setDetails] = useState>( new ScrapeResult(scene.details, scraped.details) ); const [image, setImage] = useState>( new ScrapeResult(scene.cover_image, scraped.image) ); const createNewStudio = useCreateScrapedStudio({ scrapeResult: studio, setScrapeResult: setStudio, setNewObject: setNewStudio, endpoint, }); const createNewPerformer = useCreateScrapedPerformer({ scrapeResult: performers, setScrapeResult: setPerformers, newObjects: newPerformers, setNewObjects: setNewPerformers, endpoint, }); const createNewGroup = useCreateScrapedGroup({ scrapeResult: groups, setScrapeResult: setGroups, newObjects: newGroups, setNewObjects: setNewGroups, endpoint, }); const intl = useIntl(); // don't show the dialog if nothing was scraped if ( [ title, code, urls, date, director, studio, performers, groups, tags, details, image, stashID, ].every((r) => !r.scraped) && newTags.length === 0 && newPerformers.length === 0 && newGroups.length === 0 && !newStudio ) { onClose(); return <>; } function makeNewScrapedItem(): GQL.ScrapedSceneDataFragment { const newStudioValue = studio.getNewValue(); return { title: title.getNewValue(), code: code.getNewValue(), urls: urls.getNewValue(), date: date.getNewValue(), director: director.getNewValue(), studio: newStudioValue, performers: performers.getNewValue(), groups: groups.getNewValue(), tags: tags.getNewValue(), details: details.getNewValue(), image: image.getNewValue(), remote_site_id: stashID.getNewValue(), }; } function renderScrapeRows() { return ( <> setTitle(value)} /> setCode(value)} /> setURLs(value)} /> setDate(value)} /> setDirector(value)} /> setStudio(value)} newStudio={newStudio} onCreateNew={createNewStudio} /> setPerformers(value)} newObjects={newPerformers} onCreateNew={createNewPerformer} ageFromDate={date.useNewValue ? date.newValue : date.originalValue} /> setGroups(value)} newObjects={newGroups} onCreateNew={createNewGroup} /> {scrapedTagsRow} setDetails(value)} /> setStashID(value)} /> setImage(value)} /> ); } if (linkDialog) { return linkDialog; } return ( { onClose(apply ? makeNewScrapedItem() : undefined); }} > {renderScrapeRows()} ); }; export default SceneScrapeDialog; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx ================================================ import React, { useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { Button, Form } from "react-bootstrap"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { VIDEO_PLAYER_ID } from "src/components/ScenePlayer/util"; import * as GQL from "src/core/generated-graphql"; interface ISceneVideoFilterPanelProps { scene: GQL.SceneDataFragment; } // References // https://yoksel.github.io/svg-filters/#/ // https://codepen.io/chriscoyier/pen/zbakI // http://xahlee.info/js/js_scritping_svg_basics.html#:~:text=Just%20use%20JavaScript%20to%20script,%2C%20path%2C%20%E2%80%A6.). type SliderRange = { min: number; default: number; max: number; divider: number; }; function getMatrixValue(value: number, range: SliderRange) { return (value - range.default) / range.divider; } interface ISliderProps { title: string; className?: string; range: SliderRange; value: number; setValue: (value: React.SetStateAction) => void; displayValue: string; } const Slider: React.FC = (sliderProps: ISliderProps) => { return (
    {sliderProps.title} ) => sliderProps.setValue(Number.parseInt(e.currentTarget.value, 10)) } /> sliderProps.setValue(sliderProps.range.default)} onKeyPress={() => sliderProps.setValue(sliderProps.range.default)} >
    ); }; export const SceneVideoFilterPanel: React.FC = ( props: ISceneVideoFilterPanelProps ) => { const contrastRange: SliderRange = { min: 0, default: 100, max: 200, divider: 1, }; const brightnessRange: SliderRange = { min: 0, default: 100, max: 200, divider: 1, }; const gammaRange: SliderRange = { min: 0, default: 100, max: 200, divider: 200, }; const saturateRange: SliderRange = { min: 0, default: 100, max: 200, divider: 1, }; const hueRotateRange: SliderRange = { min: 0, default: 0, max: 360, divider: 1, }; const whiteBalanceRange: SliderRange = { min: 0, default: 100, max: 200, divider: 200, }; const colourRange: SliderRange = { min: 0, default: 100, max: 200, divider: 100, }; const blurRange: SliderRange = { min: 0, default: 0, max: 250, divider: 10 }; const rotateRange: SliderRange = { min: 0, default: 2, max: 4, divider: 1 / 90, }; const scaleRange: SliderRange = { min: 0, default: 100, max: 200, divider: 1, }; const aspectRatioRange: SliderRange = { min: 0, default: 150, max: 300, divider: 100, }; const intl = useIntl(); const [contrastValue, setContrastValue] = useState(contrastRange.default); const [brightnessValue, setBrightnessValue] = useState( brightnessRange.default ); const [gammaValue, setGammaValue] = useState(gammaRange.default); const [saturateValue, setSaturateValue] = useState(saturateRange.default); const [hueRotateValue, setHueRotateValue] = useState(hueRotateRange.default); const [whiteBalanceValue, setWhiteBalanceValue] = useState( whiteBalanceRange.default ); const [redValue, setRedValue] = useState(colourRange.default); const [greenValue, setGreenValue] = useState(colourRange.default); const [blueValue, setBlueValue] = useState(colourRange.default); const [blurValue, setBlurValue] = useState(blurRange.default); const [rotateValue, setRotateValue] = useState(rotateRange.default); const [scaleValue, setScaleValue] = useState(scaleRange.default); const [aspectRatioValue, setAspectRatioValue] = useState( aspectRatioRange.default ); // eslint-disable-next-line function getVideoElement(playerVideoContainer: any) { let videoElements = playerVideoContainer.getElementsByTagName("canvas"); if (videoElements.length == 0) { videoElements = playerVideoContainer.getElementsByTagName("video"); } if (videoElements.length > 0) { return videoElements[0]; } } function updateVideoStyle() { const playerVideoContainer = document.getElementById(VIDEO_PLAYER_ID)!; if (!playerVideoContainer) { return; } const playerVideoElement = getVideoElement(playerVideoContainer); if (playerVideoElement != null) { let styleString = "filter:"; let style = playerVideoElement.attributes.getNamedItem("style"); if (style == null) { style = document.createAttribute("style"); playerVideoElement.attributes.setNamedItem(style); } if ( whiteBalanceValue !== whiteBalanceRange.default || redValue !== colourRange.default || greenValue !== colourRange.default || blueValue !== colourRange.default || gammaValue !== gammaRange.default ) { styleString += " url(#videoFilter)"; } if (contrastValue !== contrastRange.default) { styleString += ` contrast(${contrastValue}%)`; } if (brightnessValue !== brightnessRange.default) { styleString += ` brightness(${brightnessValue}%)`; } if (saturateValue !== saturateRange.default) { styleString += ` saturate(${saturateValue}%)`; } if (hueRotateValue !== hueRotateRange.default) { styleString += ` hue-rotate(${hueRotateValue}deg)`; } if (blurValue > blurRange.default) { styleString += ` blur(${blurValue / blurRange.divider}px)`; } styleString += "; transform:"; if (rotateValue !== rotateRange.default) { styleString += ` rotate(${ (rotateValue - rotateRange.default) / rotateRange.divider }deg)`; } if ( scaleValue !== scaleRange.default || aspectRatioValue !== aspectRatioRange.default ) { let xScale = scaleValue / scaleRange.divider / 100.0; let yScale = scaleValue / scaleRange.divider / 100.0; if (aspectRatioValue > aspectRatioRange.default) { xScale *= (aspectRatioRange.divider + aspectRatioValue - aspectRatioRange.default) / aspectRatioRange.divider; } else if (aspectRatioValue < aspectRatioRange.default) { yScale *= (aspectRatioRange.divider + aspectRatioRange.default - aspectRatioValue) / aspectRatioRange.divider; } styleString += ` scale(${xScale},${yScale})`; } if (playerVideoElement.tagName == "CANVAS") { styleString += "; width: 100%; height: 100%; position: absolute; top:0"; } style.value = `${styleString};`; } } function updateVideoFilters() { const filterContainer = document.getElementById("video-filter-container"); if (filterContainer == null) { return; } const svg1 = document.createElementNS("http://www.w3.org/2000/svg", "svg"); const videoFilter = document.createElementNS( "http://www.w3.org/2000/svg", "filter" ); videoFilter.setAttribute("id", "videoFilter"); if ( whiteBalanceValue !== whiteBalanceRange.default || redValue !== colourRange.default || greenValue !== colourRange.default || blueValue !== colourRange.default ) { const feColorMatrix = document.createElementNS( "http://www.w3.org/2000/svg", "feColorMatrix" ); const wbMatrixValue = getMatrixValue( whiteBalanceValue, whiteBalanceRange ); feColorMatrix.setAttribute( "values", `${ 1 + wbMatrixValue + getMatrixValue(redValue, colourRange) } 0 0 0 0 0 ${ 1.0 + getMatrixValue(greenValue, colourRange) } 0 0 0 0 0 ${ 1 - wbMatrixValue + getMatrixValue(blueValue, colourRange) } 0 0 0 0 0 1.0 0` ); videoFilter.appendChild(feColorMatrix); } if (gammaValue !== gammaRange.default) { const feComponentTransfer = document.createElementNS( "http://www.w3.org/2000/svg", "feComponentTransfer" ); const feFuncR = document.createElementNS( "http://www.w3.org/2000/svg", "feFuncR" ); feFuncR.setAttribute("type", "gamma"); feFuncR.setAttribute("amplitude", "1.0"); feFuncR.setAttribute( "exponent", `${1 + (gammaRange.default - gammaValue) / gammaRange.divider}` ); feFuncR.setAttribute("offset", "0.0"); feComponentTransfer.appendChild(feFuncR); const feFuncG = document.createElementNS( "http://www.w3.org/2000/svg", "feFuncG" ); feFuncG.setAttribute("type", "gamma"); feFuncG.setAttribute("amplitude", "1.0"); feFuncG.setAttribute( "exponent", `${1 + (gammaRange.default - gammaValue) / gammaRange.divider}` ); feFuncG.setAttribute("offset", "0.0"); feComponentTransfer.appendChild(feFuncG); const feFuncB = document.createElementNS( "http://www.w3.org/2000/svg", "feFuncB" ); feFuncB.setAttribute("type", "gamma"); feFuncB.setAttribute("amplitude", "1.0"); feFuncB.setAttribute( "exponent", `${1 + (gammaRange.default - gammaValue) / gammaRange.divider}` ); feFuncB.setAttribute("offset", "0.0"); feComponentTransfer.appendChild(feFuncB); const feFuncA = document.createElementNS( "http://www.w3.org/2000/svg", "feFuncA" ); feFuncA.setAttribute("type", "gamma"); feFuncA.setAttribute("amplitude", "1.0"); feFuncA.setAttribute("exponent", "1.0"); feFuncA.setAttribute("offset", "0.0"); feComponentTransfer.appendChild(feFuncA); videoFilter.appendChild(feComponentTransfer); } svg1.appendChild(videoFilter); // Add or Replace existing svg const filterContainerSvgs = filterContainer.getElementsByTagNameNS( "http://www.w3.org/2000/svg", "svg" ); if (filterContainerSvgs.length === 0) { // attach container to document filterContainer.appendChild(svg1); } else { // assume only one svg... maybe issue filterContainer.replaceChild(svg1, filterContainerSvgs[0]); } } function onRotateAndScale(direction: number) { if (direction === 0) { // Left -90 setRotateValue(1); } else { // Right +90 setRotateValue(3); } const file = props.scene.files.length > 0 ? props.scene.files[0] : undefined; // Calculate Required Scaling. const sceneWidth = file?.width ?? 1; const sceneHeight = file?.height ?? 1; const sceneAspectRatio = sceneWidth / sceneHeight; const sceneNewAspectRatio = sceneHeight / sceneWidth; const playerVideoElement = document.getElementById(VIDEO_PLAYER_ID); const playerWidth = playerVideoElement?.clientWidth ?? 1; const playerHeight = playerVideoElement?.clientHeight ?? 1; const playerAspectRation = playerWidth / playerHeight; // rs > ri ? (wi * hs/hi, hs) : (ws, hi * ws/wi) // Determine if video is currently constrained by player height or width. let scaledVideoHeight = 0; let scaledVideoWidth = 0; if (playerAspectRation > sceneAspectRatio) { // Video has it's width scaled // Video is constrained by it's height scaledVideoHeight = playerHeight; scaledVideoWidth = (playerHeight / sceneHeight) * sceneWidth; } else { // Video has it's height scaled // Video is constrained by it's width scaledVideoWidth = playerWidth; scaledVideoHeight = (playerWidth / sceneWidth) * sceneHeight; } // but now the video is rotated let scaleFactor = 1; if (playerAspectRation > sceneNewAspectRatio) { // Rotated video will be constrained by it's height // so we need to scaledVideoWidth to match the player height scaleFactor = playerHeight / scaledVideoWidth; } else { // Rotated video will be constrained by it's width // so we need to scaledVideoHeight to match the player width scaleFactor = playerWidth / scaledVideoHeight; } setScaleValue(scaleFactor * 100); } function renderRotateAndScale() { return (
    ); } function onResetFilters() { setContrastValue(contrastRange.default); setBrightnessValue(brightnessRange.default); setGammaValue(gammaRange.default); setSaturateValue(saturateRange.default); setHueRotateValue(hueRotateRange.default); setWhiteBalanceValue(whiteBalanceRange.default); setRedValue(colourRange.default); setGreenValue(colourRange.default); setBlueValue(colourRange.default); setBlurValue(blurRange.default); } function onResetTransforms() { setScaleValue(scaleRange.default); setRotateValue(rotateRange.default); setAspectRatioValue(aspectRatioRange.default); } function renderResetButton() { return (
    ); } function renderFilterContainer() { return
    ; } // On render update video style. updateVideoFilters(); updateVideoStyle(); return (
    {renderRotateAndScale()} {renderResetButton()} {renderFilterContainer()}
    ); }; export default SceneVideoFilterPanel; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneList.tsx ================================================ import React, { useCallback, useEffect, useMemo } from "react"; import cloneDeep from "lodash-es/cloneDeep"; import { FormattedMessage, useIntl } from "react-intl"; import { useHistory, useLocation } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { queryFindScenes, useFindScenes } from "src/core/StashService"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { Tagger } from "../Tagger/scenes/SceneTagger"; import { IPlaySceneOptions, SceneQueue } from "src/models/sceneQueue"; import { SceneWallPanel } from "./SceneWallPanel"; import { SceneListTable } from "./SceneListTable"; import { EditScenesDialog } from "./EditScenesDialog"; import { DeleteScenesDialog } from "./DeleteScenesDialog"; import { GenerateDialog } from "../Dialogs/GenerateDialog"; import { ExportDialog } from "../Shared/ExportDialog"; import { SceneCardGrid } from "./SceneCardGrid"; import { TaggerContext } from "../Tagger/context"; import { IdentifyDialog } from "../Dialogs/IdentifyDialog/IdentifyDialog"; import { useConfigurationContext } from "src/hooks/Config"; import { SceneMergeModal } from "./SceneMergeDialog"; import { objectTitle } from "src/core/files"; import TextUtils from "src/utils/text"; import { View } from "../List/views"; import { FileSize } from "../Shared/FileSize"; import { LoadedContent } from "../List/PagedList"; import { useCloseEditDelete, useFilterOperations } from "../List/util"; import { ListOperations } from "../List/ListOperationButtons"; import { useFilteredItemList } from "../List/ItemList"; import { Sidebar, SidebarPane, SidebarPaneContent, SidebarStateContext, useSidebarState, } from "../Shared/Sidebar"; import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter"; import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter"; import { SidebarTagsFilter } from "../List/Filters/TagsFilter"; import cx from "classnames"; import { SidebarRatingFilter } from "../List/Filters/RatingFilter"; import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized"; import { HasMarkersCriterionOption } from "src/models/list-filter/criteria/has-markers"; import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; import { PerformerAgeCriterionOption } from "src/models/list-filter/scenes"; import { SidebarDuplicateFilter } from "../List/Filters/DuplicateFilter"; import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter"; import { SidebarDurationFilter } from "../List/Filters/SidebarDurationFilter"; import { FilteredSidebarHeader, useFilteredSidebarKeybinds, } from "../List/Filters/FilterSidebar"; import { PatchComponent, PatchContainerComponent } from "src/patch"; import { Pagination, PaginationIndex } from "../List/Pagination"; import { Button } from "react-bootstrap"; import useFocus from "src/utils/focus"; import { useZoomKeybinds } from "../List/ZoomSlider"; import { FilteredListToolbar } from "../List/FilteredListToolbar"; import { FilterTags } from "../List/FilterTags"; import { SidebarFolderFilter } from "../List/Filters/FolderFilter"; function renderMetadataByline(result: GQL.FindScenesQueryResult) { const duration = result?.data?.findScenes?.duration; const size = result?.data?.findScenes?.filesize; if (!duration && !size) { return; } const separator = duration && size ? " - " : ""; return (  ( {duration ? ( {TextUtils.secondsAsTimeString(duration, 3)} ) : undefined} {separator} {size ? ( ) : undefined} ) ); } function usePlayScene() { const history = useHistory(); const { configuration: config } = useConfigurationContext(); const cont = config?.interface.continuePlaylistDefault ?? false; const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false; const playScene = useCallback( (queue: SceneQueue, sceneID: string, options?: IPlaySceneOptions) => { history.push( queue.makeLink(sceneID, { autoPlay, continue: cont, ...options }) ); }, [history, cont, autoPlay] ); return playScene; } function usePlaySelected(selectedIds: Set) { const playScene = usePlayScene(); const playSelected = useCallback(() => { // populate queue and go to first scene const sceneIDs = Array.from(selectedIds.values()); const queue = SceneQueue.fromSceneIDList(sceneIDs); playScene(queue, sceneIDs[0]); }, [selectedIds, playScene]); return playSelected; } function usePlayFirst() { const playScene = usePlayScene(); const playFirst = useCallback( (queue: SceneQueue, sceneID: string, index: number) => { // populate queue and go to first scene playScene(queue, sceneID, { sceneIndex: index }); }, [playScene] ); return playFirst; } function usePlayRandom(filter: ListFilterModel, count: number) { const playScene = usePlayScene(); const playRandom = useCallback(async () => { // query for a random scene if (count === 0) { return; } const pages = Math.ceil(count / filter.itemsPerPage); const page = Math.floor(Math.random() * pages) + 1; const indexMax = Math.min(filter.itemsPerPage, count); const index = Math.floor(Math.random() * indexMax); const filterCopy = cloneDeep(filter); filterCopy.currentPage = page; filterCopy.sortBy = "random"; const queryResults = await queryFindScenes(filterCopy); const scene = queryResults.data.findScenes.scenes[index]; if (scene) { // navigate to the image player page const queue = SceneQueue.fromListFilterModel(filterCopy); playScene(queue, scene.id, { sceneIndex: index }); } }, [filter, count, playScene]); return playRandom; } function useAddKeybinds(filter: ListFilterModel, count: number) { const playRandom = usePlayRandom(filter, count); useEffect(() => { Mousetrap.bind("p r", () => { playRandom(); }); return () => { Mousetrap.unbind("p r"); }; }, [playRandom]); } const SceneList: React.FC<{ scenes: GQL.SlimSceneDataFragment[]; filter: ListFilterModel; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; fromGroupId?: string; }> = PatchComponent( "SceneList", ({ scenes, filter, selectedIds, onSelectChange, fromGroupId }) => { const queue = useMemo( () => SceneQueue.fromListFilterModel(filter), [filter] ); if (scenes.length === 0 && filter.displayMode !== DisplayMode.Tagger) { return null; } if (filter.displayMode === DisplayMode.Grid) { return ( ); } if (filter.displayMode === DisplayMode.List) { return ( ); } if (filter.displayMode === DisplayMode.Wall) { return ( ); } if (filter.displayMode === DisplayMode.Tagger) { return ( ); } return null; } ); const ScenesFilterSidebarSections = PatchContainerComponent( "FilteredSceneList.SidebarSections" ); const SidebarContent: React.FC<{ filter: ListFilterModel; setFilter: (filter: ListFilterModel) => void; filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; sidebarOpen: boolean; onClose?: () => void; showEditFilter: (editingCriterion?: string) => void; count?: number; focus?: ReturnType; }> = ({ filter, setFilter, filterHook, view, showEditFilter, sidebarOpen, onClose, count, focus, }) => { const showResultsId = count !== undefined ? "actions.show_count_results" : "actions.show_results"; const hideStudios = view === View.StudioScenes; return ( <> {!hideStudios && ( )} } filter={filter} setFilter={setFilter} sectionID="folder" /> } data-type={HasMarkersCriterionOption.type} option={HasMarkersCriterionOption} filter={filter} setFilter={setFilter} sectionID="hasMarkers" /> } data-type={OrganizedCriterionOption.type} option={OrganizedCriterionOption} filter={filter} setFilter={setFilter} sectionID="organized" /> } filter={filter} setFilter={setFilter} sectionID="duplicated" /> } option={PerformerAgeCriterionOption} filter={filter} setFilter={setFilter} sectionID="performer_age" />
    ); }; interface IFilteredScenes { filterHook?: (filter: ListFilterModel) => ListFilterModel; defaultSort?: string; view?: View; alterQuery?: boolean; fromGroupId?: string; } export const FilteredSceneList = PatchComponent( "FilteredSceneList", (props: IFilteredScenes) => { const intl = useIntl(); const history = useHistory(); const location = useLocation(); const searchFocus = useFocus(); const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props; // States const { showSidebar, setShowSidebar, loading: sidebarStateLoading, sectionOpen, setSectionOpen, } = useSidebarState(view); const { filterState, queryResult, modalState, listSelect, showEditFilter } = useFilteredItemList({ filterStateProps: { filterMode: GQL.FilterMode.Scenes, defaultSort, view, useURL: alterQuery, }, queryResultProps: { useResult: useFindScenes, getCount: (r) => r.data?.findScenes.count ?? 0, getItems: (r) => r.data?.findScenes.scenes ?? [], filterHook, }, }); const { filter, setFilter } = filterState; const { effectiveFilter, result, cachedResult, items, totalCount } = queryResult; const { selectedIds, selectedItems, onSelectChange, onSelectAll, onSelectNone, onInvertSelection, hasSelection, } = listSelect; const { modal, showModal, closeModal } = modalState; // Utility hooks const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ filter, setFilter, }); useAddKeybinds(effectiveFilter, totalCount); useFilteredSidebarKeybinds({ showSidebar, setShowSidebar, }); const onCloseEditDelete = useCloseEditDelete({ closeModal, onSelectNone, result, }); const onEdit = useCallback(() => { showModal( ); }, [showModal, selectedItems, onCloseEditDelete]); const onDelete = useCallback(() => { showModal( ); }, [showModal, selectedItems, onCloseEditDelete]); useEffect(() => { Mousetrap.bind("e", () => { if (hasSelection) { onEdit?.(); } }); Mousetrap.bind("d d", () => { if (hasSelection) { onDelete?.(); } }); return () => { Mousetrap.unbind("e"); Mousetrap.unbind("d d"); }; }, [onSelectAll, onSelectNone, hasSelection, onEdit, onDelete]); useZoomKeybinds({ zoomIndex: filter.zoomIndex, onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)), }); const metadataByline = useMemo(() => { if (cachedResult.loading) return null; return renderMetadataByline(cachedResult) ?? null; }, [cachedResult]); const queue = useMemo( () => SceneQueue.fromListFilterModel(filter), [filter] ); const playRandom = usePlayRandom(effectiveFilter, totalCount); const playSelected = usePlaySelected(selectedIds); const playFirst = usePlayFirst(); function onCreateNew() { let queryParam = new URLSearchParams(location.search).get("q"); let newPath = "/scenes/new"; if (queryParam) { newPath += "?q=" + encodeURIComponent(queryParam); } history.push(newPath); } function onPlay() { if (items.length === 0) { return; } // if there are selected items, play those if (hasSelection) { playSelected(); return; } // otherwise, play the first item in the list const sceneID = items[0].id; playFirst(queue, sceneID, 0); } function onExport(all: boolean) { showModal( closeModal()} /> ); } function onMerge() { const selected = selectedItems.map((s) => { return { id: s.id, title: objectTitle(s), }; }) ?? []; showModal( { closeModal(); if (mergedID) { history.push(`/scenes/${mergedID}`); } }} show /> ); } const otherOperations = [ { text: intl.formatMessage({ id: "actions.play" }), onClick: () => onPlay(), isDisplayed: () => items.length > 0, className: "play-item", }, { text: intl.formatMessage( { id: "actions.create_entity" }, { entityType: intl.formatMessage({ id: "scene" }) } ), onClick: () => onCreateNew(), isDisplayed: () => !hasSelection, className: "create-new-item", }, { text: intl.formatMessage({ id: "actions.select_all" }), onClick: () => onSelectAll(), isDisplayed: () => totalCount > 0, }, { text: intl.formatMessage({ id: "actions.select_none" }), onClick: () => onSelectNone(), isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.invert_selection" }), onClick: () => onInvertSelection(), isDisplayed: () => totalCount > 0, }, { text: intl.formatMessage({ id: "actions.play_random" }), onClick: playRandom, isDisplayed: () => totalCount > 1, }, { text: `${intl.formatMessage({ id: "actions.generate" })}…`, onClick: () => showModal( closeModal()} /> ), isDisplayed: () => hasSelection, }, { text: `${intl.formatMessage({ id: "actions.identify" })}…`, onClick: () => showModal( closeModal()} /> ), isDisplayed: () => hasSelection, }, { text: `${intl.formatMessage({ id: "actions.merge" })}…`, onClick: () => onMerge(), isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export" }), onClick: () => onExport(false), isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export_all" }), onClick: () => onExport(true), }, ]; // render if (sidebarStateLoading) return null; const operations = ( ); return (
    {modal} setShowSidebar(false)}> setShowSidebar(false)} count={cachedResult.loading ? undefined : totalCount} focus={searchFocus} /> setShowSidebar(!showSidebar)} > showEditFilter(c.criterionOption.type) } onRemoveCriterion={removeCriterion} onRemoveAll={clearAllCriteria} />
    setFilter(filter.changePage(page))} />
    {totalCount > filter.itemsPerPage && (
    )}
    ); } ); export default FilteredSceneList; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneListTable.tsx ================================================ import React from "react"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import NavUtils from "src/utils/navigation"; import TextUtils from "src/utils/text"; import { FormattedMessage, useIntl } from "react-intl"; import { objectTitle } from "src/core/files"; import { galleryTitle } from "src/core/galleries"; import SceneQueue from "src/models/sceneQueue"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { useSceneUpdate } from "src/core/StashService"; import { IColumn, ListTable } from "../List/ListTable"; import { useTableColumns } from "src/hooks/useTableColumns"; import { FileSize } from "../Shared/FileSize"; interface ISceneListTableProps { scenes: GQL.SlimSceneDataFragment[]; queue?: SceneQueue; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; } const TABLE_NAME = "scenes"; export const SceneListTable: React.FC = ( props: ISceneListTableProps ) => { const intl = useIntl(); const [updateScene] = useSceneUpdate(); function setRating(v: number | null, sceneId: string) { if (sceneId) { updateScene({ variables: { input: { id: sceneId, rating100: v, }, }, }); } } const CoverImageCell = (scene: GQL.SlimSceneDataFragment, index: number) => { const title = objectTitle(scene); const sceneLink = props.queue ? props.queue.makeLink(scene.id, { sceneIndex: index }) : `/scenes/${scene.id}`; return ( {title} ); }; const TitleCell = (scene: GQL.SlimSceneDataFragment, index: number) => { const title = objectTitle(scene); const sceneLink = props.queue ? props.queue.makeLink(scene.id, { sceneIndex: index }) : `/scenes/${scene.id}`; return ( {title} ); }; const DateCell = (scene: GQL.SlimSceneDataFragment) => <>{scene.date}; const RatingCell = (scene: GQL.SlimSceneDataFragment) => ( setRating(value, scene.id)} clickToRate /> ); const DurationCell = (scene: GQL.SlimSceneDataFragment) => { const file = scene.files.length > 0 ? scene.files[0] : undefined; return file?.duration && TextUtils.secondsToTimestamp(file.duration); }; const TagCell = (scene: GQL.SlimSceneDataFragment) => (
      {scene.tags.map((tag) => (
    • {tag.name}
    • ))}
    ); const PerformersCell = (scene: GQL.SlimSceneDataFragment) => (
      {scene.performers.map((performer) => (
    • {performer.name}
    • ))}
    ); const StudioCell = (scene: GQL.SlimSceneDataFragment) => { if (scene.studio) { return ( {scene.studio.name} ); } }; const GroupCell = (scene: GQL.SlimSceneDataFragment) => (
      {scene.groups.map((sceneGroup) => (
    • {sceneGroup.group.name}
    • ))}
    ); const GalleriesCell = (scene: GQL.SlimSceneDataFragment) => (
      {scene.galleries.map((gallery) => (
    • {galleryTitle(gallery)}
    • ))}
    ); const PlayCountCell = (scene: GQL.SlimSceneDataFragment) => ( ); const PlayDurationCell = (scene: GQL.SlimSceneDataFragment) => ( <>{TextUtils.secondsToTimestamp(scene.play_duration ?? 0)} ); const ResolutionCell = (scene: GQL.SlimSceneDataFragment) => (
      {scene.files.map((file) => (
    • {TextUtils.resolution(file?.width, file?.height)}
    • ))}
    ); const FileSizeCell = (scene: GQL.SlimSceneDataFragment) => (
      {scene.files.map((file) => (
    • ))}
    ); const FrameRateCell = (scene: GQL.SlimSceneDataFragment) => (
      {scene.files.map((file) => (
    • ))}
    ); const BitRateCell = (scene: GQL.SlimSceneDataFragment) => (
      {scene.files.map((file) => (
    • ))}
    ); const AudioCodecCell = (scene: GQL.SlimSceneDataFragment) => (
      {scene.files.map((file) => (
    • {file.audio_codec}
    • ))}
    ); const VideoCodecCell = (scene: GQL.SlimSceneDataFragment) => (
      {scene.files.map((file) => (
    • {file.video_codec}
    • ))}
    ); const PathCell = (scene: GQL.SlimSceneDataFragment) => (
      {scene.files.map((file) => (
    • {file.path}
    • ))}
    ); interface IColumnSpec { value: string; label: string; defaultShow?: boolean; mandatory?: boolean; render?: ( scene: GQL.SlimSceneDataFragment, index: number ) => React.ReactNode; } const allColumns: IColumnSpec[] = [ { value: "cover_image", label: intl.formatMessage({ id: "cover_image" }), defaultShow: true, render: CoverImageCell, }, { value: "title", label: intl.formatMessage({ id: "title" }), defaultShow: true, mandatory: true, render: TitleCell, }, { value: "date", label: intl.formatMessage({ id: "date" }), defaultShow: true, render: DateCell, }, { value: "rating", label: intl.formatMessage({ id: "rating" }), defaultShow: true, render: RatingCell, }, { value: "scene_code", label: intl.formatMessage({ id: "scene_code" }), render: (s) => <>{s.code}, }, { value: "duration", label: intl.formatMessage({ id: "duration" }), defaultShow: true, render: DurationCell, }, { value: "studio", label: intl.formatMessage({ id: "studio" }), defaultShow: true, render: StudioCell, }, { value: "performers", label: intl.formatMessage({ id: "performers" }), defaultShow: true, render: PerformersCell, }, { value: "tags", label: intl.formatMessage({ id: "tags" }), defaultShow: true, render: TagCell, }, { value: "groups", label: intl.formatMessage({ id: "groups" }), defaultShow: true, render: GroupCell, }, { value: "galleries", label: intl.formatMessage({ id: "galleries" }), defaultShow: true, render: GalleriesCell, }, { value: "play_count", label: intl.formatMessage({ id: "play_count" }), render: PlayCountCell, }, { value: "play_duration", label: intl.formatMessage({ id: "play_duration" }), render: PlayDurationCell, }, { value: "o_counter", label: intl.formatMessage({ id: "o_count" }), render: (s) => <>{s.o_counter}, }, { value: "resolution", label: intl.formatMessage({ id: "resolution" }), render: ResolutionCell, }, { value: "path", label: intl.formatMessage({ id: "path" }), render: PathCell, }, { value: "filesize", label: intl.formatMessage({ id: "filesize" }), render: FileSizeCell, }, { value: "framerate", label: intl.formatMessage({ id: "framerate" }), render: FrameRateCell, }, { value: "bitrate", label: intl.formatMessage({ id: "bitrate" }), render: BitRateCell, }, { value: "video_codec", label: intl.formatMessage({ id: "video_codec" }), render: VideoCodecCell, }, { value: "audio_codec", label: intl.formatMessage({ id: "audio_codec" }), render: AudioCodecCell, }, ]; const defaultColumns = allColumns .filter((col) => col.defaultShow) .map((col) => col.value); const { selectedColumns, saveColumns } = useTableColumns( TABLE_NAME, defaultColumns ); const columnRenderFuncs: Record< string, (scene: GQL.SlimSceneDataFragment, index: number) => React.ReactNode > = {}; allColumns.forEach((col) => { if (col.render) { columnRenderFuncs[col.value] = col.render; } }); function renderCell( column: IColumn, scene: GQL.SlimSceneDataFragment, index: number ) { const render = columnRenderFuncs[column.value]; if (render) return render(scene, index); } return ( saveColumns(c)} selectedIds={props.selectedIds} onSelectChange={props.onSelectChange} renderCell={renderCell} /> ); }; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneMarkerCard.tsx ================================================ import { useMemo } from "react"; import { Button, ButtonGroup } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; import { TagLink } from "../Shared/TagLink"; import { HoverPopover } from "../Shared/HoverPopover"; import NavUtils from "src/utils/navigation"; import TextUtils from "src/utils/text"; import { useConfigurationContext } from "src/hooks/Config"; import { GridCard } from "../Shared/GridCard/GridCard"; import { faTag } from "@fortawesome/free-solid-svg-icons"; import { markerTitle } from "src/core/markers"; import { Link } from "react-router-dom"; import { objectTitle } from "src/core/files"; import { PatchComponent } from "src/patch"; import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton"; import { ScenePreview } from "./SceneCard"; import { TruncatedText } from "../Shared/TruncatedText"; interface ISceneMarkerCardProps { marker: GQL.SceneMarkerDataFragment; cardWidth?: number; previewHeight?: number; index?: number; compact?: boolean; selecting?: boolean; selected?: boolean | undefined; zoomIndex?: number; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; } const SceneMarkerCardPopovers = PatchComponent( "SceneMarkerCard.Popovers", (props: ISceneMarkerCardProps) => { function maybeRenderPerformerPopoverButton() { if (props.marker.scene.performers.length <= 0) return; return ( ); } function renderTagPopoverButton() { const popoverContent = [ , ]; props.marker.tags.map((tag) => popoverContent.push( ) ); return ( ); } function renderPopoverButtonGroup() { if (!props.compact) { return ( <>
    {maybeRenderPerformerPopoverButton()} {renderTagPopoverButton()} ); } } return <>{renderPopoverButtonGroup()}; } ); const SceneMarkerCardDetails = PatchComponent( "SceneMarkerCard.Details", (props: ISceneMarkerCardProps) => { return (
    {TextUtils.formatTimestampRange( props.marker.seconds, props.marker.end_seconds ?? undefined )} {objectTitle(props.marker.scene)} } />
    ); } ); const SceneMarkerCardImage = PatchComponent( "SceneMarkerCard.Image", (props: ISceneMarkerCardProps) => { const { configuration } = useConfigurationContext(); const file = useMemo( () => props.marker.scene.files.length > 0 ? props.marker.scene.files[0] : undefined, [props.marker.scene] ); function isPortrait() { const width = file?.width ? file.width : 0; const height = file?.height ? file.height : 0; return height > width; } function maybeRenderSceneSpecsOverlay() { return (
    {props.marker.end_seconds && ( {TextUtils.secondsToTimestamp( props.marker.end_seconds - props.marker.seconds )} )}
    ); } return ( <> {maybeRenderSceneSpecsOverlay()} ); } ); export const SceneMarkerCard = PatchComponent( "SceneMarkerCard", (props: ISceneMarkerCardProps) => { function zoomIndex() { if (!props.compact && props.zoomIndex !== undefined) { return `zoom-${props.zoomIndex}`; } return ""; } return ( } details={} popovers={} selected={props.selected} selecting={props.selecting} onSelectedChanged={props.onSelectedChanged} /> ); } ); ================================================ FILE: ui/v2.5/src/components/Scenes/SceneMarkerCardGrid.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { SceneMarkerCard } from "./SceneMarkerCard"; import { useCardWidth, useContainerDimensions, } from "../Shared/GridCard/GridCard"; import { PatchComponent } from "src/patch"; interface ISceneMarkerCardGrid { markers: GQL.SceneMarkerDataFragment[]; selectedIds: Set; zoomIndex: number; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; } const zoomWidths = [240, 340, 480, 640]; export const SceneMarkerCardGrid: React.FC = PatchComponent( "SceneMarkerCardGrid", ({ markers, selectedIds, zoomIndex, onSelectChange }) => { const [componentRef, { width: containerWidth }] = useContainerDimensions(); const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); return (
    {markers.map((marker, index) => ( 0} selected={selectedIds.has(marker.id)} onSelectedChanged={(selected: boolean, shiftKey: boolean) => onSelectChange(marker.id, selected, shiftKey) } /> ))}
    ); } ); ================================================ FILE: ui/v2.5/src/components/Scenes/SceneMarkerList.tsx ================================================ import cloneDeep from "lodash-es/cloneDeep"; import React, { useCallback, useEffect } from "react"; import { useHistory } from "react-router-dom"; import { FormattedMessage, useIntl } from "react-intl"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { queryFindSceneMarkers, useFindSceneMarkers, } from "src/core/StashService"; import NavUtils from "src/utils/navigation"; import { useFilteredItemList } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { MarkerWallPanel } from "./SceneMarkerWallPanel"; import { View } from "../List/views"; import { SceneMarkerCardGrid } from "./SceneMarkerCardGrid"; import { DeleteSceneMarkersDialog } from "./DeleteSceneMarkersDialog"; import { EditSceneMarkersDialog } from "./EditSceneMarkersDialog"; import { PatchComponent, PatchContainerComponent } from "src/patch"; import { FilteredListToolbar, IItemListOperation, } from "../List/FilteredListToolbar"; import { Sidebar, SidebarPane, SidebarPaneContent, SidebarStateContext, useSidebarState, } from "../Shared/Sidebar"; import { useCloseEditDelete, useFilterOperations } from "../List/util"; import { FilteredSidebarHeader, useFilteredSidebarKeybinds, } from "../List/Filters/FilterSidebar"; import { useZoomKeybinds } from "../List/ZoomSlider"; import { IListFilterOperation, ListOperations, } from "../List/ListOperationButtons"; import cx from "classnames"; import { FilterTags } from "../List/FilterTags"; import { Pagination, PaginationIndex } from "../List/Pagination"; import { LoadedContent } from "../List/PagedList"; import useFocus from "src/utils/focus"; import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter"; import { SidebarTagsFilter } from "../List/Filters/TagsFilter"; import { Button } from "react-bootstrap"; const SceneMarkerList: React.FC<{ markers: GQL.SceneMarkerDataFragment[]; filter: ListFilterModel; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; }> = PatchComponent( "SceneMarkerList", ({ markers, filter, selectedIds, onSelectChange }) => { if (markers.length === 0) { return null; } if (filter.displayMode === DisplayMode.Wall) { return ( ); } if (filter.displayMode === DisplayMode.Grid) { return ( ); } return null; } ); function usePlayRandom(filter: ListFilterModel, count: number) { const history = useHistory(); const playRandom = useCallback(async () => { // query for a random scene if (count === 0) { return; } const pages = Math.ceil(count / filter.itemsPerPage); const page = Math.floor(Math.random() * pages) + 1; const indexMax = Math.min(filter.itemsPerPage, count); const index = Math.floor(Math.random() * indexMax); const filterCopy = cloneDeep(filter); filterCopy.currentPage = page; filterCopy.sortBy = "random"; const queryResults = await queryFindSceneMarkers(filterCopy); const marker = queryResults.data.findSceneMarkers.scene_markers[index]; if (marker) { // navigate to the scene player page const url = NavUtils.makeSceneMarkerUrl(marker); history.push(url); } }, [filter, count, history]); return playRandom; } function useAddKeybinds(filter: ListFilterModel, count: number) { const playRandom = usePlayRandom(filter, count); useEffect(() => { Mousetrap.bind("p r", () => { playRandom(); }); return () => { Mousetrap.unbind("p r"); }; }, [playRandom]); } const ScenesFilterSidebarSections = PatchContainerComponent( "FilteredSceneMarkerList.SidebarSections" ); const SidebarContent: React.FC<{ filter: ListFilterModel; setFilter: (filter: ListFilterModel) => void; filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; sidebarOpen: boolean; onClose?: () => void; showEditFilter: (editingCriterion?: string) => void; count?: number; focus?: ReturnType; }> = ({ filter, setFilter, filterHook, view, showEditFilter, sidebarOpen, onClose, count, focus, }) => { const showResultsId = count !== undefined ? "actions.show_count_results" : "actions.show_results"; return ( <>
    ); }; interface ISceneMarkerList { filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; alterQuery?: boolean; defaultSort?: string; extraOperations?: IItemListOperation[]; } export const FilteredSceneMarkerList = PatchComponent( "FilteredSceneMarkerList", (props: ISceneMarkerList) => { const intl = useIntl(); const searchFocus = useFocus(); const { filterHook, defaultSort, view, alterQuery, extraOperations = [], } = props; // States const { showSidebar, setShowSidebar, loading: sidebarStateLoading, sectionOpen, setSectionOpen, } = useSidebarState(view); const { filterState, queryResult, modalState, listSelect, showEditFilter } = useFilteredItemList({ filterStateProps: { filterMode: GQL.FilterMode.SceneMarkers, defaultSort, view, useURL: alterQuery, }, queryResultProps: { useResult: useFindSceneMarkers, getCount: (r) => r.data?.findSceneMarkers.count ?? 0, getItems: (r) => r.data?.findSceneMarkers.scene_markers ?? [], filterHook, }, }); const { filter, setFilter } = filterState; const { effectiveFilter, result, cachedResult, items, totalCount } = queryResult; const { selectedIds, selectedItems, onSelectChange, onSelectAll, onSelectNone, onInvertSelection, hasSelection, } = listSelect; const { modal, showModal, closeModal } = modalState; // Utility hooks const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ filter, setFilter, }); useAddKeybinds(filter, totalCount); useFilteredSidebarKeybinds({ showSidebar, setShowSidebar, }); const onCloseEditDelete = useCloseEditDelete({ closeModal, onSelectNone, result, }); const onEdit = useCallback(() => { showModal( ); }, [showModal, selectedItems, onCloseEditDelete]); const onDelete = useCallback(() => { showModal( ); }, [showModal, selectedItems, onCloseEditDelete]); useEffect(() => { Mousetrap.bind("e", () => { if (hasSelection) { onEdit?.(); } }); Mousetrap.bind("d d", () => { if (hasSelection) { onDelete?.(); } }); return () => { Mousetrap.unbind("e"); Mousetrap.unbind("d d"); }; }, [onSelectAll, onSelectNone, hasSelection, onEdit, onDelete]); useZoomKeybinds({ zoomIndex: filter.zoomIndex, onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)), }); const playRandom = usePlayRandom(effectiveFilter, totalCount); const convertedExtraOperations: IListFilterOperation[] = extraOperations.map((o) => ({ ...o, isDisplayed: o.isDisplayed ? () => o.isDisplayed!(result, filter, selectedIds) : undefined, onClick: () => { o.onClick(result, filter, selectedIds); }, })); const otherOperations = [ ...convertedExtraOperations, { text: intl.formatMessage({ id: "actions.select_all" }), onClick: () => onSelectAll(), isDisplayed: () => totalCount > 0, }, { text: intl.formatMessage({ id: "actions.select_none" }), onClick: () => onSelectNone(), isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.invert_selection" }), onClick: () => onInvertSelection(), isDisplayed: () => totalCount > 0, }, { text: intl.formatMessage({ id: "actions.play_random" }), onClick: playRandom, isDisplayed: () => totalCount > 1, }, // { // text: `${intl.formatMessage({ id: "actions.generate" })}…`, // onClick: () => // showModal( // closeModal()} // /> // ), // isDisplayed: () => hasSelection, // }, ]; // render if (sidebarStateLoading) return null; const operations = ( ); return (
    {modal} setShowSidebar(false)}> setShowSidebar(false)} count={cachedResult.loading ? undefined : totalCount} focus={searchFocus} /> setShowSidebar(!showSidebar)} > showEditFilter(c.criterionOption.type)} onRemoveCriterion={removeCriterion} onRemoveAll={clearAllCriteria} />
    setFilter(filter.changePage(page))} />
    {totalCount > filter.itemsPerPage && (
    )}
    ); } ); export default FilteredSceneMarkerList; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneMarkerRecommendationRow.tsx ================================================ import React from "react"; import { useFindSceneMarkers } from "src/core/StashService"; import { ListFilterModel } from "src/models/list-filter/filter"; import { SceneMarkerCard } from "./SceneMarkerCard"; import { PatchComponent } from "src/patch"; import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow"; interface IProps { isTouch: boolean; filter: ListFilterModel; header: string; } export const SceneMarkerRecommendationRow: React.FC = PatchComponent( "SceneMarkerRecommendationRow", (props) => { const result = useFindSceneMarkers(props.filter); const count = result.data?.findSceneMarkers.count ?? 0; return ( {result.loading ? [...Array(props.filter.itemsPerPage)].map((i) => (
    )) : result.data?.findSceneMarkers.scene_markers.map((marker, index) => ( ))}
    ); } ); ================================================ FILE: ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx ================================================ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Form } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import Gallery, { GalleryI, PhotoProps, RenderImageProps, } from "react-photo-gallery"; import { useConfigurationContext } from "src/hooks/Config"; import { objectTitle } from "src/core/files"; import { Link, useHistory } from "react-router-dom"; import { TruncatedText } from "../Shared/TruncatedText"; import TextUtils from "src/utils/text"; import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect"; import cx from "classnames"; import NavUtils from "src/utils/navigation"; import { markerTitle } from "src/core/markers"; function wallItemTitle(sceneMarker: GQL.SceneMarkerDataFragment) { const newTitle = markerTitle(sceneMarker); const seconds = TextUtils.formatTimestampRange( sceneMarker.seconds, sceneMarker.end_seconds ?? undefined ); if (newTitle) { return `${newTitle} - ${seconds}`; } else { return seconds; } } interface IMarkerPhoto { marker: GQL.SceneMarkerDataFragment; link: string; onError?: (photo: PhotoProps) => void; } interface IExtraProps { maxHeight: number; selected?: boolean; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; selecting?: boolean; } export const MarkerWallItem: React.FC< RenderImageProps & IExtraProps > = (props: RenderImageProps & IExtraProps) => { const { dragProps } = useDragMoveSelect({ selecting: props.selecting || false, selected: props.selected || false, onSelectedChanged: props.onSelectedChanged, }); const { configuration } = useConfigurationContext(); const playSound = configuration?.interface.soundOnPreview ?? false; const showTitle = configuration?.interface.wallShowTitle ?? false; const [active, setActive] = useState(false); const height = Math.min(props.maxHeight, props.photo.height); const zoomFactor = height / props.photo.height; const width = props.photo.width * zoomFactor; type style = Record; var divStyle: style = { margin: props.margin, display: "block", }; if (props.direction === "column") { divStyle.position = "absolute"; divStyle.left = props.left; divStyle.top = props.top; } var handleClick = function handleClick(event: React.MouseEvent) { if (props.selecting && props.onSelectedChanged) { props.onSelectedChanged(!props.selected, event.shiftKey); event.preventDefault(); event.stopPropagation(); return; } if (props.onClick) { props.onClick(event, { index: props.index }); } }; const video = props.photo.src.includes("stream"); const ImagePreview = video ? "video" : "img"; const { marker } = props.photo; const title = wallItemTitle(marker); const tagNames = marker.tags.map((p) => p.name); let shiftKey = false; return (
    {props.onSelectedChanged && ( props.onSelectedChanged!(!props.selected, shiftKey)} onClick={(event: React.MouseEvent) => { shiftKey = event.shiftKey; event.stopPropagation(); }} /> )} setActive(true)} onMouseLeave={() => setActive(false)} onClick={handleClick} onError={() => { props.photo.onError?.(props.photo); }} />
    e.stopPropagation()}> {title && ( )}
    ); }; interface IMarkerWallProps { markers: GQL.SceneMarkerDataFragment[]; zoomIndex: number; selectedIds?: Set; onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; selecting?: boolean; } // HACK: typescript doesn't allow Gallery to accept a parameter for some reason const MarkerGallery = Gallery as unknown as GalleryI; function getFirstValidSrc(srcSet: string[], invalidSrcSet: string[]) { if (!srcSet.length) { return ""; } return ( srcSet.find((src) => !invalidSrcSet.includes(src)) ?? ([...srcSet].pop() as string) ); } interface IFile { width: number; height: number; } function getDimensions(file?: IFile) { const defaults = { width: 1280, height: 720 }; if (!file) return defaults; return { width: file.width || defaults.width, height: file.height || defaults.height, }; } const breakpointZoomHeights = [ { minWidth: 576, heights: [100, 120, 240, 360] }, { minWidth: 768, heights: [120, 160, 240, 480] }, { minWidth: 1200, heights: [120, 160, 240, 300] }, { minWidth: 1400, heights: [160, 240, 300, 480] }, ]; const MarkerWall: React.FC = ({ markers, zoomIndex, selectedIds, onSelectChange, selecting, }) => { const history = useHistory(); const containerRef = React.useRef(null); const margin = 3; const direction = "row"; const [erroredImgs, setErroredImgs] = useState([]); const handleError = useCallback((photo: PhotoProps) => { setErroredImgs((prev) => [...prev, photo.src]); }, []); useEffect(() => { setErroredImgs([]); }, [markers]); const photos: PhotoProps[] = useMemo(() => { return markers.map((m, index) => { const { width = 1280, height = 720 } = getDimensions(m.scene.files[0]); return { marker: m, src: getFirstValidSrc([m.stream, m.preview, m.screenshot], erroredImgs), link: NavUtils.makeSceneMarkerUrl(m), width, height, tabIndex: index, key: m.id, loading: "lazy", alt: objectTitle(m), onError: handleError, }; }); }, [markers, erroredImgs, handleError]); const onClick = useCallback( (event, { index }) => { history.push(photos[index].link); }, [history, photos] ); function columns(containerWidth: number) { let preferredSize = 300; let columnCount = containerWidth / preferredSize; return Math.round(columnCount); } const targetRowHeight = useCallback( (containerWidth: number) => { let zoomHeight = 280; breakpointZoomHeights.forEach((e) => { if (containerWidth >= e.minWidth) { zoomHeight = e.heights[zoomIndex]; } }); return zoomHeight; }, [zoomIndex] ); // set the max height as a factor of the targetRowHeight // this allows some images to be taller than the target row height // but prevents images from becoming too tall when there is a small number of items const maxHeightFactor = 1.3; const renderImage = useCallback( (props: RenderImageProps) => { const markerId = props.photo.marker.id; return ( onSelectChange(markerId, selected, shiftKey) : undefined } selecting={selecting} /> ); }, [targetRowHeight, selectedIds, onSelectChange, selecting] ); return (
    {photos.length ? ( ) : null}
    ); }; interface IMarkerWallPanelProps { markers: GQL.SceneMarkerDataFragment[]; zoomIndex: number; selectedIds?: Set; onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; } export const MarkerWallPanel: React.FC = ({ markers, zoomIndex, selectedIds, onSelectChange, }) => { const selecting = !!selectedIds && selectedIds.size > 0; return ( ); }; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx ================================================ import { Form, Col, Row, Button, FormControl } from "react-bootstrap"; import React, { useEffect, useMemo, useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { GallerySelect } from "../Shared/Select"; import * as FormUtils from "src/utils/form"; import ImageUtils from "src/utils/image"; import TextUtils from "src/utils/text"; import { mutateSceneMerge, queryFindFullScenesByID, } from "src/core/StashService"; import { FormattedMessage, useIntl } from "react-intl"; import { useToast } from "src/hooks/Toast"; import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons"; import { ScrapeDialogRow, ScrapedCustomFieldRows, ScrapedImageRow, ScrapedInputGroupRow, ScrapedStringListRow, ScrapedTextAreaRow, } from "../Shared/ScrapeDialog/ScrapeDialogRow"; import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog"; import { clone, uniq } from "lodash-es"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { ModalComponent } from "../Shared/Modal"; import { sortStoredIdObjects, uniqIDStoredIDs } from "src/utils/data"; import { CustomFieldScrapeResults, ObjectListScrapeResult, ScrapeResult, ZeroableScrapeResult, hasScrapedValues, } from "../Shared/ScrapeDialog/scrapeResult"; import { ScrapedGroupsRow, ScrapedPerformersRow, ScrapedStudioRow, ScrapedTagsRow, } from "../Shared/ScrapeDialog/ScrapedObjectsRow"; import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect"; import { StashIDsField } from "../Shared/StashID"; type MergeOptions = { values: GQL.SceneUpdateInput; includeViewHistory: boolean; includeOHistory: boolean; }; interface ISceneMergeDetailsProps { sources: GQL.SceneDataFragment[]; dest: GQL.SceneDataFragment; onClose: (options?: MergeOptions) => void; } const SceneMergeDetails: React.FC = ({ sources, dest, onClose, }) => { const intl = useIntl(); const [loading, setLoading] = useState(true); const [title, setTitle] = useState>( new ScrapeResult(dest.title) ); const [code, setCode] = useState>( new ScrapeResult(dest.code) ); const [url, setURL] = useState>( new ScrapeResult(dest.urls) ); const [date, setDate] = useState>( new ScrapeResult(dest.date) ); const [rating, setRating] = useState( new ZeroableScrapeResult(dest.rating100) ); // zero values can be treated as missing for these fields const [oCounter, setOCounter] = useState( new ScrapeResult(dest.o_counter) ); const [playCount, setPlayCount] = useState( new ScrapeResult(dest.play_count) ); const [playDuration, setPlayDuration] = useState( new ScrapeResult(dest.play_duration) ); function idToStoredID(o: { id: string; name: string }) { return { stored_id: o.id, name: o.name, }; } function groupToStoredID(o: { group: { id: string; name: string } }) { return { stored_id: o.group.id, name: o.group.name, }; } const [studio, setStudio] = useState>( new ScrapeResult( dest.studio ? idToStoredID(dest.studio) : undefined ) ); function sortIdList(idList?: string[] | null) { if (!idList) { return; } const ret = clone(idList); // sort by id numerically ret.sort((a, b) => { return parseInt(a, 10) - parseInt(b, 10); }); return ret; } const [performers, setPerformers] = useState< ObjectListScrapeResult >( new ObjectListScrapeResult( sortStoredIdObjects(dest.performers.map(idToStoredID)) ) ); const [groups, setGroups] = useState< ObjectListScrapeResult >( new ObjectListScrapeResult( sortStoredIdObjects(dest.groups.map(groupToStoredID)) ) ); const [tags, setTags] = useState>( new ObjectListScrapeResult( sortStoredIdObjects(dest.tags.map(idToStoredID)) ) ); const [details, setDetails] = useState>( new ScrapeResult(dest.details) ); const [galleries, setGalleries] = useState>( new ScrapeResult(sortIdList(dest.galleries.map((p) => p.id))) ); const [stashIDs, setStashIDs] = useState(new ScrapeResult([])); const [organized, setOrganized] = useState( new ZeroableScrapeResult(dest.organized) ); const [image, setImage] = useState>( new ScrapeResult(dest.paths.screenshot) ); const [customFields, setCustomFields] = useState( new Map() ); // calculate the values for everything // uses the first set value for single value fields, and combines all useEffect(() => { async function loadImages() { const src = sources.find((s) => s.paths.screenshot); if (!dest.paths.screenshot || !src) return; setLoading(true); const destData = await ImageUtils.imageToDataURL(dest.paths.screenshot); const srcData = await ImageUtils.imageToDataURL(src.paths.screenshot!); // keep destination image by default const useNewValue = false; setImage(new ScrapeResult(destData, srcData, useNewValue)); setLoading(false); } // append dest to all so that if dest has stash_ids with the same // endpoint, then it will be excluded first const all = sources.concat(dest); setTitle( new ScrapeResult( dest.title, sources.find((s) => s.title)?.title, !dest.title ) ); setCode( new ScrapeResult(dest.code, sources.find((s) => s.code)?.code, !dest.code) ); setURL(new ScrapeResult(dest.urls, uniq(all.map((s) => s.urls).flat()))); setDate( new ScrapeResult(dest.date, sources.find((s) => s.date)?.date, !dest.date) ); const foundStudio = sources.find((s) => s.studio)?.studio; setStudio( new ScrapeResult( dest.studio ? idToStoredID(dest.studio) : undefined, foundStudio ? { stored_id: foundStudio.id, name: foundStudio.name, } : undefined, !dest.studio ) ); setPerformers( new ObjectListScrapeResult( sortStoredIdObjects(dest.performers.map(idToStoredID)), uniqIDStoredIDs(all.map((s) => s.performers.map(idToStoredID)).flat()) ) ); setTags( new ObjectListScrapeResult( sortStoredIdObjects(dest.tags.map(idToStoredID)), uniqIDStoredIDs(all.map((s) => s.tags.map(idToStoredID)).flat()) ) ); setDetails( new ScrapeResult( dest.details, sources.find((s) => s.details)?.details, !dest.details ) ); setGroups( new ObjectListScrapeResult( sortStoredIdObjects(dest.groups.map(groupToStoredID)), uniqIDStoredIDs(all.map((s) => s.groups.map(groupToStoredID)).flat()) ) ); setGalleries( new ScrapeResult( dest.galleries.map((p) => p.id), uniq(all.map((s) => s.galleries.map((p) => p.id)).flat()) ) ); setRating( new ScrapeResult( dest.rating100, sources.find((s) => s.rating100)?.rating100, !dest.rating100 ) ); setOCounter( new ScrapeResult( dest.o_counter ?? 0, all.map((s) => s.o_counter ?? 0).reduce((pv, cv) => pv + cv, 0) ) ); setPlayCount( new ScrapeResult( dest.play_count ?? 0, all.map((s) => s.play_count ?? 0).reduce((pv, cv) => pv + cv, 0) ) ); setPlayDuration( new ScrapeResult( dest.play_duration ?? 0, all.map((s) => s.play_duration ?? 0).reduce((pv, cv) => pv + cv, 0) ) ); setOrganized( new ScrapeResult( dest.organized ?? false, sources.every((s) => s.organized) ) ); setStashIDs( new ScrapeResult( dest.stash_ids, all .map((s) => s.stash_ids) .flat() .filter((s, index, a) => { // remove entries with duplicate endpoints return index === a.findIndex((ss) => ss.endpoint === s.endpoint); }) ) ); const customFieldNames = new Set( Object.keys(dest.custom_fields ?? {}) ); for (const s of sources) { for (const n of Object.keys(s.custom_fields ?? {})) { customFieldNames.add(n); } } setCustomFields( new Map( Array.from(customFieldNames) .sort() .map((field) => { return [ field, new ScrapeResult( dest.custom_fields?.[field], sources.find((s) => s.custom_fields?.[field])?.custom_fields?.[ field ], dest.custom_fields?.[field] === undefined ), ]; }) ) ); loadImages(); }, [sources, dest]); const hasCustomFieldValues = useMemo(() => { return hasScrapedValues(Array.from(customFields.values())); }, [customFields]); // ensure this is updated if fields are changed const hasValues = useMemo(() => { return ( hasCustomFieldValues || hasScrapedValues([ title, code, url, date, rating, oCounter, galleries, studio, performers, groups, tags, details, organized, stashIDs, image, ]) ); }, [ title, code, url, date, rating, oCounter, galleries, studio, performers, groups, tags, details, organized, stashIDs, image, hasCustomFieldValues, ]); function renderScrapeRows() { if (loading) { return (
    ); } if (!hasValues) { return (
    ); } const trueString = intl.formatMessage({ id: "true" }); const falseString = intl.formatMessage({ id: "false" }); return ( <> setTitle(value)} /> setCode(value)} /> setURL(value)} /> setDate(value)} /> } newField={} onChange={(value) => setRating(value)} /> {}} className="bg-secondary text-white border-secondary" /> } newField={ {}} className="bg-secondary text-white border-secondary" /> } onChange={(value) => setOCounter(value)} /> {}} className="bg-secondary text-white border-secondary" /> } newField={ {}} className="bg-secondary text-white border-secondary" /> } onChange={(value) => setPlayCount(value)} /> {}} className="bg-secondary text-white border-secondary" /> } newField={ {}} className="bg-secondary text-white border-secondary" /> } onChange={(value) => setPlayDuration(value)} /> {}} isMulti isDisabled /> } newField={ {}} isMulti isDisabled /> } onChange={(value) => setGalleries(value)} /> setStudio(value)} /> setPerformers(value)} ageFromDate={date.useNewValue ? date.newValue : date.originalValue} /> setGroups(value)} /> setTags(value)} /> setDetails(value)} /> {}} className="bg-secondary text-white border-secondary" /> } newField={ {}} className="bg-secondary text-white border-secondary" /> } onChange={(value) => setOrganized(value)} /> } newField={} onChange={(value) => setStashIDs(value)} alwaysShow={ !!stashIDs.originalValue?.length || !!stashIDs.newValue?.length } /> setImage(value)} /> {hasCustomFieldValues && ( setCustomFields(newCustomFields)} /> )} ); } function createValues(): MergeOptions { const all = [dest, ...sources]; // only set the cover image if it's different from the existing cover image const coverImage = image.useNewValue ? image.getNewValue() : undefined; return { values: { id: dest.id, title: title.getNewValue(), code: code.getNewValue(), urls: url.getNewValue(), date: date.getNewValue(), rating100: rating.getNewValue(), o_counter: oCounter.getNewValue(), play_count: playCount.getNewValue(), play_duration: playDuration.getNewValue(), gallery_ids: galleries.getNewValue(), studio_id: studio.getNewValue()?.stored_id, performer_ids: performers.getNewValue()?.map((p) => p.stored_id!), groups: groups.getNewValue()?.map((m) => { // find the equivalent group in the original scenes const found = all .map((s) => s.groups) .flat() .find((mm) => mm.group.id === m.stored_id); return { group_id: m.stored_id!, scene_index: found!.scene_index, }; }), tag_ids: tags.getNewValue()?.map((t) => t.stored_id!), details: details.getNewValue(), organized: organized.getNewValue(), stash_ids: stashIDs.getNewValue(), cover_image: coverImage, custom_fields: { partial: Object.fromEntries( Array.from(customFields.entries()).flatMap(([field, v]) => v.useNewValue ? [[field, v.getNewValue()]] : [] ) ), }, }, includeViewHistory: playCount.getNewValue() !== undefined, includeOHistory: oCounter.getNewValue() !== undefined, }; } const dialogTitle = intl.formatMessage({ id: "actions.merge", }); const destinationLabel = !hasValues ? "" : intl.formatMessage({ id: "dialogs.merge.destination" }); const sourceLabel = !hasValues ? "" : intl.formatMessage({ id: "dialogs.merge.combined" }); return ( { if (!apply) { onClose(); } else { onClose(createValues()); } }} > {renderScrapeRows()} ); }; interface ISceneMergeModalProps { show: boolean; onClose: (mergedID?: string) => void; scenes: { id: string; title: string }[]; } export const SceneMergeModal: React.FC = ({ show, onClose, scenes, }) => { const [sourceScenes, setSourceScenes] = useState([]); const [destScene, setDestScene] = useState([]); const [loadedSources, setLoadedSources] = useState( [] ); const [loadedDest, setLoadedDest] = useState(); const [running, setRunning] = useState(false); const [secondStep, setSecondStep] = useState(false); const intl = useIntl(); const Toast = useToast(); const title = intl.formatMessage({ id: "actions.merge", }); useEffect(() => { if (scenes.length > 0) { // set the first scene as the destination, others as source setDestScene([scenes[0]]); if (scenes.length > 1) { setSourceScenes(scenes.slice(1)); } } }, [scenes]); async function loadScenes() { const sceneIDs = sourceScenes.map((s) => parseInt(s.id)); sceneIDs.push(parseInt(destScene[0].id)); const query = await queryFindFullScenesByID(sceneIDs); const { scenes: loadedScenes } = query.data.findScenes; setLoadedDest(loadedScenes.find((s) => s.id === destScene[0].id)); setLoadedSources(loadedScenes.filter((s) => s.id !== destScene[0].id)); setSecondStep(true); } async function onMerge(options: MergeOptions) { const { values, includeViewHistory, includeOHistory } = options; try { setRunning(true); const result = await mutateSceneMerge( destScene[0].id, sourceScenes.map((s) => s.id), values, includeViewHistory, includeOHistory ); if (result.data?.sceneMerge) { Toast.success(intl.formatMessage({ id: "toast.merged_scenes" })); onClose(destScene[0].id); } onClose(); } catch (e) { Toast.error(e); } finally { setRunning(false); } } function canMerge() { return sourceScenes.length > 0 && destScene.length !== 0; } function switchScenes() { if (sourceScenes.length && destScene.length) { const newDest = sourceScenes[0]; setSourceScenes([...sourceScenes.slice(1), destScene[0]]); setDestScene([newDest]); } } if (secondStep && destScene.length > 0) { return ( { setSecondStep(false); if (values) { onMerge(values); } else { onClose(); } }} /> ); } return ( loadScenes(), }} disabled={!canMerge()} cancel={{ variant: "secondary", onClick: () => onClose(), }} isRunning={running} >
    {FormUtils.renderLabel({ title: intl.formatMessage({ id: "dialogs.merge.source" }), labelProps: { column: true, sm: 3, xl: 12, }, })} setSourceScenes(items)} values={sourceScenes} menuPortalTarget={document.body} /> {FormUtils.renderLabel({ title: intl.formatMessage({ id: "dialogs.merge.destination", }), labelProps: { column: true, sm: 3, xl: 12, }, })} setDestScene(items)} values={destScene} menuPortalTarget={document.body} />
    ); }; ================================================ FILE: ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx ================================================ import React, { useMemo } from "react"; import { useFindScenes } from "src/core/StashService"; import { SceneCard } from "./SceneCard"; import { SceneQueue } from "src/models/sceneQueue"; import { ListFilterModel } from "src/models/list-filter/filter"; import { PatchComponent } from "src/patch"; import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow"; interface IProps { isTouch: boolean; filter: ListFilterModel; header: string; } export const SceneRecommendationRow: React.FC = PatchComponent( "SceneRecommendationRow", (props) => { const result = useFindScenes(props.filter); const count = result.data?.findScenes.count ?? 0; const queue = useMemo(() => { return SceneQueue.fromListFilterModel(props.filter); }, [props.filter]); return ( {result.loading ? [...Array(props.filter.itemsPerPage)].map((i) => (
    )) : result.data?.findScenes.scenes.map((scene, index) => ( ))}
    ); } ); ================================================ FILE: ui/v2.5/src/components/Scenes/SceneSelect.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { OptionProps, components as reactSelectComponents, MultiValueGenericProps, SingleValueProps, } from "react-select"; import cx from "classnames"; import * as GQL from "src/core/generated-graphql"; import { queryFindScenesForSelect, queryFindScenesByIDForSelect, } from "src/core/StashService"; import { useConfigurationContext } from "src/hooks/Config"; import { useIntl } from "react-intl"; import { defaultMaxOptionsShown } from "src/core/config"; import { ListFilterModel } from "src/models/list-filter/filter"; import { FilterSelectComponent, IFilterIDProps, IFilterProps, IFilterValueProps, Option as SelectOption, toOption, } from "../Shared/FilterSelect"; import { useCompare } from "src/hooks/state"; import { Placement } from "react-bootstrap/esm/Overlay"; import { sortByRelevance } from "src/utils/query"; import { objectTitle } from "src/core/files"; import { PatchComponent, PatchFunction } from "src/patch"; import { ModifierCriterion, CriterionValue, } from "src/models/list-filter/criteria/criterion"; import { TruncatedText } from "../Shared/TruncatedText"; import { isUUID } from "src/utils/stashIds"; import { filterByStashID } from "src/models/list-filter/utils"; export type Scene = Pick & { studio?: Pick | null; files?: Pick[]; paths?: Pick; }; type Option = SelectOption; type ExtraSceneProps = { hoverPlacement?: Placement; excludeIds?: string[]; extraCriteria?: Array>; }; type FindScenesResult = Awaited< ReturnType >["data"]["findScenes"]["scenes"]; function sortScenesByRelevance(input: string, scenes: FindScenesResult) { return sortByRelevance(input, scenes, objectTitle, (s) => { return s.files.map((f) => f.path); }); } const sceneSelectSort = PatchFunction( "SceneSelect.sort", sortScenesByRelevance ); const _SceneSelect: React.FC< IFilterProps & IFilterValueProps & ExtraSceneProps > = (props) => { const { configuration } = useConfigurationContext(); const intl = useIntl(); const maxOptionsShown = configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown; const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); function filterExcluded(scene: Scene) { // HACK - we should probably exclude these in the backend query, but // this will do in the short-term return !exclude.includes(scene.id.toString()); } async function loadScenes(input: string): Promise { const filter = new ListFilterModel(GQL.FilterMode.Scenes); filter.currentPage = 1; filter.itemsPerPage = maxOptionsShown; filter.sortBy = "title"; filter.sortDirection = GQL.SortDirectionEnum.Asc; filter.criteria = [...(props.extraCriteria ?? [])]; if (isUUID(input)) { const oldCriteria = filter.criteria; filterByStashID(filter, input); const query = await queryFindScenesForSelect(filter); const matches = query.data.findScenes.scenes.filter(filterExcluded); if (matches.length > 0) { // Matches found, return them immediately. return matches.map(toOption); } // If no stash_id matches found, continue with standard name/alias search. filter.criteria = oldCriteria; // Clear stash_id criterion to search by name/alias below. } filter.searchTerm = input; const query = await queryFindScenesForSelect(filter); const ret = query.data.findScenes.scenes.filter(filterExcluded); return sceneSelectSort(input, ret).map(toOption); } const SceneOption: React.FC> = (optionProps) => { let thisOptionProps = optionProps; const { object } = optionProps.data; const title = objectTitle(object); // if title does not match the input value but the path does, show the path const { inputValue } = optionProps.selectProps; let matchedPath: string | undefined = ""; if (!title.toLowerCase().includes(inputValue.toLowerCase())) { matchedPath = object.files?.find((a) => a.path.toLowerCase().includes(inputValue.toLowerCase()) )?.path; } thisOptionProps = { ...optionProps, children: ( {object.paths?.screenshot && ( )} {object.studio?.name && ( {object.studio?.name} )} {object.date && ( {object.date} )} {object.code && ( {object.code} )} {matchedPath && ( {`(${matchedPath})`} )} ), }; return ; }; const SceneMultiValueLabel: React.FC< MultiValueGenericProps > = (optionProps) => { let thisOptionProps = optionProps; const { object } = optionProps.data; thisOptionProps = { ...optionProps, children: objectTitle(object), }; return ; }; const SceneValueLabel: React.FC> = ( optionProps ) => { let thisOptionProps = optionProps; const { object } = optionProps.data; thisOptionProps = { ...optionProps, children: <>{objectTitle(object)}, }; return ; }; return ( {...props} className={cx( "scene-select", { "scene-select-active": props.active, }, props.className )} loadOptions={loadScenes} components={{ Option: SceneOption, MultiValueLabel: SceneMultiValueLabel, SingleValue: SceneValueLabel, }} isMulti={props.isMulti ?? false} placeholder={ props.noSelectionString ?? intl.formatMessage( { id: "actions.select_entity" }, { entityType: intl.formatMessage({ id: props.isMulti ? "scenes" : "scene", }), } ) } closeMenuOnSelect={!props.isMulti} /> ); }; export const SceneSelect = PatchComponent("SceneSelect", _SceneSelect); const _SceneIDSelect: React.FC< IFilterProps & IFilterIDProps & ExtraSceneProps > = (props) => { const { ids, onSelect: onSelectValues } = props; const [values, setValues] = useState([]); const idsChanged = useCompare(ids); function onSelect(items: Scene[]) { setValues(items); onSelectValues?.(items); } async function loadObjectsByID(idsToLoad: string[]): Promise { const query = await queryFindScenesByIDForSelect(idsToLoad); const { scenes: loadedScenes } = query.data.findScenes; return loadedScenes; } useEffect(() => { if (!idsChanged) { return; } if (!ids || ids?.length === 0) { setValues([]); return; } // load the values if we have ids and they haven't been loaded yet const filteredValues = values.filter((v) => ids.includes(v.id.toString())); if (filteredValues.length === ids.length) { return; } const load = async () => { const items = await loadObjectsByID(ids); setValues(items); }; load(); }, [ids, idsChanged, values]); return ; }; export const SceneIDSelect = PatchComponent("SceneIDSelect", _SceneIDSelect); ================================================ FILE: ui/v2.5/src/components/Scenes/SceneWallPanel.tsx ================================================ import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { Form } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import { SceneQueue } from "src/models/sceneQueue"; import Gallery, { GalleryI, PhotoProps, RenderImageProps, } from "react-photo-gallery"; import { useConfigurationContext } from "src/hooks/Config"; import { objectTitle } from "src/core/files"; import { Link, useHistory } from "react-router-dom"; import { TruncatedText } from "../Shared/TruncatedText"; import TextUtils from "src/utils/text"; import { useIntl } from "react-intl"; import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect"; import cx from "classnames"; import { defaultPreviewVolume } from "src/core/config"; interface IScenePhoto { scene: GQL.SlimSceneDataFragment; link: string; onError?: (photo: PhotoProps) => void; } interface IExtraProps { maxHeight: number; selected?: boolean; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; selecting?: boolean; } export const SceneWallItem: React.FC< RenderImageProps & IExtraProps > = (props: RenderImageProps & IExtraProps) => { const intl = useIntl(); const { dragProps } = useDragMoveSelect({ selecting: props.selecting || false, selected: props.selected || false, onSelectedChanged: props.onSelectedChanged, }); const { configuration } = useConfigurationContext(); const playSound = configuration?.interface.soundOnPreview ?? false; const volume = configuration?.ui.previewVolume ?? defaultPreviewVolume; const showTitle = configuration?.interface.wallShowTitle ?? false; const height = Math.min(props.maxHeight, props.photo.height); const zoomFactor = height / props.photo.height; const width = props.photo.width * zoomFactor; const [active, setActive] = useState(false); type style = Record; var divStyle: style = { margin: props.margin, display: "block", }; if (props.direction === "column") { divStyle.position = "absolute"; divStyle.left = props.left; divStyle.top = props.top; } var handleClick = function handleClick(event: React.MouseEvent) { if (props.selecting && props.onSelectedChanged) { props.onSelectedChanged(!props.selected, event.shiftKey); event.preventDefault(); event.stopPropagation(); return; } if (props.onClick) { props.onClick(event, { index: props.index }); } }; const video = props.photo.src.includes("preview"); const previewProps = { loading: "lazy", loop: video, muted: !video || !playSound || !active, autoPlay: video, playsInline: video, key: props.photo.key, src: props.photo.src, width, height, alt: props.photo.alt, onMouseEnter: () => setActive(true), onMouseLeave: () => setActive(false), onClick: handleClick, onError: () => { props.photo.onError?.(props.photo); }, }; const videoEl = useRef(null); useEffect(() => { if (video && videoEl?.current?.volume) videoEl.current.volume = playSound ? volume / 100 : 0; }, [video, playSound, volume]); const { scene } = props.photo; const title = objectTitle(scene); const performerNames = scene.performers.map((p) => p.name); const performers = performerNames.length >= 2 ? [...performerNames.slice(0, -2), performerNames.slice(-2).join(" & ")] : performerNames; let shiftKey = false; return (
    {props.onSelectedChanged && ( props.onSelectedChanged!(!props.selected, shiftKey)} onClick={(event: React.MouseEvent) => { shiftKey = event.shiftKey; event.stopPropagation(); }} /> )} {video ? (
    ); }; function getDimensions(s: GQL.SlimSceneDataFragment) { const defaults = { width: 1280, height: 720 }; if (!s.files.length) return defaults; return { width: s.files[0].width || defaults.width, height: s.files[0].height || defaults.height, }; } interface ISceneWallProps { scenes: GQL.SlimSceneDataFragment[]; sceneQueue?: SceneQueue; zoomIndex: number; selectedIds?: Set; onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; selecting?: boolean; } // HACK: typescript doesn't allow Gallery to accept a parameter for some reason const SceneGallery = Gallery as unknown as GalleryI; const breakpointZoomHeights = [ { minWidth: 576, heights: [100, 120, 240, 360] }, { minWidth: 768, heights: [120, 160, 240, 480] }, { minWidth: 1200, heights: [120, 160, 240, 300] }, { minWidth: 1400, heights: [160, 240, 300, 480] }, ]; const SceneWall: React.FC = ({ scenes, sceneQueue, zoomIndex, selectedIds, onSelectChange, selecting, }) => { const history = useHistory(); const containerRef = React.useRef(null); const margin = 3; const direction = "row"; const [erroredImgs, setErroredImgs] = useState([]); const handleError = useCallback((photo: PhotoProps) => { setErroredImgs((prev) => [...prev, photo.src]); }, []); useEffect(() => { setErroredImgs([]); }, [scenes]); const photos: PhotoProps[] = useMemo(() => { return scenes.map((s, index) => { const { width, height } = getDimensions(s); return { scene: s, src: s.paths.preview && !erroredImgs.includes(s.paths.preview) ? s.paths.preview! : s.paths.screenshot!, link: sceneQueue ? sceneQueue.makeLink(s.id, { sceneIndex: index }) : `/scenes/${s.id}`, width, height, tabIndex: index, key: s.id, loading: "lazy", alt: objectTitle(s), onError: handleError, }; }); }, [scenes, sceneQueue, erroredImgs, handleError]); const onClick = useCallback( (event, { index }) => { history.push(photos[index].link); }, [history, photos] ); function columns(containerWidth: number) { let preferredSize = 300; let columnCount = containerWidth / preferredSize; return Math.round(columnCount); } const targetRowHeight = useCallback( (containerWidth: number) => { let zoomHeight = 280; breakpointZoomHeights.forEach((e) => { if (containerWidth >= e.minWidth) { zoomHeight = e.heights[zoomIndex]; } }); return zoomHeight; }, [zoomIndex] ); // set the max height as a factor of the targetRowHeight // this allows some images to be taller than the target row height // but prevents images from becoming too tall when there is a small number of items const maxHeightFactor = 1.3; const renderImage = useCallback( (props: RenderImageProps) => { const sceneId = props.photo.scene.id; return ( onSelectChange(sceneId, selected, shiftKey) : undefined } selecting={selecting} /> ); }, [targetRowHeight, selectedIds, onSelectChange, selecting] ); return (
    {photos.length ? ( ) : null}
    ); }; interface ISceneWallPanelProps { scenes: GQL.SlimSceneDataFragment[]; sceneQueue?: SceneQueue; zoomIndex: number; selectedIds?: Set; onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; } export const SceneWallPanel: React.FC = ({ scenes, sceneQueue, zoomIndex, selectedIds, onSelectChange, }) => { const selecting = !!selectedIds && selectedIds.size > 0; return ( ); }; ================================================ FILE: ui/v2.5/src/components/Scenes/Scenes.tsx ================================================ import React from "react"; import { Route, Switch } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import { lazyComponent } from "src/utils/lazyComponent"; import { View } from "../List/views"; const SceneList = lazyComponent(() => import("./SceneList")); const SceneMarkerList = lazyComponent(() => import("./SceneMarkerList")); const Scene = lazyComponent(() => import("./SceneDetails/Scene")); const SceneCreate = lazyComponent(() => import("./SceneDetails/SceneCreate")); const Scenes: React.FC = () => { return ; }; const SceneMarkers: React.FC = () => { const titleProps = useTitleProps({ id: "markers" }); return ( <> ); }; const SceneRoutes: React.FC = () => { const titleProps = useTitleProps({ id: "scenes" }); return ( <> ); }; export default SceneRoutes; ================================================ FILE: ui/v2.5/src/components/Scenes/styles.scss ================================================ .card-popovers { display: flex; flex-wrap: wrap; justify-content: center; margin-bottom: 10px; .btn { padding-bottom: 3px; padding-top: 3px; } .fa-icon { margin-right: 7px; } } .video-section { position: relative; } .card-section { margin-bottom: 0; padding: 0.5rem 1rem 0 1rem; } .performer-tag-container, .group-tag-container { display: inline-block; margin: 5px; } .performer-tag-container .performer-disambiguation { color: initial; } .performer-tag.image, .group-tag.image { background-position: center; background-repeat: no-repeat; background-size: cover; height: 150px; margin: 0 auto; width: 100%; } .operation-container { .operation-item { min-width: 240px; } .rating-operation { min-width: 20px; } .apply-operation { margin-top: 2rem; } } .studio-logo { margin-top: 1rem; max-height: 8rem; max-width: 100%; } @include media-breakpoint-only(lg) { .scene-header-container { align-items: center; display: flex; justify-content: space-between; .scene-header { flex: 0 0 75%; order: 1; } .scene-studio-image { flex: 0 0 25%; order: 2; } } } .scene-header { flex-basis: auto; font-size: 1.5rem; margin-top: 30px; @include media-breakpoint-down(xl) { font-size: 1.75rem; } } .scene-subheader { display: flex; justify-content: space-between; margin-top: 0.5rem; .date { color: $text-muted; } .resolution { font-weight: bold; } } .scene-toolbar { align-items: center; display: flex; justify-content: space-between; margin-bottom: 0.25rem; margin-top: 0.5rem; padding-bottom: 0.25rem; width: 100%; .scene-toolbar-group { align-items: center; column-gap: 0.25rem; display: flex; width: 100%; &:last-child { justify-content: flex-end; } } } #scene-details-container { .tab-content { min-height: 15rem; } } textarea.scene-description { min-height: 150px; } .primary-card { margin: 1rem 0; &-body { max-height: 15rem; overflow-y: auto; } } .justify-content-center .studio-card .studio-card-image { width: 100%; } .studio-card { padding: 0.5rem; @media (max-width: 576px) { width: 100%; } &-header { height: 150px; line-height: 150px; text-align: center; } &-image { max-height: 150px; object-fit: contain; vertical-align: middle; width: 320px; @media (max-width: 576px) { width: 100%; } } } .scene-specs-overlay, .scene-interactive-speed-overlay { bottom: 1rem; color: $text-color; display: block; font-weight: 400; letter-spacing: -0.03rem; position: absolute; right: 0.7rem; text-shadow: 0 0 3px #000; } .scene-specs-overlay { right: 0.7rem; } .scene-specs-overlay > span:not(:last-child) { margin-right: 0.3rem; } .scene-interactive-speed-overlay { left: 0.7rem; } .extra-scene-info { display: none; } .overlay-resolution { font-weight: 900; text-transform: uppercase; } .scene-card { &-preview { aspect-ratio: 16/9; } .scene-group-scene-number { text-align: center; } } .scene-card, .scene-marker-card, .gallery-card { .scene-specs-overlay { transition: opacity 0.5s; } &-preview { display: flex; justify-content: center; margin-bottom: 5px; position: relative; &-image, &-video { height: 100%; object-fit: cover; object-position: top; width: 100%; } &-video { position: absolute; top: -9999px; transition: top 0s; transition-delay: 0s; } &.portrait { .scene-card-preview-image, .scene-card-preview-video { object-fit: contain; } } } &__details { margin-bottom: 1rem; } &:hover, &:active { .scene-specs-overlay { opacity: 0; transition: opacity 0.5s; } .scene-card-check { opacity: 0.75; transition: opacity 0.5s; } .scene-card-preview-video { top: 0; transition-delay: 0.2s; } } } .scene-card.card, .scene-marker-card.card { overflow: hidden; padding: 0; @media (max-width: 576px) { width: 100%; } &.fileless { background-color: darken($card-bg, 5%); } } .scene-cover { display: block; margin-bottom: 10px; margin-top: 10px; max-width: 100%; } .group-image { max-width: 100%; } .group-table { width: 100%; .group-row { align-items: center; margin-bottom: 0.25rem; } .group-scene-number-header { color: $text-muted; font-size: 0.8em; padding-bottom: 0; padding-top: 0; } } .group-table.no-groups .group-table-header { display: none; } .scene-tabs { display: flex; flex-direction: column; max-height: calc(100vh - 4rem); overflow-wrap: break-word; word-wrap: break-word; > div { flex: 0 1 auto; } } /* stylelint-disable selector-class-pattern */ .table .cover_image-head, .table .cover_image-data { text-align: center; } input[type="range"].filter-slider { height: 100%; margin: 0; padding-left: 0; padding-right: 0; } .filter-slider-value { cursor: pointer; } @mixin contrast-slider() { background: rgb(255, 255, 255); background: linear-gradient( -1deg, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 1) 40%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 1) 100% ), linear-gradient(90deg, rgba(61, 61, 61, 1) 0%, rgba(255, 255, 255, 0) 100%); background-blend-mode: color; } input[type="range"].contrast-slider { &::-webkit-slider-runnable-track { @include contrast-slider; } &::-moz-range-track { @include contrast-slider; } &::-ms-track { @include contrast-slider; } } @mixin brightness-slider() { background: rgb(41, 41, 41); background: linear-gradient( 90deg, rgba(41, 41, 41, 1) 0%, rgba(255, 255, 255, 1) 100% ); } input[type="range"].brightness-slider { &::-webkit-slider-runnable-track { @include brightness-slider; } &::-moz-range-track { @include brightness-slider; } &::-ms-track { @include brightness-slider; } } @mixin saturation-slider() { background: rgb(198, 198, 199); background: linear-gradient( 90deg, rgba(198, 198, 199, 1) 0%, rgba(255, 71, 71, 1) 100% ); } input[type="range"].saturation-slider { &::-webkit-slider-runnable-track { @include saturation-slider; } &::-moz-range-track { @include saturation-slider; } &::-ms-track { @include saturation-slider; } } @mixin hue-rotate-slider() { background: rgb(198, 198, 199); background: linear-gradient( to right, orange, yellow, green, cyan, blue, violet ); } input[type="range"].hue-rotate-slider { &::-webkit-slider-runnable-track { @include hue-rotate-slider; } &::-moz-range-track { @include hue-rotate-slider; } &::-ms-track { @include hue-rotate-slider; } } @mixin white-balance-slider() { background: rgb(90, 138, 210); background: linear-gradient( 90deg, rgba(90, 138, 210, 1) 0%, rgba(83, 72, 72, 1) 50%, rgba(252, 186, 8, 1) 100% ); } input[type="range"].white-balance-slider { &::-webkit-slider-runnable-track { @include white-balance-slider; } &::-moz-range-track { @include white-balance-slider; } &::-ms-track { @include white-balance-slider; } } @mixin red-slider() { background: rgb(255, 0, 0); } input[type="range"].red-slider { &::-webkit-slider-runnable-track { @include red-slider; } &::-moz-range-track { @include red-slider; } &::-ms-track { @include red-slider; } } @mixin green-slider() { background: rgb(0, 255, 0); } input[type="range"].green-slider { &::-webkit-slider-runnable-track { @include green-slider; } &::-moz-range-track { @include green-slider; } &::-ms-track { @include green-slider; } } @mixin blue-slider() { background: rgb(0, 0, 255); } input[type="range"].blue-slider { &::-webkit-slider-runnable-track { @include blue-slider; } &::-moz-range-track { @include blue-slider; } &::-ms-track { @include blue-slider; } } @media (min-width: 1200px), (max-width: 575px) { .performer-card .fi { height: 1.33rem; width: 2rem; } .scene-performers { .performer-card { width: 15rem; &-image { height: 22.5rem; } } } } #scene-edit-details { .rating-stars { font-size: 1.3em; height: calc(1.5em + 0.75rem + 2px); } .edit-buttons-container { background-color: #202b33; position: sticky; top: 0; z-index: 3; @media (min-width: 575px) and (max-width: 1199px) { top: 3rem; } } .form-group[data-field="urls"] .string-list-input input.form-control { font-size: 0.85em; } @include media-breakpoint-up(xl) { .custom-fields-input { .custom-fields-field { flex: 0 0 25%; max-width: 25%; } .custom-fields-value { flex: 0 0 75%; max-width: 75%; } } } } .scene-markers-panel { .wall .wall-item { height: inherit; min-height: 14rem; width: calc(100% - 2rem); &-missing { font-size: 1.5rem; } &::before { display: none; } &:hover { .wall-item-container { transform: scale(1); } } } } .organized-button { &.not-organized { color: rgba(191, 204, 214, 0.5); } &.organized { color: #664c3f; } } .o-counter .dropdown-toggle { background-color: rgba(0, 0, 0, 0); border: none; padding-left: 0; padding-right: 0.25rem; } @media (min-width: 1200px) { #queue-viewer { .queue-scene-details { width: 245px; } .queue-scene-title, .queue-scene-studio, .queue-scene-performers, .queue-scene-date { margin-right: auto; min-width: 245px; overflow: hidden; position: relative; transform: translateX(0); transition: 2s; white-space: nowrap; } .queue-scene-title:hover, .queue-scene-studio:hover, .queue-scene-performers:hover, .queue-scene-date:hover { transform: translateX(calc(245px - 100%)); } } } #queue-viewer { .queue-controls { align-items: center; background-color: $body-bg; display: flex; flex: 0 1 auto; height: 30px; justify-content: space-between; position: sticky; top: 0; z-index: 100; } .queue-scene-details { display: grid; overflow: hidden; position: relative; } .queue-scene-title { font-size: 1.2rem; @media (max-width: 576px) { font-size: 1rem; } } .queue-scene-studio { color: #d3d0d0; font-weight: 600; } .queue-scene-performers, .queue-scene-date { color: #d3d0d0; font-size: 0.9rem; font-weight: 400; @media (max-width: 576px) { font-size: 0.8rem; } } .thumbnail-container { height: 80px; margin-bottom: 5px; margin-right: 0.75rem; margin-top: 5px; min-width: 142px; width: 142px; } ol { padding-left: 20px; } img { height: 100%; object-fit: contain; object-position: center; width: 100%; } a { color: $text-color; font-weight: 500; text-decoration: none; } .current { background-color: $secondary; } } .scrape-query-dialog { max-height: calc(100vh - 10rem); } .scraper-group { & > .dropdown:not(:last-child) .btn { border-bottom-right-radius: 0; border-top-right-radius: 0; } & > .dropdown:not(:first-child) .btn { border-bottom-left-radius: 0; border-top-left-radius: 0; } } .SceneScrapeModal-list { list-style: none; max-height: 50vh; overflow-x: hidden; overflow-y: auto; padding-inline-start: 0; li { cursor: pointer; } } .scene-file-card.card { margin: 0; padding: 0; .card-header { cursor: pointer; } dl { margin-bottom: 0; } } .scrape-dialog .rating-number.disabled { padding-left: 0.5em; } .preview-scrubber { height: 100%; position: absolute; width: 100%; .scene-card-preview-image { align-items: center; display: flex; justify-content: center; overflow: hidden; } .scrubber-image { height: 100%; width: 100%; } .scrubber-timestamp { bottom: calc(20px + 0.25rem); font-weight: 400; opacity: 0.75; position: absolute; right: 0.7rem; text-shadow: 0 0 3px #000; } } .hover-scrubber { bottom: 0; height: 20px; overflow: hidden; position: absolute; width: 100%; .hover-scrubber-area { cursor: col-resize; height: 100%; position: absolute; width: 100%; z-index: 1; } &.hover-scrubber-inactive { .hover-scrubber-area { cursor: inherit; } .hover-scrubber-indicator { background-color: inherit; } } .hover-scrubber-indicator { background-color: rgba(255, 255, 255, 0.1); bottom: -100%; height: 100%; position: absolute; transition: bottom 0.2s ease-in-out; width: 100%; .hover-scrubber-indicator-marker { background-color: rgba(255, 0, 0, 0.5); bottom: 0; height: 5px; position: absolute; } } &:hover .hover-scrubber-indicator { bottom: 0; } } .play-history dl { margin-top: 0.5rem; } .play-history, .o-history { .history-header h5 { align-items: center; display: flex; justify-content: space-between; } .history-operations-dropdown { display: inline-block; } .add-date-button { color: $success; } .remove-date-button { color: $danger; } ul { padding-inline-start: 1rem; li { display: flex; justify-content: space-between; } } } .scene-select-option { .scene-select-row { align-items: center; display: flex; width: 100%; .scene-select-image { background-color: $body-bg; margin-right: 0.4em; max-height: 50px; max-width: 89px; object-fit: contain; object-position: center; } .scene-select-details { display: flex; flex-direction: column; justify-content: flex-start; max-height: 4.1rem; overflow: hidden; .scene-select-title { flex-shrink: 0; white-space: pre-wrap; word-break: break-all; } .scene-select-date, .scene-select-studio, .scene-select-code { color: $text-muted; flex-shrink: 0; font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } } } .scene-select-alias { font-size: 0.8rem; font-weight: bold; width: 100%; word-break: break-all; } } .scene-wall, .marker-wall { .wall-item { position: relative; .lineargradient { background-image: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.3)); bottom: 0; height: 100px; position: absolute; width: 100%; } &-title { font-weight: bold; } &-footer { bottom: 20px; padding: 0 1rem; position: absolute; text-shadow: 1px 1px 3px black; transition: 0s opacity; width: 100%; z-index: 2; @media (min-width: 768px) { opacity: 0; } &:hover { .wall-item-title { text-decoration: underline; } } a { color: white; } } &:hover .wall-item-footer { opacity: 1; transition: 1s opacity; transition-delay: 500ms; a { text-decoration: none; } } &.show-title .wall-item-footer { opacity: 1; } } } .table-list.scene-table { // Set max height to viewport height minus estimated header/footer height // TODO - this will need to be rolled out to other tables max-height: calc(100dvh - 210px); } .scene-list .filtered-list-toolbar { // hide play and create new buttons on xs screens // show these in the drop down menu instead @include media-breakpoint-down(xs) { .play-button, .create-new-button { display: none; } } } // hide drop down menu items for play and create new // when the buttons are visible @include media-breakpoint-up(sm) { .scene-list-operations-dropdown { .dropdown-item.play-item, .dropdown-item.create-new-item { display: none; } } } ================================================ FILE: ui/v2.5/src/components/Settings/GeneratePreviewOptions.tsx ================================================ import React from "react"; import { useIntl } from "react-intl"; import { Form } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import { NumberField } from "src/utils/form"; export type VideoPreviewSettingsInput = Pick< GQL.ConfigGeneralInput, | "previewSegments" | "previewSegmentDuration" | "previewExcludeStart" | "previewExcludeEnd" >; interface IVideoPreviewInput { value: VideoPreviewSettingsInput; setValue: (v: VideoPreviewSettingsInput) => void; } export const VideoPreviewInput: React.FC = ({ value, setValue, }) => { const intl = useIntl(); function set(v: Partial) { setValue({ ...value, ...v, }); } const { previewSegments, previewSegmentDuration, previewExcludeStart, previewExcludeEnd, } = value; return (
    {intl.formatMessage({ id: "dialogs.scene_gen.preview_seg_count_head", })}
    ) => set({ previewSegments: Number.parseInt( e.currentTarget.value || "1", 10 ), }) } /> {intl.formatMessage({ id: "dialogs.scene_gen.preview_seg_count_desc", })}
    {intl.formatMessage({ id: "dialogs.scene_gen.preview_seg_duration_head", })}
    ) => set({ previewSegmentDuration: Number.parseFloat( e.currentTarget.value || "0" ), }) } /> {intl.formatMessage({ id: "dialogs.scene_gen.preview_seg_duration_desc", })}
    {intl.formatMessage({ id: "dialogs.scene_gen.preview_exclude_start_time_head", })}
    ) => set({ previewExcludeStart: e.currentTarget.value }) } /> {intl.formatMessage({ id: "dialogs.scene_gen.preview_exclude_start_time_desc", })}
    {intl.formatMessage({ id: "dialogs.scene_gen.preview_exclude_end_time_head", })}
    ) => set({ previewExcludeEnd: e.currentTarget.value }) } /> {intl.formatMessage({ id: "dialogs.scene_gen.preview_exclude_end_time_desc", })}
    ); }; ================================================ FILE: ui/v2.5/src/components/Settings/Inputs.tsx ================================================ import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons"; import React, { PropsWithChildren, useState } from "react"; import { Button, Collapse, Form, Modal, ModalProps } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { Icon } from "../Shared/Icon"; import { StringListInput } from "../Shared/StringListInput"; import { PatchComponent } from "src/patch"; import { useSettings, useSettingsOptional } from "./context"; import { NumberField } from "src/utils/form"; interface ISetting { id?: string; advanced?: boolean; className?: string; heading?: React.ReactNode; headingID?: string; subHeadingID?: string; subHeading?: React.ReactNode; tooltipID?: string; onClick?: React.MouseEventHandler; disabled?: boolean; } export const Setting: React.FC> = PatchComponent( "Setting", (props: PropsWithChildren) => { const { id, className, heading, headingID, subHeadingID, subHeading, children, tooltipID, onClick, disabled, advanced, } = props; // these components can be used in the setup wizard, where advanced mode is not available const { advancedMode } = useSettingsOptional(); const intl = useIntl(); function renderHeading() { if (headingID) { return intl.formatMessage({ id: headingID }); } return heading; } function renderSubHeading() { if (subHeadingID) { return (
    {intl.formatMessage({ id: subHeadingID })}
    ); } if (subHeading) { return
    {subHeading}
    ; } } const tooltip = tooltipID ? intl.formatMessage({ id: tooltipID }) : undefined; const disabledClassName = disabled ? "disabled" : ""; if (advanced && !advancedMode) return null; return (

    {renderHeading()}

    {renderSubHeading()}
    {children}
    ); } ) as React.FC>; interface ISettingGroup { settingProps?: ISetting; topLevel?: JSX.Element; collapsible?: boolean; collapsedDefault?: boolean; } export const SettingGroup: React.FC> = PatchComponent( "SettingGroup", ({ settingProps, topLevel, collapsible, collapsedDefault, children }) => { const [open, setOpen] = useState(!collapsedDefault); function renderCollapseButton() { if (!collapsible) return; return ( ); } function onDivClick(e: React.MouseEvent) { if (!collapsible) return; // ensure button was not clicked let target: HTMLElement | null = e.target as HTMLElement; while (target && target !== e.currentTarget) { if ( target.nodeName.toLowerCase() === "button" || target.nodeName.toLowerCase() === "a" ) { // button clicked, swallow event return; } target = target.parentElement; } setOpen(!open); } return (
    {topLevel} {renderCollapseButton()}
    {children}
    ); } ); interface IBooleanSetting extends ISetting { id: string; checked?: boolean; onChange: (v: boolean) => void; } export const BooleanSetting: React.FC = PatchComponent( "BooleanSetting", (props) => { const { id, disabled, checked, onChange, ...settingProps } = props; return ( onChange(!checked)} /> ); } ); interface ISelectSetting extends ISetting { value?: string | number | string[]; onChange: (v: string) => void; } export const SelectSetting: React.FC> = PatchComponent( "SelectSetting", ({ id, headingID, subHeadingID, value, children, onChange, advanced }) => { return ( onChange(e.currentTarget.value)} > {children} ); } ); interface IDialogSetting extends ISetting { buttonText?: string; buttonTextID?: string; value?: T; renderValue?: (v: T | undefined) => JSX.Element; onChange: () => void; } const _ChangeButtonSetting = (props: IDialogSetting) => { const { id, className, headingID, heading, tooltipID, subHeadingID, subHeading, value, onChange, renderValue, buttonText, buttonTextID, disabled, } = props; const intl = useIntl(); const tooltip = tooltipID ? intl.formatMessage({ id: tooltipID }) : undefined; const disabledClassName = disabled ? "disabled" : ""; return (

    {headingID ? intl.formatMessage({ id: headingID }) : heading ? heading : undefined}

    {renderValue ? renderValue(value) : undefined}
    {subHeadingID ? (
    {intl.formatMessage({ id: subHeadingID })}
    ) : subHeading ? (
    {subHeading}
    ) : undefined}
    ); }; export const ChangeButtonSetting = PatchComponent( "ChangeButtonSetting", _ChangeButtonSetting ) as typeof _ChangeButtonSetting; export interface ISettingModal { heading?: React.ReactNode; headingID?: string; subHeadingID?: string; subHeading?: React.ReactNode; value: T | undefined; close: (v?: T) => void; renderField: ( value: T | undefined, setValue: (v?: T) => void, error?: string ) => JSX.Element; modalProps?: ModalProps; validate?: (v: T) => boolean | undefined; error?: string | undefined; } const _SettingModal = (props: ISettingModal) => { const { heading, headingID, subHeading, subHeadingID, value, close, renderField, modalProps, validate, error, } = props; const intl = useIntl(); const [currentValue, setCurrentValue] = useState(value); return ( close()} id="setting-dialog" {...modalProps}>
    { close(currentValue); e.preventDefault(); }} > {headingID ? : heading} {renderField(currentValue, setCurrentValue, error)} {subHeadingID ? (
    {intl.formatMessage({ id: subHeadingID })}
    ) : subHeading ? (
    {subHeading}
    ) : undefined}
    ); }; export const SettingModal = PatchComponent( "SettingModal", _SettingModal ) as typeof _SettingModal; interface IModalSetting extends ISetting { value: T | undefined; buttonText?: string; buttonTextID?: string; onChange: (v: T) => void; renderField: ( value: T | undefined, setValue: (v?: T) => void, error?: string ) => JSX.Element; renderValue?: (v: T | undefined) => JSX.Element; modalProps?: ModalProps; validateChange?: (v: T) => void | undefined; } export const _ModalSetting = (props: IModalSetting) => { const { id, className, value, headingID, heading, subHeadingID, subHeading, onChange, renderField, renderValue, tooltipID, buttonText, buttonTextID, modalProps, disabled, advanced, validateChange, } = props; const [showModal, setShowModal] = useState(false); const [error, setError] = useState(); const { advancedMode } = useSettings(); function onClose(v: T | undefined) { setError(undefined); if (v !== undefined) { if (validateChange) { try { validateChange(v); } catch (e) { setError((e as Error).message); return; } } onChange(v); } setShowModal(false); } if (advanced && !advancedMode) return null; return ( <> {showModal ? ( headingID={headingID} subHeadingID={subHeadingID} heading={heading} subHeading={subHeading} value={value} renderField={renderField} close={onClose} error={error} {...modalProps} /> ) : undefined} id={id} className={className} disabled={disabled} buttonText={buttonText} buttonTextID={buttonTextID} headingID={headingID} heading={heading} tooltipID={tooltipID} subHeadingID={subHeadingID} subHeading={subHeading} value={value} onChange={() => setShowModal(true)} renderValue={renderValue} /> ); }; export const ModalSetting = PatchComponent( "ModalSetting", _ModalSetting ) as typeof _ModalSetting; interface IStringSetting extends ISetting { value: string | undefined; onChange: (v: string) => void; } export const StringSetting: React.FC = PatchComponent( "StringSetting", (props) => { return ( {...props} renderField={(value, setValue) => ( ) => setValue(e.currentTarget.value) } /> )} renderValue={(value) => {value}} /> ); } ); interface INumberSetting extends ISetting { value: number | undefined; onChange: (v: number) => void; } export const NumberSetting: React.FC = PatchComponent( "NumberSetting", (props) => { return ( {...props} renderField={(value, setValue) => ( ) => setValue(Number.parseInt(e.currentTarget.value || "0", 10)) } /> )} renderValue={(value) => {value}} /> ); } ); interface IStringListSetting extends ISetting { value: string[] | undefined; defaultNewValue?: string; onChange: (v: string[]) => void; } export const StringListSetting: React.FC = PatchComponent( "StringListSetting", (props) => { return ( {...props} renderField={(value, setValue) => ( )} renderValue={(value) => (
    {value?.map((v, i) => ( // eslint-disable-next-line react/no-array-index-key
    {v}
    ))}
    )} /> ); } ); interface IConstantSetting extends ISetting { value?: T; renderValue?: (v: T | undefined) => JSX.Element; } export const _ConstantSetting = (props: IConstantSetting) => { const { id, headingID, subHeading, subHeadingID, renderValue, value } = props; const intl = useIntl(); return (

    {headingID ? intl.formatMessage({ id: headingID }) : undefined}

    {renderValue ? renderValue(value) : value}
    {subHeadingID ? (
    {intl.formatMessage({ id: subHeadingID })}
    ) : subHeading ? (
    {subHeading}
    ) : undefined}
    ); }; export const ConstantSetting = PatchComponent( "ConstantSetting", _ConstantSetting ) as typeof _ConstantSetting; ================================================ FILE: ui/v2.5/src/components/Settings/PluginPackageManager.tsx ================================================ import React, { useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { evictQueries, getClient, queryAvailablePluginPackages, useInstalledPluginPackages, mutateInstallPluginPackages, mutateUninstallPluginPackages, mutateUpdatePluginPackages, pluginMutationImpactedQueries, isLoading, } from "src/core/StashService"; import { useMonitorJob } from "src/utils/job"; import { AvailablePackages, InstalledPackages, RemotePackage, } from "../Shared/PackageManager/PackageManager"; import { useSettings } from "./context"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { SettingSection } from "./SettingSection"; export const InstalledPluginPackages: React.FC = () => { const [loadUpgrades, setLoadUpgrades] = useState(false); const [jobID, setJobID] = useState(); const { job } = useMonitorJob(jobID, () => onPackageChanges()); const { data, previousData, refetch, networkStatus, error } = useInstalledPluginPackages(loadUpgrades); const loading = isLoading(networkStatus); async function onUpdatePackages(packages: GQL.PackageSpecInput[]) { const r = await mutateUpdatePluginPackages(packages); setJobID(r.data?.updatePackages); } async function onUninstallPackages(packages: GQL.PackageSpecInput[]) { const r = await mutateUninstallPluginPackages(packages); setJobID(r.data?.uninstallPackages); } function onPackageChanges() { // job is complete, refresh all local data const ac = getClient(); evictQueries(ac.cache, pluginMutationImpactedQueries); } function onCheckForUpdates() { if (!loadUpgrades) { setLoadUpgrades(true); } else { refetch(); } } // when loadUpgrades changes from false to true, data is set to undefined while the request is loading // so use previousData as a fallback, which will be the result when loadUpgrades was false, // to prevent displaying a "No packages found" message const installedPackages = data?.installedPackages ?? previousData?.installedPackages ?? []; return (
    onUpdatePackages( packages.map((p) => ({ id: p.package_id, sourceURL: p.sourceURL, })) ) } onUninstallPackages={(packages) => onUninstallPackages( packages.map((p) => ({ id: p.package_id, sourceURL: p.sourceURL, })) ) } updatesLoaded={loadUpgrades && !loading} />
    ); }; export const AvailablePluginPackages: React.FC = () => { const { general, loading: configLoading, error, saveGeneral } = useSettings(); const [jobID, setJobID] = useState(); const { job } = useMonitorJob(jobID, () => onPackageChanges()); // Get installed packages to filter them out from available list const { data: installedData } = useInstalledPluginPackages(false); const installedPackageIds = new Set( installedData?.installedPackages?.map((p) => p.package_id) ?? [] ); async function onInstallPackages(packages: GQL.PackageSpecInput[]) { const r = await mutateInstallPluginPackages(packages); setJobID(r.data?.installPackages); } function onPackageChanges() { // job is complete, refresh all local data const ac = getClient(); evictQueries(ac.cache, pluginMutationImpactedQueries); } async function loadSource(source: string): Promise { const { data } = await queryAvailablePluginPackages(source); // Filter out already installed packages return data.availablePackages.filter( (pkg) => !installedPackageIds.has(pkg.package_id) ); } function addSource(source: GQL.PackageSource) { saveGeneral({ pluginPackageSources: [...(general.pluginPackageSources ?? []), source], }); } function editSource(existing: GQL.PackageSource, changed: GQL.PackageSource) { saveGeneral({ pluginPackageSources: general.pluginPackageSources?.map((s) => s.url === existing.url ? changed : s ), }); } function deleteSource(source: GQL.PackageSource) { saveGeneral({ pluginPackageSources: general.pluginPackageSources?.filter( (s) => s.url !== source.url ), }); } function renderDescription(pkg: RemotePackage) { if (pkg.metadata.description) { return pkg.metadata.description; } } if (error) return

    {error.message}

    ; if (configLoading) return ; const loading = !!job; const sources = general?.pluginPackageSources ?? []; return (
    loadSource(source)} sources={sources} addSource={addSource} editSource={editSource} deleteSource={deleteSource} />
    ); }; ================================================ FILE: ui/v2.5/src/components/Settings/ScraperPackageManager.tsx ================================================ import React, { useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { evictQueries, getClient, queryAvailableScraperPackages, useInstalledScraperPackages, mutateUpdateScraperPackages, mutateUninstallScraperPackages, mutateInstallScraperPackages, scraperMutationImpactedQueries, isLoading, } from "src/core/StashService"; import { useMonitorJob } from "src/utils/job"; import { AvailablePackages, InstalledPackages, RemotePackage, } from "../Shared/PackageManager/PackageManager"; import { useSettings } from "./context"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { SettingSection } from "./SettingSection"; export const InstalledScraperPackages: React.FC = () => { const [loadUpgrades, setLoadUpgrades] = useState(false); const [jobID, setJobID] = useState(); const { job } = useMonitorJob(jobID, () => onPackageChanges()); const { data, previousData, refetch, networkStatus, error } = useInstalledScraperPackages(loadUpgrades); const loading = isLoading(networkStatus); async function onUpdatePackages(packages: GQL.PackageSpecInput[]) { const r = await mutateUpdateScraperPackages(packages); setJobID(r.data?.updatePackages); } async function onUninstallPackages(packages: GQL.PackageSpecInput[]) { const r = await mutateUninstallScraperPackages(packages); setJobID(r.data?.uninstallPackages); } function onPackageChanges() { // job is complete, refresh all local data const ac = getClient(); evictQueries(ac.cache, scraperMutationImpactedQueries); } function onCheckForUpdates() { if (!loadUpgrades) { setLoadUpgrades(true); } else { refetch(); } } // when loadUpgrades changes from false to true, data is set to undefined while the request is loading // so use previousData as a fallback, which will be the result when loadUpgrades was false, // to prevent displaying a "No packages found" message const installedPackages = data?.installedPackages ?? previousData?.installedPackages ?? []; return (
    onUpdatePackages( packages.map((p) => ({ id: p.package_id, sourceURL: p.sourceURL, })) ) } onUninstallPackages={(packages) => onUninstallPackages( packages.map((p) => ({ id: p.package_id, sourceURL: p.sourceURL, })) ) } updatesLoaded={loadUpgrades && !loading} />
    ); }; export const AvailableScraperPackages: React.FC = () => { const { general, loading: configLoading, error, saveGeneral } = useSettings(); const [jobID, setJobID] = useState(); const { job } = useMonitorJob(jobID, () => onPackageChanges()); // Get installed packages to filter them out from available list const { data: installedData } = useInstalledScraperPackages(false); const installedPackageIds = new Set( installedData?.installedPackages?.map((p) => p.package_id) ?? [] ); async function onInstallPackages(packages: GQL.PackageSpecInput[]) { const r = await mutateInstallScraperPackages(packages); setJobID(r.data?.installPackages); } function onPackageChanges() { // job is complete, refresh all local data const ac = getClient(); evictQueries(ac.cache, scraperMutationImpactedQueries); } async function loadSource(source: string): Promise { const { data } = await queryAvailableScraperPackages(source); // Filter out already installed packages return data.availablePackages.filter( (pkg) => !installedPackageIds.has(pkg.package_id) ); } function addSource(source: GQL.PackageSource) { saveGeneral({ scraperPackageSources: [...(general.scraperPackageSources ?? []), source], }); } function editSource(existing: GQL.PackageSource, changed: GQL.PackageSource) { saveGeneral({ scraperPackageSources: general.scraperPackageSources?.map((s) => s.url === existing.url ? changed : s ), }); } function deleteSource(source: GQL.PackageSource) { saveGeneral({ scraperPackageSources: general.scraperPackageSources?.filter( (s) => s.url !== source.url ), }); } function renderDescription(pkg: RemotePackage) { if (pkg.metadata.description) { return pkg.metadata.description; } } if (error) return

    {error.message}

    ; if (configLoading) return ; const loading = !!job; const sources = general?.scraperPackageSources ?? []; return (
    loadSource(source)} sources={sources} addSource={addSource} editSource={editSource} deleteSource={deleteSource} allowSelectAll />
    ); }; ================================================ FILE: ui/v2.5/src/components/Settings/SettingSection.tsx ================================================ import React, { PropsWithChildren } from "react"; import { Card } from "react-bootstrap"; import { useIntl } from "react-intl"; import { useSettings } from "./context"; interface ISettingGroup { id?: string; headingID?: string; subHeadingID?: string; advanced?: boolean; } export const SettingSection: React.FC> = ({ id, children, headingID, subHeadingID, advanced, }) => { const intl = useIntl(); const { advancedMode } = useSettings(); if (advanced && !advancedMode) return null; return (

    {headingID ? intl.formatMessage({ id: headingID }) : undefined}

    {subHeadingID ? (
    {intl.formatMessage({ id: subHeadingID })}
    ) : undefined} {children}
    ); }; ================================================ FILE: ui/v2.5/src/components/Settings/Settings.tsx ================================================ import React from "react"; import { Tab, Nav, Row, Col, Form } from "react-bootstrap"; import { Redirect, useLocation } from "react-router-dom"; import { LinkContainer } from "react-router-bootstrap"; import { FormattedMessage } from "react-intl"; import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import { SettingsAboutPanel } from "./SettingsAboutPanel"; import { SettingsConfigurationPanel } from "./SettingsSystemPanel"; import { SettingsInterfacePanel } from "./SettingsInterfacePanel/SettingsInterfacePanel"; import { SettingsLogsPanel } from "./SettingsLogsPanel"; import { SettingsTasksPanel } from "./Tasks/SettingsTasksPanel"; import { SettingsPluginsPanel } from "./SettingsPluginsPanel"; import { SettingsScrapingPanel } from "./SettingsScrapingPanel"; import { SettingsToolsPanel } from "./SettingsToolsPanel"; import { SettingsServicesPanel } from "./SettingsServicesPanel"; import { SettingsContext, useSettings } from "./context"; import { SettingsLibraryPanel } from "./SettingsLibraryPanel"; import { SettingsSecurityPanel } from "./SettingsSecurityPanel"; import Changelog from "../Changelog/Changelog"; import { TroubleshootingModeButton } from "../TroubleshootingMode/TroubleshootingModeButton"; import { useTroubleshootingMode } from "../TroubleshootingMode/useTroubleshootingMode"; const validTabs = [ "tasks", "library", "interface", "security", "metadata-providers", "services", "system", "plugins", "logs", "tools", "changelog", "about", ] as const; type TabKey = (typeof validTabs)[number]; const defaultTab: TabKey = "tasks"; function isTabKey(tab: string | null): tab is TabKey { return validTabs.includes(tab as TabKey); } const SettingTabs: React.FC<{ tab: TabKey }> = ({ tab }) => { const { advancedMode, setAdvancedMode } = useSettings(); const { isActive: troubleshootingModeActive } = useTroubleshootingMode(); const titleProps = useTitleProps({ id: "settings" }); return ( ); }; export const Settings: React.FC = () => { const location = useLocation(); const tab = new URLSearchParams(location.search).get("tab"); if (!isTabKey(tab)) { return ( ); } return ( ); }; export default Settings; ================================================ FILE: ui/v2.5/src/components/Settings/SettingsAboutPanel.tsx ================================================ import React from "react"; import { Button } from "react-bootstrap"; import { useIntl } from "react-intl"; import { useLatestVersion } from "src/core/StashService"; import { ExternalLink } from "../Shared/ExternalLink"; import { ConstantSetting, SettingGroup } from "./Inputs"; import { SettingSection } from "./SettingSection"; export const SettingsAboutPanel: React.FC = () => { const gitHash = import.meta.env.VITE_APP_GITHASH; const stashVersion = import.meta.env.VITE_APP_STASH_VERSION; const buildTime = import.meta.env.VITE_APP_DATE; const intl = useIntl(); const { data: dataLatest, error: errorLatest, loading: loadingLatest, refetch, networkStatus, } = useLatestVersion(); function renderLatestVersion() { if (errorLatest) { return ( ); } else if (!dataLatest || loadingLatest || networkStatus === 4) { return ( ); } else { let heading = dataLatest.latestversion.version; const hashString = dataLatest.latestversion.shorthash; if (gitHash !== hashString) { heading += " " + intl.formatMessage({ id: "config.about.new_version_notice", }); } return (

    {intl.formatMessage({ id: "config.about.build_hash", })}

    {hashString}
    ); } } return ( <> {renderLatestVersion()}

    {intl.formatMessage( { id: "config.about.stash_home" }, { url: ( GitHub ), } )}

    {intl.formatMessage( { id: "config.about.stash_wiki" }, { url: ( Documentation ), } )}

    {intl.formatMessage( { id: "config.about.stash_discord" }, { url: ( Discord ), } )}

    {intl.formatMessage( { id: "config.about.stash_open_collective" }, { url: ( Open Collective ), } )}

    ); }; ================================================ FILE: ui/v2.5/src/components/Settings/SettingsInterfacePanel/CheckboxGroup.tsx ================================================ import React from "react"; import { BooleanSetting } from "../Inputs"; import { PatchComponent } from "src/patch"; interface IItem { id: string; headingID: string; } interface ICheckboxGroupProps { groupId: string; items: IItem[]; checkedIds?: string[]; onChange?: (ids: string[]) => void; } export const CheckboxGroup: React.FC = PatchComponent( "CheckboxGroup", ({ groupId, items, checkedIds = [], onChange }) => { function generateId(itemId: string) { return `${groupId}-${itemId}`; } return ( <> {items.map(({ id, headingID }) => ( { if (v) { onChange?.( items .map((item) => item.id) .filter( (itemId) => generateId(itemId) === generateId(id) || checkedIds.includes(itemId) ) ); } else { onChange?.( items .map((item) => item.id) .filter( (itemId) => generateId(itemId) !== generateId(id) && checkedIds.includes(itemId) ) ); } }} /> ))} ); } ); ================================================ FILE: ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx ================================================ import React, { useCallback, useMemo } from "react"; import { Button, Form } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { DurationInput } from "src/components/Shared/DurationInput"; import { PercentInput } from "src/components/Shared/PercentInput"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { CheckboxGroup } from "./CheckboxGroup"; import { SettingSection } from "../SettingSection"; import { BooleanSetting, ModalSetting, NumberSetting, SelectSetting, StringSetting, } from "../Inputs"; import { useSettings } from "../context"; import TextUtils from "src/utils/text"; import * as GQL from "src/core/generated-graphql"; import { imageLightboxDisplayModeIntlMap, imageLightboxScrollModeIntlMap, } from "src/core/enums"; import { useInterfaceLocalForage } from "src/hooks/LocalForage"; import { ConnectionState, connectionStateLabel, InteractiveContext, } from "src/hooks/Interactive/context"; import { defaultRatingStarPrecision, defaultRatingSystemOptions, defaultRatingSystemType, RatingStarPrecision, ratingStarPrecisionIntlMap, ratingSystemIntlMap, RatingSystemType, } from "src/utils/rating"; import { imageWallDirectionIntlMap, ImageWallDirection, defaultImageWallOptions, defaultImageWallDirection, defaultImageWallMargin, } from "src/utils/imageWall"; import { defaultMaxOptionsShown, defaultPreviewVolume } from "src/core/config"; import { PatchComponent } from "src/patch"; const allMenuItems = [ { id: "scenes", headingID: "scenes" }, { id: "images", headingID: "images" }, { id: "groups", headingID: "groups" }, { id: "markers", headingID: "markers" }, { id: "galleries", headingID: "galleries" }, { id: "performers", headingID: "performers" }, { id: "studios", headingID: "studios" }, { id: "tags", headingID: "tags" }, ]; export const SettingsInterfacePanel: React.FC = PatchComponent( "SettingsInterfacePanel", function SettingsInterfacePanel() { const intl = useIntl(); const { interface: iface, saveInterface, ui, saveUI, loading, error, } = useSettings(); // convert old movies menu item to groups const massageMenuItems = useCallback((menuItems: string[]) => { return menuItems.map((item) => { if (item === "movies") { return "groups"; } return item; }); }, []); const massagedMenuItems = useMemo(() => { if (!iface.menuItems) return iface.menuItems; return massageMenuItems(iface.menuItems); }, [iface.menuItems, massageMenuItems]); const { interactive, state: interactiveState, error: interactiveError, serverOffset: interactiveServerOffset, initialised: interactiveInitialised, initialise: initialiseInteractive, sync: interactiveSync, } = React.useContext(InteractiveContext); const [, setInterfaceLocalForage] = useInterfaceLocalForage(); function saveLightboxSettings(v: Partial) { // save in local forage as well for consistency setInterfaceLocalForage((prev) => ({ ...prev, imageLightbox: { ...prev.imageLightbox, ...v, }, })); saveInterface({ imageLightbox: { ...iface.imageLightbox, ...v, }, }); } function saveImageWallMargin(m: number) { saveUI({ imageWallOptions: { ...(ui.imageWallOptions ?? defaultImageWallOptions), margin: m, }, }); } function saveImageWallDirection(d: ImageWallDirection) { saveUI({ imageWallOptions: { ...(ui.imageWallOptions ?? defaultImageWallOptions), direction: d, }, }); } function saveRatingSystemType(t: RatingSystemType) { saveUI({ ratingSystemOptions: { ...ui.ratingSystemOptions, type: t, }, }); } function saveRatingSystemStarPrecision(p: RatingStarPrecision) { saveUI({ ratingSystemOptions: { ...(ui.ratingSystemOptions ?? defaultRatingSystemOptions), starPrecision: p, }, }); } function validateLocaleString(v: string) { if (!v) return; try { JSON.parse(v); } catch (e) { throw new Error( intl.formatMessage( { id: "errors.invalid_json_string" }, { error: (e as SyntaxError).message, } ) ); } } function validateJavascriptString(v: string) { if (!v) return; try { // creates a function from the string to validate it but does not execute it // eslint-disable-next-line @typescript-eslint/no-implied-eval new Function(v); } catch (e) { throw new Error( intl.formatMessage( { id: "errors.invalid_javascript_string" }, { error: (e as SyntaxError).message, } ) ); } } if (error) return

    {error.message}

    ; if (loading) return ; // https://en.wikipedia.org/wiki/List_of_language_names return ( <> saveInterface({ language: v })} > saveInterface({ sfwContentMode: v })} /> saveUI({ title: v })} />

    {intl.formatMessage({ id: "config.ui.menu_items.heading", })}

    {intl.formatMessage({ id: "config.ui.menu_items.description", })}
    saveInterface({ menuItems: massageMenuItems(v) }) } />
    saveUI({ abbreviateCounters: v })} /> saveInterface({ noBrowser: v })} /> saveInterface({ notificationsEnabled: v })} /> saveInterface({ soundOnPreview: v })} /> id="preview-volume" headingID="config.ui.scene_view.options.preview_volume.heading" subHeadingID="config.ui.scene_view.options.preview_volume.description" value={ui.previewVolume ?? defaultPreviewVolume} onChange={(v) => saveUI({ previewVolume: v })} disabled={!iface.soundOnPreview} renderField={(value, setValue) => ( setValue(v ?? 0)} /> )} renderValue={(v) => { return {v}%; }} /> saveInterface({ wallShowTitle: v })} /> saveInterface({ wallPlayback: v })} > saveInterface({ showStudioAsText: v })} /> saveUI({ enableChromecast: v })} /> saveUI({ disableMobileMediaAutoRotateEnabled: v })} /> saveInterface({ showScrubber: v })} /> saveUI({ showRangeMarkers: v })} /> saveUI({ alwaysStartFromBeginning: v })} /> saveUI({ trackActivity: v })} /> saveUI({ vrTag: v })} /> id="ignore-interval" headingID="config.ui.minimum_play_percent.heading" subHeadingID="config.ui.minimum_play_percent.description" value={ui.minimumPlayPercent ?? 0} onChange={(v) => saveUI({ minimumPlayPercent: v })} disabled={!ui.trackActivity} renderField={(value, setValue) => ( setValue(interval ?? 0)} /> )} renderValue={(v) => { return {v}%; }} /> saveLightboxSettings({ slideshowDelay: v })} /> saveInterface({ autostartVideo: v })} /> saveInterface({ autostartVideoOnPlaySelected: v })} /> saveInterface({ continuePlaylistDefault: v })} /> id="max-loop-duration" headingID="config.ui.max_loop_duration.heading" subHeadingID="config.ui.max_loop_duration.description" value={iface.maximumLoopDuration ?? undefined} onChange={(v) => saveInterface({ maximumLoopDuration: v })} renderField={(value, setValue) => ( setValue(duration ?? 0)} /> )} renderValue={(v) => { return {TextUtils.secondsToTimestamp(v ?? 0)}; }} /> saveUI({ showAbLoopControls: v })} /> saveUI({ showTagCardOnHover: v })} /> saveUI({ showChildTagContent: v })} /> saveUI({ showChildStudioContent: v })} /> saveUI({ showLinksOnPerformerCard: v })} /> saveImageWallMargin(v)} /> saveImageWallDirection(v as ImageWallDirection)} > {Array.from(imageWallDirectionIntlMap.entries()).map((v) => ( ))} saveLightboxSettings({ slideshowDelay: v })} /> saveLightboxSettings({ displayMode: v as GQL.ImageLightboxDisplayMode, }) } > {Array.from(imageLightboxDisplayModeIntlMap.entries()).map((v) => ( ))} saveLightboxSettings({ scaleUp: v })} /> saveLightboxSettings({ resetZoomOnNav: v })} /> saveLightboxSettings({ scrollMode: v as GQL.ImageLightboxScrollMode, }) } > {Array.from(imageLightboxScrollModeIntlMap.entries()).map((v) => ( ))} saveLightboxSettings({ scrollAttemptsBeforeChange: v }) } /> saveLightboxSettings({ disableAnimation: v })} />

    {intl.formatMessage({ id: "config.ui.detail.enable_background_image.heading", })}

    {intl.formatMessage({ id: "config.ui.detail.enable_background_image.description", })}
    saveUI({ enableMovieBackgroundImage: v })} /> saveUI({ enablePerformerBackgroundImage: v })} /> saveUI({ enableStudioBackgroundImage: v })} /> saveUI({ enableTagBackgroundImage: v })} />
    saveUI({ showAllDetails: v })} /> saveUI({ compactExpandedDetails: v })} />

    {intl.formatMessage({ id: "config.ui.editing.disable_dropdown_create.heading", })}

    {intl.formatMessage({ id: "config.ui.editing.disable_dropdown_create.description", })}
    saveInterface({ disableDropdownCreate: { ...iface.disableDropdownCreate, performer: v, }, }) } /> saveInterface({ disableDropdownCreate: { ...iface.disableDropdownCreate, studio: v, }, }) } /> saveInterface({ disableDropdownCreate: { ...iface.disableDropdownCreate, tag: v, }, }) } /> saveInterface({ disableDropdownCreate: { ...iface.disableDropdownCreate, movie: v, }, }) } /> saveInterface({ disableDropdownCreate: { ...iface.disableDropdownCreate, gallery: v, }, }) } />
    saveUI({ maxOptionsShown: v })} /> saveRatingSystemType(v as RatingSystemType)} > {Array.from(ratingSystemIntlMap.entries()).map((v) => ( ))} {(ui.ratingSystemOptions?.type ?? defaultRatingSystemType) === RatingSystemType.Stars && ( saveRatingSystemStarPrecision(v as RatingStarPrecision) } > {Array.from(ratingStarPrecisionIntlMap.entries()).map((v) => ( ))} )} saveInterface({ cssEnabled: v })} /> id="custom-css" headingID="config.ui.custom_css.heading" subHeadingID="config.ui.custom_css.description" value={iface.css ?? undefined} onChange={(v) => saveInterface({ css: v })} renderField={(value, setValue) => ( ) => setValue(e.currentTarget.value) } rows={16} className="text-input code" /> )} renderValue={() => { return <>; }} /> saveInterface({ javascriptEnabled: v })} /> id="custom-javascript" headingID="config.ui.custom_javascript.heading" subHeadingID="config.ui.custom_javascript.description" value={iface.javascript ?? undefined} onChange={(v) => saveInterface({ javascript: v })} validateChange={validateJavascriptString} renderField={(value, setValue, err) => ( <> ) => setValue(e.currentTarget.value) } rows={16} className="text-input code" isInvalid={!!err} /> {err} )} renderValue={() => { return <>; }} /> saveInterface({ customLocalesEnabled: v })} /> id="custom-locales" headingID="config.ui.custom_locales.heading" subHeadingID="config.ui.custom_locales.description" value={iface.customLocales ?? undefined} onChange={(v) => saveInterface({ customLocales: v })} validateChange={validateLocaleString} renderField={(value, setValue, err) => ( <> ) => setValue(e.currentTarget.value) } rows={16} className="text-input code" isInvalid={!!err} /> {err} )} renderValue={() => { return <>; }} /> saveInterface({ handyKey: v })} /> {interactive.handyKey && ( <>

    {intl.formatMessage({ id: "config.ui.handy_connection.status.heading", })}

    {interactiveError && : {interactiveError}}
    {!interactiveInitialised && ( )}

    {intl.formatMessage({ id: "config.ui.handy_connection.server_offset.heading", })}

    {interactiveServerOffset.toFixed()}ms
    {interactiveInitialised && ( )}
    )} saveInterface({ funscriptOffset: v })} /> saveInterface({ useStashHostedFunscript: v })} />
    ); } ); ================================================ FILE: ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx ================================================ import React from "react"; import { Icon } from "../Shared/Icon"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { StashSetting } from "./StashConfiguration"; import { SettingSection } from "./SettingSection"; import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs"; import { useSettings } from "./context"; import { useIntl } from "react-intl"; import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { ExternalLink } from "../Shared/ExternalLink"; export const SettingsLibraryPanel: React.FC = () => { const intl = useIntl(); const { general, loading, error, saveGeneral, defaults, saveDefaults } = useSettings(); function commaDelimitedToList(value: string | undefined) { if (value) { return value.split(",").map((s) => s.trim()); } } function listToCommaDelimited(value: string[] | undefined) { if (value) { return value.join(", "); } } if (error) return

    {error.message}

    ; if (loading) return ; return ( <> saveGeneral({ stashes: v })} /> saveGeneral({ videoExtensions: commaDelimitedToList(v) }) } /> saveGeneral({ imageExtensions: commaDelimitedToList(v) }) } /> saveGeneral({ galleryExtensions: commaDelimitedToList(v) }) } /> {intl.formatMessage({ id: "config.general.excluded_video_patterns_desc", })} } value={general.excludes ?? undefined} onChange={(v) => saveGeneral({ excludes: v })} defaultNewValue="sample\.mp4$" /> {intl.formatMessage({ id: "config.general.excluded_image_gallery_patterns_desc", })} } value={general.imageExcludes ?? undefined} onChange={(v) => saveGeneral({ imageExcludes: v })} defaultNewValue="sample\.jpg$" /> saveGeneral({ createGalleriesFromFolders: v })} /> saveGeneral({ writeImageThumbnails: v })} /> saveGeneral({ createImageClipsFromVideos: v })} /> saveGeneral({ galleryCoverRegex: v })} /> { saveDefaults({ deleteFile: v }); }} /> { saveDefaults({ deleteGenerated: v }); }} /> ); }; ================================================ FILE: ui/v2.5/src/components/Settings/SettingsLogsPanel.tsx ================================================ import React, { useEffect, useState } from "react"; import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { useLoggingSubscribe, queryLogs } from "src/core/StashService"; import { SelectSetting } from "./Inputs"; import { SettingSection } from "./SettingSection"; import { JobTable } from "./Tasks/JobTable"; function convertTime(logEntry: GQL.LogEntryDataFragment) { function pad(val: number) { let ret = val.toString(); if (val <= 9) { ret = `0${ret}`; } return ret; } const date = new Date(logEntry.time); const month = date.getMonth() + 1; const day = date.getDate(); let dateStr = `${date.getFullYear()}-${pad(month)}-${pad(day)}`; dateStr += ` ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad( date.getSeconds() )}`; return dateStr; } function levelClass(level: string) { return level.toLowerCase().trim(); } interface ILogElementProps { logEntry: LogEntry; } const LogElement: React.FC = ({ logEntry }) => { // pad to maximum length of level enum const level = logEntry.level.padEnd(GQL.LogLevel.Progress.length); return (
    {logEntry.time} {level} {logEntry.message}
    ); }; class LogEntry { public time: string; public level: string; public message: string; public id: string; private static nextId: number = 0; public constructor(logEntry: GQL.LogEntryDataFragment) { this.time = convertTime(logEntry); this.level = logEntry.level; this.message = logEntry.message; const id = LogEntry.nextId++; this.id = id.toString(); } } // maximum number of log entries to keep - entries are discarded oldest-first const MAX_LOG_ENTRIES = 50000; // maximum number of log entries to display const MAX_DISPLAY_LOG_ENTRIES = 1000; const logLevels = ["Trace", "Debug", "Info", "Warning", "Error"]; export const SettingsLogsPanel: React.FC = () => { const [entries, setEntries] = useState([]); const { data, error } = useLoggingSubscribe(); const [logLevel, setLogLevel] = useState("Info"); const intl = useIntl(); useEffect(() => { async function getInitialLogs() { const logQuery = await queryLogs(); if (logQuery.error) return; const initEntries = logQuery.data.logs.map((e) => new LogEntry(e)); if (initEntries.length !== 0) { setEntries((prev) => { return [...prev, ...initEntries].slice(0, MAX_LOG_ENTRIES); }); } } getInitialLogs(); }, []); useEffect(() => { if (!data) return; const newEntries = data.loggingSubscribe.map((e) => new LogEntry(e)); newEntries.reverse(); setEntries((prev) => { return [...newEntries, ...prev].slice(0, MAX_LOG_ENTRIES); }); }, [data]); const displayEntries = entries .filter(filterByLogLevel) .slice(0, MAX_DISPLAY_LOG_ENTRIES); function maybeRenderError() { if (error) { return (
    Error connecting to log server: {error.message}
    ); } } function filterByLogLevel(logEntry: LogEntry) { if (logLevel === "Trace") return true; const logLevelIndex = logLevels.indexOf(logLevel); const levelIndex = logLevels.indexOf(logEntry.level); return levelIndex >= logLevelIndex; } return ( <>

    {intl.formatMessage({ id: "config.tasks.job_queue" })}

    setLogLevel(v)} > {logLevels.map((level) => ( ))}
    {maybeRenderError()} {displayEntries.map((logEntry) => ( ))}
    ); }; ================================================ FILE: ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx ================================================ import React, { useMemo } from "react"; import { Button } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { mutateReloadPlugins, mutateSetPluginsEnabled, usePlugins, } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import TextUtils from "src/utils/text"; import { CollapseButton } from "../Shared/CollapseButton"; import { Icon } from "../Shared/Icon"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { SettingSection } from "./SettingSection"; import { BooleanSetting, NumberSetting, Setting, SettingGroup, StringSetting, } from "./Inputs"; import { faLink, faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import { useSettings } from "./context"; import { AvailablePluginPackages, InstalledPluginPackages, } from "./PluginPackageManager"; import { ExternalLink } from "../Shared/ExternalLink"; import { PatchComponent } from "src/patch"; interface IPluginSettingProps { pluginID: string; setting: GQL.PluginSetting; value: unknown; onChange: (value: unknown) => void; } const PluginSetting: React.FC = ({ pluginID, setting, value, onChange, }) => { const commonProps = { heading: setting.display_name ? setting.display_name : setting.name, id: `plugin-${pluginID}-${setting.name}`, subHeading: setting.description ?? undefined, }; switch (setting.type) { case GQL.PluginSettingTypeEnum.Boolean: return ( onChange(!value)} /> ); case GQL.PluginSettingTypeEnum.String: return ( onChange(v)} /> ); case GQL.PluginSettingTypeEnum.Number: return ( onChange(v)} /> ); } }; const PluginSettings: React.FC<{ pluginID: string; settings: GQL.PluginSetting[]; }> = PatchComponent("PluginSettings", ({ pluginID, settings }) => { const { plugins, savePluginSettings } = useSettings(); const pluginSettings = plugins[pluginID] ?? {}; return (
    {settings.map((setting) => ( savePluginSettings(pluginID, { ...pluginSettings, [setting.name]: v, }) } /> ))}
    ); }); export const SettingsPluginsPanel: React.FC = () => { const Toast = useToast(); const intl = useIntl(); const { loading: configLoading } = useSettings(); const { data, loading } = usePlugins(); const [changedPluginID, setChangedPluginID] = React.useState< string | undefined >(); async function onReloadPlugins() { try { await mutateReloadPlugins(); } catch (e) { Toast.error(e); } } const pluginElements = useMemo(() => { function renderLink(url?: string) { if (url) { return ( ); } } function renderEnableButton(pluginID: string, enabled: boolean) { async function onClick() { try { await mutateSetPluginsEnabled({ [pluginID]: !enabled }); } catch (e) { Toast.error(e); } setChangedPluginID(pluginID); } return ( ); } function onReloadUI() { window.location.reload(); } function maybeRenderReloadUI(pluginID: string) { if (pluginID === changedPluginID) { return ( ); } } function renderPlugins() { const elements = (data?.plugins ?? []).map((plugin) => ( {renderLink(plugin.url ?? undefined)} {maybeRenderReloadUI(plugin.id)} {renderEnableButton(plugin.id, plugin.enabled)} } > {renderPluginHooks(plugin.hooks ?? undefined)} )); return
    {elements}
    ; } function renderPluginHooks( hooks?: Pick[] ) { if (!hooks || hooks.length === 0) { return; } return (
    {hooks.map((h) => (
    {h.name}
      {h.hooks?.map((hh) => (
    • {hh}
    • ))}
    {h.description}
    ))}
    ); } return renderPlugins(); }, [data?.plugins, intl, Toast, changedPluginID]); if (loading || configLoading) return ; return ( <> {pluginElements} ); }; ================================================ FILE: ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx ================================================ import React, { PropsWithChildren, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { Button } from "react-bootstrap"; import { mutateReloadScrapers, useListGroupScrapers, useListPerformerScrapers, useListSceneScrapers, useListGalleryScrapers, useListImageScrapers, } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import TextUtils from "src/utils/text"; import { CollapseButton } from "../Shared/CollapseButton"; import { Icon } from "../Shared/Icon"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { ScrapeType } from "src/core/generated-graphql"; import { SettingSection } from "./SettingSection"; import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs"; import { useSettings } from "./context"; import { StashBoxSetting } from "./StashBoxConfiguration"; import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import { AvailableScraperPackages, InstalledScraperPackages, } from "./ScraperPackageManager"; import { ExternalLink } from "../Shared/ExternalLink"; import { ClearableInput } from "../Shared/ClearableInput"; import { Counter } from "../Shared/Counter"; const ScraperTable: React.FC< PropsWithChildren<{ entityType: string; count?: number; }> > = ({ entityType, count, children }) => { const intl = useIntl(); const titleEl = useMemo(() => { const title = intl.formatMessage( { id: "config.scraping.entity_scrapers" }, { entityType: intl.formatMessage({ id: entityType }) } ); if (count) { return ( {title} ); } return title; }, [count, entityType, intl]); return ( {children}
    ); }; const ScrapeTypeList: React.FC<{ types: ScrapeType[]; entityType: string; }> = ({ types, entityType }) => { const intl = useIntl(); const typeStrings = useMemo( () => types.map((t) => { switch (t) { case ScrapeType.Fragment: return intl.formatMessage( { id: "config.scraping.entity_metadata" }, { entityType: intl.formatMessage({ id: entityType }) } ); default: return t; } }), [types, entityType, intl] ); return (
      {typeStrings.map((t) => (
    • {t}
    • ))}
    ); }; interface IURLList { urls: string[]; } const URLList: React.FC = ({ urls }) => { const items = useMemo(() => { function linkSite(url: string) { const u = new URL(url); return `${u.protocol}//${u.host}`; } const ret = urls .slice() .sort() .map((u) => { const sanitised = TextUtils.sanitiseURL(u); const siteURL = linkSite(sanitised!); return (
  • {sanitised}
  • ); }); return ret; }, [urls]); return
      {items}
    ; }; const ScraperTableRow: React.FC<{ name: string; entityType: string; supportedScrapes: ScrapeType[]; urls: string[]; }> = ({ name, entityType, supportedScrapes, urls }) => { return ( {name} ); }; function filterScraper(filter: string) { return (name: string, urls: string[] | undefined | null) => { if (!filter) return true; return ( name.toLowerCase().includes(filter) || urls?.some((url) => url.toLowerCase().includes(filter)) ); }; } const ScrapersSection: React.FC = () => { const Toast = useToast(); const intl = useIntl(); const [filter, setFilter] = useState(""); const { data: performerScrapers, loading: loadingPerformers } = useListPerformerScrapers(); const { data: sceneScrapers, loading: loadingScenes } = useListSceneScrapers(); const { data: galleryScrapers, loading: loadingGalleries } = useListGalleryScrapers(); const { data: imageScrapers, loading: loadingImages } = useListImageScrapers(); const { data: groupScrapers, loading: loadingGroups } = useListGroupScrapers(); const filteredScrapers = useMemo(() => { const filterFn = filterScraper(filter.toLowerCase()); return { performers: performerScrapers?.listScrapers.filter((s) => filterFn(s.name, s.performer?.urls) ), scenes: sceneScrapers?.listScrapers.filter((s) => filterFn(s.name, s.scene?.urls) ), galleries: galleryScrapers?.listScrapers.filter((s) => filterFn(s.name, s.gallery?.urls) ), images: imageScrapers?.listScrapers.filter((s) => filterFn(s.name, s.image?.urls) ), groups: groupScrapers?.listScrapers.filter((s) => filterFn(s.name, s.group?.urls) ), }; }, [ performerScrapers, sceneScrapers, galleryScrapers, imageScrapers, groupScrapers, filter, ]); async function onReloadScrapers() { try { await mutateReloadScrapers(); } catch (e) { Toast.error(e); } } if ( loadingScenes || loadingGalleries || loadingPerformers || loadingGroups || loadingImages ) return ( ); return (
    setFilter(v)} />
    {!!filteredScrapers.scenes?.length && ( {filteredScrapers.scenes?.map((scraper) => ( ))} )} {!!filteredScrapers.galleries?.length && ( {filteredScrapers.galleries?.map((scraper) => ( ))} )} {!!filteredScrapers.images?.length && ( {filteredScrapers.images?.map((scraper) => ( ))} )} {!!filteredScrapers.performers?.length && ( {filteredScrapers.performers?.map((scraper) => ( ))} )} {!!filteredScrapers.groups?.length && ( {filteredScrapers.groups?.map((scraper) => ( ))} )}
    ); }; export const SettingsScrapingPanel: React.FC = () => { const { general, scraping, loading, error, saveGeneral, saveScraping } = useSettings(); if (error) return

    {error.message}

    ; if (loading) return ; return ( <> saveGeneral({ stashBoxes: v })} /> saveScraping({ scraperUserAgent: v })} /> saveScraping({ scraperCDPPath: v })} /> saveScraping({ scraperCertCheck: v })} /> saveScraping({ excludeTagPatterns: v })} /> ); }; ================================================ FILE: ui/v2.5/src/components/Settings/SettingsSecurityPanel.tsx ================================================ import React from "react"; import { ModalSetting, NumberSetting } from "./Inputs"; import { SettingSection } from "./SettingSection"; import * as GQL from "src/core/generated-graphql"; import { Button, Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import { useSettings } from "./context"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { useToast } from "src/hooks/Toast"; import { useGenerateAPIKey } from "src/core/StashService"; type AuthenticationSettingsInput = Pick< GQL.ConfigGeneralInput, "username" | "password" >; interface IAuthenticationInput { value: AuthenticationSettingsInput; setValue: (v: AuthenticationSettingsInput) => void; } const AuthenticationInput: React.FC = ({ value, setValue, }) => { const intl = useIntl(); function set(v: Partial) { setValue({ ...value, ...v, }); } const { username, password } = value; return (
    {intl.formatMessage({ id: "config.general.auth.username" })}
    ) => set({ username: e.currentTarget.value }) } /> {intl.formatMessage({ id: "config.general.auth.username_desc" })}
    {intl.formatMessage({ id: "config.general.auth.password" })}
    ) => set({ password: e.currentTarget.value }) } /> {intl.formatMessage({ id: "config.general.auth.password_desc" })}
    ); }; export const SettingsSecurityPanel: React.FC = () => { const intl = useIntl(); const Toast = useToast(); const { general, apiKey, loading, error, saveGeneral, refetch } = useSettings(); const [generateAPIKey] = useGenerateAPIKey(); async function onGenerateAPIKey() { try { await generateAPIKey({ variables: { input: {}, }, }); refetch(); } catch (e) { Toast.error(e); } } async function onClearAPIKey() { try { await generateAPIKey({ variables: { input: { clear: true, }, }, }); refetch(); } catch (e) { Toast.error(e); } } if (error) return

    {error.message}

    ; if (loading) return ; return ( <> id="authentication-settings" headingID="config.general.auth.credentials.heading" subHeadingID="config.general.auth.credentials.description" value={{ username: general.username, password: general.password, }} onChange={(v) => saveGeneral(v)} renderField={(value, setValue) => ( )} renderValue={(v) => { if (v?.username && v?.password) return {v?.username ?? ""}; return <>; }} />

    {intl.formatMessage({ id: "config.general.auth.api_key" })}

    {apiKey}
    {intl.formatMessage({ id: "config.general.auth.api_key_desc" })}
    saveGeneral({ maxSessionAge: v })} />
    ); }; ================================================ FILE: ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx ================================================ import React, { useState } from "react"; import { Button, Form } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { useDisableDLNA, useDLNAStatus, useEnableDLNA, useAddTempDLNAIP, useRemoveTempDLNAIP, } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import { DurationInput } from "../Shared/DurationInput"; import { Icon } from "../Shared/Icon"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { ModalComponent } from "../Shared/Modal"; import { SettingSection } from "./SettingSection"; import { BooleanSetting, StringListSetting, StringSetting, SelectSetting, NumberSetting, } from "./Inputs"; import { useSettings } from "./context"; import { videoSortOrderIntlMap, defaultVideoSort, } from "src/utils/dlnaVideoSort"; import { faClock, faTimes, faUserClock, } from "@fortawesome/free-solid-svg-icons"; const defaultDLNAPort = 1338; export const SettingsServicesPanel: React.FC = () => { const intl = useIntl(); const Toast = useToast(); const { dlna, loading: configLoading, error, saveDLNA } = useSettings(); // undefined to hide dialog, true for enable, false for disable const [enableDisable, setEnableDisable] = useState(); const [enableUntilRestart, setEnableUntilRestart] = useState(false); const [enableDuration, setEnableDuration] = useState(0); const [ipEntry, setIPEntry] = useState(""); const [tempIP, setTempIP] = useState(); const { data: statusData, loading, refetch: statusRefetch } = useDLNAStatus(); const [enableDLNA] = useEnableDLNA(); const [disableDLNA] = useDisableDLNA(); const [addTempDLANIP] = useAddTempDLNAIP(); const [removeTempDLNAIP] = useRemoveTempDLNAIP(); if (error) return

    {error.message}

    ; if (loading || configLoading) return ; async function onTempEnable() { const input = { variables: { input: { duration: enableUntilRestart ? undefined : enableDuration, }, }, }; try { if (enableDisable) { await enableDLNA(input); Toast.success( intl.formatMessage({ id: "config.dlna.enabled_dlna_temporarily", }) ); } else { await disableDLNA(input); Toast.success( intl.formatMessage({ id: "config.dlna.disabled_dlna_temporarily", }) ); } } catch (e) { Toast.error(e); } finally { setEnableDisable(undefined); statusRefetch(); } } async function onAllowTempIP() { if (!tempIP) { return; } const input = { variables: { input: { duration: enableUntilRestart ? undefined : enableDuration, address: tempIP, }, }, }; try { await addTempDLANIP(input); Toast.success( intl.formatMessage({ id: "config.dlna.allowed_ip_temporarily", }) ); } catch (e) { Toast.error(e); } finally { setTempIP(undefined); statusRefetch(); } } async function onDisallowTempIP(address: string) { const input = { variables: { input: { address, }, }, }; try { await removeTempDLNAIP(input); Toast.success(intl.formatMessage({ id: "config.dlna.disallowed_ip" })); } catch (e) { Toast.error(e); } finally { statusRefetch(); } } function renderDeadline(until?: string | null) { if (until) { const deadline = new Date(until); return `until ${intl.formatDate(deadline)}`; } return ""; } function renderStatus() { if (!statusData) { return ""; } const { dlnaStatus } = statusData; const runningText = intl.formatMessage({ id: dlnaStatus.running ? "actions.running" : "actions.not_running", }); return `${runningText} ${renderDeadline(dlnaStatus.until)}`; } function renderEnableButton() { // if enabled by default, then show the disable temporarily // if disabled by default, then show enable temporarily if (dlna.enabled) { return ( ); } return ( ); } function canCancel() { if (!statusData || !dlna) { return false; } const { dlnaStatus } = statusData; const { enabled } = dlna; return dlnaStatus.until || dlnaStatus.running !== enabled; } async function cancelTempBehaviour() { if (!canCancel()) { return; } const running = statusData?.dlnaStatus.running; const input = { variables: { input: {}, }, }; try { if (!running) { await enableDLNA(input); } else { await disableDLNA(input); } Toast.success( intl.formatMessage({ id: "config.dlna.successfully_cancelled_temporary_behaviour", }) ); } catch (e) { Toast.error(e); } finally { setEnableDisable(undefined); statusRefetch(); } } function renderTempCancelButton() { if (!canCancel()) { return; } return ( ); } function renderTempEnableDialog() { const text: string = enableDisable ? "enable" : "disable"; const capitalised = `${text[0].toUpperCase()}${text.slice(1)}`; return ( setEnableDisable(undefined), variant: "secondary", }} >

    {capitalised} temporarily

    setEnableUntilRestart(!enableUntilRestart)} /> setEnableDuration(v ?? 0)} disabled={enableUntilRestart} /> Duration to {text} for - in minutes.
    ); } function renderTempWhitelistDialog() { return ( setTempIP(undefined), variant: "secondary", }} >

    {`Allow ${tempIP} temporarily`}

    setEnableUntilRestart(!enableUntilRestart)} /> setEnableDuration(v ?? 0)} disabled={enableUntilRestart} /> Duration to allow for - in minutes.
    ); } function renderAllowedIPs() { if (!statusData || statusData.dlnaStatus.allowedIPAddresses.length === 0) { return; } const { allowedIPAddresses } = statusData.dlnaStatus; return (
    {intl.formatMessage({ id: "config.dlna.allowed_ip_addresses" })}
      {allowedIPAddresses.map((a) => (
    • {a.ipAddress}
      {renderDeadline(a.until)}
    • ))}
    ); } function renderRecentIPs() { if (!statusData) { return; } const { recentIPAddresses } = statusData.dlnaStatus; return (
      {recentIPAddresses.map((a) => (
    • {a}
    • ))}
    • ) => setIPEntry(e.currentTarget.value) } />
    ); } const DLNASettingsForm: React.FC = () => { return ( <> stash } )} value={dlna.serverName ?? undefined} onChange={(v) => saveDLNA({ serverName: v })} /> saveDLNA({ port: v ? v : defaultDLNAPort })} /> saveDLNA({ enabled: v })} /> saveDLNA({ interfaces: v })} /> * } )} defaultNewValue="*" value={dlna.whitelistedIPs ?? undefined} onChange={(v) => saveDLNA({ whitelistedIPs: v })} /> saveDLNA({ videoSortOrder: v })} > {Array.from(videoSortOrderIntlMap.entries()).map((v) => ( ))} ); }; return (
    {renderTempEnableDialog()} {renderTempWhitelistDialog()}

    DLNA

    {intl.formatMessage({ id: "status" }, { statusText: renderStatus() })}
    {renderEnableButton()} {renderTempCancelButton()} {renderAllowedIPs()}
    {intl.formatMessage({ id: "config.dlna.recent_ip_addresses" })}
    {renderRecentIPs()}
    ); }; ================================================ FILE: ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { SettingSection } from "./SettingSection"; import { BooleanSetting, ModalSetting, NumberSetting, SelectSetting, Setting, StringListSetting, StringSetting, } from "./Inputs"; import { useSettings } from "./context"; import { VideoPreviewInput, VideoPreviewSettingsInput, } from "./GeneratePreviewOptions"; import { FormattedMessage, useIntl } from "react-intl"; import { Button } from "react-bootstrap"; import { useToast } from "src/hooks/Toast"; import { useHistory } from "react-router-dom"; export const SettingsConfigurationPanel: React.FC = () => { const intl = useIntl(); const Toast = useToast(); const history = useHistory(); const { general, loading, error, saveGeneral } = useSettings(); const [mutateDownloadFFMpeg] = GQL.useDownloadFfMpegMutation(); const transcodeQualities = [ GQL.StreamingResolutionEnum.Low, GQL.StreamingResolutionEnum.Standard, GQL.StreamingResolutionEnum.StandardHd, GQL.StreamingResolutionEnum.FullHd, GQL.StreamingResolutionEnum.FourK, GQL.StreamingResolutionEnum.Original, ].map(resolutionToString); function resolutionToString(r: GQL.StreamingResolutionEnum | undefined) { switch (r) { case GQL.StreamingResolutionEnum.Low: return "240p"; case GQL.StreamingResolutionEnum.Standard: return "480p"; case GQL.StreamingResolutionEnum.StandardHd: return "720p"; case GQL.StreamingResolutionEnum.FullHd: return "1080p"; case GQL.StreamingResolutionEnum.FourK: return "4k"; case GQL.StreamingResolutionEnum.Original: return "Original"; } return "Original"; } function translateQuality(quality: string) { switch (quality) { case "240p": return GQL.StreamingResolutionEnum.Low; case "480p": return GQL.StreamingResolutionEnum.Standard; case "720p": return GQL.StreamingResolutionEnum.StandardHd; case "1080p": return GQL.StreamingResolutionEnum.FullHd; case "4k": return GQL.StreamingResolutionEnum.FourK; case "Original": return GQL.StreamingResolutionEnum.Original; } return GQL.StreamingResolutionEnum.Original; } const namingHashAlgorithms = [ GQL.HashAlgorithm.Md5, GQL.HashAlgorithm.Oshash, ].map(namingHashToString); function namingHashToString(value: GQL.HashAlgorithm | undefined) { switch (value) { case GQL.HashAlgorithm.Oshash: return "oshash"; case GQL.HashAlgorithm.Md5: return "MD5"; } return "MD5"; } function translateNamingHash(value: string) { switch (value) { case "oshash": return GQL.HashAlgorithm.Oshash; case "MD5": return GQL.HashAlgorithm.Md5; } return GQL.HashAlgorithm.Md5; } function blobStorageTypeToID(value: GQL.BlobsStorageType | undefined) { switch (value) { case GQL.BlobsStorageType.Database: return "blobs_storage_type.database"; case GQL.BlobsStorageType.Filesystem: return "blobs_storage_type.filesystem"; } return "blobs_storage_type.database"; } async function onDownloadFFMpeg() { try { await mutateDownloadFFMpeg(); // navigate to tasks page to see the progress history.push("/settings?tab=tasks"); } catch (e) { Toast.error(e); } } if (error) return

    {error.message}

    ; if (loading) return ; return ( <> saveGeneral({ generatedPath: v })} /> saveGeneral({ cachePath: v })} /> saveGeneral({ scrapersPath: v })} /> saveGeneral({ pluginsPath: v })} /> saveGeneral({ metadataPath: v })} /> saveGeneral({ customPerformerImageLocation: v })} /> saveGeneral({ ffmpegPath: v })} /> saveGeneral({ ffprobePath: v })} /> } subHeadingID="config.general.ffmpeg.download_ffmpeg.description" > saveGeneral({ pythonPath: v })} /> saveGeneral({ backupDirectoryPath: v })} /> saveGeneral({ deleteTrashPath: v })} /> saveGeneral({ databasePath: v })} /> saveGeneral({ blobsStorage: v as GQL.BlobsStorageType }) } > {Object.values(GQL.BlobsStorageType).map((q) => ( ))} saveGeneral({ blobsPath: v })} /> saveGeneral({ calculateMD5: v })} /> saveGeneral({ videoFileNamingAlgorithm: translateNamingHash(v) }) } > {namingHashAlgorithms.map((q) => ( ))} saveGeneral({ maxTranscodeSize: translateQuality(v) }) } value={resolutionToString(general.maxTranscodeSize ?? undefined)} > {transcodeQualities.map((q) => ( ))} saveGeneral({ maxStreamingTranscodeSize: translateQuality(v) }) } value={resolutionToString( general.maxStreamingTranscodeSize ?? undefined )} > {transcodeQualities.map((q) => ( ))} saveGeneral({ transcodeHardwareAcceleration: v })} /> saveGeneral({ transcodeInputArgs: v })} value={general.transcodeInputArgs ?? []} /> saveGeneral({ transcodeOutputArgs: v })} value={general.transcodeOutputArgs ?? []} /> saveGeneral({ liveTranscodeInputArgs: v })} value={general.liveTranscodeInputArgs ?? []} /> saveGeneral({ liveTranscodeOutputArgs: v })} value={general.liveTranscodeOutputArgs ?? []} /> saveGeneral({ parallelTasks: v })} /> saveGeneral({ previewPreset: (v as GQL.PreviewPreset) ?? undefined, }) } > {Object.keys(GQL.PreviewPreset).map((p) => ( ))} saveGeneral({ previewAudio: v })} /> id="video-preview-settings" headingID="dialogs.scene_gen.preview_generation_options" value={{ previewExcludeEnd: general.previewExcludeEnd, previewExcludeStart: general.previewExcludeStart, previewSegmentDuration: general.previewSegmentDuration, previewSegments: general.previewSegments, }} onChange={(v) => saveGeneral(v)} renderField={(value, setValue) => ( )} renderValue={() => { return <>; }} /> saveGeneral({ spriteScreenshotSize: v })} /> saveGeneral({ useCustomSpriteInterval: v })} /> saveGeneral({ spriteInterval: v })} /> saveGeneral({ minimumSprites: v })} /> saveGeneral({ maximumSprites: v })} /> saveGeneral({ drawFunscriptHeatmapRange: v })} /> saveGeneral({ logFile: v })} /> saveGeneral({ logOut: v })} /> saveGeneral({ logLevel: v })} value={general.logLevel ?? undefined} > {["Trace", "Debug", "Info", "Warning", "Error"].map((o) => ( ))} saveGeneral({ logAccess: v })} /> saveGeneral({ logFileMaxSize: v })} /> ); }; ================================================ FILE: ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx ================================================ import React from "react"; import { Button } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import { Link } from "react-router-dom"; import { Setting } from "./Inputs"; import { SettingSection } from "./SettingSection"; import { PatchContainerComponent } from "src/patch"; import { ExternalLink } from "../Shared/ExternalLink"; const SettingsToolsSection = PatchContainerComponent("SettingsToolsSection"); export const SettingsToolsPanel: React.FC = () => { return ( <> } /> } /> } /> ); }; ================================================ FILE: ui/v2.5/src/components/Settings/StashBoxConfiguration.tsx ================================================ import React, { useRef, useState } from "react"; import { Button, Form } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { SettingSection } from "./SettingSection"; import * as GQL from "src/core/generated-graphql"; import { SettingModal } from "./Inputs"; export interface IStashBoxModal { value: GQL.StashBoxInput; close: (v?: GQL.StashBoxInput) => void; } const defaultMaxRequestsPerMinute = 240; export const StashBoxModal: React.FC = ({ value, close }) => { const intl = useIntl(); const endpoint = useRef(null); const apiKey = useRef(null); const [validate, { data, loading }] = GQL.useValidateStashBoxLazyQuery({ fetchPolicy: "network-only", }); const handleValidate = () => { validate({ variables: { input: { endpoint: endpoint.current?.value ?? "", api_key: apiKey.current?.value ?? "", name: "test", }, }, }); }; return ( headingID="config.stashbox.title" value={value} renderField={(v, setValue) => ( <>
    {intl.formatMessage({ id: "config.stashbox.name", })}
    0} onChange={(e: React.ChangeEvent) => setValue({ ...v!, name: e.currentTarget.value }) } />
    {intl.formatMessage({ id: "config.stashbox.graphql_endpoint", })}
    0} onChange={(e: React.ChangeEvent) => setValue({ ...v!, endpoint: e.currentTarget.value.trim() }) } ref={endpoint} />
    {intl.formatMessage({ id: "config.stashbox.api_key", })}
    0} onChange={(e: React.ChangeEvent) => setValue({ ...v!, api_key: e.currentTarget.value.trim() }) } ref={apiKey} />
    {data && ( {data.validateStashBoxCredentials?.status} )}
    {intl.formatMessage({ id: "config.stashbox.max_requests_per_minute", })}
    = 0 } type="number" onChange={(e: React.ChangeEvent) => setValue({ ...v!, max_requests_per_minute: parseInt(e.currentTarget.value), }) } />
    )} close={close} /> ); }; interface IStashBoxSetting { value: GQL.StashBoxInput[]; onChange: (v: GQL.StashBoxInput[]) => void; } export const StashBoxSetting: React.FC = ({ value, onChange, }) => { const [isCreating, setIsCreating] = useState(false); const [editingIndex, setEditingIndex] = useState(); function onEdit(index: number) { setEditingIndex(index); } function onDelete(index: number) { onChange(value.filter((v, i) => i !== index)); } function onNew() { setIsCreating(true); } return ( {isCreating ? ( { if (v) onChange([...value, v]); setIsCreating(false); }} /> ) : undefined} {editingIndex !== undefined ? ( { if (v) onChange( value.map((vv, index) => { if (index === editingIndex) { return v; } return vv; }) ); setEditingIndex(undefined); }} /> ) : undefined} {value.map((b, index) => ( // eslint-disable-next-line react/no-array-index-key

    {b.name ?? `#${index}`}

    {b.endpoint ?? ""}
    ))}
    ); }; ================================================ FILE: ui/v2.5/src/components/Settings/StashConfiguration.tsx ================================================ import { faEllipsisV } from "@fortawesome/free-solid-svg-icons"; import React, { useState } from "react"; import { Button, Form, Row, Col, Dropdown } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import { Icon } from "src/components/Shared/Icon"; import * as GQL from "src/core/generated-graphql"; import { FolderSelectDialog } from "../Shared/FolderSelect/FolderSelectDialog"; import { BooleanSetting } from "./Inputs"; import { SettingSection } from "./SettingSection"; interface IStashProps { index: number; stash: GQL.StashConfig; onSave: (instance: GQL.StashConfig) => void; onEdit: () => void; onDelete: () => void; } const Stash: React.FC = ({ index, stash, onSave, onEdit, onDelete, }) => { // eslint-disable-next-line const handleInput = (key: string, value: any) => { const newObj = { ...stash, [key]: value, }; onSave(newObj); }; const classAdd = index % 2 === 1 ? "bg-dark" : ""; return ( {stash.path} {/* NOTE - language is opposite to meaning: internally exclude flags, displayed as include */}
    handleInput("excludeVideo", !v)} />
    handleInput("excludeImage", !v)} />
    onEdit()}> onDelete()}>
    ); }; interface IStashConfigurationProps { stashes: GQL.StashConfig[]; setStashes: (v: GQL.StashConfig[]) => void; } const StashConfiguration: React.FC = ({ stashes, setStashes, }) => { const [isCreating, setIsCreating] = useState(false); const [editingIndex, setEditingIndex] = useState(); function onEdit(index: number) { setEditingIndex(index); } function onDelete(index: number) { setStashes(stashes.filter((v, i) => i !== index)); } function onNew() { setIsCreating(true); } const handleSave = (index: number, stash: GQL.StashConfig) => setStashes(stashes.map((s, i) => (i === index ? stash : s))); return ( <> {isCreating ? ( { if (v) setStashes([ ...stashes, { path: v, excludeVideo: false, excludeImage: false, }, ]); setIsCreating(false); }} /> ) : undefined} {editingIndex !== undefined ? ( { if (v) setStashes( stashes.map((vv, index) => { if (index === editingIndex) { return { ...vv, path: v, }; } return vv; }) ); setEditingIndex(undefined); }} /> ) : undefined}
    {stashes.length > 0 && (
    )} {stashes.map((stash, index) => ( handleSave(index, s)} onEdit={() => onEdit(index)} onDelete={() => onDelete(index)} key={stash.path} /> ))}
    ); }; interface IStashSetting { value: GQL.StashConfigInput[]; onChange: (v: GQL.StashConfigInput[]) => void; } export const StashSetting: React.FC = ({ value, onChange }) => { return ( onChange(v)} /> ); }; export default StashConfiguration; ================================================ FILE: ui/v2.5/src/components/Settings/Tasks/CleanGeneratedDialog.tsx ================================================ import React, { useEffect, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { ModalComponent } from "src/components/Shared/Modal"; import * as GQL from "src/core/generated-graphql"; import { BooleanSetting } from "../Inputs"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; import { SettingSection } from "../SettingSection"; import { useSettings } from "../context"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; const CleanGeneratedOptions: React.FC<{ options: GQL.CleanGeneratedInput; setOptions: (s: GQL.CleanGeneratedInput) => void; }> = ({ options, setOptions: setOptionsState }) => { function setOptions(input: Partial) { setOptionsState({ ...options, ...input }); } return ( <> setOptions({ blobFiles: v })} /> setOptions({ screenshots: v })} /> setOptions({ sprites: v })} /> setOptions({ transcodes: v })} /> setOptions({ markers: v })} /> setOptions({ imageThumbnails: v })} /> setOptions({ dryRun: v })} /> ); }; export const CleanGeneratedDialog: React.FC<{ onClose: (input?: GQL.CleanGeneratedInput) => void; }> = ({ onClose }) => { const intl = useIntl(); const { ui, saveUI, loading } = useSettings(); const [options, setOptions] = useState({ blobFiles: true, imageThumbnails: true, markers: true, screenshots: true, sprites: true, transcodes: true, dryRun: false, }); useEffect(() => { const defaults = ui.taskDefaults?.cleanGenerated; if (defaults) { setOptions(defaults); } }, [ui?.taskDefaults?.cleanGenerated]); function confirm() { saveUI({ taskDefaults: { ...ui.taskDefaults, cleanGenerated: options, }, }); onClose(options); } if (loading) return ; return ( } icon={faTrashAlt} accept={{ text: intl.formatMessage({ id: "actions.clean_generated" }), variant: "danger", onClick: () => confirm(), }} cancel={{ onClick: () => onClose() }} >

    {options.dryRun && (

    )}
    ); }; ================================================ FILE: ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx ================================================ import React, { useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { Button, Col, Form, Row } from "react-bootstrap"; import { mutateMigrateHashNaming, mutateMetadataExport, mutateBackupDatabase, mutateMetadataImport, mutateMetadataClean, mutateAnonymiseDatabase, mutateMigrateSceneScreenshots, mutateMigrateBlobs, mutateOptimiseDatabase, mutateCleanGenerated, } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import downloadFile from "src/utils/download"; import { ModalComponent } from "src/components/Shared/Modal"; import { ImportDialog } from "./ImportDialog"; import * as GQL from "src/core/generated-graphql"; import { SettingSection } from "../SettingSection"; import { BooleanSetting, Setting } from "../Inputs"; import { ManualLink } from "src/components/Help/context"; import { Icon } from "src/components/Shared/Icon"; import { useConfigurationContext } from "src/hooks/Config"; import { FolderSelect } from "src/components/Shared/FolderSelect/FolderSelect"; import { faBoxArchive, faExclamationTriangle, faMinus, faPlus, faQuestionCircle, faTrashAlt, } from "@fortawesome/free-solid-svg-icons"; import { CleanGeneratedDialog } from "./CleanGeneratedDialog"; interface ICleanDialog { pathSelection?: boolean; dryRun: boolean; onClose: (paths?: string[]) => void; } const CleanDialog: React.FC = ({ pathSelection = false, dryRun, onClose, }) => { const intl = useIntl(); const { configuration } = useConfigurationContext(); const libraryPaths = configuration?.general.stashes.map((s) => s.path); const [paths, setPaths] = useState([]); const [currentDirectory, setCurrentDirectory] = useState(""); function removePath(p: string) { setPaths(paths.filter((path) => path !== p)); } function addPath(p: string) { if (p && !paths.includes(p)) { setPaths(paths.concat(p)); } } let msg; if (dryRun) { msg = (

    {intl.formatMessage({ id: "actions.tasks.dry_mode_selected" })}

    ); } else { msg = (

    {intl.formatMessage({ id: "actions.tasks.clean_confirm_message" })}

    ); } return ( onClose(paths), }} cancel={{ onClick: () => onClose() }} >
    {paths.map((p) => ( {p} ))} {pathSelection ? ( addPath(currentDirectory)} > } /> ) : undefined}
    {msg}
    ); }; interface ICleanOptions { options: GQL.CleanMetadataInput; setOptions: (s: GQL.CleanMetadataInput) => void; } const CleanOptions: React.FC = ({ options, setOptions: setOptionsState, }) => { function setOptions(input: Partial) { setOptionsState({ ...options, ...input }); } return ( <> setOptions({ ignoreZipFileContents: v })} /> setOptions({ dryRun: v })} /> ); }; const BackupDialog: React.FC<{ onClose: ( confirmed?: boolean, download?: boolean, includeBlobs?: boolean ) => void; }> = ({ onClose }) => { const intl = useIntl(); const { configuration } = useConfigurationContext(); const includeBlobsDefault = configuration?.general.blobsStorage === GQL.BlobsStorageType.Filesystem; const backupDir = configuration.general.backupDirectoryPath || `<${intl.formatMessage({ id: "config.general.backup_directory_path.heading", })}>`; const [download, setDownload] = useState(false); const [includeBlobs, setIncludeBlobs] = useState(includeBlobsDefault); let msg; if (!includeBlobs) { msg = intl.formatMessage( { id: "config.tasks.backup_database.sqlite" }, { filename_format: ( [origFilename].sqlite.[schemaVersion].[YYYYMMDD_HHMMSS] ), } ); } else { msg = intl.formatMessage( { id: "config.tasks.backup_database.zip" }, { filename_format: ( [origFilename].sqlite.[schemaVersion].[YYYYMMDD_HHMMSS].zip ), } ); } const warning = includeBlobs !== includeBlobsDefault ? (

    ) : null; const acceptID = download ? "config.tasks.backup_database.download" : "actions.backup"; return ( onClose(true, download, includeBlobs), }} cancel={{ onClick: () => onClose(), variant: "secondary", }} >
    setDownload(false)} label={intl.formatMessage( { id: "config.tasks.backup_database.to_directory", }, { directory: {backupDir}, } )} /> setDownload(true)} label={intl.formatMessage({ id: "config.tasks.backup_database.download", })} />
    setIncludeBlobs(v)} // if includeBlobsDefault is false, then blobs are in the database disabled={!includeBlobsDefault} />

    {msg}

    {warning}
    ); }; interface IDataManagementTasks { setIsBackupRunning: (v: boolean) => void; setIsAnonymiseRunning: (v: boolean) => void; } export const DataManagementTasks: React.FC = ({ setIsBackupRunning, setIsAnonymiseRunning, }) => { const intl = useIntl(); const Toast = useToast(); const [dialogOpen, setDialogOpenState] = useState({ importAlert: false, import: false, backup: false, clean: false, cleanAlert: false, cleanGenerated: false, }); const [cleanOptions, setCleanOptions] = useState({ dryRun: false, }); const [migrateBlobsOptions, setMigrateBlobsOptions] = useState({ deleteOld: true, }); const [migrateSceneScreenshotsOptions, setMigrateSceneScreenshotsOptions] = useState({ deleteFiles: false, overwriteExisting: false, }); type DialogOpenState = typeof dialogOpen; function setDialogOpen(s: Partial) { setDialogOpenState((v) => { return { ...v, ...s }; }); } async function onImport() { setDialogOpen({ importAlert: false }); try { await mutateMetadataImport(); Toast.success( intl.formatMessage( { id: "config.tasks.added_job_to_queue" }, { operation_name: intl.formatMessage({ id: "actions.import" }) } ) ); } catch (e) { Toast.error(e); } } function renderImportAlert() { return ( setDialogOpen({ importAlert: false }) }} >

    {intl.formatMessage({ id: "actions.tasks.import_warning" })}

    ); } function renderImportDialog() { if (!dialogOpen.import) { return; } return setDialogOpen({ import: false })} />; } async function onClean(paths?: string[]) { try { await mutateMetadataClean({ ...cleanOptions, paths, }); Toast.success( intl.formatMessage( { id: "config.tasks.added_job_to_queue" }, { operation_name: intl.formatMessage({ id: "actions.clean" }) } ) ); } catch (e) { Toast.error(e); } finally { setDialogOpen({ clean: false }); } } async function onCleanGenerated(options: GQL.CleanGeneratedInput) { try { await mutateCleanGenerated({ ...options, }); Toast.success( intl.formatMessage( { id: "config.tasks.added_job_to_queue" }, { operation_name: intl.formatMessage({ id: "actions.clean_generated", }), } ) ); } catch (e) { Toast.error(e); } } async function onMigrateHashNaming() { try { await mutateMigrateHashNaming(); Toast.success( intl.formatMessage( { id: "config.tasks.added_job_to_queue" }, { operation_name: intl.formatMessage({ id: "actions.hash_migration", }), } ) ); } catch (err) { Toast.error(err); } } async function onMigrateSceneScreenshots() { try { await mutateMigrateSceneScreenshots(migrateSceneScreenshotsOptions); Toast.success( intl.formatMessage( { id: "config.tasks.added_job_to_queue" }, { operation_name: intl.formatMessage({ id: "actions.migrate_scene_screenshots", }), } ) ); } catch (err) { Toast.error(err); } } async function onMigrateBlobs() { try { await mutateMigrateBlobs(migrateBlobsOptions); Toast.success( intl.formatMessage( { id: "config.tasks.added_job_to_queue" }, { operation_name: intl.formatMessage({ id: "actions.migrate_blobs", }), } ) ); } catch (err) { Toast.error(err); } } async function onExport() { try { await mutateMetadataExport(); Toast.success( intl.formatMessage( { id: "config.tasks.added_job_to_queue" }, { operation_name: intl.formatMessage({ id: "actions.export" }) } ) ); } catch (err) { Toast.error(err); } } async function onBackup(download?: boolean, includeBlobs?: boolean) { try { setIsBackupRunning(true); const ret = await mutateBackupDatabase({ download, includeBlobs, }); // download the result if (download && ret.data && ret.data.backupDatabase) { const link = ret.data.backupDatabase; downloadFile(link); } } catch (e) { Toast.error(e); } finally { setIsBackupRunning(false); } } async function onOptimiseDatabase() { try { await mutateOptimiseDatabase(); Toast.success( intl.formatMessage( { id: "config.tasks.added_job_to_queue" }, { operation_name: intl.formatMessage({ id: "actions.optimise_database", }), } ) ); } catch (e) { Toast.error(e); } } async function onAnonymise(download?: boolean) { try { setIsAnonymiseRunning(true); const ret = await mutateAnonymiseDatabase({ download, }); // download the result if (download && ret.data && ret.data.anonymiseDatabase) { const link = ret.data.anonymiseDatabase; downloadFile(link); } } catch (e) { Toast.error(e); } finally { setIsAnonymiseRunning(false); } } return ( {renderImportAlert()} {renderImportDialog()} {dialogOpen.cleanAlert || dialogOpen.clean ? ( { // undefined means cancelled if (p !== undefined) { if (dialogOpen.cleanAlert) { // don't provide paths onClean(); } else { onClean(p); } } setDialogOpen({ clean: false, cleanAlert: false, }); }} /> ) : ( dialogOpen.clean )} {dialogOpen.cleanGenerated && ( { if (options) { onCleanGenerated(options); } setDialogOpen({ cleanGenerated: false }); }} /> )} {dialogOpen.backup && ( { if (confirmed) { onBackup(download, includeBlobs); } setDialogOpen({ backup: false }); }} /> )}
    } subHeadingID="config.tasks.cleanup_desc" > setCleanOptions(o)} />
    } subHeadingID="config.tasks.clean_generated.description" >

    } >
    } subHeading={intl.formatMessage({ id: "config.tasks.backup_database.description", })} > [origFilename].anonymous.sqlite.[schemaVersion].[YYYYMMDD_HHMMSS] ), } )} >
    setMigrateBlobsOptions({ ...migrateBlobsOptions, deleteOld: v }) } />
    setMigrateSceneScreenshotsOptions({ ...migrateSceneScreenshotsOptions, overwriteExisting: v, }) } /> setMigrateSceneScreenshotsOptions({ ...migrateSceneScreenshotsOptions, deleteFiles: v, }) } />
    ); }; ================================================ FILE: ui/v2.5/src/components/Settings/Tasks/DirectorySelectionDialog.tsx ================================================ import { faMinus, faPencilAlt, faPlus, } from "@fortawesome/free-solid-svg-icons"; import React, { useState } from "react"; import { Button, Col, Form, Row } from "react-bootstrap"; import { useIntl } from "react-intl"; import { Icon } from "src/components/Shared/Icon"; import { ModalComponent } from "src/components/Shared/Modal"; import { FolderSelect } from "src/components/Shared/FolderSelect/FolderSelect"; import { useConfigurationContext } from "src/hooks/Config"; interface IDirectorySelectionDialogProps { animation?: boolean; initialPaths?: string[]; allowEmpty?: boolean; onClose: (paths?: string[]) => void; } export const DirectorySelectionDialog: React.FC< IDirectorySelectionDialogProps > = ({ animation, allowEmpty = false, initialPaths = [], onClose }) => { const intl = useIntl(); const { configuration } = useConfigurationContext(); const libraryPaths = configuration?.general.stashes.map((s) => s.path); const [paths, setPaths] = useState(initialPaths); const [currentDirectory, setCurrentDirectory] = useState(""); function removePath(p: string) { setPaths(paths.filter((path) => path !== p)); } function addPath(p: string) { if (p && !paths.includes(p)) { setPaths(paths.concat(p)); } } return ( { onClose(paths); }, text: intl.formatMessage({ id: "actions.confirm" }), }} cancel={{ onClick: () => onClose(), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} >
    {paths.map((p) => ( {p} ))} addPath(currentDirectory)} > } />
    ); }; ================================================ FILE: ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { BooleanSetting, ModalSetting } from "../Inputs"; import { VideoPreviewInput, VideoPreviewSettingsInput, } from "../GeneratePreviewOptions"; interface IGenerateOptions { type?: "scene" | "image" | "gallery"; selection?: boolean; options: GQL.GenerateMetadataInput; setOptions: (s: GQL.GenerateMetadataInput) => void; } export const GenerateOptions: React.FC = ({ type, selection, options, setOptions: setOptionsState, }) => { const previewOptions: GQL.GeneratePreviewOptionsInput = options.previewOptions ?? {}; function setOptions(input: Partial) { setOptionsState({ ...options, ...input }); } const showSceneOptions = !type || type === "scene"; const showImageOptions = !type || type === "image" || type === "gallery"; return ( <> {showSceneOptions && ( <> setOptions({ covers: v })} /> setOptions({ previews: v })} /> setOptions({ imagePreviews: v })} /> {/* #2251 - only allow preview generation options to be overridden when generating from a selection */} {selection ? ( id="video-preview-settings" className="sub-setting" disabled={!options.previews} headingID="dialogs.scene_gen.override_preview_generation_options" tooltipID="dialogs.scene_gen.override_preview_generation_options_desc" value={{ previewExcludeEnd: previewOptions.previewExcludeEnd, previewExcludeStart: previewOptions.previewExcludeStart, previewSegmentDuration: previewOptions.previewSegmentDuration, previewSegments: previewOptions.previewSegments, }} onChange={(v) => setOptions({ previewOptions: v })} renderField={(value, setValue) => ( )} renderValue={() => { return <>; }} /> ) : undefined} setOptions({ sprites: v })} /> setOptions({ markers: v })} /> setOptions({ markerImagePreviews: v, }) } /> setOptions({ markerScreenshots: v })} /> setOptions({ transcodes: v })} /> {selection ? ( setOptions({ forceTranscodes: v })} /> ) : undefined} setOptions({ phashes: v })} /> setOptions({ interactiveHeatmapsSpeeds: v })} /> )} {showImageOptions && ( <> setOptions({ clipPreviews: v })} /> setOptions({ imageThumbnails: v })} /> setOptions({ imagePhashes: v })} /> )} setOptions({ overwrite: v })} /> ); }; ================================================ FILE: ui/v2.5/src/components/Settings/Tasks/ImportDialog.tsx ================================================ import React, { useState } from "react"; import { Form } from "react-bootstrap"; import { mutateImportObjects } from "src/core/StashService"; import { ModalComponent } from "src/components/Shared/Modal"; import * as GQL from "src/core/generated-graphql"; import { useToast } from "src/hooks/Toast"; import { useIntl } from "react-intl"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; interface IImportDialogProps { onClose: () => void; } export const ImportDialog: React.FC = ( props: IImportDialogProps ) => { const [duplicateBehaviour, setDuplicateBehaviour] = useState( duplicateHandlingToString(GQL.ImportDuplicateEnum.Ignore) ); const [missingRefBehaviour, setMissingRefBehaviour] = useState( missingRefHandlingToString(GQL.ImportMissingRefEnum.Fail) ); const [file, setFile] = useState(); // Network state const [isRunning, setIsRunning] = useState(false); const intl = useIntl(); const Toast = useToast(); function duplicateHandlingToString( value: GQL.ImportDuplicateEnum | undefined ) { switch (value) { case GQL.ImportDuplicateEnum.Fail: return "Fail"; case GQL.ImportDuplicateEnum.Ignore: return "Ignore"; case GQL.ImportDuplicateEnum.Overwrite: return "Overwrite"; } return "Ignore"; } function translateDuplicateHandling(value: string) { switch (value) { case "Fail": return GQL.ImportDuplicateEnum.Fail; case "Ignore": return GQL.ImportDuplicateEnum.Ignore; case "Overwrite": return GQL.ImportDuplicateEnum.Overwrite; } return GQL.ImportDuplicateEnum.Ignore; } function missingRefHandlingToString( value: GQL.ImportMissingRefEnum | undefined ) { switch (value) { case GQL.ImportMissingRefEnum.Fail: return "Fail"; case GQL.ImportMissingRefEnum.Ignore: return "Ignore"; case GQL.ImportMissingRefEnum.Create: return "Create"; } return "Fail"; } function translateMissingRefHandling(value: string) { switch (value) { case "Fail": return GQL.ImportMissingRefEnum.Fail; case "Ignore": return GQL.ImportMissingRefEnum.Ignore; case "Create": return GQL.ImportMissingRefEnum.Create; } return GQL.ImportMissingRefEnum.Fail; } function onFileChange(event: React.ChangeEvent) { if ( event.target.validity.valid && event.target.files && event.target.files.length > 0 ) { setFile(event.target.files[0]); } } async function onImport() { if (!file) return; try { setIsRunning(true); await mutateImportObjects({ duplicateBehaviour: translateDuplicateHandling(duplicateBehaviour), missingRefBehaviour: translateMissingRefHandling(missingRefBehaviour), file, }); setIsRunning(false); Toast.success(intl.formatMessage({ id: "toast.started_importing" })); } catch (e) { Toast.error(e); } finally { props.onClose(); } } return ( { onImport(); }, text: intl.formatMessage({ id: "actions.import" }), }} cancel={{ onClick: () => props.onClose(), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} disabled={!file} isRunning={isRunning} >
    Import zip file
    Duplicate object handling
    ) => setDuplicateBehaviour(e.currentTarget.value) } > {Object.values(GQL.ImportDuplicateEnum).map((p) => ( ))}
    Missing reference handling
    ) => setMissingRefBehaviour(e.currentTarget.value) } > {Object.values(GQL.ImportMissingRefEnum).map((p) => ( ))}
    ); }; ================================================ FILE: ui/v2.5/src/components/Settings/Tasks/JobTable.tsx ================================================ import { faBan, faCheck, faCircle, faCircleExclamation, faCog, faHourglassStart, faTimes, } from "@fortawesome/free-solid-svg-icons"; import moment from "moment/min/moment-with-locales"; import React, { useEffect, useState } from "react"; import { Button, Card, ProgressBar } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { Icon } from "src/components/Shared/Icon"; import { mutateStopJob, useJobQueue, useJobsSubscribe, } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; type JobFragment = Pick< GQL.Job, | "id" | "status" | "subTasks" | "description" | "progress" | "error" | "startTime" >; interface IJob { job: JobFragment; } const Task: React.FC = ({ job }) => { const [stopping, setStopping] = useState(false); const [className, setClassName] = useState(""); useEffect(() => { setTimeout(() => setClassName("fade-in")); }, []); useEffect(() => { if ( job.status === GQL.JobStatus.Cancelled || job.status === GQL.JobStatus.Failed || job.status === GQL.JobStatus.Finished ) { // fade out around 10 seconds setTimeout(() => { setClassName("fade-out"); }, 9800); } }, [job]); async function stopJob() { setStopping(true); await mutateStopJob(job.id); } function canStop() { return ( !stopping && (job.status === GQL.JobStatus.Ready || job.status === GQL.JobStatus.Running) ); } function getStatusClass() { switch (job.status) { case GQL.JobStatus.Ready: return "ready"; case GQL.JobStatus.Running: return "running"; case GQL.JobStatus.Stopping: return "stopping"; case GQL.JobStatus.Finished: return "finished"; case GQL.JobStatus.Cancelled: return "cancelled"; case GQL.JobStatus.Failed: return "failed"; } } function getStatusIcon() { let icon = faCircle; let iconClass = ""; switch (job.status) { case GQL.JobStatus.Ready: icon = faHourglassStart; break; case GQL.JobStatus.Running: icon = faCog; iconClass = "fa-spin"; break; case GQL.JobStatus.Stopping: icon = faCog; iconClass = "fa-spin"; break; case GQL.JobStatus.Finished: icon = faCheck; break; case GQL.JobStatus.Cancelled: icon = faBan; break; case GQL.JobStatus.Failed: icon = faCircleExclamation; break; } return ; } function maybeRenderProgress() { if ( job.status === GQL.JobStatus.Running && job.progress !== undefined && job.progress !== null ) { const progress = job.progress * 100; return ( ); } } function maybeRenderETA() { if ( job.status === GQL.JobStatus.Running && job.startTime !== null && job.startTime !== undefined && job.progress !== null && job.progress !== undefined && job.progress > 0 ) { const now = new Date(); const start = new Date(job.startTime); const nowMS = now.valueOf(); const startMS = start.valueOf(); const estimatedLength = (nowMS - startMS) / job.progress; const estLenStr = moment.duration(estimatedLength).humanize(); return ( : {estLenStr} ); } } function maybeRenderSubTasks() { if ( job.status === GQL.JobStatus.Running || job.status === GQL.JobStatus.Stopping ) { return (
    {/* eslint-disable react/no-array-index-key */} {(job.subTasks ?? []).map((t, i) => (
    {t}
    ))} {/* eslint-enable react/no-array-index-key */}
    ); } if (job.status === GQL.JobStatus.Failed && job.error) { return
    {job.error}
    ; } } return (
  • {getStatusIcon()} {job.description}
    {maybeRenderETA()}
    {maybeRenderProgress()}
    {maybeRenderSubTasks()}
  • ); }; export const JobTable: React.FC = () => { const intl = useIntl(); const jobStatus = useJobQueue(); const jobsSubscribe = useJobsSubscribe(); const [queue, setQueue] = useState([]); useEffect(() => { setQueue(jobStatus.data?.jobQueue ?? []); }, [jobStatus]); useEffect(() => { if (!jobsSubscribe.data) { return; } const event = jobsSubscribe.data.jobsSubscribe; function updateJob() { setQueue((q) => q.map((j) => { if (j.id === event.job.id) { return event.job; } return j; }) ); } switch (event.type) { case GQL.JobStatusUpdateType.Add: // add to the end of the queue setQueue((q) => q.concat([event.job])); break; case GQL.JobStatusUpdateType.Remove: // update the job then remove after a timeout updateJob(); setTimeout(() => { setQueue((q) => q.filter((j) => j.id !== event.job.id)); }, 10000); break; case GQL.JobStatusUpdateType.Update: updateJob(); break; } }, [jobsSubscribe.data]); return (
      {!queue?.length ? ( {intl.formatMessage({ id: "config.tasks.empty_queue" })} ) : undefined} {(queue ?? []).map((j) => ( ))}
    ); }; ================================================ FILE: ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx ================================================ import React, { useState, useEffect } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { Button, Form } from "react-bootstrap"; import { mutateMetadataScan, mutateMetadataAutoTag, mutateMetadataGenerate, } from "src/core/StashService"; import { withoutTypename } from "src/utils/data"; import { useConfigurationContext } from "src/hooks/Config"; import { IdentifyDialog } from "../../Dialogs/IdentifyDialog/IdentifyDialog"; import * as GQL from "src/core/generated-graphql"; import { DirectorySelectionDialog } from "./DirectorySelectionDialog"; import { ScanOptions } from "./ScanOptions"; import { useToast } from "src/hooks/Toast"; import { GenerateOptions } from "./GenerateOptions"; import { SettingSection } from "../SettingSection"; import { BooleanSetting, Setting, SettingGroup } from "../Inputs"; import { ManualLink } from "src/components/Help/context"; import { Icon } from "src/components/Shared/Icon"; import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { useSettings } from "../context"; interface IAutoTagOptions { options: GQL.AutoTagMetadataInput; setOptions: (s: GQL.AutoTagMetadataInput) => void; } const AutoTagOptions: React.FC = ({ options, setOptions: setOptionsState, }) => { const { performers, studios, tags } = options; const wildcard = ["*"]; function set(v?: boolean) { if (v) { return wildcard; } return []; } function setOptions(input: Partial) { setOptionsState({ ...options, ...input }); } return ( <> setOptions({ performers: set(v) })} /> setOptions({ studios: set(v) })} /> setOptions({ tags: set(v) })} /> ); }; export const LibraryTasks: React.FC = () => { const intl = useIntl(); const Toast = useToast(); const { ui, saveUI, loading } = useSettings(); const { taskDefaults } = ui; const [dialogOpen, setDialogOpenState] = useState({ scan: false, autoTag: false, identify: false, generate: false, }); function getDefaultScanOptions(): GQL.ScanMetadataInput { return { scanGenerateCovers: true, scanGeneratePreviews: false, scanGenerateImagePreviews: false, scanGenerateSprites: false, scanGeneratePhashes: false, scanGenerateThumbnails: false, scanGenerateClipPreviews: false, }; } const [scanOptions, setScanOptions] = useState( getDefaultScanOptions() ); const [autoTagOptions, setAutoTagOptions] = useState({ performers: ["*"], studios: ["*"], tags: ["*"], }); function getDefaultGenerateOptions(): GQL.GenerateMetadataInput { return { covers: true, sprites: true, phashes: true, previews: true, markers: true, previewOptions: { previewSegments: 0, previewSegmentDuration: 0, previewPreset: GQL.PreviewPreset.Slow, }, }; } const [generateOptions, setGenerateOptions] = useState(getDefaultGenerateOptions()); type DialogOpenState = typeof dialogOpen; const { configuration } = useConfigurationContext(); const [configRead, setConfigRead] = useState(false); useEffect(() => { if (!configuration?.defaults || loading) { return; } const { scan, autoTag } = configuration.defaults; // prefer UI defaults over system defaults // other defaults should be deprecated if (taskDefaults?.scan) { setScanOptions(taskDefaults.scan); } else if (scan) { setScanOptions(withoutTypename(scan)); } if (taskDefaults?.autoTag) { setAutoTagOptions(taskDefaults.autoTag); } else if (autoTag) { setAutoTagOptions(withoutTypename(autoTag)); } if (taskDefaults?.generate) { setGenerateOptions(taskDefaults.generate); } // combine the defaults with the system preview generation settings // only do this once // don't do this if UI had a default if (!configRead && !taskDefaults?.generate) { if (configuration?.defaults.generate) { const { generate } = configuration.defaults; setGenerateOptions(withoutTypename(generate)); } setConfigRead(true); } }, [configuration, configRead, taskDefaults, loading]); function configureDefaults(partial: Record) { saveUI({ taskDefaults: { ...partial } }); } function onSetScanOptions(s: GQL.ScanMetadataInput) { configureDefaults({ scan: s }); setScanOptions(s); } function onSetGenerateOptions(s: GQL.GenerateMetadataInput) { configureDefaults({ generate: s }); setGenerateOptions(s); } function onSetAutoTagOptions(s: GQL.AutoTagMetadataInput) { configureDefaults({ autoTag: s }); setAutoTagOptions(s); } function setDialogOpen(s: Partial) { setDialogOpenState((v) => { return { ...v, ...s }; }); } function renderScanDialog() { if (!dialogOpen.scan) { return; } return ; } function onScanDialogClosed(paths?: string[]) { if (paths) { runScan(paths); } setDialogOpen({ scan: false }); } async function runScan(paths?: string[]) { try { await mutateMetadataScan({ ...scanOptions, paths, }); Toast.success( intl.formatMessage( { id: "config.tasks.added_job_to_queue" }, { operation_name: intl.formatMessage({ id: "actions.scan" }) } ) ); } catch (e) { Toast.error(e); } } function renderAutoTagDialog() { if (!dialogOpen.autoTag) { return; } return ; } function onAutoTagDialogClosed(paths?: string[]) { if (paths) { runAutoTag(paths); } setDialogOpen({ autoTag: false }); } async function runAutoTag(paths?: string[]) { try { await mutateMetadataAutoTag({ ...autoTagOptions, paths, }); Toast.success( intl.formatMessage( { id: "config.tasks.added_job_to_queue" }, { operation_name: intl.formatMessage({ id: "actions.auto_tag" }) } ) ); } catch (e) { Toast.error(e); } } function maybeRenderIdentifyDialog() { if (!dialogOpen.identify) return; return ( setDialogOpen({ identify: false })} /> ); } function renderGenerateDialog() { if (!dialogOpen.generate) { return; } return ; } function onGenerateDialogClosed(paths?: string[]) { if (paths) { runGenerate(paths); } setDialogOpen({ generate: false }); } // eslint-disable-next-line @typescript-eslint/no-unused-vars async function runGenerate(paths?: string[]) { try { await mutateMetadataGenerate({ ...generateOptions, paths, }); Toast.success( intl.formatMessage( { id: "config.tasks.added_job_to_queue" }, { operation_name: intl.formatMessage({ id: "actions.generate" }) } ) ); } catch (e) { Toast.error(e); } } async function onGenerateClicked() { try { // insert preview options here instead of loading them const general = configuration?.general; await mutateMetadataGenerate({ ...generateOptions, previewOptions: { ...generateOptions.previewOptions, previewSegments: general?.previewSegments ?? generateOptions.previewOptions?.previewSegments, previewSegmentDuration: general?.previewSegmentDuration ?? generateOptions.previewOptions?.previewSegmentDuration, previewExcludeStart: general?.previewExcludeStart ?? generateOptions.previewOptions?.previewExcludeStart, previewExcludeEnd: general?.previewExcludeEnd ?? generateOptions.previewOptions?.previewExcludeEnd, previewPreset: general?.previewPreset ?? generateOptions.previewOptions?.previewPreset, }, }); Toast.success( intl.formatMessage( { id: "config.tasks.added_job_to_queue" }, { operation_name: intl.formatMessage({ id: "actions.generate" }) } ) ); } catch (e) { Toast.error(e); } } return ( {renderScanDialog()} {renderAutoTagDialog()} {maybeRenderIdentifyDialog()} {renderGenerateDialog()} ), subHeadingID: "config.tasks.scan_for_content_desc", }} topLevel={ <> } collapsible > } subHeadingID="config.tasks.identify.description" > ), subHeadingID: "config.tasks.auto_tag_based_on_filenames", }} topLevel={ <> } collapsible > ), subHeadingID: "config.tasks.generate_desc", }} topLevel={ <> } collapsible > ); }; ================================================ FILE: ui/v2.5/src/components/Settings/Tasks/PluginTasks.tsx ================================================ import React from "react"; import { useIntl } from "react-intl"; import { Button, Form } from "react-bootstrap"; import { mutateRunPluginTask, usePlugins } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import * as GQL from "src/core/generated-graphql"; import { SettingSection } from "../SettingSection"; import { Setting, SettingGroup } from "../Inputs"; type Plugin = Pick; type PluginTask = Pick; export const PluginTasks: React.FC = () => { const intl = useIntl(); const Toast = useToast(); const plugins = usePlugins(); function renderPluginTasks(plugin: Plugin, pluginTasks: PluginTask[]) { return pluginTasks.map((o) => { return ( ); }); } async function onPluginTaskClicked(plugin: Plugin, operation: PluginTask) { await mutateRunPluginTask(plugin.id, operation.name); Toast.success( intl.formatMessage( { id: "config.tasks.added_job_to_queue" }, { operation_name: operation.name } ) ); } if (!plugins.data?.plugins) { return null; } const taskPlugins = plugins.data.plugins.filter( (p) => p.enabled && p.tasks && p.tasks.length > 0 ); if (!taskPlugins.length) { return null; } return ( {taskPlugins.map((o) => { return ( {renderPluginTasks(o, o.tasks!)} ); })} ); }; ================================================ FILE: ui/v2.5/src/components/Settings/Tasks/ScanOptions.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { BooleanSetting } from "../Inputs"; interface IScanOptions { options: GQL.ScanMetadataInput; setOptions: (s: GQL.ScanMetadataInput) => void; } export const ScanOptions: React.FC = ({ options, setOptions: setOptionsState, }) => { const { scanGenerateCovers, scanGeneratePreviews, scanGenerateImagePreviews, scanGenerateSprites, scanGeneratePhashes, scanGenerateThumbnails, scanGenerateImagePhashes, scanGenerateClipPreviews, rescan, } = options; function setOptions(input: Partial) { setOptionsState({ ...options, ...input }); } return ( <> setOptions({ scanGenerateCovers: v })} /> setOptions({ scanGeneratePreviews: v })} /> setOptions({ scanGenerateImagePreviews: v })} /> setOptions({ scanGenerateSprites: v })} /> setOptions({ scanGeneratePhashes: v })} /> setOptions({ scanGenerateThumbnails: v })} /> setOptions({ scanGenerateImagePhashes: v })} /> setOptions({ scanGenerateClipPreviews: v })} /> setOptions({ rescan: v })} /> ); }; ================================================ FILE: ui/v2.5/src/components/Settings/Tasks/SettingsTasksPanel.tsx ================================================ import React, { useState } from "react"; import { useIntl } from "react-intl"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { LibraryTasks } from "./LibraryTasks"; import { DataManagementTasks } from "./DataManagementTasks"; import { PluginTasks } from "./PluginTasks"; import { JobTable } from "./JobTable"; export const SettingsTasksPanel: React.FC = () => { const intl = useIntl(); const [isBackupRunning, setIsBackupRunning] = useState(false); const [isAnonymiseRunning, setIsAnonymiseRunning] = useState(false); if (isBackupRunning) { return ( ); } if (isAnonymiseRunning) { return ( ); } return (

    {intl.formatMessage({ id: "config.tasks.job_queue" })}



    ); }; ================================================ FILE: ui/v2.5/src/components/Settings/context.tsx ================================================ import { ApolloError } from "@apollo/client/errors"; import { faCheckCircle, faTimesCircle, } from "@fortawesome/free-solid-svg-icons"; import React, { useState, useEffect, useCallback, useRef } from "react"; import { Spinner } from "react-bootstrap"; import { IUIConfig } from "src/core/config"; import * as GQL from "src/core/generated-graphql"; import { useConfiguration, useConfigureDefaults, useConfigureDLNA, useConfigureGeneral, useConfigureInterface, useConfigurePlugin, useConfigureScraping, useConfigureUI, } from "src/core/StashService"; import { useDebounce } from "src/hooks/debounce"; import { useToast } from "src/hooks/Toast"; import { withoutTypename } from "src/utils/data"; import { Icon } from "../Shared/Icon"; type PluginConfigs = Record>; export interface ISettingsContextState { loading: boolean; error: ApolloError | undefined; general: GQL.ConfigGeneralInput; interface: GQL.ConfigInterfaceInput; defaults: GQL.ConfigDefaultSettingsInput; scraping: GQL.ConfigScrapingInput; dlna: GQL.ConfigDlnaInput; ui: IUIConfig; plugins: PluginConfigs; advancedMode: boolean; // apikey isn't directly settable, so expose it here apiKey: string; saveGeneral: (input: Partial) => void; saveInterface: (input: Partial) => void; saveDefaults: (input: Partial) => void; saveScraping: (input: Partial) => void; saveDLNA: (input: Partial) => void; saveUI: (input: Partial) => void; savePluginSettings: (pluginID: string, input: {}) => void; setAdvancedMode: (value: boolean) => void; refetch: () => void; } function noop() {} const emptyState: ISettingsContextState = { loading: false, error: undefined, general: {}, interface: {}, defaults: {}, scraping: {}, dlna: {}, ui: {}, plugins: {}, advancedMode: false, apiKey: "", saveGeneral: noop, saveInterface: noop, saveDefaults: noop, saveScraping: noop, saveDLNA: noop, saveUI: noop, savePluginSettings: noop, setAdvancedMode: noop, refetch: noop, }; export const SettingStateContext = React.createContext(null); export const useSettings = () => { const context = React.useContext(SettingStateContext); if (context === null) { throw new Error("useSettings must be used within a SettingsContext"); } return context; }; export function useSettingsOptional(): ISettingsContextState { const context = React.useContext(SettingStateContext); if (context === null) { return emptyState; } return context; } export const SettingsContext: React.FC = ({ children }) => { const Toast = useToast(); const { data, error, loading, refetch } = useConfiguration(); const initialRef = useRef(false); const [general, setGeneral] = useState({}); const [pendingGeneral, setPendingGeneral] = useState(); const [updateGeneralConfig] = useConfigureGeneral(); const [iface, setIface] = useState({}); const [pendingInterface, setPendingInterface] = useState(); const [updateInterfaceConfig] = useConfigureInterface(); const [defaults, setDefaults] = useState({}); const [pendingDefaults, setPendingDefaults] = useState(); const [updateDefaultsConfig] = useConfigureDefaults(); const [scraping, setScraping] = useState({}); const [pendingScraping, setPendingScraping] = useState(); const [updateScrapingConfig] = useConfigureScraping(); const [dlna, setDLNA] = useState({}); const [pendingDLNA, setPendingDLNA] = useState(); const [updateDLNAConfig] = useConfigureDLNA(); const [ui, setUI] = useState({}); const [pendingUI, setPendingUI] = useState<{}>(); const [updateUIConfig] = useConfigureUI(); const [plugins, setPlugins] = useState({}); const [pendingPlugins, setPendingPlugins] = useState(); const [updatePluginConfig] = useConfigurePlugin(); const [updateSuccess, setUpdateSuccess] = useState(); const [apiKey, setApiKey] = useState(""); useEffect(() => { if (!data?.configuration || error) return; // always set api key setApiKey(data.configuration.general.apiKey); // only initialise once - assume we have control over these settings and // they aren't modified elsewhere if (initialRef.current) return; initialRef.current = true; setGeneral({ ...withoutTypename(data.configuration.general) }); setIface({ ...withoutTypename(data.configuration.interface) }); setDefaults({ ...withoutTypename(data.configuration.defaults) }); setScraping({ ...withoutTypename(data.configuration.scraping) }); setDLNA({ ...withoutTypename(data.configuration.dlna) }); setUI(data.configuration.ui); setPlugins(data.configuration.plugins); }, [data, error]); const resetSuccess = useDebounce(() => setUpdateSuccess(undefined), 4000); const onSuccess = useCallback(() => { setUpdateSuccess(true); resetSuccess(); }, [resetSuccess]); const onError = useCallback( (err) => { Toast.error(err); setUpdateSuccess(false); }, [Toast] ); // saves the configuration if no further changes are made after a half second const saveGeneralConfig = useDebounce( async (input: GQL.ConfigGeneralInput) => { try { setUpdateSuccess(undefined); await updateGeneralConfig({ variables: { input, }, }); setPendingGeneral(undefined); onSuccess(); } catch (e) { onError(e); } }, 500 ); useEffect(() => { if (!pendingGeneral) { return; } saveGeneralConfig(pendingGeneral); }, [pendingGeneral, saveGeneralConfig]); function saveGeneral(input: Partial) { if (!general) { return; } setGeneral({ ...general, ...input, }); setPendingGeneral((current) => { if (!current) { return input; } return { ...current, ...input, }; }); } // saves the configuration if no further changes are made after a half second const saveInterfaceConfig = useDebounce( async (input: GQL.ConfigInterfaceInput) => { try { setUpdateSuccess(undefined); await updateInterfaceConfig({ variables: { input, }, }); setPendingInterface(undefined); onSuccess(); } catch (e) { onError(e); } }, 500 ); useEffect(() => { if (!pendingInterface) { return; } saveInterfaceConfig(pendingInterface); }, [pendingInterface, saveInterfaceConfig]); function saveInterface(input: Partial) { if (!iface) { return; } setIface({ ...iface, ...input, }); setPendingInterface((current) => { if (!current) { return input; } return { ...current, ...input, }; }); } // saves the configuration if no further changes are made after a half second const saveDefaultsConfig = useDebounce( async (input: GQL.ConfigDefaultSettingsInput) => { try { setUpdateSuccess(undefined); await updateDefaultsConfig({ variables: { input, }, }); setPendingDefaults(undefined); onSuccess(); } catch (e) { onError(e); } }, 500 ); useEffect(() => { if (!pendingDefaults) { return; } saveDefaultsConfig(pendingDefaults); }, [pendingDefaults, saveDefaultsConfig]); function saveDefaults(input: Partial) { if (!defaults) { return; } setDefaults({ ...defaults, ...input, }); setPendingDefaults((current) => { if (!current) { return input; } return { ...current, ...input, }; }); } // saves the configuration if no further changes are made after a half second const saveScrapingConfig = useDebounce( async (input: GQL.ConfigScrapingInput) => { try { setUpdateSuccess(undefined); await updateScrapingConfig({ variables: { input, }, }); setPendingScraping(undefined); onSuccess(); } catch (e) { onError(e); } }, 500 ); useEffect(() => { if (!pendingScraping) { return; } saveScrapingConfig(pendingScraping); }, [pendingScraping, saveScrapingConfig]); function saveScraping(input: Partial) { if (!scraping) { return; } setScraping({ ...scraping, ...input, }); setPendingScraping((current) => { if (!current) { return input; } return { ...current, ...input, }; }); } // saves the configuration if no further changes are made after a half second const saveDLNAConfig = useDebounce(async (input: GQL.ConfigDlnaInput) => { try { setUpdateSuccess(undefined); await updateDLNAConfig({ variables: { input, }, }); setPendingDLNA(undefined); onSuccess(); } catch (e) { onError(e); } }, 500); useEffect(() => { if (!pendingDLNA) { return; } saveDLNAConfig(pendingDLNA); }, [pendingDLNA, saveDLNAConfig]); function saveDLNA(input: Partial) { if (!dlna) { return; } setDLNA({ ...dlna, ...input, }); setPendingDLNA((current) => { if (!current) { return input; } return { ...current, ...input, }; }); } type UIConfigInput = GQL.Scalars["Map"]["input"]; // saves the configuration if no further changes are made after a half second const saveUIConfig = useDebounce(async (input: Partial) => { try { setUpdateSuccess(undefined); await updateUIConfig({ variables: { partial: input as UIConfigInput, }, }); setPendingUI(undefined); onSuccess(); } catch (e) { onError(e); } }, 500); useEffect(() => { if (!pendingUI) { return; } saveUIConfig(pendingUI); }, [pendingUI, saveUIConfig]); function saveUI(input: IUIConfig) { if (!ui) { return; } setUI({ ...ui, ...input, }); setPendingUI((current) => { return { ...current, ...input, }; }); } function setAdvancedMode(value: boolean) { saveUI({ advancedMode: value, }); } // saves the configuration if no further changes are made after a half second const savePluginConfig = useDebounce(async (input: PluginConfigs) => { try { setUpdateSuccess(undefined); for (const pluginID in input) { await updatePluginConfig({ variables: { plugin_id: pluginID, input: input[pluginID], }, }); } setPendingPlugins(undefined); onSuccess(); } catch (e) { onError(e); } }, 500); useEffect(() => { if (!pendingPlugins) { return; } savePluginConfig(pendingPlugins); }, [pendingPlugins, savePluginConfig]); function savePluginSettings( pluginID: string, input: Record ) { if (!plugins) { return; } setPlugins({ ...plugins, [pluginID]: input, }); setPendingPlugins((current) => { if (!current) { // use full UI object to ensure nothing is wiped return { ...plugins, [pluginID]: input, }; } return { ...current, [pluginID]: input, }; }); } function maybeRenderLoadingIndicator() { if (updateSuccess === false) { return (
    ); } if ( pendingGeneral || pendingInterface || pendingDefaults || pendingScraping || pendingDLNA || pendingUI || pendingPlugins ) { return (
    Loading...
    ); } if (updateSuccess) { return (
    ); } } return ( {maybeRenderLoadingIndicator()} {children} ); }; ================================================ FILE: ui/v2.5/src/components/Settings/styles.scss ================================================ @include media-breakpoint-up(sm) { #settings-menu-container { position: fixed; } } #settings-container .tab-content { max-width: 780px; } .setting-section { &:not(:first-child) { margin-top: 1.5em; } .card { padding: 0; } h1 { font-size: 2rem; } .sub-heading { font-size: 0.8rem; margin-top: 0.5rem; } .content { padding: 15px; width: 100%; } .setting { align-items: center; display: flex; justify-content: space-between; padding: 15px; width: 100%; &.sub-setting { padding-left: 2rem; } h3 { font-size: 1.25rem; margin-bottom: 0; &[title] { cursor: help; text-decoration: underline dotted; } } &.disabled { .custom-switch, h3 { opacity: 0.5; } } > div:first-child { flex-grow: 0; } > div:last-child { min-width: 100px; text-align: right; .btn { margin: 0.25rem; } } &:not(:last-child) { border-bottom: 1px solid #000; } .value { font-family: "Courier New", Courier, monospace; margin-bottom: 0.5rem; margin-top: 0.5rem; overflow-wrap: anywhere; pre { max-height: 250px; width: 100%; } } } .setting-group { &.collapsible > .setting { cursor: pointer; } padding-bottom: 15px; width: 100%; .setting-group-collapse-button { color: $text-muted; font-size: 1.5rem; padding: 0; } &:not(:last-child) { border-bottom: 1px solid #000; } > .setting:first-child { border-bottom: none; padding-bottom: 0; } > .setting:not(:first-child), .collapsible-section .setting { margin-left: 2.5rem; margin-right: 1.5rem; padding-bottom: 10px; padding-left: 0; padding-top: 10px; h3 { font-size: 1rem; } &.sub-setting { padding-left: 2rem; } } .setting { flex-wrap: wrap; width: auto; & > div:last-child { margin-left: auto; } } } } #stashes .card { // override overflow so that menu shows correctly overflow: visible; } #stash-table { @include media-breakpoint-down(sm) { padding-top: 0; } .setting { justify-content: start; padding: 0; } .stash-row .setting > div:last-child { text-align: left; } } #tasks-panel { @media (min-width: 576px) and (min-height: 600px) { .tasks-panel-queue { background-color: #202b33; margin-top: -1rem; padding-bottom: 0.25rem; padding-top: 1rem; position: sticky; top: 3rem; z-index: 2; } } h1 { font-size: 2rem; } } #setting-dialog .sub-heading { font-size: 0.8rem; margin-top: 0.5rem; } .logs { font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; font-size: smaller; max-height: 100vh; overflow-x: hidden; overflow-y: auto; padding-top: 1rem; white-space: pre-wrap; .debug { color: lightgreen; font-weight: bold; } .info { color: white; font-weight: bold; } .warning { color: orange; font-weight: bold; } .error { color: red; font-weight: bold; } } .log-time { margin-right: 1rem; } #configuration-tabs-tabpane-about .table { width: initial; } #configuration-tabs-tabpane-tasks h5 { margin-bottom: 1em; } .scraper-table { display: block; margin-bottom: 16px; max-height: 300px; overflow: auto; width: 100%; tr { border-top: 1px solid #181513; &:nth-child(2n) { background-color: #2c3b47; } } th, td { border: 1px solid #181513; padding: 6px 13px; } ul { margin-bottom: 0; max-height: 100px; overflow: auto; padding-left: 0; } li { list-style: none; } } .scraper-toolbar { display: flex; justify-content: space-between; } .job-table.card { background-color: $card-bg; height: 10em; margin-bottom: 30px; overflow-y: auto; padding: 0.5rem 15px; ul { list-style: none; padding-inline-start: 0; } li { opacity: 0; transition: opacity 0.25s; &.fade-in { opacity: 1; } > div { align-items: flex-start; display: flex; } } .job-status { width: 100%; } .job-description { display: flex; justify-content: space-between; } .stop:not(:disabled), .stopping .fa-icon, .cancelled .fa-icon { color: $danger; } .running .fa-icon, .finished .fa-icon { color: $success; } .failed .fa-icon { color: $danger; } .ready .fa-icon { color: $warning; } .cancelled, .finished { color: $text-muted; } .job-error { color: $danger; } } #temp-enable-duration .duration-control:disabled { opacity: 0.5; } #settings-dlna { .ip-whitelist-input, .interfaces-input { width: 12em; } .server-name { width: 24em; } .addresses { list-style-type: none; padding-inline-start: 0; li { display: flex; margin-bottom: 0.5rem; .address { display: inline-block; width: 12em; } .buttons { align-items: center; display: flex; } .deadline { font-size: 0.8em; } code { display: inline-block; } } } } .task-group { padding-top: 0.5rem; &:not(:last-child) { padding-bottom: 0.5rem; } .task { &:not(:first-child) { padding-top: 0.5rem; } &:not(:last-child) { padding-bottom: 1rem; } } } .loading-indicator { opacity: 50%; position: fixed; right: 30px; z-index: 1051; @include media-breakpoint-down(xs) { top: 30px; } @include media-breakpoint-up(sm) { bottom: 30px; } .fa-icon { color: $success; height: 2rem; margin: 0; width: 2rem; } &.success .fa-icon { animation: fadeOut 2s forwards; animation-delay: 2s; } &.failed .fa-icon { color: $danger; } } @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } } .empty-queue-message { color: $text-muted; } .advanced-switch { display: flex; justify-content: space-between; padding: 0.5rem 1rem; .form-label { color: $text-muted; margin-right: 0.5rem; } .custom-switch { display: inline-block; } } .troubleshooting-mode-button { bottom: 1rem; left: 1rem; position: fixed; z-index: 100; @include media-breakpoint-down(xs) { padding-left: 0.5rem; position: static; } } ================================================ FILE: ui/v2.5/src/components/SettingsButton.tsx ================================================ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import React, { useEffect, useState } from "react"; import { Button } from "react-bootstrap"; import { useJobQueue, useJobsSubscribe } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { useIntl } from "react-intl"; import { faCog } from "@fortawesome/free-solid-svg-icons"; type JobFragment = Pick< GQL.Job, "id" | "status" | "subTasks" | "description" | "progress" >; export const SettingsButton: React.FC = () => { const intl = useIntl(); const jobStatus = useJobQueue(); const jobsSubscribe = useJobsSubscribe(); const [queue, setQueue] = useState([]); useEffect(() => { setQueue(jobStatus.data?.jobQueue ?? []); }, [jobStatus]); useEffect(() => { if (!jobsSubscribe.data) { return; } const event = jobsSubscribe.data.jobsSubscribe; function updateJob() { setQueue((q) => q.map((j) => { if (j.id === event.job.id) { return event.job; } return j; }) ); } switch (event.type) { case GQL.JobStatusUpdateType.Add: // add to the end of the queue setQueue((q) => q.concat([event.job])); break; case GQL.JobStatusUpdateType.Remove: setQueue((q) => q.filter((j) => j.id !== event.job.id)); break; case GQL.JobStatusUpdateType.Update: updateJob(); break; } }, [jobsSubscribe.data]); return ( ); }; ================================================ FILE: ui/v2.5/src/components/Setup/Migrate.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { Button, Card, Container, Form, ProgressBar } from "react-bootstrap"; import { useIntl, FormattedMessage } from "react-intl"; import { useHistory } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import { useSystemStatus, mutateMigrate, postMigrate, refetchSystemStatus, } from "src/core/StashService"; import { migrationNotes } from "src/docs/en/MigrationNotes"; import { ExternalLink } from "../Shared/ExternalLink"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { MarkdownPage } from "../Shared/MarkdownPage"; import { JobFragment, useMonitorJob } from "src/utils/job"; export const Migrate: React.FC = () => { const intl = useIntl(); const history = useHistory(); const { data: systemStatus, loading } = useSystemStatus(); const [backupPath, setBackupPath] = useState(); const [migrateLoading, setMigrateLoading] = useState(false); const [migrateError, setMigrateError] = useState(""); const [jobID, setJobID] = useState(); function onJobFinished(finishedJob?: JobFragment) { setJobID(undefined); setMigrateLoading(false); if (finishedJob?.error) { setMigrateError(finishedJob.error); } else { postMigrate(); // refetch the system status so that the we get redirected refetchSystemStatus(); } } const { job } = useMonitorJob(jobID, onJobFinished); // if database path includes path separators, then this is passed through // to the migration path. Extract the base name of the database file. const databasePath = systemStatus ? systemStatus?.systemStatus.databasePath?.split(/[\\/]/).pop() : ""; // make suffix based on current time const now = new Date() .toISOString() .replace(/T/g, "_") .replace(/-/g, "") .replace(/:/g, "") .replace(/\..*/, ""); const defaultBackupPath = systemStatus ? `${databasePath}.${systemStatus.systemStatus.databaseSchema}.${now}` : ""; const discordLink = ( Discord ); const githubLink = ( ); useEffect(() => { if (backupPath === undefined && defaultBackupPath) { setBackupPath(defaultBackupPath); } }, [defaultBackupPath, backupPath]); const status = systemStatus?.systemStatus; const maybeMigrationNotes = useMemo(() => { if ( !status || status.databaseSchema === undefined || status.databaseSchema === null || status.appSchema === undefined || status.appSchema === null ) return; const notes = []; for (let i = status.databaseSchema + 1; i <= status.appSchema; ++i) { const note = migrationNotes[i]; if (note) { notes.push(note); } } if (notes.length === 0) return; return (

    {notes.map((n, i) => (
    ))}
    ); }, [status]); // only display setup wizard if system is not setup if (loading || !systemStatus || !status) { return ; } if (migrateLoading) { const progress = job && job.progress !== undefined && job.progress !== null ? job.progress * 100 : undefined; return (

    {progress !== undefined && ( )} {job?.subTasks?.map((subTask, i) => (

    {subTask}

    ))}
    ); } if ( systemStatus.systemStatus.status !== GQL.SystemStatusEnum.NeedsMigration ) { // redirect to main page history.replace("/"); return ; } async function onMigrate() { try { setMigrateLoading(true); setMigrateError(""); // migrate now uses the job manager const ret = await mutateMigrate({ backupPath: backupPath ?? "", }); setJobID(ret.data?.migrate); } catch (e) { if (e instanceof Error) setMigrateError(e.message ?? e.toString()); setMigrateLoading(false); } } function maybeRenderError() { if (!migrateError) { return; } return (

    {migrateError}

    ); } return (

    {status.databaseSchema}, appSchema: {status.appSchema}, strong: (chunks: string) => {chunks}, code: (chunks: string) => {chunks}, }} />

    {chunks}, }} />

    {maybeMigrationNotes}
    ) => setBackupPath(e.currentTarget.value) } />
    {maybeRenderError()}
    ); }; export default Migrate; ================================================ FILE: ui/v2.5/src/components/Setup/Setup.tsx ================================================ import React, { useState, useCallback } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { Alert, Button, Card, Container, Form, InputGroup, } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import { mutateSetup, useConfigureUI, useSystemStatus, } from "src/core/StashService"; import { useHistory } from "react-router-dom"; import { useConfigurationContext } from "src/hooks/Config"; import StashConfiguration from "../Settings/StashConfiguration"; import { Icon } from "../Shared/Icon"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { ModalComponent } from "../Shared/Modal"; import { FolderSelectDialog } from "../Shared/FolderSelect/FolderSelectDialog"; import { faEllipsisH, faExclamationTriangle, faQuestionCircle, } from "@fortawesome/free-solid-svg-icons"; import { releaseNotes } from "src/docs/en/ReleaseNotes"; import { ExternalLink } from "../Shared/ExternalLink"; interface ISetupContextState { configuration: GQL.ConfigDataFragment; systemStatus: GQL.SystemStatusQuery; setupState: Partial; setupError: string | undefined; pathJoin: (...paths: string[]) => string; pathDir(path: string): string; homeDir: string; windows: boolean; macApp: boolean; homeDirPath: string; pwd: string; workingDir: string; } const SetupStateContext = React.createContext(null); const useSetupContext = () => { const context = React.useContext(SetupStateContext); if (context === null) { throw new Error("useSettings must be used within a SettingsContext"); } return context; }; const SetupContext: React.FC<{ setupState: Partial; setupError: string | undefined; systemStatus: GQL.SystemStatusQuery; configuration: GQL.ConfigDataFragment; }> = ({ setupState, setupError, systemStatus, configuration, children }) => { const status = systemStatus?.systemStatus; const windows = status?.os === "windows"; const pathSep = windows ? "\\" : "/"; const homeDir = windows ? "%USERPROFILE%" : "$HOME"; const pwd = windows ? "%CD%" : "$PWD"; const pathJoin = useCallback( (...paths: string[]) => { return paths.join(pathSep); }, [pathSep] ); // simply returns everything preceding the last path separator function pathDir(path: string) { const lastSep = path.lastIndexOf(pathSep); if (lastSep === -1) return ""; return path.slice(0, lastSep); } const workingDir = status?.workingDir ?? "."; // When running Stash.app, the working directory is (usually) set to /. // Assume that the user doesn't want to set up in / (it's usually mounted read-only anyway), // so in this situation disallow setting up in the working directory. const macApp = status?.os === "darwin" && workingDir === "/"; const homeDirPath = pathJoin(status?.homeDir ?? homeDir, ".stash"); const state: ISetupContextState = { systemStatus, configuration, windows, macApp, pathJoin, pathDir, homeDir, homeDirPath, pwd, workingDir, setupState, setupError, }; return ( {children} ); }; interface IWizardStep { next: (input?: Partial) => void; goBack: () => void; } const WelcomeSpecificConfig: React.FC = ({ next }) => { const { systemStatus } = useSetupContext(); const status = systemStatus?.systemStatus; const overrideConfig = status?.configPath; function onNext() { next({ configLocation: overrideConfig! }); } return ( <>

    {chunks}, }} />

    ); }; const DefaultWelcomeStep: React.FC = ({ next }) => { const { pathJoin, homeDir, macApp, homeDirPath, pwd, workingDir } = useSetupContext(); const fallbackStashDir = pathJoin(homeDir, ".stash"); const fallbackConfigPath = pathJoin(fallbackStashDir, "config.yml"); function onConfigLocationChosen(inWorkingDir: boolean) { const configLocation = inWorkingDir ? "config.yml" : ""; next({ configLocation }); } return ( <>

    {chunks}, fallback_path: fallbackConfigPath, }} />

    {chunks}, }} />

    ); }; const WelcomeStep: React.FC = (props) => { const { systemStatus } = useSetupContext(); const status = systemStatus?.systemStatus; const overrideConfig = status?.configPath; return overrideConfig ? ( ) : ( ); }; const StashAlert: React.FC<{ close: (confirm: boolean) => void }> = ({ close, }) => { const intl = useIntl(); return ( close(true), }} cancel={{ onClick: () => close(false) }} >

    ); }; const DatabaseSection: React.FC<{ databaseFile: string; setDatabaseFile: React.Dispatch>; }> = ({ databaseFile, setDatabaseFile }) => { const intl = useIntl(); return (

    {chunks}, }} />
    {chunks}, }} />

    setDatabaseFile(e.currentTarget.value)} />
    ); }; const DirectorySelector: React.FC<{ value: string; setValue: React.Dispatch>; placeholder: string; disabled?: boolean; }> = ({ value, setValue, placeholder, disabled = false }) => { const [showSelectDialog, setShowSelectDialog] = useState(false); function onSelectClosed(dir?: string) { if (dir) { setValue(dir); } setShowSelectDialog(false); } return ( <> {showSelectDialog ? ( ) : null} setValue(e.currentTarget.value)} disabled={disabled} /> ); }; const GeneratedSection: React.FC<{ generatedLocation: string; setGeneratedLocation: React.Dispatch>; }> = ({ generatedLocation, setGeneratedLocation }) => { const intl = useIntl(); return (

    {chunks}, }} />

    ); }; const CacheSection: React.FC<{ cacheLocation: string; setCacheLocation: React.Dispatch>; }> = ({ cacheLocation, setCacheLocation }) => { const intl = useIntl(); return (

    {chunks}, }} />

    ); }; const BlobsSection: React.FC<{ blobsLocation: string; setBlobsLocation: React.Dispatch>; storeBlobsInDatabase: boolean; setStoreBlobsInDatabase: React.Dispatch>; }> = ({ blobsLocation, setBlobsLocation, storeBlobsInDatabase, setStoreBlobsInDatabase, }) => { const intl = useIntl(); return (

    {chunks}, }} />

    {chunks}, strong: (chunks: string) => {chunks}, }} />

    setStoreBlobsInDatabase(!storeBlobsInDatabase)} />
    ); }; const SetPathsStep: React.FC = ({ goBack, next }) => { const { configuration, setupState } = useSetupContext(); const [showStashAlert, setShowStashAlert] = useState(false); const [stashes, setStashes] = useState( setupState.stashes ?? [] ); const [sfwContentMode, setSfwContentMode] = useState( setupState.sfwContentMode ?? false ); const [databaseFile, setDatabaseFile] = useState( setupState.databaseFile ?? "" ); const [generatedLocation, setGeneratedLocation] = useState( setupState.generatedLocation ?? "" ); const [cacheLocation, setCacheLocation] = useState( setupState.cacheLocation ?? "" ); const [storeBlobsInDatabase, setStoreBlobsInDatabase] = useState( setupState.storeBlobsInDatabase ?? false ); const [blobsLocation, setBlobsLocation] = useState( setupState.blobsLocation ?? "" ); const overrideDatabase = configuration?.general.databasePath; const overrideGenerated = configuration?.general.generatedPath; const overrideCache = configuration?.general.cachePath; const overrideBlobs = configuration?.general.blobsPath; function preNext() { if (stashes.length === 0) { setShowStashAlert(true); } else { onNext(); } } function onNext() { const input: Partial = { stashes, databaseFile, generatedLocation, cacheLocation, blobsLocation: storeBlobsInDatabase ? "" : blobsLocation, storeBlobsInDatabase, sfwContentMode, }; next(input); } return ( <> {showStashAlert ? ( { setShowStashAlert(false); if (confirm) { onNext(); } }} /> ) : null}

    setStashes(s)} />

    } onChange={() => setSfwContentMode(!sfwContentMode)} />
    {overrideDatabase ? null : ( )} {overrideGenerated ? null : ( )} {overrideCache ? null : ( )} {overrideBlobs ? null : ( )}
    ); }; const StashExclusions: React.FC<{ stash: GQL.StashConfig }> = ({ stash }) => { if (!stash.excludeImage && !stash.excludeVideo) { return null; } const excludes = []; if (stash.excludeVideo) { excludes.push("videos"); } if (stash.excludeImage) { excludes.push("images"); } return {`(excludes ${excludes.join(" and ")})`}; }; const ConfirmStep: React.FC = ({ goBack, next }) => { const { configuration, pathDir, pathJoin, setupState, homeDirPath, workingDir, } = useSetupContext(); // if unset, means use homeDirPath const cfgFile = setupState.configLocation ? pathJoin(workingDir, setupState.configLocation) : pathJoin(homeDirPath, "config.yml"); const cfgDir = pathDir(cfgFile); const stashes = setupState.stashes ?? []; const { databaseFile, generatedLocation, cacheLocation, blobsLocation, storeBlobsInDatabase, } = setupState; const overrideDatabase = configuration?.general.databasePath; const overrideGenerated = configuration?.general.generatedPath; const overrideCache = configuration?.general.cachePath; const overrideBlobs = configuration?.general.blobsPath; function joinCfgDir(path: string) { if (cfgDir) { return pathJoin(cfgDir, path); } else { return path; } } return ( <>

    {cfgFile}
      {stashes.map((s) => (
    • {s.path}
    • ))}
    {!overrideDatabase && (
    {databaseFile || joinCfgDir("stash-go.sqlite")}
    )} {!overrideGenerated && (
    {generatedLocation || joinCfgDir("generated")}
    )} {!overrideCache && (
    {cacheLocation || joinCfgDir("cache")}
    )} {!overrideBlobs && (
    {storeBlobsInDatabase ? ( ) : ( blobsLocation || joinCfgDir("blobs") )}
    )}
    ); }; const DiscordLink = ( Discord ); const GithubLink = ( ); const ErrorStep: React.FC<{ error: string; goBack: () => void }> = ({ error, goBack, }) => { return ( <>

    {error} }} />

    ); }; const SuccessStep: React.FC<{}> = () => { const intl = useIntl(); const history = useHistory(); const [mutateDownloadFFMpeg] = GQL.useDownloadFfMpegMutation(); const [downloadFFmpeg, setDownloadFFmpeg] = useState(true); const { systemStatus } = useSetupContext(); const status = systemStatus?.systemStatus; function onFinishClick() { if ((!status?.ffmpegPath || !status?.ffprobePath) && downloadFFmpeg) { mutateDownloadFFMpeg(); } history.push("/settings?tab=library"); } return ( <>

    {chunks}, localized_task: intl.formatMessage({ id: "config.categories.tasks", }), localized_scan: intl.formatMessage({ id: "actions.scan" }), }} />

    {!status?.ffmpegPath || !status?.ffprobePath ? ( <> {chunks}, }} />

    setDownloadFFmpeg(!downloadFFmpeg)} />

    ) : null}

    }} />

    Open Collective ), }} />

    ); }; const FinishStep: React.FC = ({ goBack }) => { const { setupError } = useSetupContext(); if (setupError !== undefined) { return ; } return ; }; export const Setup: React.FC = () => { const intl = useIntl(); const { configuration } = useConfigurationContext(); const [saveUI] = useConfigureUI(); const { data: systemStatus, loading: statusLoading, error: statusError, } = useSystemStatus(); const [step, setStep] = useState(0); const [setupInput, setSetupInput] = useState>({}); const [creating, setCreating] = useState(false); const [setupError, setSetupError] = useState(undefined); const history = useHistory(); const steps: React.FC[] = [ WelcomeStep, SetPathsStep, ConfirmStep, FinishStep, ]; const Step = steps[step]; async function createSystem() { try { setCreating(true); setSetupError(undefined); await mutateSetup(setupInput as GQL.SetupInput); // Set lastNoteSeen to hide release notes dialog await saveUI({ variables: { input: { ...configuration?.ui, lastNoteSeen: releaseNotes[0].date, }, }, }); } catch (e) { if (e instanceof Error && e.message) { setSetupError(e.message); } else { setSetupError(String(e)); } } finally { setCreating(false); setStep(step + 1); } } function next(input?: Partial) { setSetupInput({ ...setupInput, ...input }); if (Step === ConfirmStep) { // create the system createSystem(); } else { setStep(step + 1); } } function goBack() { if (Step === FinishStep) { // go back to the step before ConfirmStep setStep(step - 2); } else { setStep(step - 1); } } if (statusLoading) { return ; } if ( step === 0 && systemStatus && systemStatus.systemStatus.status !== GQL.SystemStatusEnum.Setup ) { // redirect to main page history.push("/"); return ; } if (statusError) { return ( ); } if (!configuration || !systemStatus) { return ( ); } return (

    {creating ? ( ) : ( )}
    ); }; export default Setup; ================================================ FILE: ui/v2.5/src/components/Setup/styles.scss ================================================ .migration-notes { margin: 1rem; > div { background-color: darken($color: $card-bg, $amount: 3); border-radius: 3px; padding: 16px; } } .migrate-loading-status { align-items: center; display: flex; flex-direction: column; height: 70vh; justify-content: center; width: 100%; .progress { width: 60%; } h4 span { margin-left: 0.5rem; } } .setup-wizard { #blobs > div { margin-bottom: 1rem; margin-top: 0; } } ================================================ FILE: ui/v2.5/src/components/Shared/Alert.tsx ================================================ import { Button, Modal } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import { PatchComponent } from "src/patch"; export interface IAlertModalProps { text: JSX.Element | string; confirmVariant?: string; show?: boolean; confirmButtonText?: string; onConfirm: () => void; onCancel: () => void; } export const AlertModal: React.FC = PatchComponent( "AlertModal", ({ text, show, confirmVariant = "danger", confirmButtonText, onConfirm, onCancel, }) => { return ( {text} ); } ); ================================================ FILE: ui/v2.5/src/components/Shared/BatchModals.tsx ================================================ import React, { useMemo, useRef, useState } from "react"; import { Form } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { ModalComponent } from "src/components/Shared/Modal"; import { faStar, faTags } from "@fortawesome/free-solid-svg-icons"; interface IEntityWithStashIDs { stash_ids: { endpoint: string }[]; } interface IBatchUpdateModalProps { entities: IEntityWithStashIDs[]; isIdle: boolean; selectedEndpoint: { endpoint: string; index: number }; allCount: number | undefined; onBatchUpdate: (queryAll: boolean, refresh: boolean) => void; onRefreshChange?: (refresh: boolean) => void; batchAddParents: boolean; setBatchAddParents: (addParents: boolean) => void; close: () => void; localePrefix: string; entityName: string; countVariableName: string; } export const BatchUpdateModal: React.FC = ({ entities, isIdle, selectedEndpoint, allCount, onBatchUpdate, onRefreshChange, batchAddParents, setBatchAddParents, close, localePrefix, entityName, countVariableName, }) => { const intl = useIntl(); const [queryAll, setQueryAll] = useState(false); const [refresh, setRefreshState] = useState(false); const setRefresh = (value: boolean) => { setRefreshState(value); onRefreshChange?.(value); }; const entityCount = useMemo(() => { const filteredStashIDs = entities.map((e) => e.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint) ); return queryAll ? allCount : filteredStashIDs.filter((s) => refresh ? s.length > 0 : s.length === 0 ).length; }, [queryAll, refresh, entities, allCount, selectedEndpoint.endpoint]); return ( onBatchUpdate(queryAll, refresh), }} cancel={{ text: intl.formatMessage({ id: "actions.cancel" }), variant: "danger", onClick: () => close(), }} disabled={!isIdle} >
    } checked={!queryAll} onChange={() => setQueryAll(false)} /> setQueryAll(true)} />
    setRefresh(false)} /> setRefresh(true)} />
    setBatchAddParents(!batchAddParents)} />
    ); }; interface IBatchAddModalProps { isIdle: boolean; onBatchAdd: (input: string) => void; batchAddParents: boolean; setBatchAddParents: (addParents: boolean) => void; close: () => void; localePrefix: string; entityName: string; } export const BatchAddModal: React.FC = ({ isIdle, onBatchAdd, batchAddParents, setBatchAddParents, close, localePrefix, entityName, }) => { const intl = useIntl(); const inputRef = useRef(null); return ( { if (inputRef.current) { onBatchAdd(inputRef.current.value); } else { close(); } }, }} cancel={{ text: intl.formatMessage({ id: "actions.cancel" }), variant: "danger", onClick: () => close(), }} disabled={!isIdle} >
    setBatchAddParents(!batchAddParents)} />
    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/BulkUpdate.tsx ================================================ import { faBan } from "@fortawesome/free-solid-svg-icons"; import React from "react"; import { Button, Col, Form, FormControlProps, InputGroup, Row, } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { Icon } from "./Icon"; import * as FormUtils from "src/utils/form"; interface IBulkUpdateTextInputProps extends Omit { valueChanged: (value: string | null | undefined) => void; value: string | null | undefined; unsetDisabled?: boolean; as?: React.ElementType; } export const BulkUpdateTextInput: React.FC = ({ valueChanged, unsetDisabled, ...props }) => { const intl = useIntl(); const value = props.value === null ? "" : props.value ?? undefined; const unset = value === undefined; const placeholderValue = unset ? `<${intl.formatMessage({ id: "existing_value" })}>` : value === "" ? `<${intl.formatMessage({ id: "empty_value" })}>` : undefined; return ( valueChanged(event.currentTarget.value)} /> {!unsetDisabled ? ( ) : undefined} ); }; export const BulkUpdateFormGroup: React.FC<{ name: string; messageId?: string; inline?: boolean; }> = ({ name, messageId = name, inline = true, children }) => { if (inline) { return ( {FormUtils.renderLabel({ title: , })} {children} ); } return ( {children} ); }; ================================================ FILE: ui/v2.5/src/components/Shared/ClearableInput.tsx ================================================ import React from "react"; import { Button, FormControl } from "react-bootstrap"; import { faTimes } from "@fortawesome/free-solid-svg-icons"; import { useIntl } from "react-intl"; import { Icon } from "./Icon"; import useFocus from "src/utils/focus"; import cx from "classnames"; interface IClearableInput { className?: string; value: string; setValue: (value: string) => void; onEnter?: () => void; focus?: ReturnType; placeholder?: string; } export const ClearableInput: React.FC = ({ className, value, setValue, onEnter, focus, placeholder, }) => { const intl = useIntl(); const [defaultQueryRef, setQueryFocusDefault] = useFocus(); const [queryRef, setQueryFocus] = focus || [ defaultQueryRef, setQueryFocusDefault, ]; const queryClearShowing = !!value; function onChangeQuery(event: React.FormEvent) { setValue(event.currentTarget.value); } function onClearQuery() { setValue(""); setQueryFocus(); } function onInputKeyDown(e: React.KeyboardEvent) { if (e.key === "Escape") { queryRef.current?.blur(); } if (e.key === "Enter" && onEnter) { onEnter(); } } return (
    {queryClearShowing && ( )}
    ); }; export default ClearableInput; ================================================ FILE: ui/v2.5/src/components/Shared/CollapseButton.tsx ================================================ import { faChevronDown, faChevronRight, faChevronUp, IconDefinition, } from "@fortawesome/free-solid-svg-icons"; import React, { useEffect, useState } from "react"; import { Button, Collapse, CollapseProps } from "react-bootstrap"; import { Icon } from "./Icon"; interface IProps { className?: string; text: React.ReactNode; collapseProps?: Partial; outsideCollapse?: React.ReactNode; onOpenChanged?: (o: boolean) => void; open?: boolean; } export const CollapseButton: React.FC> = ( props: React.PropsWithChildren ) => { const [open, setOpen] = useState(props.open ?? false); function toggleOpen() { const nv = !open; setOpen(nv); props.onOpenChanged?.(nv); } useEffect(() => { if (props.open !== undefined) { setOpen(props.open); } }, [props.open]); return (
    {props.outsideCollapse}
    {props.children}
    ); }; export const ExpandCollapseButton: React.FC<{ collapsed: boolean; setCollapsed: (collapsed: boolean) => void; collapsedIcon?: IconDefinition; notCollapsedIcon?: IconDefinition; }> = ({ collapsedIcon, notCollapsedIcon, collapsed, setCollapsed }) => { const buttonIcon = collapsed ? collapsedIcon ?? faChevronDown : notCollapsedIcon ?? faChevronUp; return ( ); }; ================================================ FILE: ui/v2.5/src/components/Shared/CountButton.tsx ================================================ import { faEye, faThumbsUp } from "@fortawesome/free-solid-svg-icons"; import React from "react"; import { Button, ButtonGroup } from "react-bootstrap"; import { Icon } from "src/components/Shared/Icon"; import { SweatDrops } from "./SweatDrops"; import cx from "classnames"; import { useIntl } from "react-intl"; import { useConfigurationContext } from "src/hooks/Config"; interface ICountButtonProps { value: number; icon: React.ReactNode; onIncrement?: () => void; onValueClicked?: () => void; title?: string; countTitle?: string; } export const CountButton: React.FC = ({ value, icon, onIncrement, onValueClicked, title, countTitle, }) => { return ( ); }; type CountButtonPropsNoIcon = Omit; export const ViewCountButton: React.FC = (props) => { const intl = useIntl(); return ( } title={intl.formatMessage({ id: "media_info.play_count" })} countTitle={intl.formatMessage({ id: "actions.view_history" })} /> ); }; export const OCounterButton: React.FC = (props) => { const intl = useIntl(); const { configuration } = useConfigurationContext(); const { sfwContentMode } = configuration.interface; const icon = !sfwContentMode ? : ; const messageID = !sfwContentMode ? "o_count" : "o_count_sfw"; return ( ); }; ================================================ FILE: ui/v2.5/src/components/Shared/Counter.tsx ================================================ import React from "react"; import { Badge } from "react-bootstrap"; import { FormattedNumber, useIntl } from "react-intl"; import TextUtils from "src/utils/text"; interface IProps { abbreviateCounter?: boolean; count: number; hideZero?: boolean; hideOne?: boolean; } export const Counter: React.FC = ({ abbreviateCounter = false, count, hideZero = false, hideOne = false, }) => { const intl = useIntl(); if (hideZero && count === 0) return null; if (hideOne && count === 1) return null; if (abbreviateCounter) { const formatted = TextUtils.abbreviateCounter(count); return ( {formatted.unit} ); } else { return ( {intl.formatNumber(count)} ); } }; ================================================ FILE: ui/v2.5/src/components/Shared/CountryFlag.tsx ================================================ import React from "react"; import { useIntl } from "react-intl"; import { getCountryByISO } from "src/utils/country"; import { OverlayTrigger, Tooltip } from "react-bootstrap"; interface ICountryFlag { country?: string | null; className?: string; includeName?: boolean; includeOverlay?: boolean; } export const CountryFlag: React.FC = ({ className, country: isoCountry, includeName, includeOverlay, }) => { const { locale } = useIntl(); const country = getCountryByISO(isoCountry, locale); if (!isoCountry || !country) return <>; return ( <> {includeName ? country : ""} {includeOverlay ? ( {country}} > ) : ( )} ); }; ================================================ FILE: ui/v2.5/src/components/Shared/CountryLabel.tsx ================================================ import React from "react"; import { useIntl } from "react-intl"; import { CountryFlag } from "./CountryFlag"; import { getCountryByISO } from "src/utils/country"; interface IProps { country: string | undefined; showFlag?: boolean; } export const CountryLabel: React.FC = ({ country, showFlag = true, }) => { const { locale } = useIntl(); // #3063 - use alpha2 values only const fromISO = country?.length === 2 ? getCountryByISO(country, locale) : undefined; return (
    {showFlag && } {fromISO ?? country}
    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/CountrySelect.tsx ================================================ import React from "react"; import Creatable from "react-select/creatable"; import { useIntl } from "react-intl"; import { getCountries } from "src/utils/country"; import { CountryLabel } from "./CountryLabel"; import { PatchComponent } from "src/patch"; interface IProps { value?: string; onChange?: (value: string) => void; disabled?: boolean; className?: string; showFlag?: boolean; isClearable?: boolean; menuPortalTarget?: HTMLElement | null; } const _CountrySelect: React.FC = ({ value, onChange, disabled = false, isClearable = true, showFlag, className, menuPortalTarget, }) => { const { locale } = useIntl(); const options = getCountries(locale); const selected = options.find((opt) => opt.value === value) ?? { label: value, value, }; return ( ( )} placeholder="Country" options={options} onChange={(selectedOption) => onChange?.(selectedOption?.value ?? "")} isDisabled={disabled || !onChange} components={{ IndicatorSeparator: null, }} className={`CountrySelect ${className}`} menuPortalTarget={menuPortalTarget} /> ); }; export const CountrySelect = PatchComponent("CountrySelect", _CountrySelect); ================================================ FILE: ui/v2.5/src/components/Shared/CustomFields.tsx ================================================ import React, { useEffect, useMemo, useRef, useState } from "react"; import { CollapseButton } from "./CollapseButton"; import { DetailItem } from "./DetailItem"; import { Button, Col, Form, FormGroup, InputGroup, Row } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { cloneDeep } from "@apollo/client/utilities"; import { Icon } from "./Icon"; import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons"; import cx from "classnames"; import { PatchComponent } from "src/patch"; import { TruncatedText } from "./TruncatedText"; const maxFieldNameLength = 64; export type CustomFieldMap = { [key: string]: unknown; }; interface ICustomFields { values: CustomFieldMap; fullWidth?: boolean; } function convertValue(value: unknown): string { if (typeof value === "string") { return value; } else if (typeof value === "number") { return value.toString(); } else if (typeof value === "boolean") { return value ? "true" : "false"; } else if (Array.isArray(value)) { return value.join(", "); } else { return JSON.stringify(value); } } const CustomField: React.FC<{ field: string; value: unknown }> = ({ field, value, }) => { const valueStr = convertValue(value); // replace spaces with hyphen characters for css id const id = `custom-field-${field.toLowerCase().replace(/ /g, "-")}`; return ( {valueStr}} />} fullWidth={true} showEmpty /> ); }; export const CustomFields: React.FC = PatchComponent( "CustomFields", ({ values, fullWidth }) => { const intl = useIntl(); if (Object.keys(values).length === 0) { return null; } return ( // according to linter rule CSS classes shouldn't use underscores
    {Object.entries(values).map(([key, value]) => ( ))}
    ); } ); function isNumeric(v: string) { return /^-?(?:0|(?:[1-9][0-9]*))(?:\.[0-9]+)?$/.test(v); } function convertCustomValue(v: string) { // if the value is numeric, convert it to a number if (isNumeric(v)) { return Number(v); } else { return v; } } const CustomFieldInput: React.FC<{ field: string; value: unknown; onChange: (field: string, value: unknown) => void; isNew?: boolean; error?: string; }> = PatchComponent( "CustomFieldInput", ({ field, value, onChange, isNew = false, error }) => { const intl = useIntl(); const [currentField, setCurrentField] = useState(field); const [currentValue, setCurrentValue] = useState(value as string); const fieldRef = useRef(null); const valueRef = useRef(null); useEffect(() => { setCurrentField(field); setCurrentValue(value as string); }, [field, value]); function onBlur() { onChange(currentField, convertCustomValue(currentValue)); } function onDelete() { onChange("", ""); } return ( {isNew ? ( <> setCurrentField(event.currentTarget.value) } onBlur={onBlur} /> ) : ( {currentField} )} setCurrentValue(event.currentTarget.value)} onBlur={onBlur} /> {!isNew && ( )} {error} ); } ); interface ICustomField { field: string; value: unknown; } interface ICustomFieldsInput { values: CustomFieldMap; error?: string; onChange: (values: CustomFieldMap) => void; setError: (error?: string) => void; } export function formatCustomFieldInput(isNew: boolean, input: {}) { if (isNew) { return input; } else { return { full: input, }; } } export const CustomFieldsInput: React.FC = PatchComponent( "CustomFieldsInput", ({ values, error, onChange, setError }) => { const intl = useIntl(); const [newCustomField, setNewCustomField] = useState({ field: "", value: "", }); const fields = useMemo(() => { const valueCopy = cloneDeep(values); if (newCustomField.field !== "" && error === undefined) { delete valueCopy[newCustomField.field]; } const ret = Object.keys(valueCopy); ret.sort(); return ret; }, [values, newCustomField, error]); function onSetNewField(v: ICustomField) { // validate the field name let newError = undefined; if (v.field.length > maxFieldNameLength) { newError = intl.formatMessage({ id: "errors.custom_fields.field_name_length", }); } if (v.field.trim() === "" && v.value !== "") { newError = intl.formatMessage({ id: "errors.custom_fields.field_name_required", }); } if (v.field.trim() !== v.field) { newError = intl.formatMessage({ id: "errors.custom_fields.field_name_whitespace", }); } if (fields.includes(v.field)) { newError = intl.formatMessage({ id: "errors.custom_fields.duplicate_field", }); } const oldField = newCustomField; setNewCustomField(v); const valuesCopy = cloneDeep(values); if (oldField.field !== "" && error === undefined) { delete valuesCopy[oldField.field]; } // if valid, pass up if (!newError && v.field !== "") { valuesCopy[v.field] = v.value; } onChange(valuesCopy); setError(newError); } function onAdd() { const newValues = { ...values, [newCustomField.field]: newCustomField.value, }; setNewCustomField({ field: "", value: "" }); onChange(newValues); } function fieldChanged( currentField: string, newField: string, value: unknown ) { let newValues = cloneDeep(values); delete newValues[currentField]; if (newField !== "") { newValues[newField] = value; } onChange(newValues); } return ( {fields.map((field) => ( fieldChanged(field, newField, newValue) } /> ))} onSetNewField({ field, value })} isNew /> ); } ); ================================================ FILE: ui/v2.5/src/components/Shared/Date.tsx ================================================ import React from "react"; import { FormattedDate as IntlDate } from "react-intl"; import { PatchComponent } from "src/patch"; // wraps FormattedDate to handle year or year/month dates export const FormattedDate: React.FC<{ value: string | number | Date | undefined; }> = PatchComponent("Date", ({ value }) => { if (typeof value === "string") { // try parsing as year or year/month const yearMatch = value.match(/^(\d{4})$/); if (yearMatch) { const year = parseInt(yearMatch[1], 10); return ( ); } const yearMonthMatch = value.match(/^(\d{4})-(\d{2})$/); if (yearMonthMatch) { const year = parseInt(yearMonthMatch[1], 10); const month = parseInt(yearMonthMatch[2], 10) - 1; return ( ); } } return ; }); ================================================ FILE: ui/v2.5/src/components/Shared/DateInput.tsx ================================================ import { faCalendar } from "@fortawesome/free-regular-svg-icons"; import React, { forwardRef, useMemo } from "react"; import { Button, InputGroup, Form } from "react-bootstrap"; import ReactDatePicker from "react-datepicker"; import TextUtils from "src/utils/text"; import { Icon } from "./Icon"; import "react-datepicker/dist/react-datepicker.css"; import { useIntl } from "react-intl"; import { PatchComponent } from "src/patch"; import { faBan, faTimes } from "@fortawesome/free-solid-svg-icons"; interface IProps { groupClassName?: string; className?: string; disabled?: boolean; value: string; isTime?: boolean; onValueChange(value: string): void; placeholder?: string; placeholderOverride?: string; error?: string; appendBefore?: React.ReactNode; appendAfter?: React.ReactNode; } const ShowPickerButton = forwardRef< HTMLButtonElement, { onClick: (event: React.MouseEvent) => void; } >(({ onClick }, ref) => ( )); const _DateInput: React.FC = (props: IProps) => { const intl = useIntl(); const { groupClassName = "date-input-group", className = "date-input text-input", } = props; const date = useMemo(() => { const toDate = props.isTime ? TextUtils.stringToFuzzyDateTime : TextUtils.stringToFuzzyDate; if (props.value) { const ret = toDate(props.value); if (ret && !Number.isNaN(ret.getTime())) { return ret; } } }, [props.value, props.isTime]); function maybeRenderButton() { if (!props.disabled) { const dateToString = props.isTime ? TextUtils.dateTimeToString : TextUtils.dateToString; return ( { props.onValueChange(v ? dateToString(v) : ""); }} customInput={ {}} />} showMonthDropdown showYearDropdown scrollableMonthYearDropdown scrollableYearDropdown maxDate={new Date()} yearDropdownItemNumber={100} portalId="date-picker-portal" showTimeSelect={props.isTime} /> ); } } const formatHint = intl.formatMessage({ id: props.isTime ? "datetime_format" : "date_format", }); const placeholderText = props.placeholder ? `${props.placeholder} (${formatHint})` : formatHint; return ( props.onValueChange(e.currentTarget.value)} placeholder={ !props.disabled ? props.placeholderOverride ?? placeholderText : undefined } isInvalid={!!props.error} /> {props.appendBefore} {maybeRenderButton()} {props.appendAfter} {props.error} ); }; export const DateInput = PatchComponent("DateInput", _DateInput); interface IBulkUpdateDateInputProps extends Omit { value: string | null | undefined; valueChanged: (value: string | null | undefined) => void; unsetDisabled?: boolean; as?: React.ElementType; error?: string; } export const BulkUpdateDateInput: React.FC = ({ valueChanged, unsetDisabled, ...props }) => { const intl = useIntl(); const unset = props.value === undefined; const unsetButton = !unsetDisabled ? ( ) : undefined; const clearButton = props.value !== null ? ( ) : undefined; const placeholderValue = props.value === null ? `<${intl.formatMessage({ id: "empty_value" })}>` : props.value === undefined ? `<${intl.formatMessage({ id: "existing_value" })}>` : undefined; function outValue(v: string | undefined) { if (v === "") { return null; } return v; } return ( valueChanged(outValue(v))} groupClassName="bulk-update-date-input" className="date-input text-input" appendBefore={clearButton} appendAfter={unsetButton} /> ); }; ================================================ FILE: ui/v2.5/src/components/Shared/DeleteEntityDialog.tsx ================================================ import React, { useState } from "react"; import { defineMessages, FormattedMessage, useIntl } from "react-intl"; import { FetchResult } from "@apollo/client"; import { ModalComponent } from "./Modal"; import { useToast } from "src/hooks/Toast"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; interface IDeletionEntity { id: string; name?: string | null; } type DestroyMutation = (input: { ids: string[]; }) => [() => Promise, {}]; interface IDeleteEntityDialogProps { selected: IDeletionEntity[]; onClose: (confirmed: boolean) => void; singularEntity: string; pluralEntity: string; destroyMutation: DestroyMutation; onDeleted?: () => void; } const messages = defineMessages({ deleteHeader: { id: "dialogs.delete_object_title", }, deleteToast: { id: "toast.delete_past_tense", }, deleteMessage: { id: "dialogs.delete_object_desc", }, overflowMessage: { id: "dialogs.delete_object_overflow", }, }); export const DeleteEntityDialog: React.FC = ({ selected, onClose, singularEntity, pluralEntity, destroyMutation, onDeleted, }) => { const intl = useIntl(); const Toast = useToast(); const [deleteEntities] = destroyMutation({ ids: selected.map((p) => p.id) }); const count = selected.length; // Network state const [isDeleting, setIsDeleting] = useState(false); async function onDelete() { setIsDeleting(true); try { await deleteEntities(); if (onDeleted) { onDeleted(); } Toast.success( intl.formatMessage(messages.deleteToast, { count, singularEntity, pluralEntity, }) ); } catch (e) { Toast.error(e); } setIsDeleting(false); onClose(true); } return ( onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} isRunning={isDeleting} >

      {selected.slice(0, 10).map((s) => (
    • {s.name}
    • ))} {selected.length > 10 && ( )}
    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/DeleteFilesDialog.tsx ================================================ import React, { useState } from "react"; import { mutateDeleteFiles } from "src/core/StashService"; import { ModalComponent } from "./Modal"; import { useToast } from "src/hooks/Toast"; import { ConfigurationContext } from "src/hooks/Config"; import { FormattedMessage, useIntl } from "react-intl"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; interface IFile { id: string; path: string; } interface IDeleteSceneDialogProps { selected: IFile[]; onClose: (confirmed: boolean) => void; } export const DeleteFilesDialog: React.FC = ( props: IDeleteSceneDialogProps ) => { const intl = useIntl(); const singularEntity = intl.formatMessage({ id: "file" }); const pluralEntity = intl.formatMessage({ id: "files" }); const header = intl.formatMessage( { id: "dialogs.delete_entity_title" }, { count: props.selected.length, singularEntity, pluralEntity } ); const toastMessage = intl.formatMessage( { id: "toast.delete_past_tense" }, { count: props.selected.length, singularEntity, pluralEntity } ); const message = intl.formatMessage( { id: "dialogs.delete_entity_simple_desc" }, { count: props.selected.length, singularEntity, pluralEntity } ); const Toast = useToast(); // Network state const [isDeleting, setIsDeleting] = useState(false); const context = React.useContext(ConfigurationContext); const config = context?.configuration; async function onDelete() { setIsDeleting(true); try { await mutateDeleteFiles(props.selected.map((f) => f.id)); Toast.success(toastMessage); props.onClose(true); } catch (e) { Toast.error(e); props.onClose(false); } setIsDeleting(false); } function renderDeleteFileAlert() { const deletedFiles = props.selected.map((f) => f.path); const deleteTrashPath = config?.general.deleteTrashPath; const deleteAlertId = deleteTrashPath ? "dialogs.delete_alert_to_trash" : "dialogs.delete_alert"; return (

      {deletedFiles.slice(0, 5).map((s) => (
    • {s}
    • ))} {deletedFiles.length > 5 && ( )}
    ); } return ( props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} isRunning={isDeleting} >

    {message}

    {renderDeleteFileAlert()}
    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/DetailImage.tsx ================================================ import { useLayoutEffect, useRef } from "react"; import { PatchComponent } from "src/patch"; import { remToPx } from "src/utils/units"; const DEFAULT_WIDTH = Math.round(remToPx(30)); // Props used by the element type IDetailImageProps = JSX.IntrinsicElements["img"]; export const DetailImage = PatchComponent( "DetailImage", (props: IDetailImageProps) => { const imgRef = useRef(null); function fixWidth() { const img = imgRef.current; if (!img) return; // prevent SVG's w/o intrinsic size from rendering as 0x0 if (img.naturalWidth === 0) { // If the naturalWidth is zero, it means the image either hasn't loaded yet // or we're on Firefox and it is an SVG w/o an intrinsic size. // So set the width to our fallback width. img.setAttribute("width", String(DEFAULT_WIDTH)); } else { // If we have a `naturalWidth`, this could either be the actual intrinsic width // of the image, or the image is an SVG w/o an intrinsic size and we're on Chrome or Safari, // which seem to return a size calculated in some browser-specific way. // Worse yet, once rendered, Safari will then return the value of `img.width` as `img.naturalWidth`, // so we need to clone the image to disconnect it from the DOM, and then get the `naturalWidth` of the clone, // in order to always return the same `naturalWidth` for a given src. const i = img.cloneNode() as HTMLImageElement; img.setAttribute("width", String(i.naturalWidth || DEFAULT_WIDTH)); } } useLayoutEffect(() => { fixWidth(); }, [props.src]); return fixWidth()} {...props} />; } ); ================================================ FILE: ui/v2.5/src/components/Shared/DetailItem.tsx ================================================ import React from "react"; import { FormattedMessage } from "react-intl"; interface IDetailItem { id?: string | null; className?: string; label?: React.ReactNode; value?: React.ReactNode; labelTitle?: string; title?: string; fullWidth?: boolean; showEmpty?: boolean; } export const DetailItem: React.FC = ({ id, className = "", label, value, labelTitle, title, fullWidth, showEmpty = false, }) => { if (!id || (!showEmpty && (!value || value === "Na"))) { return <>; } const message = label ?? ; // according to linter rule CSS classes shouldn't use underscores const sanitisedID = id.replace(/_/g, "-"); return (
    {message} {fullWidth ? ":" : ""} {value}
    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx ================================================ import { Button, Dropdown, Modal, SplitButton } from "react-bootstrap"; import React, { useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { ImageInput } from "./ImageInput"; import cx from "classnames"; interface IProps { objectName?: string; isNew: boolean; isEditing: boolean; onToggleEdit: () => void; onSave: () => void; onSaveAndNew?: () => void; saveDisabled?: boolean; onDelete: () => void; onAutoTag?: () => void; autoTagDisabled?: boolean; onImageChange: (event: React.FormEvent) => void; onBackImageChange?: (event: React.FormEvent) => void; onImageChangeURL?: (url: string) => void; onBackImageChangeURL?: (url: string) => void; onClearImage?: () => void; onClearBackImage?: () => void; acceptSVG?: boolean; customButtons?: JSX.Element; classNames?: string; children?: JSX.Element | JSX.Element[]; } export const DetailsEditNavbar: React.FC = (props: IProps) => { const intl = useIntl(); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); function renderEditButton() { if (props.isNew) return; return ( ); } function renderSaveButton() { if (!props.isEditing) return; if (props.isNew && props.onSaveAndNew) { return ( props.onSave()} > props.onSaveAndNew!()}> ); } return ( ); } function renderDeleteButton() { if (props.isNew || props.isEditing) return; return ( ); } function renderBackImageInput() { if (!props.isEditing || !props.onBackImageChange) { return; } return ( ); } function renderAutoTagButton() { if (props.isNew || props.isEditing) return; if (props.onAutoTag) { return (
    ); } } function renderDeleteAlert() { return ( ); } return (
    {renderEditButton()} {props.isEditing && props.onClearImage ? (
    ) : null} {renderBackImageInput()} {props.isEditing && props.onClearBackImage ? (
    ) : null} {renderAutoTagButton()} {props.customButtons} {renderSaveButton()} {renderDeleteButton()} {renderDeleteAlert()}
    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/DetailsPage/AliasList.tsx ================================================ export const AliasList: React.FC<{ aliases: string[] | undefined }> = ({ aliases, }) => { if (!aliases?.length) { return null; } return (
    {aliases.join(", ")}
    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/DetailsPage/BackgroundImage.tsx ================================================ import React from "react"; import { PatchComponent } from "src/patch"; export const BackgroundImage: React.FC<{ imagePath: string | undefined; show: boolean; alt?: string; }> = PatchComponent("BackgroundImage", ({ imagePath, show, alt }) => { if (imagePath && show) { const imageURL = new URL(imagePath); let isDefaultImage = imageURL.searchParams.get("default"); if (!isDefaultImage) { return (
    {alt}
    ); } } return null; }); ================================================ FILE: ui/v2.5/src/components/Shared/DetailsPage/DetailTitle.tsx ================================================ import React, { PropsWithChildren } from "react"; export const DetailTitle: React.FC< PropsWithChildren<{ name: string; disambiguation?: string; classNamePrefix: string; }> > = ({ name, disambiguation, classNamePrefix, children }) => { return (

    {name} {disambiguation && ( {` (${disambiguation})`} )} {children}

    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/DetailsPage/HeaderImage.tsx ================================================ import { PropsWithChildren } from "react"; import { LoadingIndicator } from "../LoadingIndicator"; import { FormattedMessage } from "react-intl"; import { PatchComponent } from "src/patch"; export const HeaderImage: React.FC< PropsWithChildren<{ encodingImage: boolean; }> > = PatchComponent("HeaderImage", ({ encodingImage, children }) => { return (
    {encodingImage ? ( } /> ) : ( children )}
    ); }); ================================================ FILE: ui/v2.5/src/components/Shared/DetailsPage/Tabs.tsx ================================================ import { FormattedMessage } from "react-intl"; import { Counter } from "../Counter"; import { useCallback, useEffect } from "react"; import { useHistory } from "react-router-dom"; import { PatchComponent } from "src/patch"; export const TabTitleCounter: React.FC<{ messageID: string; count: number; abbreviateCounter: boolean; }> = PatchComponent( "TabTitleCounter", ({ messageID, count, abbreviateCounter }) => { return ( <> ); } ); export function useTabKey(props: { tabKey: string | undefined; validTabs: readonly string[]; defaultTabKey: string; baseURL: string; }) { const { tabKey, validTabs, defaultTabKey, baseURL } = props; const history = useHistory(); const setTabKey = useCallback( (newTabKey: string | null) => { if (!newTabKey) newTabKey = defaultTabKey; if (newTabKey === tabKey) return; if (validTabs.includes(newTabKey)) { history.replace(`${baseURL}/${newTabKey}`); } }, [defaultTabKey, validTabs, tabKey, history, baseURL] ); useEffect(() => { if (!tabKey) { setTabKey(defaultTabKey); } }, [setTabKey, defaultTabKey, tabKey]); return { setTabKey }; } ================================================ FILE: ui/v2.5/src/components/Shared/DoubleRangeInput.tsx ================================================ import React from "react"; export const DoubleRangeInput: React.FC<{ className?: string; minInput: React.ReactNode; maxInput: React.ReactNode; min?: number; max: number; value: [number, number]; onChange(value: [number, number]): void; }> = ({ className = "", minInput, maxInput, min = 0, max, value, onChange, }) => { const minValue = value[0]; const maxValue = value[1]; return (
    {minInput} {maxInput}
    { const rawValue = parseInt(e.target.value); if (rawValue < maxValue) { onChange([rawValue, maxValue]); } }} className="double-range-slider double-range-slider-min" /> { const rawValue = parseInt(e.target.value); if (rawValue > minValue) { onChange([minValue, rawValue]); } }} className="double-range-slider double-range-slider-max" />
    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/DurationInput.tsx ================================================ import { faChevronDown, faChevronUp, faClock, } from "@fortawesome/free-solid-svg-icons"; import React, { useMemo, useState } from "react"; import { Button, ButtonGroup, InputGroup, Form } from "react-bootstrap"; import { Icon } from "./Icon"; import TextUtils from "src/utils/text"; interface IProps { disabled?: boolean; value: number | null | undefined; setValue(value: number | null): void; onReset?(): void; className?: string; placeholder?: string; error?: string; allowNegative?: boolean; } const includeMS = true; export const DurationInput: React.FC = ({ disabled, value, setValue, onReset, className, placeholder, error, allowNegative = false, }) => { const [tmpValue, setTmpValue] = useState(); function onChange(e: React.ChangeEvent) { setTmpValue(e.currentTarget.value); } function onBlur() { if (tmpValue !== undefined) { updateValue(TextUtils.timestampToSeconds(tmpValue)); setTmpValue(undefined); } } function updateValue(v: number | null) { if (v !== null && !allowNegative && v < 0) { v = null; } setValue(v); } function increment() { setTmpValue(undefined); updateValue((value ?? 0) + 1); } function decrement() { setTmpValue(undefined); if (allowNegative) { updateValue((value ?? 0) - 1); } else { updateValue(value ? value - 1 : 0); } } function renderButtons() { if (!disabled) { return ( ); } } function maybeRenderReset() { if (onReset) { return ( ); } } const inputValue = useMemo(() => { if (tmpValue !== undefined) { return tmpValue; } else if (value !== null && value !== undefined) { return TextUtils.secondsToTimestamp(value, includeMS); } }, [value, tmpValue]); const format = "hh:mm:ss.ms"; if (placeholder) { placeholder = `${placeholder} (${format})`; } else { placeholder = format; } return (
    {maybeRenderReset()} {renderButtons()} {error}
    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/ErrorMessage.tsx ================================================ import { faWarning } from "@fortawesome/free-solid-svg-icons"; import React, { ReactNode } from "react"; import { Alert } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import { Icon } from "./Icon"; interface IProps { message?: React.ReactNode; error: string | ReactNode; } export const ErrorMessage: React.FC = (props) => { const { error, message = } = props; return (
    {message}
    {error}
    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/ExportDialog.tsx ================================================ import React, { useState } from "react"; import { Form } from "react-bootstrap"; import { mutateExportObjects } from "src/core/StashService"; import { ModalComponent } from "./Modal"; import { useToast } from "src/hooks/Toast"; import downloadFile from "src/utils/download"; import { ExportObjectsInput } from "src/core/generated-graphql"; import { useIntl } from "react-intl"; import { faCogs } from "@fortawesome/free-solid-svg-icons"; interface IExportDialogProps { exportInput: ExportObjectsInput; onClose: () => void; } export const ExportDialog: React.FC = ( props: IExportDialogProps ) => { const [includeDependencies, setIncludeDependencies] = useState(true); // Network state const [isRunning, setIsRunning] = useState(false); const intl = useIntl(); const Toast = useToast(); async function onExport() { try { setIsRunning(true); const ret = await mutateExportObjects({ ...props.exportInput, includeDependencies, }); // download the result if (ret.data && ret.data.exportObjects) { const link = ret.data.exportObjects; downloadFile(link); } } catch (e) { Toast.error(e); } finally { setIsRunning(false); props.onClose(); } } return ( props.onClose(), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} isRunning={isRunning} >
    setIncludeDependencies(!includeDependencies)} />
    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/ExternalLink.tsx ================================================ type IExternalLinkProps = JSX.IntrinsicElements["a"]; export const ExternalLink: React.FC = (props) => { return ; }; ================================================ FILE: ui/v2.5/src/components/Shared/ExternalLinksButton.tsx ================================================ import { Button, Dropdown } from "react-bootstrap"; import { ExternalLink } from "./ExternalLink"; import TextUtils from "src/utils/text"; import { Icon } from "./Icon"; import { IconDefinition, faLink } from "@fortawesome/free-solid-svg-icons"; import { useMemo } from "react"; import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons"; import ReactDOM from "react-dom"; import { PatchComponent } from "src/patch"; export const ExternalLinksButton: React.FC<{ icon?: IconDefinition; urls: string[]; className?: string; openIfSingle?: boolean; }> = PatchComponent( "ExternalLinksButton", ({ urls, icon = faLink, className = "", openIfSingle = false }) => { if (!urls.length) { return null; } const Menu = () => ReactDOM.createPortal( {urls.map((url) => ( {url} ))} , document.body ); if (openIfSingle && urls.length === 1) { return ( ); } else { return ( ); } } ); export const ExternalLinkButtons: React.FC<{ urls: string[] | undefined }> = PatchComponent("ExternalLinkButtons", ({ urls }) => { const urlSpecs = useMemo(() => { if (!urls?.length) { return []; } const twitter = urls.filter((u) => u.match(/https?:\/\/(?:www\.)?(?:twitter|x).com\//) ); const instagram = urls.filter((u) => u.match(/https?:\/\/(?:www\.)?instagram.com\//) ); const others = urls.filter( (u) => !twitter.includes(u) && !instagram.includes(u) ); return [ { icon: faLink, className: "", urls: others }, { icon: faTwitter, className: "twitter", urls: twitter }, { icon: faInstagram, className: "instagram", urls: instagram }, ]; }, [urls]); return ( <> {urlSpecs.map((spec, i) => ( ))} ); }); ================================================ FILE: ui/v2.5/src/components/Shared/FavoriteIcon.tsx ================================================ import React from "react"; import { Icon } from "../Shared/Icon"; import { Button } from "react-bootstrap"; import { faHeart } from "@fortawesome/free-solid-svg-icons"; import cx from "classnames"; import { SizeProp } from "@fortawesome/fontawesome-svg-core"; export const FavoriteIcon: React.FC<{ favorite: boolean; onToggleFavorite: (v: boolean) => void; size?: SizeProp; className?: string; }> = ({ favorite, onToggleFavorite, size, className }) => { return ( ); }; ================================================ FILE: ui/v2.5/src/components/Shared/FileSize.tsx ================================================ import React from "react"; import { FormattedNumber } from "react-intl"; import TextUtils from "src/utils/text"; export const FileSize: React.FC<{ size: number }> = ({ size: fileSize }) => { const { size, unit } = TextUtils.fileSize(fileSize); return ( <> {` ${TextUtils.formatFileSizeUnit(unit)}`} ); }; ================================================ FILE: ui/v2.5/src/components/Shared/FilterSelect.tsx ================================================ import React, { useMemo, useState } from "react"; import { OnChangeValue, StylesConfig, GroupBase, OptionsOrGroups, Options, } from "react-select"; import AsyncSelect from "react-select/async"; import AsyncCreatableSelect, { AsyncCreatableProps, } from "react-select/async-creatable"; import cx from "classnames"; import { useToast } from "src/hooks/Toast"; import { useDebounce } from "src/hooks/debounce"; import { IHasID } from "src/utils/data"; export type Option = { value: string; object: T }; interface ISelectProps extends AsyncCreatableProps, IsMulti, GroupBase>> { selectedOptions?: OnChangeValue, IsMulti>; creatable?: boolean; isLoading?: boolean; isDisabled?: boolean; placeholder?: string; showDropdown?: boolean; groupHeader?: string; noOptionsMessageText?: string | null; } interface IFilterSelectProps extends Pick< ISelectProps, | "selectedOptions" | "isLoading" | "isMulti" | "components" | "placeholder" | "closeMenuOnSelect" > {} const getSelectedItems = ( selectedItems: OnChangeValue, boolean> ) => { if (Array.isArray(selectedItems)) { return selectedItems; } else if (selectedItems) { return [selectedItems]; } else { return []; } }; const SelectComponent = ( props: ISelectProps ) => { const { selectedOptions, isDisabled = false, creatable = false, components, placeholder, showDropdown = true, noOptionsMessageText: noOptionsMessage = "None", } = props; const styles: StylesConfig, IsMulti> = { option: (base) => ({ ...base, color: "#000", }), container: (base, state) => ({ ...base, zIndex: state.isFocused ? 10 : base.zIndex, }), multiValueRemove: (base, state) => ({ ...base, color: state.isFocused ? base.color : "#333333", }), }; const componentProps = { ...props, styles, defaultOptions: true, isClearable: true, value: selectedOptions ?? null, className: cx("react-select", props.className), classNamePrefix: "react-select", noOptionsMessage: () => noOptionsMessage, placeholder: isDisabled ? "" : placeholder, components: { ...components, IndicatorSeparator: () => null, ...((!showDropdown || isDisabled) && { DropdownIndicator: () => null }), ...(isDisabled && { MultiValueRemove: () => null }), }, }; return creatable ? ( ) : ( ); }; export interface IFilterValueProps { values?: T[]; onSelect?: (item: T[]) => void; } export interface IFilterProps { noSelectionString?: string; className?: string; active?: boolean; isMulti?: boolean; isClearable?: boolean; isDisabled?: boolean; creatable?: boolean; menuPortalTarget?: HTMLElement | null; } export interface IFilterComponentProps extends IFilterProps { loadOptions: (inputValue: string) => Promise[]>; onCreate?: ( name: string ) => Promise<{ value: string; item: T; message: string }>; getNamedObject?: (id: string, name: string) => T; isValidNewOption?: (inputValue: string, options: T[]) => boolean; } export const FilterSelectComponent = < T extends IHasID, IsMulti extends boolean >( props: IFilterValueProps & IFilterComponentProps & IFilterSelectProps ) => { const { values, isMulti, onSelect, creatable = false, isValidNewOption, getNamedObject, loadOptions, } = props; const [loading, setLoading] = useState(false); const Toast = useToast(); const selectedOptions = useMemo(() => { if (isMulti && values) { return values.map( (value) => ({ object: value, value: value.id, } as Option) ) as unknown as OnChangeValue, IsMulti>; } if (values?.length) { return { object: values[0], value: values[0].id, } as OnChangeValue, IsMulti>; } }, [values, isMulti]); const onChange = (selectedItems: OnChangeValue, boolean>) => { const selected = getSelectedItems(selectedItems); onSelect?.(selected.map((item) => item.object)); }; const onCreate = creatable && props.onCreate ? async (name: string) => { try { setLoading(true); const { value, item: newItem, message, } = await props.onCreate!(name); const newItemOption = { object: newItem, value, } as Option; if (!isMulti) { onChange(newItemOption); } else { const o = (selectedOptions ?? []) as Option[]; onChange([...o, newItemOption]); } setLoading(false); Toast.success( {message}: {name} ); } catch (e) { Toast.error(e); } } : undefined; const getNewOptionData = creatable && getNamedObject ? (inputValue: string, optionLabel: React.ReactNode) => { return { value: "", object: getNamedObject("", optionLabel as string), }; } : undefined; const validNewOption = creatable && isValidNewOption ? ( inputValue: string, value: Options>, options: OptionsOrGroups, GroupBase>> ) => { return isValidNewOption( inputValue, (options as Options>).map((o) => o.object) ); } : undefined; const debounceDelay = 100; const debounceLoadOptions = useDebounce((inputValue, callback) => { loadOptions(inputValue).then(callback); }, debounceDelay); return ( {...props} loadOptions={debounceLoadOptions} isLoading={props.isLoading || loading} onChange={onChange} selectedOptions={selectedOptions} onCreateOption={onCreate} getNewOptionData={getNewOptionData} isValidNewOption={validNewOption} /> ); }; export interface IFilterIDProps { ids?: string[]; onSelect?: (item: T[]) => void; } export function toOption(item: T): Option { return { value: item.id, object: item, }; } ================================================ FILE: ui/v2.5/src/components/Shared/FolderSelect/FolderSelect.tsx ================================================ import React, { useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { Button, InputGroup, Form, Collapse } from "react-bootstrap"; import { Icon } from "../Icon"; import { LoadingIndicator } from "../LoadingIndicator"; import { faEllipsis, faTimes } from "@fortawesome/free-solid-svg-icons"; import { useDebounce } from "src/hooks/debounce"; import TextUtils from "src/utils/text"; import { useDirectoryPaths } from "./useDirectoryPaths"; import { PatchComponent } from "src/patch"; interface IProps { currentDirectory: string; onChangeDirectory: (value: string) => void; defaultDirectories?: string[]; appendButton?: JSX.Element; collapsible?: boolean; quotePath?: boolean; hideError?: boolean; } const _FolderSelect: React.FC = ({ currentDirectory, onChangeDirectory, defaultDirectories = [], appendButton, collapsible = false, quotePath = false, hideError = false, }) => { const intl = useIntl(); const [showBrowser, setShowBrowser] = useState(false); const [path, setPath] = useState(currentDirectory); const normalizedPath = quotePath ? TextUtils.stripQuotes(path) : path; const { directories, parent, error, loading } = useDirectoryPaths( normalizedPath, hideError ); const selectableDirectories = (currentDirectory ? directories : defaultDirectories) ?? defaultDirectories; const debouncedSetDirectory = useDebounce(setPath, 250); function setInstant(value: string) { const normalizedValue = quotePath && value.includes(" ") ? TextUtils.addQuotes(value) : value; onChangeDirectory(normalizedValue); setPath(normalizedValue); } function setDebounced(value: string) { onChangeDirectory(value); debouncedSetDirectory(value); } function goUp() { if (defaultDirectories?.includes(currentDirectory)) { setInstant(""); } else if (parent) { setInstant(parent); } } const topDirectory = currentDirectory && parent && (
  • ); return ( <> { setDebounced(e.currentTarget.value); }} value={currentDirectory} spellCheck={false} /> {appendButton && {appendButton}} {collapsible && ( )} {(loading || error) && ( {loading ? ( ) : ( !hideError && )} )} {!hideError && error !== undefined && (
    Error: {error.message}
    )}
      {topDirectory} {selectableDirectories.map((dir) => (
    • ))}
    ); }; export const FolderSelect = PatchComponent("FolderSelect", _FolderSelect); ================================================ FILE: ui/v2.5/src/components/Shared/FolderSelect/FolderSelectDialog.tsx ================================================ import React, { useState } from "react"; import { FormattedMessage } from "react-intl"; import { Button, Modal } from "react-bootstrap"; import { FolderSelect } from "./FolderSelect"; interface IProps { defaultValue?: string; onClose: (directory?: string) => void; } export const FolderSelectDialog: React.FC = ({ defaultValue: currentValue, onClose, }) => { const [currentDirectory, setCurrentDirectory] = useState( currentValue ?? "" ); return ( onClose()} title="">
    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/FolderSelect/useDirectoryPaths.ts ================================================ import { useRef } from "react"; import { useDirectory } from "src/core/StashService"; export const useDirectoryPaths = (path: string, hideError: boolean) => { const { data, loading, error } = useDirectory(path); const prevData = useRef(undefined); if (!loading) prevData.current = data; const currentData = loading ? prevData.current : data; const directories = error && hideError ? [] : currentData?.directory.directories; const parent = currentData?.directory.parent; return { directories, parent, loading, error }; }; ================================================ FILE: ui/v2.5/src/components/Shared/GridCard/GridCard.tsx ================================================ import React, { MutableRefObject, PropsWithChildren, useMemo, useRef, useState, } from "react"; import { Card, Form } from "react-bootstrap"; import { Link } from "react-router-dom"; import cx from "classnames"; import { TruncatedText } from "../TruncatedText"; import ScreenUtils from "src/utils/screen"; import useResizeObserver from "@react-hook/resize-observer"; import { Icon } from "../Icon"; import { faGripLines } from "@fortawesome/free-solid-svg-icons"; import { DragSide, useDragMoveSelect } from "./dragMoveSelect"; import { useDebounce } from "src/hooks/debounce"; import { PatchComponent } from "src/patch"; interface ICardProps { className?: string; linkClassName?: string; thumbnailSectionClassName?: string; width?: number; url: string; pretitleIcon?: JSX.Element; title: JSX.Element | string; image: JSX.Element; details?: JSX.Element; overlays?: JSX.Element; popovers?: JSX.Element; selecting?: boolean; selected?: boolean; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; resumeTime?: number; duration?: number; interactiveHeatmap?: string; // move logic - both of the following are required to enable move dragging objectId?: string; // required for move dragging onMove?: (srcIds: string[], targetId: string, after: boolean) => void; } export const calculateCardWidth = ( containerWidth: number, preferredWidth: number ) => { const containerPadding = 30; const cardMargin = 10; let maxUsableWidth = containerWidth - containerPadding; let maxElementsOnRow = Math.ceil(maxUsableWidth / preferredWidth); return maxUsableWidth / maxElementsOnRow - cardMargin; }; interface IDimension { width: number; height: number; } export const useContainerDimensions = ( sensitivityThreshold = 20 ): [MutableRefObject, IDimension] => { const target = useRef(null); const [dimension, setDimension] = useState({ width: 0, height: 0, }); const debouncedSetDimension = useDebounce((entry: ResizeObserverEntry) => { const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0]; let difference = Math.abs(dimension.width - width); // Only adjust when width changed by a significant margin. This addresses the cornercase that sees // the dimensions toggle back and forward when the window is adjusted perfectly such that overflow // is trigger then immediable disabled because of a resize event then continues this loop endlessly. // the scrollbar size varies between platforms. Windows is apparently around 17 pixels. if (difference > sensitivityThreshold) { setDimension({ width, height }); } }, 50); useResizeObserver(target, debouncedSetDimension); return [target, dimension]; }; export function useCardWidth( containerWidth: number, zoomIndex: number, zoomWidths: number[] ) { return useMemo(() => { if (ScreenUtils.isMobile()) { return; } if ( zoomIndex === undefined || zoomIndex < 0 || zoomIndex >= zoomWidths.length ) return; // use a default card width if we don't have the container width yet if (!containerWidth) { return zoomWidths[zoomIndex]; } let zoomValue = zoomIndex; const preferredCardWidth = zoomWidths[zoomValue]; let fittedCardWidth = calculateCardWidth( containerWidth, preferredCardWidth! ); return fittedCardWidth; }, [containerWidth, zoomIndex, zoomWidths]); } const Checkbox: React.FC<{ selected?: boolean; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; }> = ({ selected = false, onSelectedChanged }) => { let shiftKey = false; return ( onSelectedChanged!(!selected, shiftKey)} onClick={(event: React.MouseEvent) => { shiftKey = event.shiftKey; event.stopPropagation(); }} /> ); }; const DragHandle: React.FC<{ setInHandle: (inHandle: boolean) => void; }> = ({ setInHandle }) => { function onMouseEnter() { setInHandle(true); } function onMouseLeave() { setInHandle(false); } return ( ); }; const Controls: React.FC> = ({ children }) => { return
    {children}
    ; }; const MoveTarget: React.FC<{ dragSide: DragSide }> = ({ dragSide }) => { if (dragSide === undefined) { return null; } return (
    ); }; export const GridCard: React.FC = PatchComponent( "GridCard", (props: ICardProps) => { const { setInHandle, moveTarget, dragProps } = useDragMoveSelect({ selecting: props.selecting || false, selected: props.selected || false, onSelectedChanged: props.onSelectedChanged, objectId: props.objectId, onMove: props.onMove, }); function handleImageClick( event: React.MouseEvent ) { const { shiftKey } = event; if (!props.onSelectedChanged) { return; } if (props.selecting) { props.onSelectedChanged(!props.selected, shiftKey); event.preventDefault(); event.stopPropagation(); } } function maybeRenderInteractiveHeatmap() { if (props.interactiveHeatmap) { return ( interactive heatmap ); } } function maybeRenderProgressBar() { if ( props.resumeTime && props.duration && props.duration > props.resumeTime ) { const percentValue = (100 / props.duration) * props.resumeTime; const percentStr = percentValue + "%"; return (
    ); } } return ( {moveTarget !== undefined && } {props.onSelectedChanged && ( )} {!!props.objectId && props.onMove && ( )}
    {props.image} {props.overlays} {maybeRenderProgressBar()}
    {maybeRenderInteractiveHeatmap()}
    {props.pretitleIcon}
    {props.details}
    {props.popovers}
    ); } ); ================================================ FILE: ui/v2.5/src/components/Shared/GridCard/StudioOverlay.tsx ================================================ import React, { useMemo } from "react"; import { Link } from "react-router-dom"; import { useConfigurationContext } from "src/hooks/Config"; interface IStudio { id: string; name: string; image_path?: string | null; } export const StudioOverlay: React.FC<{ studio: IStudio | null | undefined; disabled?: boolean; }> = ({ studio, disabled }) => { const { configuration } = useConfigurationContext(); const configValue = configuration?.interface.showStudioAsText; const showStudioAsText = useMemo(() => { if (configValue || !studio?.image_path) { return true; } // If the studio has a default image, show the studio name as text const studioImageURL = new URL(studio.image_path); if (studioImageURL.searchParams.get("default") === "true") { return true; } return false; }, [configValue, studio?.image_path]); function onClick(e: React.MouseEvent) { if (disabled) { e.preventDefault(); } } if (!studio) return <>; return ( // this class name is incorrect
    {showStudioAsText ? ( studio.name ) : ( {studio.name} )}
    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/GridCard/dragMoveSelect.ts ================================================ import { useState } from "react"; import { useListContextOptional } from "src/components/List/ListProvider"; // Enum representing the possible sides for a drag operation. export enum DragSide { BEFORE, AFTER, } /** * Hook to manage drag and move selection functionality. * Dragging while selecting will allow the user to select multiple items. * Dragging from the drag handle will allow the user to move the item or selected items. * * @param props - The properties for the hook. * @param props.selecting - Whether the one or more items have been selected. * @param props.selected - Whether this item is currently selected. * @param props.onSelectedChanged - Callback when the selected state changes. * @param props.objectId - The ID of this object. * @param props.onMove - Callback when a move operation occurs. * * @returns An object containing the drag event handlers and state. */ export function useDragMoveSelect(props: { selecting: boolean; selected: boolean; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; objectId?: string; onMove?: (srcIds: string[], targetId: string, after: boolean) => void; }) { const { selectedIds } = useListContextOptional(); // true if the mouse is over the drag handle const [inHandle, setInHandle] = useState(false); // true if this is the source of a move operation const [moveSrc, setMoveSrc] = useState(false); // the target side for a move operation const [moveTarget, setMoveTarget] = useState(); const canSelect = props.onSelectedChanged && props.selecting; const canMove = !!props.objectId && props.onMove && inHandle; const draggable = canSelect || canMove; function onDragStart(event: React.DragEvent) { if (!draggable) { event.preventDefault(); return; } if (!inHandle && props.selecting) { event.dataTransfer.setData("text/plain", ""); // event.dataTransfer.setDragImage(new Image(), 0, 0); event.dataTransfer.effectAllowed = "copy"; event.stopPropagation(); } else if (inHandle && props.objectId) { if (selectedIds.size > 1 && selectedIds.has(props.objectId)) { // moving all selected const movingIds = Array.from(selectedIds.values()).join(","); event.dataTransfer.setData("text/plain", movingIds); } else { // moving single setMoveSrc(true); event.dataTransfer.setData("text/plain", props.objectId); } event.dataTransfer.effectAllowed = "move"; event.stopPropagation(); } } function doSetMoveTarget(event: React.DragEvent) { const isBefore = event.nativeEvent.offsetX < event.currentTarget.clientWidth / 2; if (isBefore && moveTarget !== DragSide.BEFORE) { setMoveTarget(DragSide.BEFORE); } else if (!isBefore && moveTarget !== DragSide.AFTER) { setMoveTarget(DragSide.AFTER); } } function onDragEnter(event: React.DragEvent) { const ev = event; const shiftKey = false; if (ev.dataTransfer.effectAllowed === "copy") { if (!props.onSelectedChanged) { return; } if (props.selecting && !props.selected) { props.onSelectedChanged(true, shiftKey); } ev.dataTransfer.dropEffect = "copy"; ev.preventDefault(); } else if (ev.dataTransfer.effectAllowed === "move" && !moveSrc) { // don't allow move on self doSetMoveTarget(event); ev.dataTransfer.dropEffect = "move"; ev.preventDefault(); } else { ev.dataTransfer.dropEffect = "none"; } } function onDragLeave(event: React.DragEvent) { if (event.currentTarget.contains(event.relatedTarget as Node)) { return; } setMoveTarget(undefined); } function onDragOver(event: React.DragEvent) { // only set move target if move is allowed, or if this is not the source of the move if (event.dataTransfer.effectAllowed !== "move" || moveSrc) { return; } doSetMoveTarget(event); event.preventDefault(); } function onDragEnd() { setMoveTarget(undefined); setMoveSrc(false); } function onDrop(event: React.DragEvent) { const ev = event; if ( ev.dataTransfer.effectAllowed === "copy" || !props.onMove || !props.objectId ) { return; } const srcIds = ev.dataTransfer.getData("text/plain").split(","); const targetId = props.objectId; const after = moveTarget === DragSide.AFTER; props.onMove(srcIds, targetId, after); onDragEnd(); } return { inHandle, setInHandle, moveTarget, dragProps: { draggable: draggable || undefined, onDragStart, onDragEnter, onDragLeave, onDragOver, onDragEnd, onDrop, }, }; } ================================================ FILE: ui/v2.5/src/components/Shared/GridCard/styles.scss ================================================ .grid-card { a { color: $text-color; text-decoration: none; } .rating-banner { transition: opacity 0.5s; } &:hover, &:active { .rating-banner, .studio-overlay { opacity: 0; transition: opacity 0.5s; } .studio-overlay:hover, .studio-overlay:active { opacity: 0.75; transition: opacity 0.5s; } } } .studio-overlay { display: block; font-weight: 900; height: 10%; max-width: 40%; opacity: 0.75; position: absolute; right: 0.7rem; top: 0.7rem; transition: opacity 0.5s; z-index: 8; .image-thumbnail { height: 50px; object-fit: contain; width: 100%; } a { color: $text-color; display: inline-block; letter-spacing: -0.03rem; text-align: right; text-decoration: none; text-shadow: 0 0 3px #000; } &:hover, &:active { opacity: 0.75; transition: opacity 0.5s; } } .move-target { align-items: center; background-color: $primary; color: $secondary; display: flex; height: 100%; justify-content: center; opacity: 0.5; pointer-events: none; position: absolute; width: 10%; &.move-target-before { left: 0; } &.move-target-after { right: 0; } } .card-drag-handle { filter: drop-shadow(1px 1px 1px rgba(0, 0, 0, 0.7)); } ================================================ FILE: ui/v2.5/src/components/Shared/HoverPopover.tsx ================================================ import React, { useState, useCallback, useEffect, useRef } from "react"; import { Overlay, Popover, OverlayProps } from "react-bootstrap"; import { PatchComponent } from "src/patch"; import { Icon } from "./Icon"; import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons"; interface IHoverPopover { enterDelay?: number; leaveDelay?: number; content: JSX.Element[] | JSX.Element | string; className?: string; placement?: OverlayProps["placement"]; onOpen?: () => void; onClose?: () => void; target?: React.RefObject; } export const HoverPopover: React.FC = PatchComponent( "HoverPopover", ({ enterDelay = 200, leaveDelay = 200, content, children, className, placement = "top", onOpen, onClose, target, }) => { const [show, setShow] = useState(false); const triggerRef = useRef(null); const enterTimer = useRef(); const leaveTimer = useRef(); const handleMouseEnter = useCallback(() => { window.clearTimeout(leaveTimer.current); enterTimer.current = window.setTimeout(() => { setShow(true); onOpen?.(); }, enterDelay); }, [enterDelay, onOpen]); const handleMouseLeave = useCallback(() => { window.clearTimeout(enterTimer.current); leaveTimer.current = window.setTimeout(() => { setShow(false); onClose?.(); }, leaveDelay); }, [leaveDelay, onClose]); useEffect( () => () => { window.clearTimeout(enterTimer.current); window.clearTimeout(leaveTimer.current); }, [] ); return ( <>
    {children}
    {triggerRef.current && ( {content} )} ); } ); // convenience component to set the padding on popover content export const PopoverCard: React.FC<{ className?: string }> = ({ className, children, }) => { return
    {children}
    ; }; export const WarningHoverPopover: React.FC = PatchComponent( "WarningHoverPopover", ({ children, ...props }) => ( ) ); ================================================ FILE: ui/v2.5/src/components/Shared/HoverScrubber.tsx ================================================ import React, { useMemo } from "react"; import cx from "classnames"; // #5231: TouchEvent is not defined on all browsers const touchEventDefined = window.TouchEvent !== undefined; interface IHoverScrubber { totalSprites: number; activeIndex: number | undefined; setActiveIndex: (index: number | undefined) => void; onClick?: (index: number) => void; disabled?: boolean; } export const HoverScrubber: React.FC = ({ totalSprites, activeIndex, setActiveIndex, onClick, disabled, }) => { function getActiveIndex( e: | React.MouseEvent | React.TouchEvent ) { const { width } = e.currentTarget.getBoundingClientRect(); let x = 0; if (e.nativeEvent instanceof MouseEvent) { x = e.nativeEvent.offsetX; } else if (touchEventDefined && e.nativeEvent instanceof TouchEvent) { x = e.nativeEvent.touches[0].clientX - e.currentTarget.getBoundingClientRect().x; } const i = Math.round((x / width) * (totalSprites - 1)); // clamp to [0, totalSprites) if (i < 0) return 0; if (i >= totalSprites) return totalSprites - 1; return i; } function onMove( e: | React.MouseEvent | React.TouchEvent ) { const relatedTarget = e.currentTarget; if ( (e instanceof MouseEvent && relatedTarget !== e.target) || (touchEventDefined && e instanceof TouchEvent && document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY)) ) return; setActiveIndex(getActiveIndex(e)); } function onLeave() { setActiveIndex(undefined); } function onScrubberClick( e: | React.MouseEvent | React.TouchEvent ) { if (!onClick) return; if (disabled) { // allow propagation up so that selection still works e.preventDefault(); return; } const relatedTarget = e.currentTarget; if ( (e instanceof MouseEvent && relatedTarget !== e.target) || (touchEventDefined && e instanceof TouchEvent && document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY)) ) return; e.preventDefault(); e.stopPropagation(); const i = getActiveIndex(e); if (i === undefined) return; onClick(i); } const indicatorStyle = useMemo(() => { if (activeIndex === undefined || !totalSprites) return {}; const width = ((activeIndex + 1) / totalSprites) * 100; return { width: `${width}%`, }; }, [activeIndex, totalSprites]); return (
    {activeIndex !== undefined && (
    )}
    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/Icon.tsx ================================================ import React from "react"; import { FontAwesomeIcon, FontAwesomeIconProps, } from "@fortawesome/react-fontawesome"; import { PatchComponent } from "src/patch"; export const Icon: React.FC = PatchComponent( "Icon", (props) => ( ) ); ================================================ FILE: ui/v2.5/src/components/Shared/ImageInput.tsx ================================================ import React, { useState } from "react"; import { Button, Col, Form, OverlayTrigger, Popover, Row, } from "react-bootstrap"; import { useIntl } from "react-intl"; import { ModalComponent } from "./Modal"; import { Icon } from "./Icon"; import { faClipboard, faFile, faLink } from "@fortawesome/free-solid-svg-icons"; import { PatchComponent } from "src/patch"; import ImageUtils from "src/utils/image"; import { useToast } from "src/hooks/Toast"; interface IImageInput { isEditing: boolean; text?: string; onImageChange: (event: React.ChangeEvent) => void; onImageURL?: (url: string) => void; onReset?: () => void; acceptSVG?: boolean; } function acceptExtensions(acceptSVG: boolean = false) { return `.jpg,.jpeg,.png,.webp,.gif${acceptSVG ? ",.svg" : ""}`; } export const ImageInput: React.FC = PatchComponent( "ImageInput", ({ isEditing, text, onImageChange, onImageURL, onReset, acceptSVG = false, }) => { const [isShowDialog, setIsShowDialog] = useState(false); const [url, setURL] = useState(""); const intl = useIntl(); const Toast = useToast(); if (!isEditing) return
    ; if (!onImageURL) { // just return the file input return ( ); } async function onPasteClipboard() { try { const data = await ImageUtils.readClipboardImage(); if (data && onImageURL) { onImageURL(data); Toast.success( intl.formatMessage({ id: "toast.clipboard_image_pasted" }) ); } else { Toast.error(intl.formatMessage({ id: "toast.clipboard_no_image" })); } } catch (e) { if (e instanceof DOMException && e.name === "NotAllowedError") { Toast.error( intl.formatMessage({ id: "toast.clipboard_access_denied" }) ); } else { Toast.error(e); } } } function showDialog() { setURL(""); setIsShowDialog(true); } function onConfirmURL() { if (!onImageURL) { return; } setIsShowDialog(false); onImageURL(url); } function renderDialog() { return ( setIsShowDialog(false)} header={intl.formatMessage({ id: "dialogs.set_image_url_title" })} accept={{ onClick: onConfirmURL, text: intl.formatMessage({ id: "actions.confirm" }), }} >
    {intl.formatMessage({ id: "url" })} ) => setURL(event.currentTarget.value) } value={url} placeholder={intl.formatMessage({ id: "url" })} />
    ); } const popover = ( <>
    {window.isSecureContext && (
    )}
    ); return ( <> {renderDialog()} {onReset && ( )} ); } ); ================================================ FILE: ui/v2.5/src/components/Shared/ImageSelector.tsx ================================================ import React, { useEffect, useState } from "react"; import cx from "classnames"; import { LoadingIndicator } from "./LoadingIndicator"; import { Button } from "react-bootstrap"; import { faArrowLeft, faArrowRight } from "@fortawesome/free-solid-svg-icons"; import { Icon } from "./Icon"; import { FormattedMessage } from "react-intl"; interface IImageSelectorProps { imageClassName?: string; images: string[]; imageIndex: number; setImageIndex: (index: number) => void; } export const ImageSelector: React.FC = ({ imageClassName, images, imageIndex, setImageIndex, }) => { const [imageState, setImageState] = useState< "loading" | "error" | "loaded" | "empty" >("empty"); const [loadDict, setLoadDict] = useState>({}); const [currentImage, setCurrentImage] = useState(""); useEffect(() => { if (imageState !== "loading") { setCurrentImage(images[imageIndex]); } }, [imageState, imageIndex, images]); const changeImage = (index: number) => { setImageIndex(index); if (!loadDict[index]) setImageState("loading"); }; const setPrev = () => changeImage(imageIndex === 0 ? images.length - 1 : imageIndex - 1); const setNext = () => changeImage(imageIndex === images.length - 1 ? 0 : imageIndex + 1); const handleLoad = (index: number) => { setLoadDict({ ...loadDict, [index]: true, }); setImageState("loaded"); }; const handleError = () => setImageState("error"); return (
    {images.length > 1 && (
    )}
    {/* hidden image to handle loading */} handleLoad(imageIndex)} onError={handleError} /> {imageState === "loading" && } {imageState === "error" && (
    )}
    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/IndeterminateCheckbox.tsx ================================================ import React, { useEffect } from "react"; import { Form, FormCheckProps } from "react-bootstrap"; const useIndeterminate = ( ref: React.RefObject, value: boolean | undefined ) => { useEffect(() => { if (ref.current) { // eslint-disable-next-line no-param-reassign ref.current.indeterminate = value === undefined; } }, [ref, value]); }; interface IIndeterminateCheckbox extends FormCheckProps { setChecked: (v: boolean | undefined) => void; allowIndeterminate?: boolean; indeterminateClassname?: string; } export const IndeterminateCheckbox: React.FC = ({ checked, setChecked, allowIndeterminate, indeterminateClassname, ...props }) => { const ref = React.createRef(); useIndeterminate(ref, checked); function cycleState() { const undefAllowed = allowIndeterminate ?? true; if (undefAllowed && checked) { return undefined; } if ((!undefAllowed && checked) || checked === undefined) { return false; } return true; } return ( setChecked(cycleState())} /> ); }; ================================================ FILE: ui/v2.5/src/components/Shared/Link.tsx ================================================ import { useMemo } from "react"; import { Link } from "react-router-dom"; import NavUtils from "src/utils/navigation"; // common link components export const DirectorLink: React.FC<{ director: string; linkType: "scene" | "group"; }> = ({ director: director, linkType = "scene" }) => { const link = useMemo(() => { switch (linkType) { case "scene": return NavUtils.makeDirectorScenesUrl(director); case "group": return NavUtils.makeDirectorGroupsUrl(director); } }, [director, linkType]); return {director}; }; export const PhotographerLink: React.FC<{ photographer: string; linkType: "gallery" | "image"; }> = ({ photographer, linkType = "image" }) => { const link = useMemo(() => { switch (linkType) { case "gallery": return NavUtils.makePhotographerGalleriesUrl(photographer); case "image": return NavUtils.makePhotographerImagesUrl(photographer); } }, [photographer, linkType]); return {photographer}; }; ================================================ FILE: ui/v2.5/src/components/Shared/LoadingIndicator.tsx ================================================ import React from "react"; import { Spinner } from "react-bootstrap"; import cx from "classnames"; import { useIntl } from "react-intl"; import { PatchComponent } from "src/patch"; interface ILoadingProps { message?: JSX.Element | string; inline?: boolean; small?: boolean; card?: boolean; } const CLASSNAME = "LoadingIndicator"; const CLASSNAME_MESSAGE = `${CLASSNAME}-message`; export const LoadingIndicator: React.FC = PatchComponent( "LoadingIndicator", ({ message, inline = false, small = false, card = false }) => { const intl = useIntl(); const text = intl.formatMessage({ id: "loading.generic" }); return (
    {text} {message !== "" && (

    {message ?? text}

    )}
    ); } ); ================================================ FILE: ui/v2.5/src/components/Shared/MarkdownPage.tsx ================================================ import React, { useEffect, useState } from "react"; import { Remark } from "react-remark"; import remarkGfm from "remark-gfm"; interface IPageProps { // page is a markdown module page: string; } export const MarkdownPage: React.FC = ({ page }) => { const [markdown, setMarkdown] = useState(""); useEffect(() => { if (!markdown) { fetch(page) .then((res) => res.text()) .then((text) => setMarkdown(text)); } }, [page, markdown]); return (
    {markdown}
    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/Modal.tsx ================================================ import React from "react"; import { Button, Modal, Spinner, ModalProps } from "react-bootstrap"; import { ButtonVariant } from "react-bootstrap/types"; import { Icon } from "./Icon"; import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; import { FormattedMessage } from "react-intl"; interface IButton { text?: string; variant?: ButtonVariant; onClick?: () => void; } interface IModal { show: boolean; onHide?: () => void; header?: JSX.Element | string; icon?: IconDefinition; cancel?: IButton; accept?: IButton; isRunning?: boolean; disabled?: boolean; modalProps?: ModalProps; dialogClassName?: string; footerButtons?: React.ReactNode; leftFooterButtons?: React.ReactNode; } const defaultOnHide = () => {}; export const ModalComponent: React.FC = ({ children, show, icon, header, cancel, accept, onHide, isRunning, disabled, modalProps, dialogClassName, footerButtons, leftFooterButtons, }) => ( {icon ? : ""} {header ?? ""} {children}
    {leftFooterButtons}
    {footerButtons} {cancel ? ( ) : ( "" )}
    ); ================================================ FILE: ui/v2.5/src/components/Shared/MultiSet.tsx ================================================ import React from "react"; import { IntlShape, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { Button, ButtonGroup } from "react-bootstrap"; import { FilterSelect, SelectObject } from "./Select"; import { GalleryIDSelect, excludeFileBasedGalleries, } from "../Galleries/GallerySelect"; import { PerformerIDSelect } from "../Performers/PerformerSelect"; import { StudioIDSelect } from "../Studios/StudioSelect"; import { TagIDSelect } from "../Tags/TagSelect"; import { GroupIDSelect } from "../Groups/GroupSelect"; import { SceneIDSelect } from "../Scenes/SceneSelect"; interface IMultiSetProps { type: "performers" | "studios" | "tags" | "groups" | "galleries" | "scenes"; existingIds?: string[]; ids?: string[]; mode: GQL.BulkUpdateIdMode; disabled?: boolean; onUpdate: (ids: string[]) => void; onSetMode: (mode: GQL.BulkUpdateIdMode) => void; menuPortalTarget?: HTMLElement | null; } const Select: React.FC = (props) => { const { type, disabled } = props; function onUpdate(items: SelectObject[]) { props.onUpdate(items.map((i) => i.id)); } switch (type) { case "performers": return ( ); case "studios": return ( ); case "tags": return ( ); case "groups": return ( ); case "galleries": return ( ); case "scenes": return ( ); default: return ( ); } }; function getModeText(intl: IntlShape, mode: GQL.BulkUpdateIdMode) { switch (mode) { case GQL.BulkUpdateIdMode.Set: return intl.formatMessage({ id: "actions.overwrite", defaultMessage: "Overwrite", }); case GQL.BulkUpdateIdMode.Add: return intl.formatMessage({ id: "actions.add", defaultMessage: "Add" }); case GQL.BulkUpdateIdMode.Remove: return intl.formatMessage({ id: "actions.remove", defaultMessage: "Remove", }); } } export const MultiSetModeButton: React.FC<{ mode: GQL.BulkUpdateIdMode; active: boolean; onClick: () => void; disabled?: boolean; }> = ({ mode, active, onClick, disabled }) => { const intl = useIntl(); return ( ); }; const modes = [ GQL.BulkUpdateIdMode.Set, GQL.BulkUpdateIdMode.Add, GQL.BulkUpdateIdMode.Remove, ]; export const MultiSetModeButtons: React.FC<{ mode: GQL.BulkUpdateIdMode; onSetMode: (mode: GQL.BulkUpdateIdMode) => void; disabled?: boolean; }> = ({ mode, onSetMode, disabled }) => { return ( {modes.map((m) => ( onSetMode(m)} disabled={disabled} /> ))} ); }; export const MultiSet: React.FC = (props) => { const { mode, onUpdate, existingIds } = props; function onSetMode(m: GQL.BulkUpdateIdMode) { if (m === mode) { return; } // if going to Set, set the existing ids if (m === GQL.BulkUpdateIdMode.Set && existingIds) { onUpdate(existingIds); // if going from Set, wipe the ids } else if ( m !== GQL.BulkUpdateIdMode.Set && mode === GQL.BulkUpdateIdMode.Set ) { onUpdate([]); } props.onSetMode(m); } return (
    ); } } ); ================================================ FILE: ui/v2.5/src/components/Shared/Rating/RatingStars.tsx ================================================ import { useState } from "react"; import { Button } from "react-bootstrap"; import { Icon } from "../Icon"; import { faStar as fasStar } from "@fortawesome/free-solid-svg-icons"; import { faStar as farStar } from "@fortawesome/free-regular-svg-icons"; import { convertFromRatingFormat, convertToRatingFormat, getRatingPrecision, RatingStarPrecision, RatingSystemType, } from "src/utils/rating"; import { useIntl } from "react-intl"; import { PatchComponent } from "src/patch"; export interface IRatingStarsProps { value: number | null; onSetRating?: (value: number | null) => void; disabled?: boolean; precision: RatingStarPrecision; valueRequired?: boolean; orMore?: boolean; } export const RatingStars = PatchComponent( "RatingStars", (props: IRatingStarsProps) => { const intl = useIntl(); const [hoverRating, setHoverRating] = useState(); const disabled = props.disabled || !props.onSetRating; const rating = convertToRatingFormat(props.value, { type: RatingSystemType.Stars, starPrecision: props.precision, }); const stars = rating ? Math.floor(rating) : 0; // the upscaling was necesary to fix rounding issue present with tenth place precision const fraction = rating ? ((rating * 10) % 10) / 10 : 0; const max = 5; const precision = getRatingPrecision(props.precision); function newToggleFraction() { if (precision !== 1) { if (fraction !== precision) { if (fraction == 0) { return 1 - precision; } return fraction - precision; } } } function setRating(thisStar: number) { if (!props.onSetRating) { return; } let newRating: number | undefined = thisStar; // toggle rating fraction if we're clicking on the current rating if ( (stars === thisStar && !fraction) || (stars + 1 === thisStar && fraction) ) { const f = newToggleFraction(); if (!f) { if (props.valueRequired) { if (fraction) { newRating = stars + 1; } else { newRating = stars; } } else { newRating = undefined; } } else if (fraction) { // we're toggling from an existing fraction so use the stars value newRating = stars + f; } else { // we're toggling from a whole value, so decrement from current rating newRating = stars - 1 + f; } } // set the hover rating to undefined so that it doesn't immediately clear // the stars setHoverRating(undefined); if (!newRating) { props.onSetRating(null); return; } props.onSetRating( convertFromRatingFormat(newRating, RatingSystemType.Stars) ); } function onMouseOver(thisStar: number) { if (!disabled) { setHoverRating(thisStar); } } function onMouseOut(thisStar: number) { if (!disabled && hoverRating === thisStar) { setHoverRating(undefined); } } function getClassName(thisStar: number) { if (hoverRating && hoverRating >= thisStar) { if (hoverRating === stars) { return "unsetting"; } return "setting"; } if (stars && stars >= thisStar) { return "set"; } return "unset"; } function getTooltip(thisStar: number, current: RatingFraction | undefined) { if (disabled) { if (rating) { // always return current rating for disabled control return rating.toString(); } return undefined; } // adjust tooltip to use fractions if (!current) { return intl.formatMessage({ id: "actions.unset" }); } return (current.rating + current.fraction).toString(); } type RatingFraction = { rating: number; fraction: number; }; function getCurrentSelectedRating(): RatingFraction | undefined { let r: number = hoverRating ? hoverRating : stars; let f: number | undefined = fraction; if (hoverRating) { if (hoverRating === stars && precision === 1) { if (props.valueRequired) { return { rating: r, fraction: 0 }; } // unsetting return undefined; } if (hoverRating === stars + 1 && fraction && fraction === precision) { if (props.valueRequired) { return { rating: r, fraction: 0 }; } // unsetting return undefined; } if (f && hoverRating === stars + 1) { f = newToggleFraction(); r--; } else if (!f && hoverRating === stars) { f = newToggleFraction(); r--; } else { f = 0; } } return { rating: r, fraction: f ?? 0 }; } function getButtonClassName( thisStar: number, current: RatingFraction | undefined ) { if (!current || thisStar > current.rating + 1) { return "star-fill-0"; } if (thisStar <= current.rating) { return "star-fill-100"; } let w = current.fraction * 100; return `star-fill-${w}`; } const suffix = props.orMore ? "+" : ""; const renderRatingButton = (thisStar: number) => { const ratingFraction = getCurrentSelectedRating(); return ( ); }; const maybeGetStarRatingNumber = () => { const ratingFraction = getCurrentSelectedRating(); if ( !ratingFraction || (ratingFraction.rating == 0 && ratingFraction.fraction == 0) ) { return ""; } return ratingFraction.rating + ratingFraction.fraction + suffix; }; const precisionClassName = `rating-stars-precision-${props.precision}`; return (
    {Array.from(Array(max)).map((value, index) => renderRatingButton(index + 1) )} {maybeGetStarRatingNumber()}
    ); } ); ================================================ FILE: ui/v2.5/src/components/Shared/Rating/RatingSystem.tsx ================================================ import { useConfigurationContext } from "src/hooks/Config"; import { defaultRatingStarPrecision, defaultRatingSystemOptions, RatingSystemType, } from "src/utils/rating"; import { RatingNumber } from "./RatingNumber"; import { RatingStars } from "./RatingStars"; import { PatchComponent } from "src/patch"; export interface IRatingSystemProps { value: number | null | undefined; onSetRating?: (value: number | null) => void; disabled?: boolean; valueRequired?: boolean; // if true, requires a click first to edit the rating clickToRate?: boolean; // true if we should indicate that this is a rating withoutContext?: boolean; } export const RatingSystem = PatchComponent( "RatingSystem", (props: IRatingSystemProps) => { const { configuration: config } = useConfigurationContext(); const ratingSystemOptions = config?.ui.ratingSystemOptions ?? defaultRatingSystemOptions; if (ratingSystemOptions.type === RatingSystemType.Stars) { return ( ); } else { return ( ); } } ); ================================================ FILE: ui/v2.5/src/components/Shared/Rating/styles.scss ================================================ .rating-stars { display: inline-flex; vertical-align: middle; button { font-size: inherit; margin-right: 1px; padding: 0; position: relative; &:hover { background-color: inherit; } &:disabled { background-color: inherit; opacity: inherit; } &.star-fill-0 .filled-star { width: 0; } &.star-fill-10 .filled-star { width: 10%; } &.star-fill-20 .filled-star { width: 20%; } &.star-fill-25 .filled-star { width: 35%; } &.star-fill-30 .filled-star { width: 30%; } &.star-fill-40 .filled-star { width: 40%; } &.star-fill-50 .filled-star { width: 50%; } &.star-fill-60 .filled-star { width: 60%; } &.star-fill-75 .filled-star { width: 65%; } &.star-fill-70 .filled-star { width: 70%; } &.star-fill-80 .filled-star { width: 80%; } &.star-fill-90 .filled-star { width: 90%; } &.star-fill-100 .filled-star { width: 100%; } .filled-star { overflow: hidden; position: absolute; } } .unsetting { color: gold; } .setting { color: gold; } .set { color: gold; } } .star-rating-number { font-size: 1rem; margin: auto 0.5rem; } .rating-number { .fa-icon { color: gold; margin-left: 0; } .edit-rating-button { font-size: 0.75rem; } &.disabled { align-items: center; display: inline-flex; } } ================================================ FILE: ui/v2.5/src/components/Shared/RatingBanner.tsx ================================================ import React from "react"; import { FormattedMessage } from "react-intl"; import { convertToRatingFormat, defaultRatingSystemOptions, RatingStarPrecision, RatingSystemType, } from "src/utils/rating"; import { useConfigurationContext } from "src/hooks/Config"; interface IProps { rating?: number | null; } export const RatingBanner: React.FC = ({ rating }) => { const { configuration: config } = useConfigurationContext(); const ratingSystemOptions = config?.ui.ratingSystemOptions ?? defaultRatingSystemOptions; const isLegacy = ratingSystemOptions.type === RatingSystemType.Stars && ratingSystemOptions.starPrecision === RatingStarPrecision.Full; const convertedRating = convertToRatingFormat( rating ?? undefined, ratingSystemOptions ); return rating ? (
    : {convertedRating}
    ) : ( <> ); }; ================================================ FILE: ui/v2.5/src/components/Shared/ReassignFilesDialog.tsx ================================================ import React, { useState } from "react"; import { ModalComponent } from "./Modal"; import { useToast } from "src/hooks/Toast"; import { useIntl } from "react-intl"; import { faSignOutAlt } from "@fortawesome/free-solid-svg-icons"; import { Col, Form, Row } from "react-bootstrap"; import * as FormUtils from "src/utils/form"; import { mutateSceneAssignFile } from "src/core/StashService"; import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect"; interface IFile { id: string; path: string; } interface IReassignFilesDialogProps { selected: IFile; onClose: () => void; } export const ReassignFilesDialog: React.FC = ( props: IReassignFilesDialogProps ) => { const [scenes, setScenes] = useState([]); const intl = useIntl(); const singularEntity = intl.formatMessage({ id: "file" }); const pluralEntity = intl.formatMessage({ id: "files" }); const header = intl.formatMessage( { id: "dialogs.reassign_entity_title" }, { count: 1, singularEntity, pluralEntity } ); const toastMessage = intl.formatMessage( { id: "toast.reassign_past_tense" }, { count: 1, singularEntity, pluralEntity } ); const Toast = useToast(); // Network state const [reassigning, setReassigning] = useState(false); async function onAccept() { if (!scenes.length) { return; } setReassigning(true); try { await mutateSceneAssignFile(scenes[0].id, props.selected.id); Toast.success(toastMessage); props.onClose(); } catch (e) { Toast.error(e); props.onClose(); } setReassigning(false); } return ( props.onClose(), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} isRunning={reassigning} >
    {FormUtils.renderLabel({ title: intl.formatMessage({ id: "dialogs.reassign_files.destination", }), labelProps: { column: true, sm: 3, xl: 12, }, })} setScenes(items)} />
    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/RevealInFilesystemButton.tsx ================================================ import React from "react"; import { Button } from "react-bootstrap"; import { faFolderOpen } from "@fortawesome/free-solid-svg-icons"; import { useIntl } from "react-intl"; import { Icon } from "./Icon"; import { mutateRevealFileInFileManager, mutateRevealFolderInFileManager, } from "src/core/StashService"; import { getPlatformURL } from "src/core/createClient"; interface IRevealInFilesystemButtonProps { fileId?: string; folderId?: string; } function isLocalhost(): boolean { const { hostname } = getPlatformURL(); return ( hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" ); } export const RevealInFilesystemButton: React.FC< IRevealInFilesystemButtonProps > = ({ fileId, folderId }) => { const intl = useIntl(); if (!isLocalhost()) return null; function onClick() { if (folderId) { mutateRevealFolderInFileManager(folderId); } else if (fileId) { mutateRevealFileInFileManager(fileId); } } return ( ); }; ================================================ FILE: ui/v2.5/src/components/Shared/ScrapeDialog/CreateLinkTagDialog.tsx ================================================ import React, { useEffect, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "src/components/Shared/Modal"; import { faLink } from "@fortawesome/free-solid-svg-icons"; import { Form } from "react-bootstrap"; import { Tag, TagSelect } from "../../Tags/TagSelect"; export const CreateLinkTagDialog: React.FC<{ tag: GQL.ScrapedTag; onClose: (result: { create?: GQL.TagCreateInput; update?: GQL.TagUpdateInput; }) => void; endpoint?: string; }> = ({ tag, onClose, endpoint }) => { const intl = useIntl(); const [createNew, setCreateNew] = useState(false); const [name, setName] = useState(tag.name); const [existingTag, setExistingTag] = useState(null); const [addAsAlias, setAddAsAlias] = useState(false); const canAddAlias = (createNew && name !== tag.name) || !createNew; useEffect(() => { setAddAsAlias(canAddAlias); }, [canAddAlias]); function handleTagSave() { if (createNew) { const createInput: GQL.TagCreateInput = { name: name, aliases: addAsAlias ? [tag.name] : [], stash_ids: endpoint && tag.remote_site_id ? [{ endpoint: endpoint!, stash_id: tag.remote_site_id }] : undefined, }; onClose({ create: createInput }); } else if (existingTag) { const updateInput: GQL.TagUpdateInput = { id: existingTag.id, aliases: addAsAlias ? [...(existingTag.aliases || []), tag.name] : undefined, // add stash id if applicable stash_ids: endpoint && tag.remote_site_id ? [ ...(existingTag.stash_ids || []), { endpoint: endpoint!, stash_id: tag.remote_site_id }, ] : undefined, }; onClose({ update: updateInput }); } } return ( handleTagSave(), }} disabled={createNew ? name.trim() === "" : existingTag === null} cancel={{ text: intl.formatMessage({ id: "actions.cancel" }), onClick: () => { onClose({}); }, }} dialogClassName="create-link-tag-modal" icon={faLink} header={intl.formatMessage({ id: "component_tagger.verb_match_tag" })} >
    setCreateNew(true)} /> setName(e.target.value)} disabled={!createNew} /> setCreateNew(false)} /> setExistingTag(t.length > 0 ? t[0] : null)} isDisabled={createNew} menuPortalTarget={document.body} /> setAddAsAlias(!addAsAlias)} disabled={!canAddAlias} />
    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx ================================================ import React, { useMemo } from "react"; import { Form, Col, Row } from "react-bootstrap"; import { ModalComponent } from "../Modal"; import { FormattedMessage, useIntl } from "react-intl"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; import { useConfigurationContext } from "src/hooks/Config"; export interface IScrapeDialogContextState { existingLabel?: React.ReactNode; scrapedLabel?: React.ReactNode; } export const ScrapeDialogContext = React.createContext({}); interface IScrapeDialogProps { className?: string; title: string; existingLabel?: React.ReactNode; scrapedLabel?: React.ReactNode; onClose: (apply?: boolean) => void; } export const ScrapeDialog: React.FC< React.PropsWithChildren > = (props: React.PropsWithChildren) => { const intl = useIntl(); const { configuration } = useConfigurationContext(); const { sfwContentMode } = configuration.interface; const existingLabel = useMemo( () => props.existingLabel ?? ( ), [props.existingLabel] ); const scrapedLabel = useMemo( () => props.scrapedLabel ?? ( ), [props.scrapedLabel] ); const contextState = useMemo( () => ({ existingLabel: existingLabel, scrapedLabel: scrapedLabel, }), [existingLabel, scrapedLabel] ); return ( { props.onClose(true); }, text: intl.formatMessage({ id: "actions.apply" }), }} cancel={{ onClick: () => props.onClose(), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} modalProps={{ size: "lg", dialogClassName: `${props.className ?? ""} scrape-dialog ${ sfwContentMode ? "sfw-mode" : "" }`, }} >
    {existingLabel} {scrapedLabel} {props.children}
    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx ================================================ import React, { useContext, useState } from "react"; import { Form, Col, Row, InputGroup, Button, FormControl, } from "react-bootstrap"; import { Icon } from "../Icon"; import clone from "lodash-es/clone"; import { faCheck, faTimes } from "@fortawesome/free-solid-svg-icons"; import { getCountryByISO } from "src/utils/country"; import { CountrySelect } from "../CountrySelect"; import { StringListInput } from "../StringListInput"; import { ImageSelector } from "../ImageSelector"; import { CustomFieldScrapeResults, ScrapeResult } from "./scrapeResult"; import { ScrapeDialogContext } from "./ScrapeDialog"; function renderButtonIcon(selected: boolean) { const className = selected ? "text-success" : "text-muted"; return ( ); } interface IScrapedFieldProps { result: ScrapeResult; } interface IScrapedRowProps extends IScrapedFieldProps { className?: string; field: string; title: string; originalField: React.ReactNode; newField: React.ReactNode; onChange: (value: ScrapeResult) => void; newValues?: React.ReactNode; alwaysShow?: boolean; } export const ScrapeDialogRow = (props: IScrapedRowProps) => { const { existingLabel, scrapedLabel } = useContext(ScrapeDialogContext); function handleSelectClick(isNew: boolean) { const ret = clone(props.result); ret.useNewValue = isNew; props.onChange(ret); } if (!props.result.scraped && !props.newValues && !props.alwaysShow) { return <>; } return ( {props.title} {existingLabel} {props.originalField} {scrapedLabel} {props.newField} {props.newValues} ); }; interface IScrapedInputGroupProps { isNew?: boolean; placeholder?: string; locked?: boolean; result: ScrapeResult; onChange?: (value: string) => void; } const ScrapedInputGroup: React.FC = (props) => { return ( { if (props.isNew && props.onChange) { props.onChange(e.target.value); } }} className="bg-secondary text-white border-secondary" /> ); }; interface IScrapedInputGroupRowProps { title: string; field: string; className?: string; placeholder?: string; result: ScrapeResult; locked?: boolean; onChange: (value: ScrapeResult) => void; } export const ScrapedInputGroupRow: React.FC = ( props ) => { return ( } newField={ props.onChange(props.result.cloneWithValue(value)) } /> } onChange={props.onChange} /> ); }; interface IScrapedNumberInputProps { isNew?: boolean; placeholder?: string; locked?: boolean; result: ScrapeResult; onChange?: (value: number) => void; } const ScrapedNumberInput: React.FC = (props) => { return ( { if (props.isNew && props.onChange) { props.onChange(Number(e.target.value)); } }} className="bg-secondary text-white border-secondary" type="number" /> ); }; interface IScrapedNumberRowProps { title: string; field: string; className?: string; placeholder?: string; result: ScrapeResult; locked?: boolean; onChange: (value: ScrapeResult) => void; } export const ScrapedNumberRow: React.FC = (props) => { return ( } newField={ props.onChange(props.result.cloneWithValue(value)) } /> } onChange={props.onChange} /> ); }; interface IScrapedStringListProps { isNew?: boolean; placeholder?: string; locked?: boolean; result: ScrapeResult; onChange?: (value: string[]) => void; } const ScrapedStringList: React.FC = (props) => { const value = props.isNew ? props.result.newValue : props.result.originalValue; return ( { if (props.isNew && props.onChange) { props.onChange(v); } }} placeholder={props.placeholder} readOnly={!props.isNew || props.locked} /> ); }; interface IScrapedStringListRowProps { title: string; field: string; placeholder?: string; result: ScrapeResult; locked?: boolean; onChange: (value: ScrapeResult) => void; } export const ScrapedStringListRow: React.FC = ( props ) => { return ( } newField={ props.onChange(props.result.cloneWithValue(value)) } /> } onChange={props.onChange} /> ); }; const ScrapedTextArea: React.FC = (props) => { return ( { if (props.isNew && props.onChange) { props.onChange(e.target.value); } }} className="bg-secondary text-white border-secondary scene-description" /> ); }; export const ScrapedTextAreaRow: React.FC = ( props ) => { return ( } newField={ props.onChange(props.result.cloneWithValue(value)) } /> } onChange={props.onChange} /> ); }; interface IScrapedImageProps { isNew?: boolean; className?: string; placeholder?: string; result: ScrapeResult; } const ScrapedImage: React.FC = (props) => { const value = props.isNew ? props.result.newValue : props.result.originalValue; if (!value) { return <>; } return ( {props.placeholder} ); }; interface IScrapedImageRowProps { title: string; field: string; className?: string; result: ScrapeResult; onChange: (value: ScrapeResult) => void; } export const ScrapedImageRow: React.FC = (props) => { return ( } newField={ } onChange={props.onChange} /> ); }; interface IScrapedImagesRowProps { title: string; field: string; className?: string; result: ScrapeResult; images: string[]; onChange: (value: ScrapeResult) => void; } export const ScrapedImagesRow: React.FC = (props) => { const [imageIndex, setImageIndex] = useState(0); function onSetImageIndex(newIdx: number) { const ret = props.result.cloneWithValue(props.images[newIdx]); props.onChange(ret); setImageIndex(newIdx); } return ( } newField={
    } onChange={props.onChange} /> ); }; interface IScrapedCountryRowProps { title: string; field: string; result: ScrapeResult; onChange: (value: ScrapeResult) => void; locked?: boolean; locale?: string; } export const ScrapedCountryRow: React.FC = ({ title, field, result, onChange, locked, locale, }) => ( } newField={ { if (onChange) { onChange(result.cloneWithValue(value)); } }} showFlag={false} isClearable={false} className="flex-grow-1" /> } onChange={onChange} /> ); export const ScrapedCustomFieldRows: React.FC<{ results: CustomFieldScrapeResults; onChange: (newCustomFields: CustomFieldScrapeResults) => void; }> = ({ results, onChange }) => { return ( <> {Array.from(results.entries()).map(([field, result]) => { const fieldName = `custom_${field}`; return ( { const newResults = new Map(results); newResults.set(field, newResult); onChange(newResults); }} /> ); })} ); }; ================================================ FILE: ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx ================================================ import React, { useMemo } from "react"; import * as GQL from "src/core/generated-graphql"; import { ScrapeDialogRow } from "src/components/Shared/ScrapeDialog/ScrapeDialogRow"; import { PerformerSelect } from "src/components/Performers/PerformerSelect"; import { ObjectScrapeResult, ScrapeResult, } from "src/components/Shared/ScrapeDialog/scrapeResult"; import { TagIDSelect } from "src/components/Tags/TagSelect"; import { StudioSelect } from "src/components/Studios/StudioSelect"; import { GroupSelect } from "src/components/Groups/GroupSelect"; import { uniq } from "lodash-es"; import { CollapseButton } from "../CollapseButton"; import { Badge, Button } from "react-bootstrap"; import { Icon } from "../Icon"; import { faLink, faPlus } from "@fortawesome/free-solid-svg-icons"; import { useIntl } from "react-intl"; interface INewScrapedObjects { newValues: T[]; onCreateNew: (value: T) => void; onLinkExisting?: (value: T) => void; getName: (value: T) => string; } export const NewScrapedObjects = (props: INewScrapedObjects) => { const intl = useIntl(); if (props.newValues.length === 0) { return null; } const ret = ( <> {props.newValues.map((t) => ( props.onCreateNew(t)} > {props.getName(t)} {props.onLinkExisting ? ( ) : null} ))} ); const minCollapseLength = 10; if (props.newValues!.length >= minCollapseLength) { const missingText = intl.formatMessage({ id: "dialogs.scrape_results_missing", }); return ( {ret} ); } return ret; }; interface IScrapedStudioRow { title: string; field: string; result: ObjectScrapeResult; onChange: (value: ObjectScrapeResult) => void; newStudio?: GQL.ScrapedStudio; onCreateNew?: (value: GQL.ScrapedStudio) => void; onLinkExisting?: (value: GQL.ScrapedStudio) => void; } function getObjectName(value: T) { return value.name; } export const ScrapedStudioRow: React.FC = ({ title, field, result, onChange, newStudio, onCreateNew, onLinkExisting, }) => { function renderScrapedStudio( scrapeResult: ObjectScrapeResult, isNew?: boolean, onChangeFn?: (value: GQL.ScrapedStudio) => void ) { const resultValue = isNew ? scrapeResult.newValue : scrapeResult.originalValue; const value = resultValue ? [resultValue] : []; const selectValue = value.map((p) => { const aliases: string[] = p.aliases ? p.aliases.split(",").map((a) => a.trim()) : []; return { id: p.stored_id ?? "", name: p.name ?? "", aliases, }; }); return ( { if (onChangeFn) { const { id, aliases, ...data } = items[0]; onChangeFn({ ...data, stored_id: id, aliases: aliases?.join(", "), }); } }} values={selectValue} /> ); } return ( onChange(result.cloneWithValue(value)) )} onChange={onChange} newValues={ newStudio && onCreateNew ? ( ) : undefined } /> ); }; interface IScrapedObjectsRow { title: string; field: string; result: ScrapeResult; onChange: (value: ScrapeResult) => void; newObjects?: T[]; onCreateNew?: (value: T) => void; onLinkExisting?: (value: T) => void; renderObjects: ( result: ScrapeResult, isNew?: boolean, onChange?: (value: T[]) => void ) => JSX.Element; getName: (value: T) => string; } export const ScrapedObjectsRow = (props: IScrapedObjectsRow) => { const { title, field, result, onChange, newObjects = [], onCreateNew, onLinkExisting, renderObjects, getName, } = props; return ( onChange(result.cloneWithValue(value)) )} onChange={onChange} newValues={ onCreateNew && newObjects.length > 0 ? ( ) : undefined } /> ); }; type IScrapedObjectRowImpl = Omit< IScrapedObjectsRow, "renderObjects" | "getName" >; export const ScrapedPerformersRow: React.FC< IScrapedObjectRowImpl & { ageFromDate?: string | null } > = ({ title, field, result, onChange, newObjects, onCreateNew, ageFromDate, onLinkExisting, }) => { const performersCopy = useMemo(() => { return ( newObjects?.map((p) => { const name: string = p.name ?? ""; return { ...p, name }; }) ?? [] ); }, [newObjects]); function renderScrapedPerformers( scrapeResult: ScrapeResult, isNew?: boolean, onChangeFn?: (value: GQL.ScrapedPerformer[]) => void ) { const resultValue = isNew ? scrapeResult.newValue : scrapeResult.originalValue; const value = resultValue ?? []; const selectValue = value.map((p) => { const alias_list: string[] = []; return { id: p.stored_id ?? "", name: p.name ?? "", alias_list, }; }); return ( { if (onChangeFn) { // map the id back to stored_id onChangeFn(items.map((p) => ({ ...p, stored_id: p.id }))); } }} values={selectValue} ageFromDate={ageFromDate} /> ); } return ( title={title} field={field} result={result} renderObjects={renderScrapedPerformers} onChange={onChange} newObjects={performersCopy} onCreateNew={onCreateNew} getName={(value) => value.name ?? ""} onLinkExisting={onLinkExisting} /> ); }; export const ScrapedGroupsRow: React.FC< IScrapedObjectRowImpl > = ({ title, field, result, onChange, newObjects, onCreateNew, onLinkExisting, }) => { const groupsCopy = useMemo(() => { return ( newObjects?.map((p) => { const name: string = p.name ?? ""; return { ...p, name }; }) ?? [] ); }, [newObjects]); function renderScrapedGroups( scrapeResult: ScrapeResult, isNew?: boolean, onChangeFn?: (value: GQL.ScrapedGroup[]) => void ) { const resultValue = isNew ? scrapeResult.newValue : scrapeResult.originalValue; const value = resultValue ?? []; const selectValue = value.map((p) => { const aliases: string = ""; return { id: p.stored_id ?? "", name: p.name ?? "", aliases, }; }); return ( { if (onChangeFn) { // map the id back to stored_id onChangeFn(items.map((p) => ({ ...p, stored_id: p.id }))); } }} values={selectValue} /> ); } return ( title={title} field={field} result={result} renderObjects={renderScrapedGroups} onChange={onChange} newObjects={groupsCopy} onCreateNew={onCreateNew} getName={(value) => value.name ?? ""} onLinkExisting={onLinkExisting} /> ); }; export const ScrapedTagsRow: React.FC< IScrapedObjectRowImpl > = ({ title, field, result, onChange, newObjects, onCreateNew, onLinkExisting, }) => { function renderScrapedTags( scrapeResult: ScrapeResult, isNew?: boolean, onChangeFn?: (value: GQL.ScrapedTag[]) => void ) { const resultValue = isNew ? scrapeResult.newValue : scrapeResult.originalValue; const value = resultValue ?? []; const selectValue = uniq(value.map((p) => p.stored_id ?? "")); // we need to use TagIDSelect here because we want to use the local name // of the tag instead of the name from the source return ( { if (onChangeFn) { // map the id back to stored_id onChangeFn( items.map((p) => ({ ...p, stored_id: p.id, alias_list: p.aliases, })) ); } }} ids={selectValue} /> ); } return ( title={title} field={field} result={result} renderObjects={renderScrapedTags} onChange={onChange} newObjects={newObjects} onCreateNew={onCreateNew} onLinkExisting={onLinkExisting} getName={getObjectName} /> ); }; ================================================ FILE: ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts ================================================ import { useToast } from "src/hooks/Toast"; import * as GQL from "src/core/generated-graphql"; import { useGroupCreate, usePerformerCreate, useStudioCreate, useTagCreate, } from "src/core/StashService"; import { ObjectScrapeResult, ScrapeResult } from "./scrapeResult"; import { useIntl } from "react-intl"; import { scrapedPerformerToCreateInput } from "src/core/performers"; import { scrapedGroupToCreateInput } from "src/core/groups"; function useCreateObject( entityTypeID: string, createFunc: (o: T) => Promise ) { const Toast = useToast(); const intl = useIntl(); async function createNewObject(o: T) { try { await createFunc(o); Toast.success( intl.formatMessage( { id: "toast.created_entity" }, { entity: intl .formatMessage({ id: entityTypeID }) .toLocaleLowerCase(), } ) ); } catch (e) { Toast.error(e); } } return createNewObject; } interface IUseCreateNewStudioProps { scrapeResult: ObjectScrapeResult; setScrapeResult: ( scrapeResult: ObjectScrapeResult ) => void; setNewObject: (newObject: GQL.ScrapedStudio | undefined) => void; endpoint?: string; } export function useCreateScrapedStudio(props: IUseCreateNewStudioProps) { const [createStudio] = useStudioCreate(); const { scrapeResult, setScrapeResult, setNewObject } = props; async function createNewStudio(toCreate: GQL.ScrapedStudio) { const input: GQL.StudioCreateInput = { name: toCreate.name, urls: toCreate.urls, aliases: toCreate.aliases ?.split(",") .map((a) => a.trim()) .filter((a) => a) || [], details: toCreate.details, image: toCreate.image, tag_ids: (toCreate.tags ?? []) .filter((t) => t.stored_id) .map((t) => t.stored_id!), }; if (props.endpoint && toCreate.remote_site_id) { input.stash_ids = [ { endpoint: props.endpoint, stash_id: toCreate.remote_site_id, }, ]; } const result = await createStudio({ variables: { input, }, }); // set the new studio as the value setScrapeResult( scrapeResult.cloneWithValue({ stored_id: result.data!.studioCreate!.id, name: toCreate.name, }) ); setNewObject(undefined); } return useCreateObject("studio", createNewStudio); } interface IUseCreateNewObjectProps { scrapeResult: ScrapeResult; setScrapeResult: (scrapeResult: ScrapeResult) => void; newObjects: T[]; setNewObjects: (newObject: T[]) => void; endpoint?: string; } export function useCreateScrapedPerformer( props: IUseCreateNewObjectProps ) { const [createPerformer] = usePerformerCreate(); const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props; async function createNewPerformer(toCreate: GQL.ScrapedPerformer) { const input = scrapedPerformerToCreateInput(toCreate, props.endpoint); const result = await createPerformer({ variables: { input }, }); const newValue = [...(scrapeResult.newValue ?? [])]; if (result.data?.performerCreate) newValue.push({ stored_id: result.data.performerCreate.id, name: result.data.performerCreate.name, }); // add the new performer to the new performers value const performerClone = scrapeResult.cloneWithValue(newValue); setScrapeResult(performerClone); // remove the performer from the list const newPerformersClone = newObjects.concat(); const pIndex = newPerformersClone.findIndex( (p) => p.name === toCreate.name ); if (pIndex === -1) throw new Error("Could not find performer to remove"); newPerformersClone.splice(pIndex, 1); setNewObjects(newPerformersClone); } return useCreateObject("performer", createNewPerformer); } export function useCreateScrapedGroup( props: IUseCreateNewObjectProps ) { const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props; const [createGroup] = useGroupCreate(); async function createNewGroup(toCreate: GQL.ScrapedGroup) { const input = scrapedGroupToCreateInput(toCreate); const result = await createGroup({ variables: { input: input }, }); const newValue = [...(scrapeResult.newValue ?? [])]; if (result.data?.groupCreate) newValue.push({ stored_id: result.data.groupCreate.id, name: result.data.groupCreate.name, }); // add the new object to the new object value const resultClone = scrapeResult.cloneWithValue(newValue); setScrapeResult(resultClone); // remove the object from the list const newObjectsClone = newObjects.concat(); const pIndex = newObjectsClone.findIndex((p) => p.name === toCreate.name); if (pIndex === -1) throw new Error("Could not find group to remove"); newObjectsClone.splice(pIndex, 1); setNewObjects(newObjectsClone); } return useCreateObject("group", createNewGroup); } export function useLinkScrapedTag( props: IUseCreateNewObjectProps ) { const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props; function linkTag(id: string, matchedName: string, scrapedName: string) { const newValue = [...(scrapeResult.newValue ?? [])]; newValue.push({ stored_id: id, name: matchedName, }); // add the new tag to the new tags value const tagClone = scrapeResult.cloneWithValue(newValue); setScrapeResult(tagClone); // remove the tag from the list const newTagsClone = newObjects.concat(); const pIndex = newTagsClone.findIndex((p) => p.name === scrapedName); if (pIndex === -1) throw new Error("Could not find tag to remove"); newTagsClone.splice(pIndex, 1); setNewObjects(newTagsClone); } return linkTag; } export function useCreateScrapedTag( props: IUseCreateNewObjectProps ) { const [createTag] = useTagCreate(); const linkTag = useLinkScrapedTag(props); async function createNewTag(toCreate: GQL.ScrapedTag) { const input: GQL.TagCreateInput = { name: toCreate.name ?? "", }; if (props.endpoint && toCreate.remote_site_id) { input.stash_ids = [ { endpoint: props.endpoint, stash_id: toCreate.remote_site_id, }, ]; } const result = await createTag({ variables: { input }, }); if (result.data?.tagCreate) linkTag( result.data.tagCreate.id, result.data.tagCreate.name, toCreate.name ?? "" ); } return useCreateObject("tag", createNewTag); } ================================================ FILE: ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts ================================================ import lodashIsEqual from "lodash-es/isEqual"; import clone from "lodash-es/clone"; import { IHasStoredID } from "src/utils/data"; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ export type CustomFieldScrapeResults = Map>; export class ScrapeResult { public newValue?: T; public originalValue?: T; public scraped: boolean = false; public useNewValue: boolean = false; private isEqual: ( v1: T | undefined | null, v2: T | undefined | null ) => boolean; public constructor( originalValue?: T | null, newValue?: T | null, useNewValue?: boolean, isEqual: ( v1: T | undefined | null, v2: T | undefined | null ) => boolean = lodashIsEqual ) { this.originalValue = originalValue ?? undefined; this.newValue = newValue ?? undefined; this.isEqual = isEqual; // NOTE: this means that zero values are treated as null // this is incorrect for numbers and booleans, but correct for strings const hasNewValue = !!this.newValue; const valuesEqual = isEqual(originalValue, newValue); this.useNewValue = useNewValue ?? (hasNewValue && !valuesEqual); this.scraped = hasNewValue && !valuesEqual; } public setOriginalValue(value?: T) { this.originalValue = value; this.newValue = value; } public cloneWithValue(value?: T) { const ret = clone(this); ret.newValue = value; ret.useNewValue = !this.isEqual(ret.newValue, ret.originalValue); // #2691 - if we're setting the value, assume it should be treated as // scraped ret.scraped = true; return ret; } public getNewValue() { if (this.useNewValue) { return this.newValue; } } } // for types where !!value is a valid value (boolean and number) export class ZeroableScrapeResult extends ScrapeResult { public constructor( originalValue?: T | null, newValue?: T | null, useNewValue?: boolean, isEqual: ( v1: T | undefined | null, v2: T | undefined | null ) => boolean = lodashIsEqual ) { super(originalValue, newValue, useNewValue, isEqual); const hasNewValue = this.newValue !== undefined; const valuesEqual = isEqual(originalValue, newValue); this.useNewValue = useNewValue ?? (hasNewValue && !valuesEqual); this.scraped = hasNewValue && !valuesEqual; } } function storedIDsEqual( o1: T[] | undefined | null, o2: T[] | undefined | null ) { return ( !!o1 && !!o2 && o1.length === o2.length && o1.every((o) => { return o2.find((oo) => o.stored_id === oo.stored_id); }) ); } export class ObjectListScrapeResult< T extends IHasStoredID > extends ScrapeResult { public constructor( originalValue?: T[] | null, newValue?: T[] | null, useNewValue?: boolean ) { super(originalValue, newValue, useNewValue, storedIDsEqual); } } export class ObjectScrapeResult< T extends IHasStoredID > extends ScrapeResult { public constructor( originalValue?: T | null, newValue?: T | null, useNewValue?: boolean ) { super( originalValue, newValue, useNewValue, (o1, o2) => o1?.stored_id === o2?.stored_id ); } } export function hasScrapedValues(values: { scraped: boolean }[]): boolean { return values.some((r) => r.scraped); } ================================================ FILE: ui/v2.5/src/components/Shared/ScrapeDialog/scrapedTags.tsx ================================================ import { useState } from "react"; import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { ObjectListScrapeResult } from "./scrapeResult"; import { sortStoredIdObjects } from "src/utils/data"; import { Tag } from "src/components/Tags/TagSelect"; import { useCreateScrapedTag, useLinkScrapedTag } from "./createObjects"; import { ScrapedTagsRow } from "./ScrapedObjectsRow"; import { CreateLinkTagDialog } from "src/components/Shared/ScrapeDialog/CreateLinkTagDialog"; import { useTagCreate, useTagUpdate } from "src/core/StashService"; import { toastOperation, useToast } from "src/hooks/Toast"; export function useScrapedTags( existingTags: Tag[], scrapedTags?: GQL.Maybe, endpoint?: string ) { const intl = useIntl(); const Toast = useToast(); const [tags, setTags] = useState>( new ObjectListScrapeResult( sortStoredIdObjects( existingTags.map((t) => ({ stored_id: t.id, name: t.name, })) ), sortStoredIdObjects(scrapedTags ?? undefined) ) ); const [newTags, setNewTags] = useState( scrapedTags?.filter((t) => !t.stored_id) ?? [] ); const [linkedTag, setLinkedTag] = useState(null); const createNewTag = useCreateScrapedTag({ scrapeResult: tags, setScrapeResult: setTags, newObjects: newTags, setNewObjects: setNewTags, endpoint, }); const [createTag] = useTagCreate(); const [updateTag] = useTagUpdate(); const linkScrapedTag = useLinkScrapedTag({ scrapeResult: tags, setScrapeResult: setTags, newObjects: newTags, setNewObjects: setNewTags, }); async function handleLinkTagResult(tag: { create?: GQL.TagCreateInput; update?: GQL.TagUpdateInput; }) { if (tag.create) { await toastOperation( Toast, async () => { // create the new tag const result = await createTag({ variables: { input: tag.create! } }); // adjust scrape result if (result.data?.tagCreate) { linkScrapedTag( result.data.tagCreate.id, result.data.tagCreate.name, linkedTag?.name ?? "" ); } }, intl.formatMessage( { id: "toast.created_entity" }, { entity: intl.formatMessage({ id: "tag" }).toLocaleLowerCase(), } ) )(); } else if (tag.update) { // link existing tag await toastOperation( Toast, async () => { const result = await updateTag({ variables: { input: tag.update! } }); // adjust scrape result if (result.data?.tagUpdate) { linkScrapedTag( result.data.tagUpdate.id, result.data.tagUpdate.name, linkedTag?.name ?? "" ); } }, intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl.formatMessage({ id: "tag" }).toLocaleLowerCase(), } ) )(); } setLinkedTag(null); } const linkDialog = linkedTag ? ( ) : null; const scrapedTagsRow = ( setTags(value)} newObjects={newTags} onCreateNew={createNewTag} onLinkExisting={(l) => setLinkedTag(l)} /> ); return { tags, newTags, linkDialog, scrapedTagsRow, }; } ================================================ FILE: ui/v2.5/src/components/Shared/ScraperMenu.tsx ================================================ import React, { useMemo, useState } from "react"; import { Dropdown, Button } from "react-bootstrap"; import { useIntl } from "react-intl"; import { Icon } from "./Icon"; import { stashboxDisplayName } from "src/utils/stashbox"; import { ScraperSourceInput, StashBox } from "src/core/generated-graphql"; import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import { ClearableInput } from "./ClearableInput"; import useFocus from "src/utils/focus"; import ScreenUtils from "src/utils/screen"; export const ScraperMenu: React.FC<{ toggle: React.ReactNode; variant?: string; stashBoxes?: StashBox[]; scrapers: { id: string; name: string }[]; onScraperClicked: (s: ScraperSourceInput) => void; onReloadScrapers: () => void; }> = ({ toggle, variant, stashBoxes, scrapers, onScraperClicked, onReloadScrapers, }) => { const intl = useIntl(); const [filter, setFilter] = useState(""); const focusOnOpen = !ScreenUtils.isTouch(); const focusRef = useFocus(); const [, setFocus] = focusRef; const filteredStashboxes = useMemo(() => { if (!stashBoxes) return []; if (!filter) return stashBoxes; return stashBoxes.filter((s) => s.name.toLowerCase().includes(filter.toLowerCase()) ); }, [stashBoxes, filter]); const filteredScrapers = useMemo(() => { if (!filter) return scrapers; return scrapers.filter( (s) => s.name.toLowerCase().includes(filter.toLowerCase()) || s.id.toLowerCase().includes(filter.toLowerCase()) ); }, [scrapers, filter]); return ( { if (focusOnOpen && v) setTimeout(() => setFocus(true), 0); }} > {toggle}
    {filteredStashboxes.map((s, index) => ( onScraperClicked({ stash_box_endpoint: s.endpoint, }) } > {stashboxDisplayName(s.name, index)} ))} {filteredStashboxes.length > 0 && filteredScrapers.length > 0 && ( )} {filteredScrapers.map((s) => ( onScraperClicked({ scraper_id: s.id })} > {s.name} ))}
    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/Select.tsx ================================================ import React, { useMemo, useState } from "react"; import Select, { OnChangeValue, StylesConfig, OptionProps, components as reactSelectComponents, Options, MenuListProps, GroupBase, OptionsOrGroups, DropdownIndicatorProps, } from "react-select"; import CreatableSelect from "react-select/creatable"; import * as GQL from "src/core/generated-graphql"; import { useMarkerStrings } from "src/core/StashService"; import { SelectComponents } from "react-select/dist/declarations/src/components"; import { useConfigurationContext } from "src/hooks/Config"; import { objectTitle } from "src/core/files"; import { defaultMaxOptionsShown } from "src/core/config"; import { useDebounce } from "src/hooks/debounce"; import { Placement } from "react-bootstrap/esm/Overlay"; import { PerformerIDSelect } from "../Performers/PerformerSelect"; import { Icon } from "./Icon"; import { faTableColumns } from "@fortawesome/free-solid-svg-icons"; import { TagIDSelect } from "../Tags/TagSelect"; import { StudioIDSelect } from "../Studios/StudioSelect"; import { GalleryIDSelect } from "../Galleries/GallerySelect"; import { GroupIDSelect } from "../Groups/GroupSelect"; import { SceneIDSelect } from "../Scenes/SceneSelect"; export type SelectObject = { id: string; name?: string | null; title?: string | null; }; type Option = { value: string; label: string }; interface ITypeProps { type?: | "performers" | "studios" | "tags" | "scene_tags" | "performer_tags" | "scenes" | "groups" | "galleries"; } interface IFilterProps { ids?: string[]; initialIds?: string[]; onSelect?: (item: SelectObject[]) => void; noSelectionString?: string; className?: string; isMulti?: boolean; isClearable?: boolean; isDisabled?: boolean; creatable?: boolean; menuPortalTarget?: HTMLElement | null; } interface ISelectProps { className?: string; items: Option[]; selectedOptions?: OnChangeValue; creatable?: boolean; onCreateOption?: (value: string) => void; isLoading: boolean; isDisabled?: boolean; onChange: (item: OnChangeValue) => void; initialIds?: string[]; isMulti: T; isClearable?: boolean; onInputChange?: (input: string) => void; components?: Partial>>; filterOption?: (option: Option, rawInput: string) => boolean; isValidNewOption?: ( inputValue: string, value: Options
    {props.errors}
    ); }; ================================================ FILE: ui/v2.5/src/components/Shared/SuccessIcon.tsx ================================================ import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; import React from "react"; import { Icon } from "./Icon"; interface ISuccessIconProps { className?: string; } export const SuccessIcon: React.FC = ({ className }) => ( ); ================================================ FILE: ui/v2.5/src/components/Shared/SweatDrops.tsx ================================================ import React from "react"; import { PatchComponent } from "src/patch"; export const SweatDrops: React.FC = PatchComponent("SweatDrops", () => ( )); ================================================ FILE: ui/v2.5/src/components/Shared/TagLink.tsx ================================================ import { Badge, OverlayTrigger, Tooltip } from "react-bootstrap"; import React, { useMemo } from "react"; import { Link } from "react-router-dom"; import cx from "classnames"; import NavUtils, { INamedObject } from "src/utils/navigation"; import TextUtils from "src/utils/text"; import { IFile, IObjectWithTitleFiles, objectTitle } from "src/core/files"; import { galleryTitle } from "src/core/galleries"; import * as GQL from "src/core/generated-graphql"; import { TagPopover } from "../Tags/TagPopover"; import { markerTitle } from "src/core/markers"; import { Placement } from "react-bootstrap/esm/Overlay"; import { faFolderTree } from "@fortawesome/free-solid-svg-icons"; import { Icon } from "../Shared/Icon"; import { FormattedMessage } from "react-intl"; import { PatchComponent } from "src/patch"; type SceneMarkerFragment = Pick & { scene: Pick; primary_tag: Pick; }; interface ISortNameLinkProps { link: string; className?: string; sortName?: string; } const SortNameLinkComponent: React.FC = ({ link, sortName, className, children, }) => { return ( {children} ); }; interface ICommonLinkProps { link: string; className?: string; } const CommonLinkComponent: React.FC = ({ link, className, children, }) => { return ( {children} ); }; interface IPerformerLinkProps { performer: INamedObject & { disambiguation?: string | null }; linkType?: "scene" | "gallery" | "image" | "scene_marker"; className?: string; } export type PerformerLinkType = IPerformerLinkProps["linkType"]; export const PerformerLink: React.FC = ({ performer, linkType = "scene", className, }) => { const link = useMemo(() => { switch (linkType) { case "gallery": return NavUtils.makePerformerGalleriesUrl(performer); case "image": return NavUtils.makePerformerImagesUrl(performer); case "scene_marker": return NavUtils.makePerformerSceneMarkersUrl(performer); case "scene": default: return NavUtils.makePerformerScenesUrl(performer); } }, [performer, linkType]); const title = performer.name || ""; return ( {title} {performer.disambiguation && ( {` (${performer.disambiguation})`} )} ); }; interface IGroupLinkProps { group: INamedObject; description?: string; linkType?: "scene" | "sub_group" | "details"; className?: string; } export const GroupLink: React.FC = ({ group, description, linkType = "scene", className, }) => { const link = useMemo(() => { switch (linkType) { case "scene": return NavUtils.makeGroupScenesUrl(group); case "sub_group": return NavUtils.makeSubGroupsUrl(group); case "details": return NavUtils.makeGroupUrl(group.id ?? ""); } }, [group, linkType]); const title = group.name || ""; return ( {title}{" "} {description && ( ({description}) )} ); }; interface ISceneMarkerLinkProps { marker: SceneMarkerFragment; linkType?: "scene"; className?: string; } export const SceneMarkerLink: React.FC = ({ marker, linkType = "scene", className, }) => { const link = useMemo(() => { switch (linkType) { case "scene": return NavUtils.makeSceneMarkerUrl(marker); } }, [marker, linkType]); const title = `${markerTitle(marker)} - ${TextUtils.secondsToTimestamp( marker.seconds || 0 )}`; return ( {title} ); }; interface IObjectWithIDTitleFiles extends IObjectWithTitleFiles { id: string; } interface ISceneLinkProps { scene: IObjectWithIDTitleFiles; linkType?: "details"; className?: string; } export const SceneLink: React.FC = ({ scene, linkType = "details", className, }) => { const link = useMemo(() => { switch (linkType) { case "details": return `/scenes/${scene.id}`; } }, [scene, linkType]); const title = objectTitle(scene); return ( {title} ); }; interface IGallery extends IObjectWithIDTitleFiles { folder?: GQL.Maybe; } interface IGalleryLinkProps { gallery: IGallery; linkType?: "details"; className?: string; } export const GalleryLink: React.FC = ({ gallery, linkType = "details", className, }) => { const link = useMemo(() => { switch (linkType) { case "details": return `/galleries/${gallery.id}`; } }, [gallery, linkType]); const title = galleryTitle(gallery); return ( {title} ); }; interface ITagLinkProps { tag: INamedObject; linkType?: | "scene" | "gallery" | "image" | "details" | "performer" | "group" | "studio" | "scene_marker"; className?: string; hoverPlacement?: Placement; showHierarchyIcon?: boolean; hierarchyTooltipID?: string; } export const TagLink: React.FC = PatchComponent( "TagLink", ({ tag, linkType = "scene", className, hoverPlacement, showHierarchyIcon = false, hierarchyTooltipID, }) => { const link = useMemo(() => { switch (linkType) { case "scene": return NavUtils.makeTagScenesUrl(tag); case "performer": return NavUtils.makeTagPerformersUrl(tag); case "studio": return NavUtils.makeTagStudiosUrl(tag); case "gallery": return NavUtils.makeTagGalleriesUrl(tag); case "image": return NavUtils.makeTagImagesUrl(tag); case "group": return NavUtils.makeTagGroupsUrl(tag); case "scene_marker": return NavUtils.makeTagSceneMarkersUrl(tag); case "details": return NavUtils.makeTagUrl(tag.id ?? ""); } }, [tag, linkType]); const title = tag.name || ""; const tooltip = useMemo(() => { if (!hierarchyTooltipID) { return <>; } return ( ); }, [hierarchyTooltipID]); return ( {title} {showHierarchyIcon && ( | )} ); } ); ================================================ FILE: ui/v2.5/src/components/Shared/ThreeStateCheckbox.tsx ================================================ import { faCheck, faMinus, faTimes } from "@fortawesome/free-solid-svg-icons"; import React from "react"; import { Button } from "react-bootstrap"; import { Icon } from "./Icon"; interface IThreeStateCheckbox { value: boolean | undefined; setValue: (v: boolean | undefined) => void; allowUndefined?: boolean; label?: React.ReactNode; disabled?: boolean; } export const ThreeStateCheckbox: React.FC = ({ value, setValue, allowUndefined, label, disabled = false, }) => { function cycleState() { const undefAllowed = allowUndefined ?? true; if (undefAllowed && value) { return undefined; } if ((!undefAllowed && value) || value === undefined) { return false; } return true; } const icon = value === undefined ? faMinus : value ? faCheck : faTimes; const labelClassName = value === undefined ? "unset" : value ? "checked" : "not-checked"; return ( {label} ); }; ================================================ FILE: ui/v2.5/src/components/Shared/TruncatedText.tsx ================================================ import React, { useRef, useState } from "react"; import { Overlay, Tooltip } from "react-bootstrap"; import { Placement } from "react-bootstrap/Overlay"; import cx from "classnames"; import { useDebounce } from "src/hooks/debounce"; import { PatchComponent } from "src/patch"; const CLASSNAME = "TruncatedText"; const CLASSNAME_TOOLTIP = `${CLASSNAME}-tooltip`; interface ITruncatedTextProps { text?: JSX.Element | string | null; lineCount?: number; placement?: Placement; delay?: number; className?: string; } export const TruncatedText: React.FC = PatchComponent( "TruncatedText", ({ text, className, lineCount = 1, placement = "bottom", delay = 1000 }) => { const [showTooltip, setShowTooltip] = useState(false); const target = useRef(null); const startShowingTooltip = useDebounce(() => setShowTooltip(true), delay); if (!text) return <>; const handleFocus = (element: HTMLElement) => { // Check if visible size is smaller than the content size if ( element.offsetWidth < element.scrollWidth || element.offsetHeight + 10 < element.scrollHeight ) startShowingTooltip(); }; const handleBlur = () => { startShowingTooltip.cancel(); setShowTooltip(false); }; const overlay = ( {text} ); return (
    handleFocus(e.currentTarget)} onFocus={(e) => handleFocus(e.currentTarget)} onMouseLeave={handleBlur} onBlur={handleBlur} > {text} {overlay}
    ); } ); export const TruncatedInlineText: React.FC = ({ text, className, placement = "bottom", delay = 1000, }) => { const [showTooltip, setShowTooltip] = useState(false); const target = useRef(null); const startShowingTooltip = useDebounce(() => setShowTooltip(true), delay); if (!text) return <>; const handleFocus = (element: HTMLElement) => { // Check if visible size is smaller than the content size if ( element.offsetWidth < element.scrollWidth || element.offsetHeight + 10 < element.scrollHeight ) startShowingTooltip(); }; const handleBlur = () => { startShowingTooltip.cancel(); setShowTooltip(false); }; const overlay = ( {text} ); return ( handleFocus(e.currentTarget)} onFocus={(e) => handleFocus(e.currentTarget)} onMouseLeave={handleBlur} onBlur={handleBlur} > {text} {overlay} ); }; ================================================ FILE: ui/v2.5/src/components/Shared/URLField.tsx ================================================ import React from "react"; import { useIntl } from "react-intl"; import { Button, InputGroup, Form } from "react-bootstrap"; import { Icon } from "./Icon"; import { FormikHandlers } from "formik"; import { faFileDownload } from "@fortawesome/free-solid-svg-icons"; import { IStringListInputProps, StringInput, StringListInput, } from "./StringListInput"; interface IProps { value: string; name: string; onChange: FormikHandlers["handleChange"]; onBlur: FormikHandlers["handleBlur"]; onScrapeClick(): void; urlScrapable(url: string): boolean; isInvalid?: boolean; } export const URLField: React.FC = (props: IProps) => { const intl = useIntl(); return ( ); }; interface IURLListProps extends IStringListInputProps { onScrapeClick?: (url: string) => void; urlScrapable?: (url: string) => boolean; } export const URLListInput: React.FC = ( listProps: IURLListProps ) => { const intl = useIntl(); const { onScrapeClick, urlScrapable } = listProps; return ( { if (!onScrapeClick || !urlScrapable) { return <>; } return ( ); }} /> ); }; ================================================ FILE: ui/v2.5/src/components/Shared/styles.scss ================================================ .LoadingIndicator { // fade in animation - delay showing animation: fadeInAnimation ease 200ms; animation-delay: 200ms; animation-fill-mode: forwards; animation-iteration-count: 1; opacity: 0; } @keyframes fadeInAnimation { 0% { opacity: 0; } 100% { opacity: 1; } } .LoadingIndicator { align-items: center; display: flex; flex-direction: column; justify-content: center; width: 100%; &:not(.card-based) { padding-top: 2rem; } &-message { margin-top: 1rem; } .spinner-border { height: 3rem; width: 3rem; } &.inline { display: inline; height: auto; margin-left: 0.5rem; } &.small .spinner-border { height: 1rem; width: 1rem; } } .details-edit { /* The penultimate button should be wrapped in an unstyled div. This allows the div to expand, to right-justify the last (save / delete) button. */ display: flex; flex-wrap: wrap; justify-content: left; padding: 0; row-gap: 0.5rem; > .btn { margin-right: 0.5rem; white-space: nowrap; } > .btn-group { margin-right: 0.5rem; .btn { margin-right: 0; } // Show caret on split button dropdown toggle .dropdown-toggle-split::after { content: ""; } } } .col-md-8 .details-edit div:nth-last-child(2), .detail-header.edit .details-edit div:nth-last-child(2) { flex: 1; max-width: 100%; } .select-suggest { &:hover { cursor: text; } } .duration-input, .percent-input { .duration-control, .percent-control { min-width: 3rem; } .duration-button, .percent-button { border-bottom-left-radius: 0; border-top-left-radius: 0; line-height: 10px; padding: 1px 7px; } .btn + .btn { margin-left: 0; } } // z-index gets set on button groups for some reason .multi-set .btn-group > button.btn { z-index: auto; } .folder-item { button { padding: 0; } } .folder-list { list-style-type: none; margin: 0; max-height: 30vw; overflow-x: auto; padding-bottom: 0.5rem; padding-top: 1rem; &-item { white-space: nowrap; .btn { border: none; color: white; font-weight: 400; padding: 0; text-align: left; width: 100%; } &:last-child .btn span::before { content: "└ \1F4C1"; } .btn span::before { content: "├ \1F4C1"; display: inline-block; padding-right: 1rem; transform: scale(1.5); } } &-parent { .btn span::before { visibility: hidden; } .btn-link { font-weight: 500; } } } .scrape-dialog { .column-label { color: $muted-gray; font-size: 0.85em; } .string-list-input { width: 100%; } .modal-content .dialog-container { max-height: calc(100vh - 14rem); overflow-y: auto; padding-right: 15px; } .image-selection-parent { min-width: 100%; } .image-selection { .select-buttons { align-items: center; display: flex; justify-content: space-between; margin-top: 1rem; .image-index { flex-grow: 1; text-align: center; } } .loading { opacity: 0.5; } .LoadingIndicator { height: 100%; position: absolute; top: 0; } } } button.collapse-button.btn-primary:not(:disabled):not(.disabled):hover, button.collapse-button.btn-primary:not(:disabled):not(.disabled):focus, button.collapse-button.btn-primary:not(:disabled):not(.disabled):active { background: none; border: none; box-shadow: none; color: #f5f8fa; text-align: left; } button.collapse-button { .fa-icon { margin-left: 0; } padding-left: 0; } .hover-popover-content { max-width: 32rem; text-align: center; .popover-card { padding: 0.5rem; } } .warning-hover-popover { display: inline-flex; margin: 0 0.25rem; .fa-icon { color: $warning; } } .ErrorMessage-container { display: flex; justify-content: center; width: 100%; } .ErrorMessage { .fa-icon { color: $warning; font-size: 1.5em; margin-right: 0.3em; vertical-align: middle; } background-color: initial; border-color: $danger; color: $text-color; margin: 1rem; text-align: left; width: 500px; @include media-breakpoint-down(xs) { width: 100%; } } .grid-card { a .card-section-title { color: $text-color; text-decoration: none; } .progress-bar { background-color: #73859f80; bottom: 5px; display: block; height: 5px; position: absolute; width: 100%; } .progress-indicator { background-color: #137cbd; height: 5px; } .card-controls { align-items: center; display: flex; left: 0.5rem; position: absolute; top: 0.7rem; z-index: 1; } .card-check, .card-drag-handle { height: 1.2rem; opacity: 0; width: 1.2rem; &:checked { opacity: 0.75; } @media (hover: none), (pointer: coarse) { // always show card controls when hovering not supported opacity: 0.25; } } .card-drag-handle { cursor: move; } .card-check { padding-left: 15px; @media (hover: none), (pointer: coarse) { // and make it bigger when hovering not supported width: 1.5rem; } } &:hover .card-check, &:hover .card-drag-handle { opacity: 0.75; transition: opacity 0.5s; } } .search-item-check, .wall-item-check { height: 1.2rem; width: 1.2rem; } // Wall item checkbox styles .wall-item-check { left: 0.5rem; opacity: 0; position: absolute; top: 0.5rem; z-index: 10; &:checked { opacity: 0.75; } @media (hover: none) { opacity: 0.25; } } .wall-item:hover .wall-item-check { opacity: 0.75; transition: opacity 0.5s; } .TruncatedText { -webkit-box-orient: vertical; display: -webkit-box; overflow: hidden; white-space: pre-line; &-tooltip .tooltip-inner { max-width: 300px; white-space: pre-line; } .file-info-panel a > & { word-break: break-all; } &.inline { display: inline; text-overflow: ellipsis; white-space: nowrap; } } .RatingStars { &-unfilled { path { fill: white; } } &-filled { path { fill: gold; } } } .three-state-checkbox { align-items: center; display: flex; button.btn { font-size: 12.67px; margin-left: -0.2em; margin-right: 0.25rem; padding: 0; &:not(:disabled):active, &:not(:disabled):active:focus, &:not(:disabled):hover, &:not(:disabled):not(:hover) { background-color: initial; box-shadow: none; } } &.unset { .label { color: #bfccd6; text-decoration: line-through; } } &.checked svg { color: #0f9960; } &.not-checked svg { color: #db3737; } } .input-group-prepend { .btn { border-bottom-right-radius: 0; border-top-right-radius: 0; } } .input-group-append { .btn { border-bottom-left-radius: 0; border-top-left-radius: 0; } } .ModalComponent .modal-footer { justify-content: space-between; } .scrape-url-button:disabled { opacity: 0.5; } .string-list-input { .form-group { margin-bottom: 0; } .input-group { margin-bottom: 0.35rem; &:last-child { margin-bottom: 0; } } .btn.drag-handle { display: inline-block; margin: -0.25em 0.25em -0.25em -0.25em; padding: 0.25em 0.5em 0.25em; &:not(:disabled):not(.disabled) { cursor: move; } &:hover, &:active, &:focus, &:focus:active { background-color: initial; border-color: initial; box-shadow: initial; } } } .bulk-update-date-input { .react-datepicker-wrapper .btn { border-bottom-right-radius: 0; border-top-right-radius: 0; } } .date-input.form-control:focus { // z-index gets set to 3 in input groups z-index: inherit; } /* stylelint-disable */ div.react-datepicker { background-color: $body-bg; border-color: $card-bg; color: $text-color; .react-datepicker__header, .react-datepicker-time__header { background-color: $secondary; color: $text-color; padding-top: 0.4rem; } .react-datepicker__navigation { top: 0.4rem; } .react-datepicker__day { color: $text-color; &.react-datepicker__day--disabled { color: $text-muted; } &:hover { background: rgba(138, 155, 168, 0.15); } } div.react-datepicker__time-container div.react-datepicker__time { background-color: $body-bg; color: $text-color; ul.react-datepicker__time-list li.react-datepicker__time-list-item:hover { background-color: rgba(138, 155, 168, 0.15); } } .react-datepicker__day-name { color: $text-color; } // replace the current month with the dropdowns .react-datepicker__current-month { display: none; } .react-datepicker__triangle { display: none; } .react-datepicker__month-dropdown-container { margin-left: 0; margin-right: 0.1rem; } .react-datepicker__year-dropdown-container { margin-left: 0.1rem; margin-right: 0; } .react-datepicker__month-dropdown-container .react-datepicker__month-read-view, .react-datepicker__year-dropdown-container .react-datepicker__year-read-view { font-weight: bold; font-size: 0.944rem; // react-datepicker hides these fields when the dropdown is shown visibility: visible !important; } // hide the dropdown arrows .react-datepicker__month-dropdown-container .react-datepicker__month-read-view--down-arrow, .react-datepicker__year-dropdown-container .react-datepicker__year-read-view--down-arrow { display: none; } .react-datepicker__year-dropdown, .react-datepicker__month-dropdown { background-color: $body-bg; .react-datepicker__year-option:hover, .react-datepicker__month-option:hover { background-color: #8a9ba826; } } } /* stylelint-enable */ #date-picker-portal .react-datepicker-popper { z-index: 1600; } .clearable-input-group { align-items: stretch; display: flex; flex-wrap: wrap; position: relative; } .clearable-text-field, .clearable-text-field:active, .clearable-text-field:focus { background-color: $secondary; border: 0; border-color: $secondary; color: #fff; } .clearable-text-field-clear { background-color: $secondary; bottom: 0; color: $muted-gray; font-size: 0.875rem; margin: 0.375rem 0.75rem; padding: 0; position: absolute; right: 0; top: 0; z-index: 4; &:hover, &:focus, &:active, &:not(:disabled):not(.disabled):active, &:not(:disabled):not(.disabled):active:focus { background-color: $secondary; border-color: transparent; box-shadow: none; } } .string-list-row .input-group { flex-wrap: nowrap; } .stash-id-pill { display: inline-flex; font-size: 90%; font-weight: 700; line-height: 1; max-width: 100%; padding-bottom: 0.25em; padding-top: 0.25em; text-align: center; vertical-align: baseline; white-space: nowrap; span, a { display: inline-block; padding: 0.25em 0.6em; } span { background-color: $primary; border-radius: 0.25rem 0 0 0.25rem; flex-shrink: 0; min-width: 5em; } a { background-color: $secondary; border-radius: 0 0.25rem 0.25rem 0; overflow: hidden; text-overflow: ellipsis; } } .react-select-image-option { align-items: baseline; display: flex; } button.btn.favorite-button { opacity: 1; transition: opacity 0.5s; &.not-favorite { color: rgba(191, 204, 214, 0.5); filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9)); &.hide-not-favorite { opacity: 0; } } &.favorite { color: #ff7373; filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9)); } &:hover, &:active, &:focus, &:active:focus { background: none; box-shadow: none; } } .count-button { border-radius: 5px; &:hover { background: rgba(138, 155, 168, 0.15); color: #f5f8fa; } .count-icon { padding-left: 0.5rem; padding-right: 0.25rem; } .count-value { padding-left: 0.25rem; padding-right: 0.5rem; } button.count-icon, &.increment-only button.count-value { &:hover { background: none; color: #f5f8fa; } } button.btn-secondary.count-icon, button.btn-secondary.count-value { &:focus { border: none; box-shadow: none; color: #f5f8fa; &:not(:hover) { background: none; } } } } .external-links-button { display: inline-block; } .scraper-menu .dropdown-menu { min-width: 250px; padding-top: 0; .dropdown-divider { border-top-color: $textfield-bg; margin: 0; } .scraper-filter-container { background-color: $secondary; border-bottom: solid 1px $textfield-bg; display: flex; padding: 5px; position: sticky; top: 0; z-index: 1; .clearable-input-group { flex-grow: 1; } .clearable-text-field { background-color: $textfield-bg; } .clearable-text-field-clear { background-color: unset; border: unset; } .reload-button.btn { border-bottom-right-radius: 0.25rem; border-top-right-radius: 0.25rem; } } } .custom-fields { width: 100%; .detail-item { max-width: 100%; } .detail-item-title, .detail-item-value { font-family: "Courier New", Courier, monospace; } } .custom-fields .detail-item .detail-item-title { max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .custom-fields .detail-item .detail-item-value { word-break: break-word; .TruncatedText { white-space: pre-line; } } .custom-fields-input > .collapse-button { font-weight: 700; } .custom-fields-input { .custom-fields-field { flex: 0 0 100%; max-width: 100%; @include media-breakpoint-up(sm) { flex: 0 0 25%; max-width: 25%; } @include media-breakpoint-up(xl) { flex: 0 0 16.667%; max-width: 16.667%; } } .custom-fields-value { flex: 0 0 100%; max-width: 100%; @include media-breakpoint-up(sm) { flex: 0 0 75%; max-width: 75%; } @include media-breakpoint-up(xl) { flex: 0 0 58.33%; max-width: 58.33%; } } } .custom-fields-row { align-items: center; font-family: "Courier New", Courier, monospace; font-size: 0.875rem; .form-label { margin-bottom: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; white-space: nowrap; } // labels with titles are styled with help cursor and dotted underline elsewhere div.custom-fields-field label.form-label { cursor: inherit; text-decoration: inherit; } .form-control, .btn { font-size: 0.875rem; } &.custom-fields-new > div:not(:last-child) { padding-right: 0; } } .sidebar-pane { display: flex; .sidebar { // TODO - use different colours for sidebar and toolbar background-color: $body-bg; border-right: 1px solid $secondary; flex: $sidebar-width; flex-grow: 0; flex-shrink: 0; padding-left: 15px; transition: margin-left 0.1s; } .sidebar { bottom: 0; left: 0; margin-top: $navbar-height; overflow-y: auto; padding-top: 0.5rem; position: fixed; scrollbar-gutter: stable; top: 0; width: $sidebar-width; z-index: 100; } &.hide-sidebar .sidebar { margin-left: -$sidebar-width; } &.hide-sidebar .sidebar + div { width: 100%; } &:not(.hide-sidebar) .sidebar + div { width: calc(100% - $sidebar-width); } > :nth-child(2) { flex-grow: 1; padding-left: 0.5rem; } &.hide-sidebar { > :nth-child(2) { padding-left: 0; } } @include media-breakpoint-up(md) { transition: margin-left 0.1s; &:not(.hide-sidebar) { > :nth-child(2) { margin-left: calc($sidebar-width - 15px); } } } @include media-breakpoint-down(xs) { .sidebar { margin-top: 0; } } } .sidebar-toggle-button-container { height: 100%; position: absolute; .sidebar-toggle-button { border-bottom: 1px solid $secondary; border-bottom-left-radius: 0; border-bottom-right-radius: 10px; border-right: 1px solid $secondary; border-top: 1px solid $secondary; border-top-left-radius: 0; border-top-right-radius: 10px; margin-left: -15px; opacity: 0.5; position: sticky; top: calc($navbar-height + 0.5rem); z-index: 10; @include media-breakpoint-down(sm) { top: 0.5rem; } } } .sidebar-pane:not(.hide-sidebar) .sidebar-toggle-button-container { .sidebar-toggle-button { margin-left: -0.5rem; } } .sidebar-toolbar { // TODO - use different colours for sidebar and toolbar background-color: $body-bg; display: flex; justify-content: space-between; margin-bottom: 0; padding-bottom: 1rem; position: sticky; top: 0; z-index: 101; } @include media-breakpoint-down(xs) { .sidebar-toolbar { padding-top: 1rem; } } .sidebar-section { border-bottom: 1px solid $secondary; .collapse-header { // background-color: $secondary; padding: 0.25rem; .collapse-button { font-weight: bold; text-align: left; width: 100%; } } .collapse, // include collapsing to allow for the transition .collapsing { padding-top: 0.25rem; } } .sidebar-section:first-child .collapse-header { border-top: 1px solid $secondary; } $sticky-header-height: calc(50px + 3.3rem); // special case for sidebar in details view .detail-body .sidebar-toggle-button-container .sidebar-toggle-button { top: calc($sticky-header-height + 0.5rem); @include media-breakpoint-down(sm) { top: 0.5rem; } } .detail-body { .sidebar-pane { position: sticky; top: calc($sticky-detail-header-height + $navbar-height); } .sidebar { // required for sticky to work align-self: flex-start; // take a further 15px padding to match the detail body margin-top: -15px; max-height: calc(100vh - $sticky-header-height - 15px); overflow-y: auto; padding-left: 0; position: sticky; top: calc($sticky-detail-header-height + $navbar-height); .sidebar-toolbar { padding-top: 15px; } } .sidebar-pane:not(.hide-sidebar) .sidebar { height: calc(100vh - $sticky-header-height - 15px); } .sidebar-pane.hide-sidebar .sidebar { left: -$sidebar-width; margin-left: calc(-15px - $sidebar-width); } // on smaller viewports we want the sidebar to overlap content @include media-breakpoint-down(sm) { .sidebar-pane:not(.hide-sidebar) .sidebar { margin-right: -$sidebar-width; } .sidebar-pane > .sidebar-pane-content { transition: none; } } @include media-breakpoint-down(xs) { .sidebar-pane { top: 0; } .sidebar { // flex: 100% 0 0; height: calc(100vh - $navbar-height); max-height: calc(100vh - $navbar-height); top: 0; } } @include media-breakpoint-up(md) { .sidebar-pane:not(.hide-sidebar) { > :nth-child(2) { margin-left: 0; } } } .sidebar-pane.hide-sidebar { > :nth-child(2) { padding-left: 15px; } } } // Duration slider styles .duration-slider-container { padding: 0.5rem 0 1rem; width: 100%; } .double-range-input-labels { color: $text-color; display: flex; font-size: 0.875rem; font-weight: 500; justify-content: space-between; margin-bottom: 0.5rem; padding: 0 0.25rem; input[type="text"] { &:first-child { text-align: left; } &:last-child { text-align: right; } } } .double-range-sliders { height: 22px; position: relative; } .double-range-slider { pointer-events: none; position: absolute; width: 100%; &::-webkit-slider-thumb { appearance: none; background-color: $primary; border: 2px solid $primary; border-radius: 50%; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); cursor: pointer; height: 18px; pointer-events: all; position: relative; width: 18px; } &::-moz-range-thumb { appearance: none; background-color: $primary; border: 2px solid $primary; border-radius: 50%; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); cursor: pointer; height: 18px; pointer-events: all; position: relative; width: 18px; } &::-ms-thumb { appearance: none; background-color: $primary; border: 2px solid $primary; border-radius: 50%; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); cursor: pointer; height: 18px; pointer-events: all; position: relative; width: 18px; } } .double-range-slider-min { z-index: 1; } input[type="range"].double-range-slider-max { z-index: 2; // combining these into one rule doesn't work for some reason &::-webkit-slider-runnable-track { background: transparent; } &::-moz-range-track { background: transparent; } &::-ms-track { background: transparent; } } // Label offset for buttons that need to align with form fields .ml-label { @include media-breakpoint-up(sm) { // sm: label is 3 of 12 columns = 25%, plus partial gutter margin-left: calc(25% + 7.5px); } @include media-breakpoint-up(xl) { // xl: label is 2 of 12 columns = 16.667%, plus partial gutter margin-left: calc(16.667% + 7.5px); } } // StashBox Search Modal .StashBoxSearchModal { &-list { list-style: none; padding: 0; li { border-radius: 0.25rem; cursor: pointer; margin-bottom: 0.5rem; padding: 0.5rem; transition: background-color 0.2s; &:hover { background-color: rgba(138, 155, 168, 0.1); } &.selected { background-color: #e7f3ff; } } } &-list-container { max-height: 60vh; overflow-y: auto; } } .reveal-in-filesystem-button { margin-left: 0.25rem; padding: 0 0.25rem; } // general styling for appended minimal button to input group .text-input + .input-group-append .btn.minimal { background-color: $textfield-bg; } ================================================ FILE: ui/v2.5/src/components/Stats.tsx ================================================ import React from "react"; import { useStats } from "src/core/StashService"; import { FormattedMessage, FormattedNumber } from "react-intl"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import TextUtils from "src/utils/text"; import { FileSize } from "./Shared/FileSize"; import { useConfigurationContext } from "src/hooks/Config"; export const Stats: React.FC = () => { const { configuration } = useConfigurationContext(); const { sfwContentMode } = configuration.interface; const oCountID = sfwContentMode ? "stats.total_o_count_sfw" : "stats.total_o_count"; const { data, error, loading } = useStats(); if (error) return {error.message}; if (loading || !data) return ; const scenesDuration = TextUtils.secondsAsTimeString( data.stats.scenes_duration, 3 ); const totalPlayDuration = TextUtils.secondsAsTimeString( data.stats.total_play_duration, 3 ); return (

    {scenesDuration || "-"}

    {totalPlayDuration || "-"}

    ); }; export default Stats; ================================================ FILE: ui/v2.5/src/components/Studios/EditStudiosDialog.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import { useBulkStudioUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "../Shared/Modal"; import { useToast } from "src/hooks/Toast"; import { MultiSet } from "../Shared/MultiSet"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { getAggregateInputValue, getAggregateState, getAggregateStateObject, } from "src/utils/bulkUpdate"; import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; import { StudioSelect } from "../Shared/Select"; interface IListOperationProps { selected: GQL.SlimStudioDataFragment[]; onClose: (applied: boolean) => void; } const studioFields = [ "favorite", "rating100", "details", "ignore_auto_tag", "organized", ]; export const EditStudiosDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); const [updateInput, setUpdateInput] = useState({ ids: props.selected.map((studio) => { return studio.id; }), }); const [tagIds, setTagIds] = useState({ mode: GQL.BulkUpdateIdMode.Add, }); const unsetDisabled = props.selected.length < 2; const [updateStudios] = useBulkStudioUpdate(); // Network state const [isUpdating, setIsUpdating] = useState(false); const aggregateState = useMemo(() => { const updateState: Partial = {}; const state = props.selected; let updateTagIds: string[] = []; let first = true; state.forEach((studio: GQL.SlimStudioDataFragment) => { getAggregateStateObject(updateState, studio, studioFields, first); // studio data fragment doesn't have parent_id, so handle separately updateState.parent_id = getAggregateState( updateState.parent_id, studio.parent_studio?.id, first ); const studioTagIDs = (studio.tags ?? []).map((p) => p.id).sort(); updateTagIds = getAggregateState(updateTagIds, studioTagIDs, first) ?? []; first = false; }); return { state: updateState, tagIds: updateTagIds }; }, [props.selected]); // update initial state from aggregate useEffect(() => { setUpdateInput((current) => ({ ...current, ...aggregateState.state })); }, [aggregateState]); function setUpdateField(input: Partial) { setUpdateInput((current) => ({ ...current, ...input })); } function getStudioInput(): GQL.BulkStudioUpdateInput { const studioInput: GQL.BulkStudioUpdateInput = { ...updateInput, tag_ids: tagIds, }; // we don't have unset functionality for the rating star control // so need to determine if we are setting a rating or not studioInput.rating100 = getAggregateInputValue( updateInput.rating100, aggregateState.state.rating100 ); return studioInput; } async function onSave() { setIsUpdating(true); try { await updateStudios({ variables: { input: getStudioInput(), }, }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl.formatMessage({ id: "studios" }).toLocaleLowerCase(), } ) ); props.onClose(true); } catch (e) { Toast.error(e); } setIsUpdating(false); } function render() { return ( props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} isRunning={isUpdating} >
    setUpdateField({ parent_id: items.length > 0 ? items[0]?.id : undefined, }) } ids={updateInput.parent_id ? [updateInput.parent_id] : []} isDisabled={isUpdating} menuPortalTarget={document.body} /> setUpdateField({ rating100: value ?? undefined }) } disabled={isUpdating} /> setUpdateField({ favorite: checked })} checked={updateInput.favorite ?? undefined} label={intl.formatMessage({ id: "favourite" })} /> { setTagIds((c) => ({ ...c, ids: itemIDs })); }} onSetMode={(newMode) => { setTagIds((c) => ({ ...c, mode: newMode })); }} ids={tagIds.ids ?? []} existingIds={aggregateState.tagIds} mode={tagIds.mode} menuPortalTarget={document.body} /> setUpdateField({ details: newValue })} unsetDisabled={unsetDisabled} as="textarea" /> setUpdateField({ ignore_auto_tag: checked }) } checked={updateInput.ignore_auto_tag ?? undefined} /> setUpdateField({ organized: checked })} checked={updateInput.organized ?? undefined} />
    ); } return render(); }; ================================================ FILE: ui/v2.5/src/components/Studios/StudioCard.tsx ================================================ import React from "react"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import NavUtils from "src/utils/navigation"; import { GridCard } from "src/components/Shared/GridCard/GridCard"; import { PatchComponent } from "src/patch"; import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; import { TagLink } from "../Shared/TagLink"; import { Button, ButtonGroup, OverlayTrigger, Tooltip } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import { PopoverCountButton } from "../Shared/PopoverCountButton"; import { RatingBanner } from "../Shared/RatingBanner"; import { FavoriteIcon } from "../Shared/FavoriteIcon"; import { useStudioUpdate } from "src/core/StashService"; import { faTag, faBox } from "@fortawesome/free-solid-svg-icons"; import { OCounterButton } from "../Shared/CountButton"; interface IProps { studio: GQL.StudioDataFragment; cardWidth?: number; hideParent?: boolean; selecting?: boolean; selected?: boolean; zoomIndex?: number; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; } function maybeRenderParent( studio: GQL.StudioDataFragment, hideParent?: boolean ) { if (!hideParent && studio.parent_studio) { return (
    {studio.parent_studio.name} ), }} />
    ); } } function maybeRenderChildren(studio: GQL.StudioDataFragment) { if (studio.child_studios.length > 0) { return (
    {studio.child_studios.length}  ), }} />
    ); } } export const StudioCard: React.FC = PatchComponent( "StudioCard", ({ studio, cardWidth, hideParent, selecting, selected, zoomIndex, onSelectedChanged, }) => { const [updateStudio] = useStudioUpdate(); function onToggleFavorite(v: boolean) { if (studio.id) { updateStudio({ variables: { input: { id: studio.id, favorite: v, }, }, }); } } function maybeRenderScenesPopoverButton() { if (!studio.scene_count) return; return ( ); } function maybeRenderImagesPopoverButton() { if (!studio.image_count) return; return ( ); } function maybeRenderGalleriesPopoverButton() { if (!studio.gallery_count) return; return ( ); } function maybeRenderGroupsPopoverButton() { if (!studio.group_count) return; return ( ); } function maybeRenderPerformersPopoverButton() { if (!studio.performer_count) return; return ( ); } function maybeRenderTagPopoverButton() { if (studio.tags.length <= 0) return; const popoverContent = studio.tags.map((tag) => ( )); return ( ); } function maybeRenderOCounter() { if (!studio.o_counter) return; return ; } function maybeRenderOrganized() { if (studio.organized) { return ( } placement="bottom" >
    ); } } function maybeRenderPopoverButtonGroup() { if ( studio.scene_count || studio.image_count || studio.gallery_count || studio.group_count || studio.performer_count || studio.o_counter || studio.tags.length > 0 || studio.organized ) { return ( <>
    {maybeRenderScenesPopoverButton()} {maybeRenderGroupsPopoverButton()} {maybeRenderImagesPopoverButton()} {maybeRenderGalleriesPopoverButton()} {maybeRenderPerformersPopoverButton()} {maybeRenderTagPopoverButton()} {maybeRenderOCounter()} {maybeRenderOrganized()} ); } } return ( } details={
    {maybeRenderParent(studio, hideParent)} {maybeRenderChildren(studio)}
    } overlays={ onToggleFavorite(v)} size="2x" className="hide-not-favorite" /> } popovers={maybeRenderPopoverButtonGroup()} selected={selected} selecting={selecting} onSelectedChanged={onSelectedChanged} /> ); } ); ================================================ FILE: ui/v2.5/src/components/Studios/StudioCardGrid.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useCardWidth, useContainerDimensions, } from "../Shared/GridCard/GridCard"; import { StudioCard } from "./StudioCard"; import { PatchComponent } from "src/patch"; interface IStudioCardGrid { studios: GQL.StudioDataFragment[]; fromParent: boolean | undefined; selectedIds: Set; zoomIndex: number; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; } const zoomWidths = [280, 340, 420, 560]; export const StudioCardGrid: React.FC = PatchComponent( "StudioCardGrid", ({ studios, fromParent, selectedIds, zoomIndex, onSelectChange }) => { const [componentRef, { width: containerWidth }] = useContainerDimensions(); const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); return (
    {studios.map((studio) => ( 0} selected={selectedIds.has(studio.id)} onSelectedChanged={(selected: boolean, shiftKey: boolean) => onSelectChange(studio.id, selected, shiftKey) } /> ))}
    ); } ); ================================================ FILE: ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx ================================================ import { Tabs, Tab, Form } from "react-bootstrap"; import React, { useEffect, useMemo, useState } from "react"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom"; import { FormattedMessage, useIntl } from "react-intl"; import { Helmet } from "react-helmet"; import cx from "classnames"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { useFindStudio, useStudioUpdate, useStudioDestroy, mutateMetadataAutoTag, } from "src/core/StashService"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { ModalComponent } from "src/components/Shared/Modal"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { useToast } from "src/hooks/Toast"; import { useConfigurationContext } from "src/hooks/Config"; import { StudioScenesPanel } from "./StudioScenesPanel"; import { StudioGalleriesPanel } from "./StudioGalleriesPanel"; import { StudioImagesPanel } from "./StudioImagesPanel"; import { StudioChildrenPanel } from "./StudioChildrenPanel"; import { StudioPerformersPanel } from "./StudioPerformersPanel"; import { StudioEditPanel } from "./StudioEditPanel"; import { CompressedStudioDetailsPanel, StudioDetailsPanel, } from "./StudioDetailsPanel"; import { StudioGroupsPanel } from "./StudioGroupsPanel"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { DetailImage } from "src/components/Shared/DetailImage"; import { useRatingKeybinds } from "src/hooks/keybinds"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { BackgroundImage } from "src/components/Shared/DetailsPage/BackgroundImage"; import { TabTitleCounter, useTabKey, } from "src/components/Shared/DetailsPage/Tabs"; import { DetailTitle } from "src/components/Shared/DetailsPage/DetailTitle"; import { ExpandCollapseButton } from "src/components/Shared/CollapseButton"; import { FavoriteIcon } from "src/components/Shared/FavoriteIcon"; import { ExternalLinkButtons } from "src/components/Shared/ExternalLinksButton"; import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; import { goBackOrReplace } from "src/utils/history"; import { OCounterButton } from "src/components/Shared/CountButton"; import { OrganizedButton } from "src/components/Scenes/SceneDetails/OrganizedButton"; interface IProps { studio: GQL.StudioDataFragment; tabKey?: TabKey; } interface IStudioParams { id: string; tab?: string; } const validTabs = [ "default", "scenes", "galleries", "images", "performers", "groups", "childstudios", ] as const; type TabKey = (typeof validTabs)[number]; function isTabKey(tab: string): tab is TabKey { return validTabs.includes(tab as TabKey); } const StudioTabs: React.FC<{ tabKey?: TabKey; studio: GQL.StudioDataFragment; abbreviateCounter: boolean; showAllCounts?: boolean; }> = ({ tabKey, studio, abbreviateCounter, showAllCounts = false }) => { const [showAllDetails, setShowAllDetails] = useState( showAllCounts && studio.child_studios.length > 0 ); const sceneCount = (showAllDetails ? studio.scene_count_all : studio.scene_count) ?? 0; const galleryCount = (showAllDetails ? studio.gallery_count_all : studio.gallery_count) ?? 0; const imageCount = (showAllDetails ? studio.image_count_all : studio.image_count) ?? 0; const performerCount = (showAllDetails ? studio.performer_count_all : studio.performer_count) ?? 0; const groupCount = (showAllDetails ? studio.group_count_all : studio.group_count) ?? 0; const populatedDefaultTab = useMemo(() => { let ret: TabKey = "scenes"; if (sceneCount == 0) { if (galleryCount != 0) { ret = "galleries"; } else if (imageCount != 0) { ret = "images"; } else if (performerCount != 0) { ret = "performers"; } else if (groupCount != 0) { ret = "groups"; } else if (studio.child_studios.length != 0) { ret = "childstudios"; } } return ret; }, [ sceneCount, galleryCount, imageCount, performerCount, groupCount, studio, ]); const { setTabKey } = useTabKey({ tabKey, validTabs, defaultTabKey: populatedDefaultTab, baseURL: `/studios/${studio.id}`, }); const contentSwitch = useMemo(() => { if (!studio.child_studios.length) { return null; } return (
    setShowAllDetails(!showAllDetails)} type="switch" label={} />
    ); }, [showAllDetails, studio.child_studios.length]); return ( } > {contentSwitch} } > {contentSwitch} } > {contentSwitch} } > {contentSwitch} } > {contentSwitch} } > ); }; const StudioPage: React.FC = ({ studio, tabKey }) => { const history = useHistory(); const Toast = useToast(); const intl = useIntl(); // Configuration settings const { configuration } = useConfigurationContext(); const uiConfig = configuration?.ui; const abbreviateCounter = uiConfig?.abbreviateCounters ?? false; const enableBackgroundImage = uiConfig?.enableStudioBackgroundImage ?? false; const showAllDetails = uiConfig?.showAllDetails ?? true; const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false; const [collapsed, setCollapsed] = useState(!showAllDetails); const loadStickyHeader = useLoadStickyHeader(); // Editing state const [isEditing, setIsEditing] = useState(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); // Editing studio state const [image, setImage] = useState(); const [encodingImage, setEncodingImage] = useState(false); const [updateStudio] = useStudioUpdate(); const [deleteStudio] = useStudioDestroy({ id: studio.id }); const showAllCounts = uiConfig?.showChildStudioContent; const studioImage = useMemo(() => { const existingPath = studio.image_path; if (isEditing) { if (image === null && existingPath) { const studioImageURL = new URL(existingPath); studioImageURL.searchParams.set("default", "true"); return studioImageURL.toString(); } else if (image) { return image; } } return existingPath; }, [isEditing, image, studio.image_path]); function setFavorite(v: boolean) { if (studio.id) { updateStudio({ variables: { input: { id: studio.id, favorite: v, }, }, }); } } const [organizedLoading, setOrganizedLoading] = useState(false); async function onOrganizedClick() { if (!studio.id) return; setOrganizedLoading(true); try { await updateStudio({ variables: { input: { id: studio.id, organized: !studio.organized, }, }, }); } catch (e) { Toast.error(e); } finally { setOrganizedLoading(false); } } // set up hotkeys useEffect(() => { Mousetrap.bind("e", () => toggleEditing()); Mousetrap.bind("d d", () => { setIsDeleteAlertOpen(true); }); Mousetrap.bind(",", () => setCollapsed(!collapsed)); Mousetrap.bind("f", () => setFavorite(!studio.favorite)); return () => { Mousetrap.unbind("e"); Mousetrap.unbind("d d"); Mousetrap.unbind(","); Mousetrap.unbind("f"); }; }); useRatingKeybinds( true, configuration?.ui.ratingSystemOptions?.type, setRating ); async function onSave(input: GQL.StudioCreateInput) { await updateStudio({ variables: { input: { id: studio.id, ...input, }, }, }); toggleEditing(false); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl.formatMessage({ id: "studio" }).toLocaleLowerCase() } ) ); } async function onAutoTag() { if (!studio.id) return; try { await mutateMetadataAutoTag({ studios: [studio.id] }); Toast.success(intl.formatMessage({ id: "toast.started_auto_tagging" })); } catch (e) { Toast.error(e); } } async function onDelete() { try { await deleteStudio(); } catch (e) { Toast.error(e); return; } goBackOrReplace(history, "/studios"); } function renderDeleteAlert() { return ( setIsDeleteAlertOpen(false) }} >

    ); } function toggleEditing(value?: boolean) { if (value !== undefined) { setIsEditing(value); } else { setIsEditing((e) => !e); } setImage(undefined); } function setRating(v: number | null) { if (studio.id) { updateStudio({ variables: { input: { id: studio.id, rating100: v, }, }, }); } } const headerClassName = cx("detail-header", { edit: isEditing, collapsed, "full-width": !collapsed && !compactExpandedDetails, }); return (
    {studio.name ?? intl.formatMessage({ id: "studio" })}
    {studioImage && ( )}
    {!isEditing && ( setCollapsed(v)} /> )} setFavorite(v)} />
    setRating(value)} clickToRate withoutContext /> {!!studio.o_counter && ( )}
    {!isEditing && ( )} {isEditing ? ( toggleEditing()} onDelete={onDelete} setImage={setImage} setEncodingImage={setEncodingImage} /> ) : ( toggleEditing()} onSave={() => {}} onImageChange={() => {}} onClearImage={() => {}} onAutoTag={onAutoTag} autoTagDisabled={studio.ignore_auto_tag} onDelete={onDelete} /> )}
    {!isEditing && loadStickyHeader && ( )}
    {!isEditing && ( )}
    {renderDeleteAlert()}
    ); }; const StudioLoader: React.FC> = ({ location, match, }) => { const { id, tab } = match.params; const { data, loading, error } = useFindStudio(id); useScrollToTopOnMount(); if (loading) return ; if (error) return ; if (!data?.findStudio) return ; if (tab && !isTabKey(tab)) { return ( ); } return ( ); }; export default StudioLoader; ================================================ FILE: ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { ParentStudiosCriterion } from "src/models/list-filter/criteria/studios"; import { ListFilterModel } from "src/models/list-filter/filter"; import { FilteredStudioList } from "../StudioList"; import { View } from "src/components/List/views"; function useFilterHook(studio: GQL.StudioDataFragment) { return (filter: ListFilterModel) => { const studioValue = { id: studio.id!, label: studio.name! }; // if studio is already present, then we modify it, otherwise add let parentStudioCriterion = filter.criteria.find((c) => { return c.criterionOption.type === "parents"; }) as ParentStudiosCriterion | undefined; if ( parentStudioCriterion && (parentStudioCriterion.modifier === GQL.CriterionModifier.IncludesAll || parentStudioCriterion.modifier === GQL.CriterionModifier.Includes) ) { // add the studio if not present if ( !parentStudioCriterion.value.find((p) => { return p.id === studio.id; }) ) { parentStudioCriterion.value.push(studioValue); } parentStudioCriterion.modifier = GQL.CriterionModifier.IncludesAll; } else { // overwrite parentStudioCriterion = new ParentStudiosCriterion(); parentStudioCriterion.value = [studioValue]; filter.criteria.push(parentStudioCriterion); } return filter; }; } interface IStudioChildrenPanel { active: boolean; studio: GQL.StudioDataFragment; } export const StudioChildrenPanel: React.FC = ({ active, studio, }) => { const filterHook = useFilterHook(studio); return ( ); }; ================================================ FILE: ui/v2.5/src/components/Studios/StudioDetails/StudioCreate.tsx ================================================ import React, { useMemo, useState } from "react"; import { useHistory, useLocation } from "react-router-dom"; import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { useStudioCreate } from "src/core/StashService"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useToast } from "src/hooks/Toast"; import { StudioEditPanel } from "./StudioEditPanel"; const StudioCreate: React.FC = () => { const history = useHistory(); const location = useLocation(); const Toast = useToast(); const query = useMemo(() => new URLSearchParams(location.search), [location]); const studio = { name: query.get("q") ?? undefined, }; const intl = useIntl(); // Editing studio state const [image, setImage] = useState(); const [encodingImage, setEncodingImage] = useState(false); const [createStudio] = useStudioCreate(); async function onSave(input: GQL.StudioCreateInput, andNew?: boolean) { const result = await createStudio({ variables: { input }, }); if (result.data?.studioCreate?.id) { if (!andNew) { history.push(`/studios/${result.data.studioCreate.id}`); } Toast.success( intl.formatMessage( { id: "toast.created_entity" }, { entity: intl.formatMessage({ id: "studio" }).toLocaleLowerCase() } ) ); } } function renderImage() { if (image) { return ; } } return (

    {intl.formatMessage( { id: "actions.add_entity" }, { entityType: intl.formatMessage({ id: "studio" }) } )}

    {encodingImage ? ( ) : ( renderImage() )}
    history.push("/studios")} onDelete={() => {}} setImage={setImage} setEncodingImage={setEncodingImage} />
    ); }; export default StudioCreate; ================================================ FILE: ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx ================================================ import React from "react"; import { TagLink } from "src/components/Shared/TagLink"; import * as GQL from "src/core/generated-graphql"; import { DetailItem } from "src/components/Shared/DetailItem"; import { StashIDPill } from "src/components/Shared/StashID"; import { PatchComponent } from "src/patch"; import { CustomFields } from "src/components/Shared/CustomFields"; import { Link } from "react-router-dom"; interface IStudioDetailsPanel { studio: GQL.StudioDataFragment; collapsed?: boolean; fullWidth?: boolean; } export const StudioDetailsPanel: React.FC = PatchComponent( "StudioDetailsPanel", ({ studio, fullWidth }) => { function renderTagsField() { if (!studio.tags.length) { return; } return (
      {(studio.tags ?? []).map((tag) => ( ))}
    ); } function renderStashIDs() { if (!studio.stash_ids?.length) { return; } return (
      {studio.stash_ids.map((stashID) => { return (
    • ); })}
    ); } function renderURLs() { if (!studio.urls?.length) { return; } return (
    ); } return (
    {studio.parent_studio.name} ) : ( "" ) } fullWidth={fullWidth} />
    ); } ); export const CompressedStudioDetailsPanel: React.FC = ({ studio, }) => { function scrollToTop() { window.scrollTo({ top: 0, behavior: "smooth" }); } return (
    scrollToTop()}> {studio.name} {studio?.parent_studio?.name ? ( <> / {studio?.parent_studio?.name} ) : ( "" )}
    ); }; ================================================ FILE: ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx ================================================ import React, { useEffect, useState } from "react"; import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; import Mousetrap from "mousetrap"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { Button, Form } from "react-bootstrap"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; import ImageUtils from "src/utils/image"; import { addUpdateStashID, getStashIDs } from "src/utils/stashIds"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; import isEqual from "lodash-es/isEqual"; import { useToast } from "src/hooks/Toast"; import { useConfigurationContext } from "src/hooks/Config"; import { handleUnsavedChanges } from "src/utils/navigation"; import { formikUtils } from "src/utils/form"; import { yupFormikValidate, yupRequiredStringArray } from "src/utils/yup"; import { Studio, StudioSelect } from "../StudioSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { Icon } from "src/components/Shared/Icon"; import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; import { CustomFieldsInput, formatCustomFieldInput, } from "src/components/Shared/CustomFields"; import { cloneDeep } from "@apollo/client/utilities"; interface IStudioEditPanel { studio: Partial; onSubmit: (studio: GQL.StudioCreateInput, andNew?: boolean) => Promise; onCancel: () => void; onDelete: () => void; setImage: (image?: string | null) => void; setEncodingImage: (loading: boolean) => void; } export const StudioEditPanel: React.FC = ({ studio, onSubmit, onCancel, onDelete, setImage, setEncodingImage, }) => { const intl = useIntl(); const Toast = useToast(); const { configuration: stashConfig } = useConfigurationContext(); const isNew = studio.id === undefined; // Editing state const [isStashIDSearchOpen, setIsStashIDSearchOpen] = useState(false); // Network state const [isLoading, setIsLoading] = useState(false); const [parentStudio, setParentStudio] = useState(null); const schema = yup.object({ name: yup.string().required(), urls: yup.array(yup.string().required()).defined(), details: yup.string().ensure(), parent_id: yup.string().required().nullable(), aliases: yupRequiredStringArray(intl).defined(), tag_ids: yup.array(yup.string().required()).defined(), ignore_auto_tag: yup.boolean().defined(), stash_ids: yup.mixed().defined(), image: yup.string().nullable().optional(), custom_fields: yup.object().required().defined(), }); const initialValues = { id: studio.id, name: studio.name ?? "", urls: studio.urls ?? [], details: studio.details ?? "", parent_id: studio.parent_studio?.id ?? null, aliases: studio.aliases ?? [], tag_ids: (studio.tags ?? []).map((t) => t.id), ignore_auto_tag: studio.ignore_auto_tag ?? false, stash_ids: getStashIDs(studio.stash_ids), custom_fields: cloneDeep(studio.custom_fields ?? {}), }; type InputValues = yup.InferType; const [customFieldsError, setCustomFieldsError] = useState(); function submit(values: InputValues) { const input = { ...schema.cast(values), custom_fields: formatCustomFieldInput(isNew, values.custom_fields), }; onSave(input); } const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), onSubmit: submit, }); const { tagsControl } = useTagsEdit(studio.tags, (ids) => formik.setFieldValue("tag_ids", ids) ); function onSetParentStudio(item: Studio | null) { setParentStudio(item); formik.setFieldValue("parent_id", item ? item.id : null); } const encodingImage = ImageUtils.usePasteImage((imageData) => formik.setFieldValue("image", imageData) ); useEffect(() => { setParentStudio( studio.parent_studio ? { id: studio.parent_studio.id, name: studio.parent_studio.name, aliases: [], } : null ); }, [studio.parent_studio]); useEffect(() => { setImage(formik.values.image); }, [formik.values.image, setImage]); useEffect(() => { setEncodingImage(encodingImage); }, [setEncodingImage, encodingImage]); // set up hotkeys useEffect(() => { Mousetrap.bind("s s", () => { if (formik.dirty) { formik.submitForm(); } }); return () => { Mousetrap.unbind("s s"); }; }); async function onSave(input: InputValues, andNew?: boolean) { setIsLoading(true); try { await onSubmit(input, andNew); formik.resetForm(); } catch (e) { Toast.error(e); } setIsLoading(false); } async function onSaveAndNewClick() { const input = { ...schema.cast(formik.values), custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), }; onSave(input, true); } function onImageLoad(imageData: string | null) { formik.setFieldValue("image", imageData); } function onImageChange(event: React.FormEvent) { ImageUtils.onImageChange(event, onImageLoad); } function onStashIDSelected(item?: GQL.StashIdInput) { if (!item) return; formik.setFieldValue( "stash_ids", addUpdateStashID(formik.values.stash_ids, item) ); } const { renderField, renderInputField, renderStringListField, renderStashIDsField, } = formikUtils(intl, formik); function renderParentStudioField() { const title = intl.formatMessage({ id: "parent_studio" }); const control = ( onSetParentStudio(items.length > 0 ? items[0] : null) } values={parentStudio ? [parentStudio] : []} /> ); return renderField("parent_id", title, control); } function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); return renderField("tag_ids", title, tagsControl()); } if (isLoading) return ; return ( <> {isStashIDSearchOpen && ( s.endpoint )} onSelectItem={(item) => { onStashIDSelected(item); setIsStashIDSearchOpen(false); }} initialQuery={studio.name ?? ""} /> )} { // Check if it's a redirect after studio creation if (action === "PUSH" && location.pathname.startsWith("/studios/")) return true; return handleUnsavedChanges(intl, "studios", studio.id)(location); }} />
    {renderInputField("name")} {renderStringListField("aliases")} {renderStringListField("urls")} {renderInputField("details", "textarea")} {renderParentStudioField()} {renderTagsField()} {renderStashIDsField( "stash_ids", "studios", "stash_ids", undefined, )} formik.setFieldValue("custom_fields", v)} error={customFieldsError} setError={(e) => setCustomFieldsError(e)} />
    {renderInputField("ignore_auto_tag", "checkbox")} onImageLoad(null)} onDelete={onDelete} acceptSVG /> ); }; ================================================ FILE: ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { FilteredGalleryList } from "src/components/Galleries/GalleryList"; import { useStudioFilterHook } from "src/core/studios"; import { View } from "src/components/List/views"; interface IStudioGalleriesPanel { active: boolean; studio: GQL.StudioDataFragment; showChildStudioContent?: boolean; } export const StudioGalleriesPanel: React.FC = ({ active, studio, showChildStudioContent, }) => { const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( ); }; ================================================ FILE: ui/v2.5/src/components/Studios/StudioDetails/StudioGroupsPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { FilteredGroupList } from "src/components/Groups/GroupList"; import { useStudioFilterHook } from "src/core/studios"; import { View } from "src/components/List/views"; interface IStudioGroupsPanel { active: boolean; studio: GQL.StudioDataFragment; showChildStudioContent?: boolean; } export const StudioGroupsPanel: React.FC = ({ active, studio, showChildStudioContent, }) => { const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( ); }; ================================================ FILE: ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useStudioFilterHook } from "src/core/studios"; import { FilteredImageList } from "src/components/Images/ImageList"; import { View } from "src/components/List/views"; interface IStudioImagesPanel { active: boolean; studio: GQL.StudioDataFragment; showChildStudioContent?: boolean; } export const StudioImagesPanel: React.FC = ({ active, studio, showChildStudioContent, }) => { const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( ); }; ================================================ FILE: ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useStudioFilterHook } from "src/core/studios"; import { FilteredPerformerList } from "src/components/Performers/PerformerList"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; import { View } from "src/components/List/views"; interface IStudioPerformersPanel { active: boolean; studio: GQL.StudioDataFragment; showChildStudioContent?: boolean; } export const StudioPerformersPanel: React.FC = ({ active, studio, showChildStudioContent, }) => { const studioCriterion = new StudiosCriterion(); studioCriterion.value = { items: [{ id: studio.id!, label: studio.name || `Studio ${studio.id}` }], excluded: [], depth: 0, }; const extraCriteria = { scenes: [studioCriterion], images: [studioCriterion], galleries: [studioCriterion], groups: [studioCriterion], }; const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( ); }; ================================================ FILE: ui/v2.5/src/components/Studios/StudioDetails/StudioScenesPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { FilteredSceneList } from "src/components/Scenes/SceneList"; import { useStudioFilterHook } from "src/core/studios"; import { View } from "src/components/List/views"; interface IStudioScenesPanel { active: boolean; studio: GQL.StudioDataFragment; showChildStudioContent?: boolean; } export const StudioScenesPanel: React.FC = ({ active, studio, showChildStudioContent, }) => { const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( ); }; ================================================ FILE: ui/v2.5/src/components/Studios/StudioList.tsx ================================================ import React, { useCallback, useEffect } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import cloneDeep from "lodash-es/cloneDeep"; import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { queryFindStudios, useFindStudios, useStudiosDestroy, } from "src/core/StashService"; import { useFilteredItemList } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { ExportDialog } from "../Shared/ExportDialog"; import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; import { StudioTagger } from "../Tagger/studios/StudioTagger"; import { StudioCardGrid } from "./StudioCardGrid"; import { View } from "../List/views"; import { EditStudiosDialog } from "./EditStudiosDialog"; import { FilteredListToolbar, IItemListOperation, } from "../List/FilteredListToolbar"; import { PatchComponent, PatchContainerComponent } from "src/patch"; import { useCloseEditDelete, useFilterOperations } from "../List/util"; import { ListOperations } from "../List/ListOperationButtons"; import { Sidebar, SidebarPane, SidebarPaneContent, SidebarStateContext, useSidebarState, } from "../Shared/Sidebar"; import useFocus from "src/utils/focus"; import { FilteredSidebarHeader, useFilteredSidebarKeybinds, } from "../List/Filters/FilterSidebar"; import { FilterTags } from "../List/FilterTags"; import { Pagination, PaginationIndex } from "../List/Pagination"; import { LoadedContent } from "../List/PagedList"; import { SidebarTagsFilter } from "../List/Filters/TagsFilter"; import { SidebarRatingFilter } from "../List/Filters/RatingFilter"; import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; import { FavoriteStudioCriterionOption } from "src/models/list-filter/criteria/favorite"; import { Button } from "react-bootstrap"; import cx from "classnames"; const StudioList: React.FC<{ studios: GQL.StudioDataFragment[]; filter: ListFilterModel; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; fromParent?: boolean; }> = PatchComponent( "StudioList", ({ studios, filter, selectedIds, onSelectChange, fromParent }) => { if (studios.length === 0 && filter.displayMode !== DisplayMode.Tagger) { return null; } if (filter.displayMode === DisplayMode.Grid) { return ( ); } if (filter.displayMode === DisplayMode.List) { return

    TODO

    ; } if (filter.displayMode === DisplayMode.Wall) { return

    TODO

    ; } if (filter.displayMode === DisplayMode.Tagger) { return ; } return null; } ); const StudioFilterSidebarSections = PatchContainerComponent( "FilteredStudioList.SidebarSections" ); const SidebarContent: React.FC<{ filter: ListFilterModel; setFilter: (filter: ListFilterModel) => void; filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; sidebarOpen: boolean; onClose?: () => void; showEditFilter: (editingCriterion?: string) => void; count?: number; focus?: ReturnType; }> = ({ filter, setFilter, filterHook, view, showEditFilter, sidebarOpen, onClose, count, focus, }) => { const showResultsId = count !== undefined ? "actions.show_count_results" : "actions.show_results"; return ( <> } filter={filter} setFilter={setFilter} option={FavoriteStudioCriterionOption} sectionID="favourite" />
    ); }; interface IStudioList { fromParent?: boolean; filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; alterQuery?: boolean; extraOperations?: IItemListOperation[]; } function useViewRandom(filter: ListFilterModel, count: number) { const history = useHistory(); const viewRandom = useCallback(async () => { // query for a random studio if (count === 0) { return; } const index = Math.floor(Math.random() * count); const filterCopy = cloneDeep(filter); filterCopy.itemsPerPage = 1; filterCopy.currentPage = index + 1; const singleResult = await queryFindStudios(filterCopy); if (singleResult.data.findStudios.studios.length === 1) { const { id } = singleResult.data.findStudios.studios[0]; // navigate to the studio page history.push(`/studios/${id}`); } }, [history, filter, count]); return viewRandom; } function useAddKeybinds(filter: ListFilterModel, count: number) { const viewRandom = useViewRandom(filter, count); useEffect(() => { Mousetrap.bind("p r", () => { viewRandom(); }); return () => { Mousetrap.unbind("p r"); }; }, [viewRandom]); } export const FilteredStudioList = PatchComponent( "FilteredStudioList", (props: IStudioList) => { const intl = useIntl(); const searchFocus = useFocus(); const { filterHook, view, alterQuery, extraOperations = [] } = props; // States const { showSidebar, setShowSidebar, sectionOpen, setSectionOpen, loading: sidebarStateLoading, } = useSidebarState(view); const { filterState, queryResult, modalState, listSelect, showEditFilter } = useFilteredItemList({ filterStateProps: { filterMode: GQL.FilterMode.Studios, view, useURL: alterQuery, }, queryResultProps: { useResult: useFindStudios, getCount: (r) => r.data?.findStudios.count ?? 0, getItems: (r) => r.data?.findStudios.studios ?? [], filterHook, }, }); const { filter, setFilter } = filterState; const { effectiveFilter, result, cachedResult, items, totalCount } = queryResult; const { selectedIds, selectedItems, onSelectChange, onSelectAll, onSelectNone, onInvertSelection, hasSelection, } = listSelect; const { modal, showModal, closeModal } = modalState; // Utility hooks const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ filter, setFilter, }); useAddKeybinds(effectiveFilter, totalCount); useFilteredSidebarKeybinds({ showSidebar, setShowSidebar, }); useEffect(() => { Mousetrap.bind("e", () => { if (hasSelection) { onEdit?.(); } }); Mousetrap.bind("d d", () => { if (hasSelection) { onDelete?.(); } }); return () => { Mousetrap.unbind("e"); Mousetrap.unbind("d d"); }; }); const onCloseEditDelete = useCloseEditDelete({ closeModal, onSelectNone, result, }); const viewRandom = useViewRandom(effectiveFilter, totalCount); function onExport(all: boolean) { showModal( closeModal()} /> ); } function onEdit() { showModal( ); } function onDelete() { showModal( ); } const convertedExtraOperations = extraOperations.map((op) => ({ text: op.text, onClick: () => op.onClick(result, filter, selectedIds), isDisplayed: () => op.isDisplayed?.(result, filter, selectedIds) ?? true, })); const otherOperations = [ ...convertedExtraOperations, { text: intl.formatMessage({ id: "actions.select_all" }), onClick: () => onSelectAll(), isDisplayed: () => totalCount > 0, }, { text: intl.formatMessage({ id: "actions.select_none" }), onClick: () => onSelectNone(), isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.invert_selection" }), onClick: () => onInvertSelection(), isDisplayed: () => totalCount > 0, }, { text: intl.formatMessage({ id: "actions.view_random" }), onClick: viewRandom, }, { text: intl.formatMessage({ id: "actions.export" }), onClick: () => onExport(false), isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export_all" }), onClick: () => onExport(true), }, ]; // render if (sidebarStateLoading) return null; const operations = ( ); return (
    {modal} setShowSidebar(false)}> setShowSidebar(false)} count={cachedResult.loading ? undefined : totalCount} focus={searchFocus} /> setShowSidebar(!showSidebar)} > showEditFilter(c.criterionOption.type)} onRemoveCriterion={removeCriterion} onRemoveAll={clearAllCriteria} />
    setFilter(filter.changePage(page))} />
    {totalCount > filter.itemsPerPage && (
    )}
    ); } ); ================================================ FILE: ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx ================================================ import React from "react"; import { useFindStudios } from "src/core/StashService"; import { StudioCard } from "./StudioCard"; import { ListFilterModel } from "src/models/list-filter/filter"; import { PatchComponent } from "src/patch"; import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow"; interface IProps { isTouch: boolean; filter: ListFilterModel; header: string; } export const StudioRecommendationRow: React.FC = PatchComponent( "StudioRecommendationRow", (props) => { const result = useFindStudios(props.filter); const count = result.data?.findStudios.count ?? 0; return ( {result.loading ? [...Array(props.filter.itemsPerPage)].map((i) => (
    )) : result.data?.findStudios.studios.map((s) => ( ))}
    ); } ); ================================================ FILE: ui/v2.5/src/components/Studios/StudioSelect.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { OptionProps, components as reactSelectComponents, MultiValueGenericProps, SingleValueProps, } from "react-select"; import cx from "classnames"; import * as GQL from "src/core/generated-graphql"; import { useStudioCreate, queryFindStudiosByIDForSelect, queryFindStudiosForSelect, } from "src/core/StashService"; import { useConfigurationContext } from "src/hooks/Config"; import { useIntl } from "react-intl"; import { defaultMaxOptionsShown, IUIConfig } from "src/core/config"; import { ListFilterModel } from "src/models/list-filter/filter"; import { FilterSelectComponent, IFilterIDProps, IFilterProps, IFilterValueProps, Option as SelectOption, toOption, } from "../Shared/FilterSelect"; import { useCompare } from "src/hooks/state"; import { Placement } from "react-bootstrap/esm/Overlay"; import { sortByRelevance } from "src/utils/query"; import { PatchComponent, PatchFunction } from "src/patch"; import { isUUID } from "src/utils/stashIds"; import { filterByStashID } from "src/models/list-filter/utils"; export type SelectObject = { id: string; name?: string | null; title?: string | null; }; export type Studio = Pick; type Option = SelectOption; type FindStudiosResult = Awaited< ReturnType >["data"]["findStudios"]["studios"]; function sortStudiosByRelevance(input: string, studios: FindStudiosResult) { return sortByRelevance( input, studios, (s) => s.name, (s) => s.aliases ); } const studioSelectSort = PatchFunction( "StudioSelect.sort", sortStudiosByRelevance ); const _StudioSelect: React.FC< IFilterProps & IFilterValueProps & { hoverPlacement?: Placement; excludeIds?: string[]; } > = (props) => { const [createStudio] = useStudioCreate(); const { configuration } = useConfigurationContext(); const intl = useIntl(); const maxOptionsShown = (configuration?.ui as IUIConfig).maxOptionsShown ?? defaultMaxOptionsShown; const defaultCreatable = !configuration?.interface.disableDropdownCreate.studio; const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); function filterExcluded(studio: Studio) { // HACK - we should probably exclude these in the backend query, but // this will do in the short-term return !exclude.includes(studio.id.toString()); } async function loadStudios(input: string): Promise { const filter = new ListFilterModel(GQL.FilterMode.Studios); filter.currentPage = 1; filter.itemsPerPage = maxOptionsShown; filter.sortBy = "name"; filter.sortDirection = GQL.SortDirectionEnum.Asc; if (isUUID(input)) { filterByStashID(filter, input); const query = await queryFindStudiosForSelect(filter); const matches = query.data.findStudios.studios.filter(filterExcluded); if (matches.length > 0) { // Matches found, return them immediately. return matches.map(toOption); } // If no stash_id matches found, continue with standard name/alias search. filter.criteria = []; // Clear stash_id criterion to search by name/alias below. } filter.searchTerm = input; const query = await queryFindStudiosForSelect(filter); const ret = query.data.findStudios.studios.filter(filterExcluded); return studioSelectSort(input, ret).map(toOption); } const StudioOption: React.FC> = ( optionProps ) => { let thisOptionProps = optionProps; const { object } = optionProps.data; let { name } = object; // if name does not match the input value but an alias does, show the alias const { inputValue } = optionProps.selectProps; let alias: string | undefined = ""; if (!name.toLowerCase().includes(inputValue.toLowerCase())) { alias = object.aliases?.find((a) => a.toLowerCase().includes(inputValue.toLowerCase()) ); } thisOptionProps = { ...optionProps, children: ( {name} {alias &&  ({alias})} ), }; return ; }; const StudioMultiValueLabel: React.FC< MultiValueGenericProps > = (optionProps) => { let thisOptionProps = optionProps; const { object } = optionProps.data; thisOptionProps = { ...optionProps, children: object.name, }; return ; }; const StudioValueLabel: React.FC> = ( optionProps ) => { let thisOptionProps = optionProps; const { object } = optionProps.data; thisOptionProps = { ...optionProps, children: <>{object.name}, }; return ; }; const onCreate = async (name: string) => { const result = await createStudio({ variables: { input: { name } }, }); return { value: result.data!.studioCreate!.id, item: result.data!.studioCreate!, message: "Created studio", }; }; const getNamedObject = (id: string, name: string) => { return { id, name, aliases: [], }; }; const isValidNewOption = (inputValue: string, options: Studio[]) => { if (!inputValue) { return false; } if ( options.some((o) => { return ( o.name.toLowerCase() === inputValue.toLowerCase() || o.aliases?.some((a) => a.toLowerCase() === inputValue.toLowerCase()) ); }) ) { return false; } return true; }; return ( {...props} className={cx( "studio-select", { "studio-select-active": props.active, }, props.className )} loadOptions={loadStudios} getNamedObject={getNamedObject} isValidNewOption={isValidNewOption} components={{ Option: StudioOption, MultiValueLabel: StudioMultiValueLabel, SingleValue: StudioValueLabel, }} isMulti={props.isMulti ?? false} creatable={props.creatable ?? defaultCreatable} onCreate={onCreate} placeholder={ props.noSelectionString ?? intl.formatMessage( { id: "actions.select_entity" }, { entityType: intl.formatMessage({ id: props.isMulti ? "studios" : "studio", }), } ) } closeMenuOnSelect={!props.isMulti} /> ); }; export const StudioSelect = PatchComponent("StudioSelect", _StudioSelect); const _StudioIDSelect: React.FC> = ( props ) => { const { ids, onSelect: onSelectValues } = props; const [values, setValues] = useState([]); const idsChanged = useCompare(ids); function onSelect(items: Studio[]) { setValues(items); onSelectValues?.(items); } async function loadObjectsByID(idsToLoad: string[]): Promise { const query = await queryFindStudiosByIDForSelect(idsToLoad); const { studios: loadedStudios } = query.data.findStudios; return loadedStudios; } useEffect(() => { if (!idsChanged) { return; } if (!ids || ids?.length === 0) { setValues([]); return; } // load the values if we have ids and they haven't been loaded yet const filteredValues = values.filter((v) => ids.includes(v.id.toString())); if (filteredValues.length === ids.length) { return; } const load = async () => { const items = await loadObjectsByID(ids); setValues(items); }; load(); }, [ids, idsChanged, values]); return ; }; export const StudioIDSelect = PatchComponent("StudioIDSelect", _StudioIDSelect); ================================================ FILE: ui/v2.5/src/components/Studios/Studios.tsx ================================================ import React from "react"; import { Route, Switch } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import Studio from "./StudioDetails/Studio"; import StudioCreate from "./StudioDetails/StudioCreate"; import { FilteredStudioList } from "./StudioList"; import { View } from "../List/views"; const Studios: React.FC = () => { return ; }; const StudioRoutes: React.FC = () => { const titleProps = useTitleProps({ id: "studios" }); return ( <> ); }; export default StudioRoutes; ================================================ FILE: ui/v2.5/src/components/Studios/styles.scss ================================================ .studio-details { .logo { margin-bottom: 4rem; max-height: 50vh; max-width: 100%; } } .studio-card { button.btn.favorite-button { padding: 0; position: absolute; right: 5px; top: 10px; svg.fa-icon { margin-left: 0.4rem; margin-right: 0.4rem; } } &:hover button.btn.favorite-button.not-favorite { opacity: 1; } } #studio-page { .studio-head { .name-icons { .not-favorite { color: rgba(191, 204, 214, 0.5); } .favorite { color: #ff7373; } } } .rating-number .text-input { width: auto; } .quality-group { display: inline-flex; margin-top: 0.25rem; } // The following min-width declarations prevent // the O-Count from moving around // when hovering over rating stars .rating-stars-precision-full .star-rating-number { min-width: 0.75rem; } .rating-stars-precision-half .star-rating-number, .rating-stars-precision-tenth .star-rating-number { min-width: 1.45rem; } .rating-stars-precision-quarter .star-rating-number { min-width: 2rem; } // the detail element ids are the same as field type name // which don't follow the correct convention /* stylelint-disable selector-class-pattern */ .collapsed { .detail-item.stash_ids { display: none; } } .detail-item.urls ul { list-style-type: none; } /* stylelint-enable selector-class-pattern */ } ================================================ FILE: ui/v2.5/src/components/Tagger/FieldSelector.tsx ================================================ import { faCheck, faList, faTimes } from "@fortawesome/free-solid-svg-icons"; import React, { useState } from "react"; import { Button, Row, Col } from "react-bootstrap"; import { useIntl } from "react-intl"; import { ModalComponent } from "../Shared/Modal"; import { Icon } from "../Shared/Icon"; interface IProps { show: boolean; fields: string[]; excludedFields: string[]; onSelect: (fields: string[]) => void; } const FieldSelector: React.FC = ({ show, fields, excludedFields, onSelect, }) => { const intl = useIntl(); const [excluded, setExcluded] = useState>( excludedFields .filter((field) => fields.includes(field)) .reduce((dict, field) => ({ ...dict, [field]: true }), {}) ); const toggleField = (field: string) => setExcluded({ ...excluded, [field]: !excluded[field], }); const renderField = (field: string) => ( {intl.formatMessage({ id: field })} ); return ( onSelect(Object.keys(excluded).filter((f) => excluded[f])), }} >

    Select tagged fields

    These fields will be tagged by default. Click the button to toggle.
    {fields.map((f) => renderField(f))}
    ); }; export default FieldSelector; ================================================ FILE: ui/v2.5/src/components/Tagger/IncludeButton.tsx ================================================ import { faCheck, faTimes } from "@fortawesome/free-solid-svg-icons"; import React from "react"; import { Button } from "react-bootstrap"; import { Icon } from "../Shared/Icon"; interface IIncludeExcludeButton { exclude: boolean; disabled?: boolean; setExclude: (v: boolean) => void; } export const IncludeExcludeButton: React.FC = ({ exclude, disabled, setExclude, }) => ( ); interface IOptionalField { exclude: boolean; title?: string; disabled?: boolean; setExclude: (v: boolean) => void; } export const OptionalField: React.FC = ({ exclude, setExclude, children, title, }) => { return (
    {title && {title}}
    {children}
    ); }; ================================================ FILE: ui/v2.5/src/components/Tagger/LinkButton.tsx ================================================ import React from "react"; import { useIntl } from "react-intl"; import { faLink } from "@fortawesome/free-solid-svg-icons"; import { OperationButton } from "../Shared/OperationButton"; import { Icon } from "../Shared/Icon"; export const LinkButton: React.FC<{ disabled: boolean; onLink: () => Promise; }> = ({ disabled, onLink }) => { const intl = useIntl(); return ( ); }; ================================================ FILE: ui/v2.5/src/components/Tagger/PerformerModal.tsx ================================================ import React, { useState } from "react"; import { Button } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import cx from "classnames"; import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { Icon } from "../Shared/Icon"; import { ModalComponent } from "../Shared/Modal"; import { TruncatedText } from "../Shared/TruncatedText"; import * as GQL from "src/core/generated-graphql"; import { stringToGender } from "src/utils/gender"; import { getCountryByISO } from "src/utils/country"; import { faArrowLeft, faArrowRight, faCheck, faTimes, } from "@fortawesome/free-solid-svg-icons"; import { ExternalLink } from "../Shared/ExternalLink"; import { StashIDPill } from "../Shared/StashID"; interface IPerformerModalProps { performer: GQL.ScrapedScenePerformerDataFragment; modalVisible: boolean; closeModal: () => void; onSave: (input: GQL.PerformerCreateInput) => void; excludedPerformerFields?: string[]; header: string; icon: IconDefinition; create?: boolean; endpoint?: string; } const PerformerModal: React.FC = ({ modalVisible, performer, onSave, closeModal, excludedPerformerFields = [], header, icon, create = false, endpoint, }) => { const intl = useIntl(); const [imageIndex, setImageIndex] = useState(0); const [imageState, setImageState] = useState< "loading" | "error" | "loaded" | "empty" >("empty"); const [loadDict, setLoadDict] = useState>({}); const [excluded, setExcluded] = useState>( excludedPerformerFields.reduce( (dict, field) => ({ ...dict, [field]: true }), {} ) ); const images = performer.images ?? []; const changeImage = (index: number) => { setImageIndex(index); if (!loadDict[index]) setImageState("loading"); }; const setPrev = () => changeImage(imageIndex === 0 ? images.length - 1 : imageIndex - 1); const setNext = () => changeImage(imageIndex === images.length - 1 ? 0 : imageIndex + 1); const handleLoad = (index: number) => { setLoadDict({ ...loadDict, [index]: true, }); setImageState("loaded"); }; const handleError = () => setImageState("error"); const toggleField = (name: string) => setExcluded({ ...excluded, [name]: !excluded[name], }); function maybeRenderField( name: string, text: string | null | undefined, truncate: boolean = true ) { if (!text) return; return (
    {!create && ( )} :
    {truncate ? (
    ) : ( {text} )}
    ); } function maybeRenderURLListField( name: string, text: string[] | null | undefined, truncate: boolean = true ) { if (!text) return; return (
    {!create && ( )} :
      {text.map((t, i) => (
    • {truncate ? : t}
    • ))}
    ); } function maybeRenderImage() { if (!images.length) return; return (
    {!create && ( )} handleLoad(imageIndex)} onError={handleError} /> {imageState === "loading" && ( )} {imageState === "error" && (
    Error loading image.
    )}
    Select performer image
    {imageIndex + 1} of {images.length}
    ); } function maybeRenderStashBoxLink() { const base = endpoint?.match(/https?:\/\/.*?\//)?.[0]; if (!base || !performer.remote_site_id) return; return ( ); } function onSaveClicked() { if (!performer.name) { throw new Error("performer name must set"); } const performerData: GQL.PerformerCreateInput & { [index: string]: unknown; } = { name: performer.name ?? "", disambiguation: performer.disambiguation ?? "", alias_list: performer.aliases?.split(",").map((a) => a.trim()) ?? undefined, gender: stringToGender(performer.gender ?? undefined, true), birthdate: performer.birthdate, ethnicity: performer.ethnicity, eye_color: performer.eye_color, country: performer.country, height_cm: Number.parseFloat(performer.height ?? "") ?? undefined, measurements: performer.measurements, fake_tits: performer.fake_tits, career_start: performer.career_start, career_end: performer.career_end, tattoos: performer.tattoos, piercings: performer.piercings, urls: performer.urls, image: images.length > imageIndex ? images[imageIndex] : undefined, details: performer.details, death_date: performer.death_date, hair_color: performer.hair_color, weight: Number.parseFloat(performer.weight ?? "") ?? undefined, }; if (Number.isNaN(performerData.weight ?? 0)) { performerData.weight = undefined; } if (Number.isNaN(performerData.height ?? 0)) { performerData.height = undefined; } if (performer.tags) { performerData.tag_ids = performer.tags .map((t) => t.stored_id) .filter((t) => t) as string[]; } // stashid handling code const remoteSiteID = performer.remote_site_id; if (remoteSiteID && endpoint) { performerData.stash_ids = [ { endpoint, stash_id: remoteSiteID, updated_at: new Date().toISOString(), }, ]; } // handle exclusions Object.keys(performerData).forEach((k) => { if (excluded[k] || !performerData[k]) { performerData[k] = undefined; } // #5565 - special case aliases as the names differ if (k == "alias_list" && excluded.aliases) { performerData.alias_list = undefined; } }); onSave(performerData); } return ( closeModal(), variant: "secondary" }} onHide={() => closeModal()} dialogClassName="performer-create-modal" icon={icon} header={header} >
    {maybeRenderField("name", performer.name)} {maybeRenderField("disambiguation", performer.disambiguation)} {maybeRenderField("aliases", performer.aliases)} {maybeRenderField( "gender", performer.gender ? intl.formatMessage({ id: "gender_types." + performer.gender }) : "" )} {maybeRenderField("birthdate", performer.birthdate)} {maybeRenderField("death_date", performer.death_date)} {maybeRenderField("ethnicity", performer.ethnicity)} {maybeRenderField("country", getCountryByISO(performer.country))} {maybeRenderField("hair_color", performer.hair_color)} {maybeRenderField("eye_color", performer.eye_color)} {maybeRenderField("height", performer.height)} {maybeRenderField("weight", performer.weight)} {maybeRenderField("measurements", performer.measurements)} {performer?.gender !== GQL.GenderEnum.Male && maybeRenderField("fake_tits", performer.fake_tits)} {maybeRenderField("career_start", performer.career_start?.toString())} {maybeRenderField("career_end", performer.career_end?.toString())} {maybeRenderField("tattoos", performer.tattoos, false)} {maybeRenderField("piercings", performer.piercings, false)} {maybeRenderField("weight", performer.weight, false)} {maybeRenderField("details", performer.details)} {maybeRenderURLListField("urls", performer.urls)} {maybeRenderStashBoxLink()}
    {maybeRenderImage()}
    ); }; export default PerformerModal; ================================================ FILE: ui/v2.5/src/components/Tagger/StashBoxSelector.tsx ================================================ import { Form } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import { StashBox } from "src/core/generated-graphql"; interface IStashBoxSelectorProps { stashBoxes: StashBox[]; selectedEndpoint: string; onEndpointChange: (endpoint: string) => void; } export const StashBoxSelector: React.FC = ({ stashBoxes, selectedEndpoint, onEndpointChange, }) => { return ( onEndpointChange(e.target.value)} > {!stashBoxes.length && ( )} {stashBoxes.map((i) => ( ))} ); }; export const StashBoxSelectorField: React.FC = ( props ) => { return (
    ); }; ================================================ FILE: ui/v2.5/src/components/Tagger/TaggerConfig.tsx ================================================ import React, { useState } from "react"; import { Badge, Button, Card, Collapse, Form } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import FieldSelector from "./FieldSelector"; import { Icon } from "../Shared/Icon"; import { faCog } from "@fortawesome/free-solid-svg-icons"; interface ITaggerConfigProps { show: boolean; excludedFields: string[]; onFieldsChange: (fields: string[]) => void; fields: string[]; entityName: string; extraConfig?: React.ReactNode; } const TaggerConfig: React.FC = ({ show, excludedFields, onFieldsChange, fields, entityName, extraConfig, }) => { const [showExclusionModal, setShowExclusionModal] = useState(false); const handleFieldSelect = (selectedFields: string[]) => { onFieldsChange(selectedFields); setShowExclusionModal(false); }; return ( <>


    {extraConfig}
    {excludedFields.length > 0 ? ( excludedFields.map((f) => ( )) ) : ( )}
    ); }; export default TaggerConfig; export const ConfigButton: React.FC<{ onClick: () => void; showConfig: boolean; }> = ({ onClick, showConfig }) => { const intl = useIntl(); const showHideConfigId = showConfig ? "actions.hide_configuration" : "actions.show_configuration"; return ( ); }; ================================================ FILE: ui/v2.5/src/components/Tagger/config.ts ================================================ import { useCallback } from "react"; import { useConfigurationContext } from "src/hooks/Config"; import { initialConfig, ITaggerConfig } from "./constants"; import { useConfigureUISetting } from "src/core/StashService"; export function useTaggerConfig() { const { configuration: stashConfig } = useConfigurationContext(); const [saveUISetting] = useConfigureUISetting(); const config = stashConfig?.ui.taggerConfig ?? initialConfig; const setConfig = useCallback( (c: ITaggerConfig) => { saveUISetting({ variables: { key: "taggerConfig", value: c } }); }, [saveUISetting] ); return { config, setConfig }; } ================================================ FILE: ui/v2.5/src/components/Tagger/constants.ts ================================================ import { GenderEnum, ScraperSourceInput } from "src/core/generated-graphql"; export const STASH_BOX_PREFIX = "stashbox:"; export const SCRAPER_PREFIX = "scraper:"; export interface ITaggerSource { id: string; sourceInput: ScraperSourceInput; displayName: string; supportSceneQuery?: boolean; supportSceneFragment?: boolean; } export const DEFAULT_BLACKLIST = [ "\\sXXX\\s", "1080p", "720p", "2160p", "KTR", "RARBG", "\\scom\\s", "\\[", "\\]", ]; export const DEFAULT_EXCLUDED_PERFORMER_FIELDS = ["name"]; export const DEFAULT_EXCLUDED_STUDIO_FIELDS = ["name"]; export const DEFAULT_EXCLUDED_TAG_FIELDS = ["name"]; export const initialConfig: ITaggerConfig = { blacklist: DEFAULT_BLACKLIST, mode: "auto", setCoverImage: true, setTags: true, tagOperation: "merge", fingerprintQueue: {}, excludedPerformerFields: DEFAULT_EXCLUDED_PERFORMER_FIELDS, markSceneAsOrganizedOnSave: false, excludedStudioFields: DEFAULT_EXCLUDED_STUDIO_FIELDS, excludedTagFields: DEFAULT_EXCLUDED_TAG_FIELDS, createParentStudios: true, createParentTags: true, }; export type ParseMode = "auto" | "filename" | "dir" | "path" | "metadata"; export type TagOperation = "merge" | "overwrite"; export interface ITaggerConfig { blacklist: string[]; performerGenders?: GenderEnum[]; mode: ParseMode; setCoverImage: boolean; setTags: boolean; tagOperation: TagOperation; selectedEndpoint?: string; fingerprintQueue: Record; excludedPerformerFields?: string[]; markSceneAsOrganizedOnSave?: boolean; excludedStudioFields?: string[]; excludedTagFields?: string[]; createParentStudios: boolean; createParentTags: boolean; } export const PERFORMER_FIELDS = [ "name", "image", "disambiguation", "aliases", "gender", "birthdate", "death_date", "country", "ethnicity", "hair_color", "eye_color", "height", "weight", "penis_length", "circumcised", "measurements", "fake_tits", "tattoos", "piercings", "career_start", "career_end", "urls", "details", ]; export const STUDIO_FIELDS = ["name", "image", "url", "parent_studio"]; export const TAG_FIELDS = ["name", "description", "aliases", "parent_tags"]; ================================================ FILE: ui/v2.5/src/components/Tagger/context.tsx ================================================ import React, { useState, useEffect, useRef } from "react"; import { initialConfig, ITaggerConfig } from "src/components/Tagger/constants"; import * as GQL from "src/core/generated-graphql"; import { queryFindPerformer, queryFindStudio, queryScrapeScene, queryScrapeSceneQuery, queryScrapeSceneQueryFragment, stashBoxSceneBatchQuery, useListSceneScrapers, usePerformerCreate, usePerformerUpdate, useSceneUpdate, useStudioCreate, useStudioUpdate, useTagCreate, useTagUpdate, } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import { useConfigurationContext } from "src/hooks/Config"; import { ITaggerSource, SCRAPER_PREFIX, STASH_BOX_PREFIX } from "./constants"; import { errorToString } from "src/utils"; import { mergeStudioStashIDs } from "./utils"; import { useTaggerConfig } from "./config"; export interface ITaggerContextState { config: ITaggerConfig; setConfig: (c: ITaggerConfig) => void; loading: boolean; loadingMulti?: boolean; multiError?: string; sources: ITaggerSource[]; currentSource?: ITaggerSource; searchResults: Record; setCurrentSource: (src?: ITaggerSource) => void; doSceneQuery: (sceneID: string, searchStr: string) => Promise; doSceneFragmentScrape: (sceneID: string) => Promise; doMultiSceneFragmentScrape: (sceneIDs: string[]) => Promise; stopMultiScrape: () => void; createNewTag: ( tag: GQL.ScrapedTag, toCreate: GQL.TagCreateInput ) => Promise; createNewPerformer: ( performer: GQL.ScrapedPerformer, toCreate: GQL.PerformerCreateInput ) => Promise; linkPerformer: ( performer: GQL.ScrapedPerformer, performerID: string ) => Promise; createNewStudio: ( studio: GQL.ScrapedStudio, toCreate: GQL.StudioCreateInput ) => Promise; updateStudio: (studio: GQL.StudioUpdateInput) => Promise; linkStudio: (studio: GQL.ScrapedStudio, studioID: string) => Promise; updateTag: ( tag: GQL.ScrapedTag, updateInput: GQL.TagUpdateInput ) => Promise; resolveScene: ( sceneID: string, index: number, scene: IScrapedScene ) => Promise; submitFingerprints: () => Promise; pendingFingerprints: string[]; saveScene: ( sceneCreateInput: GQL.SceneUpdateInput, queueFingerprint: boolean ) => Promise; } const dummyFn = () => { return Promise.resolve(); }; const dummyValFn = () => { return Promise.resolve(undefined); }; export const TaggerStateContext = React.createContext({ config: initialConfig, setConfig: () => {}, loading: false, sources: [], searchResults: {}, setCurrentSource: () => {}, doSceneQuery: dummyFn, doSceneFragmentScrape: dummyFn, doMultiSceneFragmentScrape: dummyFn, stopMultiScrape: () => {}, createNewTag: dummyValFn, createNewPerformer: dummyValFn, linkPerformer: dummyFn, createNewStudio: dummyValFn, updateStudio: dummyFn, linkStudio: dummyFn, updateTag: dummyFn, resolveScene: dummyFn, submitFingerprints: dummyFn, pendingFingerprints: [], saveScene: dummyFn, }); export type IScrapedScene = GQL.ScrapedScene & { resolved?: boolean }; export interface ISceneQueryResult { results?: IScrapedScene[]; error?: string; } export const TaggerContext: React.FC = ({ children }) => { const [loading, setLoading] = useState(false); const [loadingMulti, setLoadingMulti] = useState(false); const [sources, setSources] = useState([]); const [currentSource, setCurrentSource] = useState(); const [multiError, setMultiError] = useState(); const [searchResults, setSearchResults] = useState< Record >({}); const stopping = useRef(false); const { configuration: stashConfig } = useConfigurationContext(); const { config, setConfig } = useTaggerConfig(); const Scrapers = useListSceneScrapers(); const Toast = useToast(); const [createTag] = useTagCreate(); const [createPerformer] = usePerformerCreate(); const [updatePerformer] = usePerformerUpdate(); const [createStudio] = useStudioCreate(); const [updateStudio] = useStudioUpdate(); const [updateScene] = useSceneUpdate(); const [updateTag] = useTagUpdate(); useEffect(() => { if (!stashConfig || !Scrapers.data) { return; } const { stashBoxes } = stashConfig.general; const scrapers = Scrapers.data.listScrapers; const stashboxSources: ITaggerSource[] = stashBoxes.map((s, i) => ({ id: `${STASH_BOX_PREFIX}${s.endpoint}`, sourceInput: { stash_box_endpoint: s.endpoint, }, displayName: `stash-box: ${s.name || `#${i + 1}`}`, supportSceneFragment: true, supportSceneQuery: true, })); // filter scraper sources such that only those that can query scrape or // scrape via fragment are added const scraperSources: ITaggerSource[] = scrapers .filter((s) => s.scene?.supported_scrapes.some( (t) => t === GQL.ScrapeType.Name || t === GQL.ScrapeType.Fragment ) ) .map((s) => ({ id: `${SCRAPER_PREFIX}${s.id}`, sourceInput: { scraper_id: s.id, }, displayName: s.name, supportSceneQuery: s.scene?.supported_scrapes.includes( GQL.ScrapeType.Name ), supportSceneFragment: s.scene?.supported_scrapes.includes( GQL.ScrapeType.Fragment ), })); setSources(stashboxSources.concat(scraperSources)); }, [Scrapers.data, stashConfig]); // set the current source on load useEffect(() => { if (!sources.length || currentSource) { return; } // First, see if we have a saved endpoint. if (config.selectedEndpoint) { let source = sources.find( (s) => s.sourceInput.stash_box_endpoint == config.selectedEndpoint ); if (source) { setCurrentSource(source); return; } } // Otherwise, just use the first source. setCurrentSource(sources[0]); }, [sources, currentSource, config]); // clear the search results when the source changes useEffect(() => { setSearchResults({}); }, [currentSource]); // keep selected endpoint in config in sync with current source useEffect(() => { const selectedEndpoint = currentSource?.sourceInput.stash_box_endpoint; if (selectedEndpoint && selectedEndpoint !== config.selectedEndpoint) { setConfig({ ...config, selectedEndpoint, }); } }, [currentSource, config, setConfig]); function getPendingFingerprints() { const endpoint = currentSource?.sourceInput.stash_box_endpoint; if (!config || !endpoint) return []; return config.fingerprintQueue[endpoint] ?? []; } function clearSubmissionQueue() { const endpoint = currentSource?.sourceInput.stash_box_endpoint; if (!config || !endpoint) return; setConfig({ ...config, fingerprintQueue: { ...config.fingerprintQueue, [endpoint]: [], }, }); } const [submitFingerprintsMutation] = GQL.useSubmitStashBoxFingerprintsMutation(); async function submitFingerprints() { const endpoint = currentSource?.sourceInput.stash_box_endpoint; if (!config || !endpoint) return; try { setLoading(true); await submitFingerprintsMutation({ variables: { input: { stash_box_endpoint: endpoint, scene_ids: config.fingerprintQueue[endpoint], }, }, }); clearSubmissionQueue(); } catch (err) { Toast.error(err); } finally { setLoading(false); } } function queueFingerprintSubmission(sceneId: string) { const endpoint = currentSource?.sourceInput.stash_box_endpoint; if (!config || !endpoint) return; setConfig({ ...config, fingerprintQueue: { ...config.fingerprintQueue, [endpoint]: [...(config.fingerprintQueue[endpoint] ?? []), sceneId], }, }); } function clearSearchResults(sceneID: string) { setSearchResults((current) => { const newSearchResults = { ...current }; delete newSearchResults[sceneID]; return newSearchResults; }); } async function doSceneQuery(sceneID: string, searchVal: string) { if (!currentSource) { return; } try { setLoading(true); clearSearchResults(sceneID); const results = await queryScrapeSceneQuery( currentSource.sourceInput, searchVal ); let newResult: ISceneQueryResult; // scenes are already resolved if they come from stash-box const resolved = currentSource.sourceInput.stash_box_endpoint !== undefined; if (results.error) { newResult = { error: results.error.message }; } else if (results.errors) { newResult = { error: results.errors.toString() }; } else { newResult = { results: results.data.scrapeSingleScene.map((r) => ({ ...r, resolved, })), }; } setSearchResults({ ...searchResults, [sceneID]: newResult }); } catch (err) { Toast.error(err); } finally { setLoading(false); } } async function sceneFragmentScrape(sceneID: string) { if (!currentSource) { return; } clearSearchResults(sceneID); let newResult: ISceneQueryResult; try { const results = await queryScrapeScene( currentSource.sourceInput, sceneID ); if (results.error) { newResult = { error: results.error.message }; } else if (results.errors) { newResult = { error: results.errors.toString() }; } else { newResult = { results: results.data.scrapeSingleScene.map((r) => ({ ...r, // scenes are already resolved if they are scraped via fragment resolved: true, })), }; } } catch (err: unknown) { newResult = { error: errorToString(err) }; } setSearchResults((current) => { return { ...current, [sceneID]: newResult }; }); } async function doSceneFragmentScrape(sceneID: string) { if (!currentSource) { return; } clearSearchResults(sceneID); try { setLoading(true); await sceneFragmentScrape(sceneID); } catch (err) { Toast.error(err); } finally { setLoading(false); } } async function doMultiSceneFragmentScrape(sceneIDs: string[]) { if (!currentSource) { return; } setSearchResults({}); try { stopping.current = false; setLoading(true); setMultiError(undefined); const stashBoxEndpoint = currentSource.sourceInput.stash_box_endpoint ?? undefined; // if current source is stash-box, we can use the multi-scene // interface if (stashBoxEndpoint !== undefined) { const results = await stashBoxSceneBatchQuery( sceneIDs, stashBoxEndpoint ); if (results.error) { setMultiError(results.error.message); } else if (results.errors) { setMultiError(results.errors.toString()); } else { const newSearchResults = { ...searchResults }; sceneIDs.forEach((sceneID, index) => { const newResults = results.data.scrapeMultiScenes[index].map( (r) => ({ ...r, resolved: true, }) ); newSearchResults[sceneID] = { results: newResults, }; }); setSearchResults(newSearchResults); } } else { setLoadingMulti(true); // do singular calls await sceneIDs.reduce(async (promise, id) => { await promise; if (!stopping.current) { await sceneFragmentScrape(id); } }, Promise.resolve()); } } catch (err) { Toast.error(err); } finally { setLoading(false); setLoadingMulti(false); } } function stopMultiScrape() { stopping.current = true; } async function resolveScene( sceneID: string, index: number, scene: IScrapedScene ) { if (!currentSource || scene.resolved || !searchResults[sceneID].results) { return Promise.resolve(); } try { const sceneInput: GQL.ScrapedSceneInput = { date: scene.date, details: scene.details, remote_site_id: scene.remote_site_id, title: scene.title, urls: scene.urls, }; const result = await queryScrapeSceneQueryFragment( currentSource.sourceInput, sceneInput ); if (result.data.scrapeSingleScene.length) { const resolvedScene = result.data.scrapeSingleScene[0]; // set the scene in the results and mark as resolved const newResult = [...searchResults[sceneID].results!]; newResult[index] = { ...resolvedScene, resolved: true }; setSearchResults({ ...searchResults, [sceneID]: { ...searchResults[sceneID], results: newResult }, }); } } catch (err) { Toast.error(err); const newResult = [...searchResults[sceneID].results!]; newResult[index] = { ...newResult[index], resolved: true }; setSearchResults({ ...searchResults, [sceneID]: { ...searchResults[sceneID], results: newResult }, }); } } async function saveScene( sceneCreateInput: GQL.SceneUpdateInput, queueFingerprint: boolean ) { try { await updateScene({ variables: { input: { ...sceneCreateInput, // only set organized if it is enabled in the config organized: config?.markSceneAsOrganizedOnSave || undefined, }, }, }); if (queueFingerprint) { queueFingerprintSubmission(sceneCreateInput.id); } clearSearchResults(sceneCreateInput.id); } catch (err) { Toast.error(err); } finally { setLoading(false); } } function mapResults(fn: (r: IScrapedScene) => IScrapedScene) { const newSearchResults = { ...searchResults }; Object.keys(newSearchResults).forEach((k) => { const searchResult = searchResults[k]; if (!searchResult.results) { return; } newSearchResults[k].results = searchResult.results.map(fn); }); return newSearchResults; } async function createNewTag( tag: GQL.ScrapedTag, toCreate: GQL.TagCreateInput ) { try { const result = await createTag({ variables: { input: toCreate, }, }); const tagID = result.data?.tagCreate?.id; if (tagID === undefined) return undefined; const newSearchResults = mapResults((r) => { if (!r.tags) { return r; } return { ...r, tags: r.tags.map((t) => { if (t.name === tag.name) { return { ...t, stored_id: tagID, }; } return t; }), }; }); setSearchResults(newSearchResults); Toast.success( Created tag: {toCreate.name} ); return tagID; } catch (e) { Toast.error(e); } } async function createNewPerformer( performer: GQL.ScrapedPerformer, toCreate: GQL.PerformerCreateInput ) { try { const result = await createPerformer({ variables: { input: toCreate, }, }); const performerID = result.data?.performerCreate?.id; if (performerID === undefined) return undefined; const newSearchResults = mapResults((r) => { if (!r.performers) { return r; } return { ...r, performers: r.performers.map((p) => { // Match by remote_site_id if available, otherwise fall back to name const matches = performer.remote_site_id ? p.remote_site_id === performer.remote_site_id : p.name === performer.name; if (matches) { return { ...p, stored_id: performerID, }; } return p; }), }; }); setSearchResults(newSearchResults); Toast.success( Created performer: {toCreate.name} ); return performerID; } catch (e) { Toast.error(e); } } async function linkPerformer( performer: GQL.ScrapedPerformer, performerID: string ) { if ( !performer.remote_site_id || !currentSource?.sourceInput.stash_box_endpoint ) return; try { const queryResult = await queryFindPerformer(performerID); if (queryResult.data.findPerformer) { const target = queryResult.data.findPerformer; const stashIDs: GQL.StashIdInput[] = target.stash_ids.map((e) => { return { endpoint: e.endpoint, stash_id: e.stash_id, updated_at: e.updated_at, }; }); stashIDs.push({ stash_id: performer.remote_site_id, endpoint: currentSource?.sourceInput.stash_box_endpoint, updated_at: new Date().toISOString(), }); await updatePerformer({ variables: { input: { id: performerID, stash_ids: stashIDs, }, }, }); const newSearchResults = mapResults((r) => { if (!r.performers) { return r; } return { ...r, performers: r.performers.map((p) => { if (p.remote_site_id === performer.remote_site_id) { return { ...p, stored_id: performerID, }; } return p; }), }; }); setSearchResults(newSearchResults); Toast.success(Added stash-id to performer); } } catch (e) { Toast.error(e); } } async function createNewStudio( studio: GQL.ScrapedStudio, toCreate: GQL.StudioCreateInput ) { try { const result = await createStudio({ variables: { input: toCreate, }, }); const studioID = result.data?.studioCreate?.id; if (studioID === undefined) return undefined; const newSearchResults = mapResults((r) => { if (!r.studio) { return r; } let resultStudio = r.studio; if (resultStudio.name === studio.name) { resultStudio = { ...resultStudio, stored_id: studioID, }; } // #5821 - set the stored_id of the parent studio if it matches too if (resultStudio.parent?.name === studio.name) { resultStudio = { ...resultStudio, parent: { ...resultStudio.parent, stored_id: studioID, }, }; } return { ...r, studio: resultStudio, }; }); setSearchResults(newSearchResults); Toast.success( Created studio: {toCreate.name} ); return studioID; } catch (e) { Toast.error(e); } } async function updateExistingStudio(input: GQL.StudioUpdateInput) { try { const inputCopy = { ...input }; inputCopy.stash_ids = await mergeStudioStashIDs( input.id, input.stash_ids ?? [] ); const result = await updateStudio({ variables: { input: input, }, }); const studioID = result.data?.studioUpdate?.id; const stashID = input.stash_ids?.find((e) => { return e.endpoint === currentSource?.sourceInput.stash_box_endpoint; })?.stash_id; if (stashID) { const newSearchResults = mapResults((r) => { if (!r.studio) { return r; } return { ...r, studio: r.remote_site_id === stashID ? { ...r.studio, stored_id: studioID, } : r.studio, }; }); setSearchResults(newSearchResults); } Toast.success( Created studio: {input.name} ); } catch (e) { Toast.error(e); } } async function linkStudio(studio: GQL.ScrapedStudio, studioID: string) { if ( !studio.remote_site_id || !currentSource?.sourceInput.stash_box_endpoint ) return; try { const queryResult = await queryFindStudio(studioID); if (queryResult.data.findStudio) { const target = queryResult.data.findStudio; const stashIDs: GQL.StashIdInput[] = target.stash_ids.map((e) => { return { endpoint: e.endpoint, stash_id: e.stash_id, updated_at: e.updated_at, }; }); stashIDs.push({ stash_id: studio.remote_site_id, endpoint: currentSource?.sourceInput.stash_box_endpoint, updated_at: new Date().toISOString(), }); await updateStudio({ variables: { input: { id: studioID, stash_ids: stashIDs, }, }, }); const newSearchResults = mapResults((r) => { if (!r.studio) { return r; } return { ...r, studio: r.studio.remote_site_id === studio.remote_site_id ? { ...r.studio, stored_id: studioID, } : r.studio, }; }); setSearchResults(newSearchResults); Toast.success(Added stash-id to studio); } } catch (e) { Toast.error(e); } } async function updateExistingTag( tag: GQL.ScrapedTag, updateInput: GQL.TagUpdateInput ) { const hasRemoteID = !!tag.remote_site_id; try { await updateTag({ variables: { input: updateInput, }, }); const newSearchResults = mapResults((r) => { if (!r.tags) { return r; } return { ...r, tags: r.tags.map((t) => { if ( (hasRemoteID && t.remote_site_id === tag.remote_site_id) || (!hasRemoteID && t.name === tag.name) ) { return { ...t, stored_id: updateInput.id, }; } return t; }), }; }); setSearchResults(newSearchResults); Toast.success(Updated tag); } catch (e) { Toast.error(e); } } return ( { setCurrentSource(src); }, doSceneQuery, doSceneFragmentScrape, doMultiSceneFragmentScrape, stopMultiScrape, createNewTag, createNewPerformer, linkPerformer, createNewStudio, updateStudio: updateExistingStudio, linkStudio, updateTag: updateExistingTag, resolveScene, saveScene, submitFingerprints, pendingFingerprints: getPendingFingerprints(), }} > {children} ); }; ================================================ FILE: ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx ================================================ import React, { useEffect, useMemo, useRef, useState } from "react"; import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { Link } from "react-router-dom"; import { HashLink } from "react-router-hash-link"; import * as GQL from "src/core/generated-graphql"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { ModalComponent } from "src/components/Shared/Modal"; import { stashBoxPerformerQuery, useJobsSubscribe, mutateStashBoxBatchPerformerTag, getClient, evictQueries, performerMutationImpactedQueries, } from "src/core/StashService"; import { useConfigurationContext } from "src/hooks/Config"; import StashSearchResult from "./StashSearchResult"; import TaggerConfig, { ConfigButton } from "../TaggerConfig"; import { ITaggerConfig, PERFORMER_FIELDS } from "../constants"; import PerformerModal from "../PerformerModal"; import { useUpdatePerformer } from "../queries"; import { faStar, faTags } from "@fortawesome/free-solid-svg-icons"; import { mergeStashIDs } from "src/utils/stashbox"; import { separateNamesAndStashIds } from "src/utils/stashIds"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { useTaggerConfig } from "../config"; import { StashBoxSelectorField } from "../StashBoxSelector"; type JobFragment = Pick< GQL.Job, "id" | "status" | "subTasks" | "description" | "progress" >; const CLASSNAME = "PerformerTagger"; interface IPerformerBatchUpdateModal { performers: GQL.PerformerDataFragment[]; isIdle: boolean; selectedEndpoint: { endpoint: string; index: number }; onBatchUpdate: (queryAll: boolean, refresh: boolean) => void; close: () => void; } const PerformerBatchUpdateModal: React.FC = ({ performers, isIdle, selectedEndpoint, onBatchUpdate, close, }) => { const intl = useIntl(); const [queryAll, setQueryAll] = useState(false); const [refresh, setRefresh] = useState(false); const { data: allPerformers } = GQL.useFindPerformersQuery({ variables: { performer_filter: { stash_id_endpoint: { endpoint: selectedEndpoint.endpoint, modifier: refresh ? GQL.CriterionModifier.NotNull : GQL.CriterionModifier.IsNull, }, }, filter: { per_page: 0, }, }, }); const performerCount = useMemo(() => { // get all stash ids for the selected endpoint const filteredStashIDs = performers.map((p) => p.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint) ); return queryAll ? allPerformers?.findPerformers.count : filteredStashIDs.filter((s) => // if refresh, then we filter out the performers without a stash id // otherwise, we want untagged performers, filtering out those with a stash id refresh ? s.length > 0 : s.length === 0 ).length; }, [queryAll, refresh, performers, allPerformers, selectedEndpoint.endpoint]); return ( onBatchUpdate(queryAll, refresh), }} cancel={{ text: intl.formatMessage({ id: "actions.cancel" }), variant: "danger", onClick: () => close(), }} disabled={!isIdle} >
    } checked={!queryAll} onChange={() => setQueryAll(false)} /> setQueryAll(true)} />
    setRefresh(false)} /> setRefresh(true)} />
    ); }; interface IPerformerBatchAddModal { isIdle: boolean; onBatchAdd: (input: string) => void; close: () => void; } const PerformerBatchAddModal: React.FC = ({ isIdle, onBatchAdd, close, }) => { const intl = useIntl(); const performerInput = useRef(null); return ( { if (performerInput.current) { onBatchAdd(performerInput.current.value); } else { close(); } }, }} cancel={{ text: intl.formatMessage({ id: "actions.cancel" }), variant: "danger", onClick: () => close(), }} disabled={!isIdle} > ); }; interface IPerformerTaggerListProps { performers: GQL.PerformerDataFragment[]; selectedEndpoint: { endpoint: string; index: number }; isIdle: boolean; config: ITaggerConfig; onBatchAdd: (performerInput: string) => void; onBatchUpdate: (ids: string[] | undefined, refresh: boolean) => void; } const PerformerTaggerList: React.FC = ({ performers, selectedEndpoint, isIdle, config, onBatchAdd, onBatchUpdate, }) => { const intl = useIntl(); const [loading, setLoading] = useState(false); const [searchResults, setSearchResults] = useState< Record >({}); const [searchErrors, setSearchErrors] = useState< Record >({}); const [taggedPerformers, setTaggedPerformers] = useState< Record> >({}); const [queries, setQueries] = useState>({}); const [showBatchAdd, setShowBatchAdd] = useState(false); const [showBatchUpdate, setShowBatchUpdate] = useState(false); const [error, setError] = useState< Record >({}); const [loadingUpdate, setLoadingUpdate] = useState(); const [modalPerformer, setModalPerformer] = useState< GQL.ScrapedPerformerDataFragment | undefined >(); const doBoxSearch = (performerID: string, searchVal: string) => { stashBoxPerformerQuery(searchVal, selectedEndpoint.endpoint) .then((queryData) => { const s = queryData.data?.scrapeSinglePerformer ?? []; setSearchResults({ ...searchResults, [performerID]: s, }); setSearchErrors({ ...searchErrors, [performerID]: undefined, }); setLoading(false); }) .catch(() => { setLoading(false); // Destructure to remove existing result const { [performerID]: unassign, ...results } = searchResults; setSearchResults(results); setSearchErrors({ ...searchErrors, [performerID]: intl.formatMessage({ id: "performer_tagger.network_error", }), }); }); setLoading(true); }; const doBoxUpdate = ( performerID: string, stashID: string, endpoint: string ) => { setLoadingUpdate(stashID); setError({ ...error, [performerID]: undefined, }); stashBoxPerformerQuery(stashID, endpoint) .then((queryData) => { const data = queryData.data?.scrapeSinglePerformer ?? []; if (data.length > 0) { setModalPerformer({ ...data[0], stored_id: performerID, }); } }) .finally(() => setLoadingUpdate(undefined)); }; async function handleBatchAdd(input: string) { onBatchAdd(input); setShowBatchAdd(false); } const handleBatchUpdate = (queryAll: boolean, refresh: boolean) => { onBatchUpdate(!queryAll ? performers.map((p) => p.id) : undefined, refresh); setShowBatchUpdate(false); }; const handleTaggedPerformer = ( performer: Pick & Partial> ) => { setTaggedPerformers({ ...taggedPerformers, [performer.id]: performer, }); }; const updatePerformer = useUpdatePerformer(); function handleSaveError(performerID: string, name: string, message: string) { setError({ ...error, [performerID]: { message: intl.formatMessage( { id: "performer_tagger.failed_to_save_performer" }, { performer: modalPerformer?.name } ), details: message === "UNIQUE constraint failed: performers.name" ? intl.formatMessage({ id: "performer_tagger.name_already_exists", }) : message, }, }); } const handlePerformerUpdate = async ( existing: GQL.PerformerDataFragment, input: GQL.PerformerCreateInput ) => { setModalPerformer(undefined); const performerID = modalPerformer?.stored_id; if (performerID) { // handle stash ids - we want to add, not set them if (input.stash_ids?.length) { input.stash_ids = mergeStashIDs(existing.stash_ids, input.stash_ids); } const updateData: GQL.PerformerUpdateInput = { ...input, id: performerID, }; const res = await updatePerformer(updateData); if (!res.data?.performerUpdate) handleSaveError( performerID, modalPerformer?.name ?? "", res?.errors?.[0]?.message ?? "" ); } }; const renderPerformers = () => performers.map((performer) => { const isTagged = taggedPerformers[performer.id]; const stashID = performer.stash_ids.find((s) => { return s.endpoint === selectedEndpoint.endpoint; }); let mainContent; if (!isTagged && stashID !== undefined) { mainContent = (
    ); } else if (!isTagged && !stashID) { mainContent = ( setQueries({ ...queries, [performer.id]: e.currentTarget.value, }) } onKeyPress={(e: React.KeyboardEvent) => e.key === "Enter" && doBoxSearch( performer.id, queries[performer.id] ?? performer.name ?? "" ) } /> ); } else if (isTagged) { mainContent = (
    {taggedPerformers[performer.id].name}
    ); } let subContent; if (stashID !== undefined) { const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; const link = base ? ( {stashID.stash_id} ) : (
    {stashID.stash_id}
    ); subContent = (
    {link} {error[performer.id] && (
    Error: {error[performer.id]?.message}
    {error[performer.id]?.details}
    )}
    ); } else if (searchErrors[performer.id]) { subContent = (
    {searchErrors[performer.id]}
    ); } else if (searchResults[performer.id]?.length === 0) { subContent = (
    ); } let searchResult; if (searchResults[performer.id]?.length > 0 && !isTagged) { searchResult = ( ); } return (
    {modalPerformer && ( setModalPerformer(undefined)} modalVisible={modalPerformer.stored_id === performer.id} performer={modalPerformer} onSave={(input) => { handlePerformerUpdate(performer, input); }} excludedPerformerFields={config.excludedPerformerFields} icon={faTags} header={intl.formatMessage({ id: "performer_tagger.update_performer", })} endpoint={selectedEndpoint.endpoint} /> )}

    {performer.name} {performer.disambiguation && ( {` (${performer.disambiguation})`} )}

    {mainContent}
    {subContent}
    {searchResult}
    ); }); return ( {showBatchUpdate && ( setShowBatchUpdate(false)} isIdle={isIdle} selectedEndpoint={selectedEndpoint} performers={performers} onBatchUpdate={handleBatchUpdate} /> )} {showBatchAdd && ( setShowBatchAdd(false)} isIdle={isIdle} onBatchAdd={handleBatchAdd} /> )}
    {renderPerformers()}
    ); }; interface ITaggerProps { performers: GQL.PerformerDataFragment[]; } export const PerformerTagger: React.FC = ({ performers }) => { const jobsSubscribe = useJobsSubscribe(); const { configuration: stashConfig } = useConfigurationContext(); const { config, setConfig } = useTaggerConfig(); const [showConfig, setShowConfig] = useState(false); const [batchJobID, setBatchJobID] = useState(); const [batchJob, setBatchJob] = useState(); // monitor batch operation useEffect(() => { if (!jobsSubscribe.data) { return; } const event = jobsSubscribe.data.jobsSubscribe; if (event.job.id !== batchJobID) { return; } if (event.type !== GQL.JobStatusUpdateType.Remove) { setBatchJob(event.job); } else { setBatchJob(undefined); setBatchJobID(undefined); // Once the performer batch is complete, refresh all local performer data const ac = getClient(); evictQueries(ac.cache, performerMutationImpactedQueries); } }, [jobsSubscribe, batchJobID]); if (!config) return ; const savedEndpointIndex = stashConfig?.general.stashBoxes.findIndex( (s) => s.endpoint === config.selectedEndpoint ) ?? -1; const selectedEndpointIndex = savedEndpointIndex === -1 && stashConfig?.general.stashBoxes.length ? 0 : savedEndpointIndex; const selectedEndpoint = stashConfig?.general.stashBoxes[selectedEndpointIndex]; async function batchAdd(performerInput: string) { if (performerInput && selectedEndpoint) { const inputs = performerInput .split(",") .map((n) => n.trim()) .filter((n) => n.length > 0); const { names, stashIds } = separateNamesAndStashIds(inputs); if (names.length > 0 || stashIds.length > 0) { const ret = await mutateStashBoxBatchPerformerTag({ names: names.length > 0 ? names : undefined, stash_ids: stashIds.length > 0 ? stashIds : undefined, endpoint: selectedEndpointIndex, refresh: false, createParent: false, }); setBatchJobID(ret.data?.stashBoxBatchPerformerTag); } } } async function batchUpdate(ids: string[] | undefined, refresh: boolean) { if (config && selectedEndpoint) { const ret = await mutateStashBoxBatchPerformerTag({ ids: ids, endpoint: selectedEndpointIndex, refresh, exclude_fields: config.excludedPerformerFields ?? [], createParent: false, }); setBatchJobID(ret.data?.stashBoxBatchPerformerTag); } } // const progress = // jobStatus.data?.metadataUpdate.status === // "Stash-Box Performer Batch Operation" && // jobStatus.data.metadataUpdate.progress >= 0 // ? jobStatus.data.metadataUpdate.progress * 100 // : null; function renderStatus() { if (batchJob) { const progress = batchJob.progress !== undefined && batchJob.progress !== null ? batchJob.progress * 100 : undefined; return (
    {progress !== undefined && ( )}
    ); } if (batchJobID !== undefined) { return (
    ); } } if (selectedEndpointIndex === -1 || !selectedEndpoint) { return (

    el.scrollIntoView({ behavior: "smooth", block: "center" }) } > ), }} />
    ); } return ( <> {renderStatus()}
    setConfig({ ...config, selectedEndpoint: endpoint }) } />
    setShowConfig(!showConfig)} />
    setConfig({ ...config, excludedPerformerFields: fields }) } fields={PERFORMER_FIELDS} entityName="performers" />
    ); }; ================================================ FILE: ui/v2.5/src/components/Tagger/performers/StashSearchResult.tsx ================================================ import React, { useState } from "react"; import { Button } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import { useUpdatePerformer } from "../queries"; import PerformerModal from "../PerformerModal"; import { faTags } from "@fortawesome/free-solid-svg-icons"; import { mergeStashIDs } from "src/utils/stashbox"; interface IStashSearchResultProps { performer: GQL.SlimPerformerDataFragment; stashboxPerformers: GQL.ScrapedPerformerDataFragment[]; endpoint: string; onPerformerTagged: ( performer: Pick & Partial> ) => void; excludedPerformerFields: string[]; } // #4596 - remove any duplicate aliases or aliases that are the same as the performer's name function cleanAliases(currentName: string, aliases: string[]) { const ret: string[] = []; aliases.forEach((alias) => { if ( alias.toLowerCase() !== currentName.toLowerCase() && !ret.find((r) => r.toLowerCase() === alias.toLowerCase()) ) { ret.push(alias); } }); return ret; } const StashSearchResult: React.FC = ({ performer, stashboxPerformers, onPerformerTagged, excludedPerformerFields, endpoint, }) => { const [modalPerformer, setModalPerformer] = useState(); const [saveState, setSaveState] = useState(""); const [error, setError] = useState<{ message?: string; details?: string }>( {} ); const updatePerformer = useUpdatePerformer(); const handleSave = async (input: GQL.PerformerCreateInput) => { setError({}); setSaveState("Saving performer"); setModalPerformer(undefined); if (input.stash_ids?.length) { input.stash_ids = mergeStashIDs(performer.stash_ids, input.stash_ids); } if (input.alias_list) { input.alias_list = cleanAliases(performer.name, input.alias_list); } const updateData: GQL.PerformerUpdateInput = { ...input, id: performer.id, }; const res = await updatePerformer(updateData); if (!res?.data?.performerUpdate) setError({ message: `Failed to save performer "${performer.name}"`, details: res?.errors?.[0].message === "UNIQUE constraint failed: performers.name" ? "Name already exists" : res?.errors?.[0].message, }); else onPerformerTagged(performer); setSaveState(""); }; const performers = stashboxPerformers.map((p) => ( )); return ( <> {modalPerformer && ( setModalPerformer(undefined)} modalVisible={modalPerformer !== undefined} performer={modalPerformer} onSave={handleSave} icon={faTags} header="Update Performer" excludedPerformerFields={excludedPerformerFields} endpoint={endpoint} /> )}
    {performers}
    {error.message && (
    Error: {error.message}
    {error.details}
    )} {saveState && ( {saveState} )}
    ); }; export default StashSearchResult; ================================================ FILE: ui/v2.5/src/components/Tagger/queries.ts ================================================ import * as GQL from "src/core/generated-graphql"; import { evictQueries, getClient, studioMutationImpactedQueries, } from "src/core/StashService"; export const useUpdatePerformer = () => { const [updatePerformer] = GQL.usePerformerUpdateMutation({ onError: (errors) => errors, errorPolicy: "all", }); const updatePerformerHandler = (input: GQL.PerformerUpdateInput) => updatePerformer({ variables: { input, }, update: (store, updatedPerformer) => { if (!updatedPerformer.data?.performerUpdate) return; updatedPerformer.data.performerUpdate.stash_ids.forEach((id) => { store.writeQuery< GQL.FindPerformersQuery, GQL.FindPerformersQueryVariables >({ query: GQL.FindPerformersDocument, variables: { performer_filter: { stash_id_endpoint: { stash_id: id.stash_id, endpoint: id.endpoint, modifier: GQL.CriterionModifier.Equals, }, }, }, data: { findPerformers: { count: 1, performers: [updatedPerformer.data!.performerUpdate!], __typename: "FindPerformersResultType", }, }, }); }); }, }); return updatePerformerHandler; }; export const useUpdateStudio = () => { const [updateStudio] = GQL.useStudioUpdateMutation({ onError: (errors) => errors, errorPolicy: "all", }); const updateStudioHandler = (input: GQL.StudioUpdateInput) => updateStudio({ variables: { input, }, update: (store, updatedStudio) => { if (!updatedStudio.data?.studioUpdate) return; if (updatedStudio.data?.studioUpdate?.parent_studio) { const ac = getClient(); evictQueries(ac.cache, studioMutationImpactedQueries); } else { updatedStudio.data.studioUpdate.stash_ids.forEach((id) => { store.writeQuery< GQL.FindStudiosQuery, GQL.FindStudiosQueryVariables >({ query: GQL.FindStudiosDocument, variables: { studio_filter: { stash_id_endpoint: { stash_id: id.stash_id, endpoint: id.endpoint, modifier: GQL.CriterionModifier.Equals, }, }, }, data: { findStudios: { count: 1, studios: [updatedStudio.data!.studioUpdate!], __typename: "FindStudiosResultType", }, }, }); }); } }, }); return updateStudioHandler; }; export const useUpdateTag = () => { const [updateTag] = GQL.useTagUpdateMutation({ onError: (errors) => errors, errorPolicy: "all", }); const updateTagHandler = (input: GQL.TagUpdateInput) => updateTag({ variables: { input, }, update: (store, updatedTag) => { if (!updatedTag.data?.tagUpdate) return; updatedTag.data.tagUpdate.stash_ids.forEach((id) => { store.writeQuery({ query: GQL.FindTagsDocument, variables: { tag_filter: { stash_id_endpoint: { stash_id: id.stash_id, endpoint: id.endpoint, modifier: GQL.CriterionModifier.Equals, }, }, }, data: { findTags: { count: 1, tags: [updatedTag.data!.tagUpdate!], __typename: "FindTagsResultType", }, }, }); }); }, }); return updateTagHandler; }; ================================================ FILE: ui/v2.5/src/components/Tagger/scenes/Config.tsx ================================================ import { faTimes } from "@fortawesome/free-solid-svg-icons"; import React, { useContext, useState } from "react"; import { Badge, Button, Card, Collapse, Form, InputGroup, } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { Icon } from "src/components/Shared/Icon"; import { ParseMode, TagOperation } from "../constants"; import { TaggerStateContext } from "../context"; import { GenderEnum } from "src/core/generated-graphql"; import { genderList } from "src/utils/gender"; const Blacklist: React.FC<{ list: string[]; setList: (blacklist: string[]) => void; }> = ({ list, setList }) => { const intl = useIntl(); const [currentValue, setCurrentValue] = useState(""); const [error, setError] = useState(); function addBlacklistItem() { if (!currentValue) return; // don't add duplicate items if (list.includes(currentValue)) { setError( intl.formatMessage({ id: "component_tagger.config.errors.blacklist_duplicate", }) ); return; } // validate regex try { new RegExp(currentValue); } catch (e) { setError((e as SyntaxError).message); return; } setList([...list, currentValue]); setCurrentValue(""); } function removeBlacklistItem(index: number) { const newBlacklist = [...list]; newBlacklist.splice(index, 1); setList(newBlacklist); } return (
    { setCurrentValue(e.currentTarget.value); setError(undefined); }} onKeyDown={(e: React.KeyboardEvent) => { if (e.key === "Enter") { addBlacklistItem(); e.preventDefault(); } }} isInvalid={!!error} /> {error}
    {intl.formatMessage( { id: "component_tagger.config.blacklist_desc" }, { chars_require_escape: [\^$.|?*+() } )}
    {list.map((item, index) => ( {item.toString()} ))}
    ); }; interface IConfigProps { show: boolean; } const Config: React.FC = ({ show }) => { const { config, setConfig } = useContext(TaggerStateContext); const intl = useIntl(); function renderGenderCheckbox(gender: GenderEnum) { const performerGenders = config.performerGenders || genderList.slice(); return ( } checked={performerGenders.includes(gender)} onChange={(e) => { const isChecked = e.currentTarget.checked; setConfig({ ...config, performerGenders: isChecked ? [...performerGenders, gender] : performerGenders.filter((g) => g !== gender), }); }} /> ); } return (


    {genderList.map(renderGenderCheckbox)} } checked={config.setCoverImage} onChange={(e) => setConfig({ ...config, setCoverImage: e.currentTarget.checked, }) } />
    } className="mr-4" checked={config.setTags} onChange={(e) => setConfig({ ...config, setTags: e.currentTarget.checked }) } /> setConfig({ ...config, tagOperation: e.currentTarget.value as TagOperation, }) } disabled={!config.setTags} >
    : setConfig({ ...config, mode: e.currentTarget.value as ParseMode, }) } >
    {intl.formatMessage({ id: `component_tagger.config.query_mode_${config.mode}_desc`, defaultMessage: "Unknown query mode", })}
    } checked={config.markSceneAsOrganizedOnSave} onChange={(e) => setConfig({ ...config, markSceneAsOrganizedOnSave: e.currentTarget.checked, }) } />
    setConfig({ ...config, blacklist })} />
    ); }; export default Config; ================================================ FILE: ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { Button, ButtonGroup } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { OptionalField } from "../IncludeButton"; import { Performer, PerformerSelect, } from "src/components/Performers/PerformerSelect"; import { getStashboxBase } from "src/utils/stashbox"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { Link } from "react-router-dom"; import { LinkButton } from "../LinkButton"; const PerformerLink: React.FC<{ performer: GQL.ScrapedPerformer | Performer; url: string | undefined; internal?: boolean; }> = ({ performer, url, internal = false }) => { const name = useMemo(() => { if (!url) return performer.name; return internal ? ( {performer.name} ) : ( {performer.name} ); }, [url, performer.name, internal]); return ( <> {name} {performer.disambiguation && ( {` (${performer.disambiguation})`} )} ); }; interface IPerformerResultProps { performer: GQL.ScrapedPerformer; selectedID: string | undefined; setSelectedID: (id: string | undefined) => void; onCreate: () => void; onLink?: () => Promise; endpoint?: string; ageFromDate?: string | null; } const PerformerResult: React.FC = ({ performer, selectedID, setSelectedID, onCreate, onLink, endpoint, ageFromDate, }) => { const { data: performerData, loading: stashLoading } = GQL.useFindPerformerQuery({ variables: { id: performer.stored_id ?? "" }, skip: !performer.stored_id, }); const matchedPerformer = performerData?.findPerformer; const matchedStashID = matchedPerformer?.stash_ids.some( (stashID) => stashID.endpoint === endpoint && stashID.stash_id === performer.remote_site_id ); const [selectedPerformer, setSelectedPerformer] = useState(); const stashboxPerformerPrefix = endpoint ? `${getStashboxBase(endpoint)}performers/` : undefined; const performerURLPrefix = "/performers/"; function selectPerformer(selected: Performer | undefined) { setSelectedPerformer(selected); setSelectedID(selected?.id); } useEffect(() => { if ( performerData?.findPerformer && selectedID === performerData?.findPerformer?.id ) { setSelectedPerformer(performerData.findPerformer); } }, [performerData?.findPerformer, selectedID]); const handleSelect = (performers: Performer[]) => { if (performers.length) { selectPerformer(performers[0]); } else { selectPerformer(undefined); } }; const handleSkip = () => { selectPerformer(undefined); }; if (stashLoading) return
    Loading performer
    ; if (matchedPerformer && matchedStashID) { return (
    :
    v ? handleSkip() : setSelectedID(matchedPerformer.id) } >
    :
    ); } const selectedSource = !selectedID ? "skip" : "existing"; const safeBuildPerformerScraperLink = (id: string | null | undefined) => { return stashboxPerformerPrefix && id ? `${stashboxPerformerPrefix}${id}` : undefined; }; return (
    :
    {endpoint && onLink && ( )}
    ); }; export default PerformerResult; ================================================ FILE: ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx ================================================ import React, { useContext, useMemo, useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { SceneQueue } from "src/models/sceneQueue"; import { Button, Form } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { OperationButton } from "src/components/Shared/OperationButton"; import { ISceneQueryResult, TaggerStateContext } from "../context"; import Config from "./Config"; import { TaggerScene } from "./TaggerScene"; import { SceneTaggerModals } from "./sceneTaggerModals"; import { SceneSearchResults } from "./StashSearchResult"; import { useConfigurationContext } from "src/hooks/Config"; import { useLightbox } from "src/hooks/Lightbox/hooks"; import { ConfigButton } from "../TaggerConfig"; const Scene: React.FC<{ scene: GQL.SlimSceneDataFragment; searchResult?: ISceneQueryResult; queue?: SceneQueue; index: number; showLightboxImage: (imagePath: string) => void; selected?: boolean; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; }> = ({ scene, searchResult, queue, index, showLightboxImage, selected, onSelectedChanged, }) => { const intl = useIntl(); const { currentSource, doSceneQuery, doSceneFragmentScrape, loading } = useContext(TaggerStateContext); const { configuration } = useConfigurationContext(); const cont = configuration?.interface.continuePlaylistDefault ?? false; const sceneLink = useMemo( () => queue ? queue.makeLink(scene.id, { sceneIndex: index, continue: cont }) : `/scenes/${scene.id}`, [queue, scene.id, index, cont] ); const errorMessage = useMemo(() => { if (searchResult?.error) { return searchResult.error; } else if (searchResult && searchResult.results?.length === 0) { return intl.formatMessage({ id: "component_tagger.results.match_failed_no_result", }); } }, [intl, searchResult]); return ( { await doSceneQuery(scene.id, v); } : undefined } scrapeSceneFragment={ currentSource?.supportSceneFragment ? async () => { await doSceneFragmentScrape(scene.id); } : undefined } showLightboxImage={showLightboxImage} queue={queue} index={index} selected={selected} onSelectedChanged={onSelectedChanged} > {searchResult && searchResult.results?.length ? ( ) : undefined} ); }; interface ITaggerProps { scenes: GQL.SlimSceneDataFragment[]; queue?: SceneQueue; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; } export const Tagger: React.FC = ({ scenes, queue, selectedIds, onSelectChange, }) => { const { sources, setCurrentSource, currentSource, doMultiSceneFragmentScrape, stopMultiScrape, searchResults, loading, loadingMulti, multiError, submitFingerprints, pendingFingerprints, } = useContext(TaggerStateContext); const [showConfig, setShowConfig] = useState(false); const [hideUnmatched, setHideUnmatched] = useState(false); const intl = useIntl(); const hasSelection = selectedIds.size > 0; function handleSourceSelect(e: React.ChangeEvent) { setCurrentSource(sources!.find((s) => s.id === e.currentTarget.value)); } function renderSourceSelector() { return (
    {!sources.length && } {sources.map((i) => ( ))}
    ); } const [spriteImage, setSpriteImage] = useState(null); const lightboxImage = useMemo( () => [{ paths: { thumbnail: spriteImage, image: spriteImage } }], [spriteImage] ); const showLightbox = useLightbox({ images: lightboxImage, }); function showLightboxImage(imagePath: string) { setSpriteImage(imagePath); showLightbox({ images: lightboxImage }); } const filteredScenes = useMemo( () => !hideUnmatched ? scenes : scenes.filter((s) => searchResults[s.id]?.results?.length), [scenes, searchResults, hideUnmatched] ); const toggleHideUnmatchedScenes = () => { setHideUnmatched(!hideUnmatched); }; function maybeRenderShowHideUnmatchedButton() { if (Object.keys(searchResults).length) { return ( ); } } function maybeRenderSubmitFingerprintsButton() { if (pendingFingerprints.length) { return ( ); } } function renderFragmentScrapeButton() { if (!currentSource?.supportSceneFragment) { return; } // Use selected scenes if any, otherwise all scenes const scenesToScrape = hasSelection ? scenes.filter((s) => selectedIds.has(s.id)) : scenes; if (scenesToScrape.length === 0) { return; } if (loadingMulti) { return ( ); } // Change button text based on selection state const buttonTextId = hasSelection ? "component_tagger.verb_scrape_selected" : "component_tagger.verb_scrape_all"; return (
    { await doMultiSceneFragmentScrape(scenesToScrape.map((s) => s.id)); }} > {intl.formatMessage({ id: buttonTextId })} {multiError && ( <>
    {multiError} )}
    ); } return (
    {renderSourceSelector()}
    {maybeRenderShowHideUnmatchedButton()} {maybeRenderSubmitFingerprintsButton()} {renderFragmentScrapeButton()}
    setShowConfig(!showConfig)} />
    {filteredScenes.map((s, i) => ( onSelectChange(s.id, selected, shiftKey) } /> ))}
    ); }; ================================================ FILE: ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx ================================================ import React, { useState, useEffect, useCallback, useMemo } from "react"; import cx from "classnames"; import { Badge, Button, Col, Form, Row } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import uniq from "lodash-es/uniq"; import { blobToBase64 } from "base64-blob"; import { distance } from "src/utils/hamming"; import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; import { faLink, faPlus, faTriangleExclamation, faXmark, } from "@fortawesome/free-solid-svg-icons"; import * as GQL from "src/core/generated-graphql"; import { HoverPopover } from "src/components/Shared/HoverPopover"; import { Icon } from "src/components/Shared/Icon"; import { SuccessIcon } from "src/components/Shared/SuccessIcon"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { TagSelect } from "src/components/Shared/Select"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { OperationButton } from "src/components/Shared/OperationButton"; import * as FormUtils from "src/utils/form"; import { genderList, stringToGender } from "src/utils/gender"; import { IScrapedScene, TaggerStateContext } from "../context"; import { OptionalField } from "../IncludeButton"; import { SceneTaggerModalsState } from "./sceneTaggerModals"; import PerformerResult from "./PerformerResult"; import StudioResult from "./StudioResult"; import { useInitialState } from "src/hooks/state"; import { getStashboxBase } from "src/utils/stashbox"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { compareScenesForSort } from "./utils"; const getDurationIcon = (matchPercentage: number) => { if (matchPercentage > 65) return ( ); if (matchPercentage > 35) return ( ); return ; }; const getDurationStatus = ( scene: IScrapedScene, stashDuration: number | undefined | null ) => { if (!stashDuration) return ""; const durations = scene.fingerprints ?.map((f) => f.duration) .map((d) => Math.abs(d - stashDuration)) ?? []; if (!scene.duration && durations.length === 0) return ""; const matchCount = durations.filter((duration) => duration <= 5).length; let match; if (matchCount > 0) match = ( ); else if (scene.duration && Math.abs(scene.duration - stashDuration) < 5) match = ; const matchPercentage = (matchCount / durations.length) * 100; if (match) return (
    {getDurationIcon(matchPercentage)} {match}
    ); let minDiff = Math.min(...durations); if (scene.duration) { minDiff = Math.min(minDiff, Math.abs(scene.duration - stashDuration)); } return ( ); }; function matchPhashes( scenePhashes: Pick[], fingerprints: GQL.StashBoxFingerprint[] ) { const phashes = fingerprints.filter((f) => f.algorithm === "PHASH"); const matches: { [key: string]: number } = {}; phashes.forEach((p) => { let bestMatch = -1; scenePhashes.forEach((fp) => { const d = distance(p.hash, fp.value); if (d <= 8 && (bestMatch === -1 || d < bestMatch)) { bestMatch = d; } }); if (bestMatch !== -1) { matches[p.hash] = bestMatch; } }); // convert to tuple and sort by distance descending const entries = Object.entries(matches); entries.sort((a, b) => { return a[1] - b[1]; }); return entries; } const getFingerprintStatus = ( scene: IScrapedScene, stashScene: GQL.SlimSceneDataFragment ) => { const checksumMatch = scene.fingerprints?.some((f) => stashScene.files.some((ff) => ff.fingerprints.some( (fp) => fp.value === f.hash && (fp.type === "oshash" || fp.type === "md5") ) ) ); const allPhashes = stashScene.files.reduce( (pv: Pick[], cv) => { return [...pv, ...cv.fingerprints.filter((f) => f.type === "phash")]; }, [] ); const phashMatches = matchPhashes(allPhashes, scene.fingerprints ?? []); const phashList = (
    {phashMatches.map((fp: [string, number]) => { const hash = fp[0]; const d = fp[1]; return (
    {hash} {d === 0 ? ", Exact match" : `, distance ${d}`}
    ); })}
    ); if (checksumMatch || phashMatches.length > 0) return (
    {phashMatches.length > 0 && (
    {phashMatches.length > 1 ? ( ) : ( , }} /> )}
    )} {checksumMatch && (
    , }} />
    )}
    ); }; interface IStashSearchResultProps { scene: IScrapedScene; stashScene: GQL.SlimSceneDataFragment; index: number; isActive: boolean; } const StashSearchResult: React.FC = ({ scene, stashScene, index, isActive, }) => { const intl = useIntl(); const { config, createNewTag, createNewPerformer, linkPerformer, createNewStudio, updateStudio, linkStudio, updateTag, resolveScene, currentSource, saveScene, } = React.useContext(TaggerStateContext); const performerGenders = config.performerGenders || genderList; const performers = useMemo( () => scene.performers?.filter((p) => { const gender = p.gender ? stringToGender(p.gender, true) : undefined; return !gender || performerGenders.includes(gender); }) ?? [], [scene, performerGenders] ); const { createPerformerModal, createStudioModal, createTagModal } = React.useContext(SceneTaggerModalsState); const getInitialTags = useCallback(() => { const stashSceneTags = stashScene.tags.map((t) => t.id); if (!config.setTags) { return stashSceneTags; } const { tagOperation } = config; const newTags = scene.tags?.filter((t) => t.stored_id).map((t) => t.stored_id!) ?? []; if (tagOperation === "overwrite") { return newTags; } if (tagOperation === "merge") { return uniq(stashSceneTags.concat(newTags)); } throw new Error("unexpected tagOperation"); }, [stashScene, scene, config]); const getInitialPerformers = useCallback(() => { return performers.map((p) => p.stored_id ?? undefined); }, [performers]); const getInitialStudio = useCallback(() => { return scene.studio?.stored_id ?? stashScene.studio?.id; }, [stashScene, scene]); const [loading, setLoading] = useState(false); const [excludedFields, setExcludedFields] = useState>( {} ); const [tagIDs, setTagIDs, setInitialTagIDs] = useInitialState( getInitialTags() ); // map of original performer to id const [performerIDs, setPerformerIDs, setInitialPerformerIDs] = useInitialState<(string | undefined)[]>(getInitialPerformers()); const [studioID, setStudioID, setInitialStudioID] = useInitialState< string | undefined >(getInitialStudio()); useEffect(() => { setInitialTagIDs(getInitialTags()); }, [getInitialTags, setInitialTagIDs]); useEffect(() => { setInitialPerformerIDs(getInitialPerformers()); }, [getInitialPerformers, setInitialPerformerIDs]); useEffect(() => { setInitialStudioID(getInitialStudio()); }, [getInitialStudio, setInitialStudioID]); useEffect(() => { async function doResolveScene() { try { setLoading(true); await resolveScene(stashScene.id, index, scene); } finally { setLoading(false); } } if (isActive && !loading && !scene.resolved) { doResolveScene(); } }, [isActive, loading, stashScene, index, resolveScene, scene]); const stashBoxBaseURL = currentSource?.sourceInput.stash_box_endpoint ? getStashboxBase(currentSource.sourceInput.stash_box_endpoint) : undefined; const stashBoxURL = useMemo(() => { if (stashBoxBaseURL) { return `${stashBoxBaseURL}scenes/${scene.remote_site_id}`; } }, [scene, stashBoxBaseURL]); const setExcludedField = (name: string, value: boolean) => setExcludedFields({ ...excludedFields, [name]: value, }); async function handleSave() { const excludedFieldList = Object.keys(excludedFields).filter( (f) => excludedFields[f] ); function resolveField(field: string, stashField: T, remoteField: T) { // #2452 - don't overwrite fields that are already set if the remote field is empty const remoteFieldIsNull = remoteField === null || remoteField === undefined; if (excludedFieldList.includes(field) || remoteFieldIsNull) { return stashField; } return remoteField; } let imgData; if (!excludedFields.cover_image && config.setCoverImage) { const imgurl = scene.image; if (imgurl) { const img = await fetch(imgurl, { mode: "cors", cache: "no-store", }); if (img.status === 200) { const blob = await img.blob(); // Sanity check on image size since bad images will fail if (blob.size > 10000) imgData = await blobToBase64(blob); } } } const filteredPerformerIDs = performerIDs.filter( (id) => id !== undefined ) as string[]; const sceneCreateInput: GQL.SceneUpdateInput = { id: stashScene.id ?? "", title: resolveField("title", stashScene.title, scene.title), details: resolveField("details", stashScene.details, scene.details), date: resolveField("date", stashScene.date, scene.date), performer_ids: uniq( stashScene.performers.map((p) => p.id).concat(filteredPerformerIDs) ), studio_id: studioID, cover_image: resolveField("cover_image", undefined, imgData), tag_ids: tagIDs, stash_ids: stashScene.stash_ids ?? [], code: resolveField("code", stashScene.code, scene.code), director: resolveField("director", stashScene.director, scene.director), }; const includeUrl = !excludedFieldList.includes("url"); if (includeUrl && scene.urls) { sceneCreateInput.urls = uniq(stashScene.urls.concat(scene.urls)); } else { sceneCreateInput.urls = stashScene.urls; } const includeStashID = !excludedFieldList.includes("stash_ids"); if ( includeStashID && currentSource?.sourceInput.stash_box_endpoint && scene.remote_site_id ) { sceneCreateInput.stash_ids = [ ...(stashScene?.stash_ids .map((s) => { return { endpoint: s.endpoint, stash_id: s.stash_id, updated_at: s.updated_at, }; }) .filter( (s) => s.endpoint !== currentSource.sourceInput.stash_box_endpoint ) ?? []), { endpoint: currentSource.sourceInput.stash_box_endpoint, stash_id: scene.remote_site_id, updated_at: new Date().toISOString(), }, ]; } else { // #2348 - don't include stash_ids if we're not setting them delete sceneCreateInput.stash_ids; } await saveScene(sceneCreateInput, includeStashID); } function showPerformerModal(t: GQL.ScrapedPerformer) { createPerformerModal(t, (toCreate) => { if (toCreate) { createNewPerformer(t, toCreate); } }); } async function onCreateTag( t: GQL.ScrapedTag, createInput?: GQL.TagCreateInput ) { const toCreate: GQL.TagCreateInput = createInput ?? { name: t.name }; // If the tag has a remote_site_id and we have an endpoint, include the stash_id const endpoint = currentSource?.sourceInput.stash_box_endpoint; if (!createInput && t.remote_site_id && endpoint) { toCreate.stash_ids = [ { endpoint: endpoint, stash_id: t.remote_site_id, }, ]; } const newTagID = await createNewTag(t, toCreate); if (newTagID !== undefined) { setTagIDs([...tagIDs, newTagID]); } } async function onUpdateTag( t: GQL.ScrapedTag, updateInput: GQL.TagUpdateInput ) { await updateTag(t, updateInput); setTagIDs(uniq([...tagIDs, updateInput.id])); } function showTagModal(t: GQL.ScrapedTag) { createTagModal(t, (result) => { if (result.create) { onCreateTag(t, result.create); } else if (result.update) { onUpdateTag(t, result.update); } }); } async function studioModalCallback( studio: GQL.ScrapedStudio, toCreate?: GQL.StudioCreateInput, parentInput?: GQL.StudioCreateInput ) { if (toCreate) { if (parentInput && studio.parent) { if (toCreate.parent_id) { const parentUpdateData: GQL.StudioUpdateInput = { ...parentInput, id: toCreate.parent_id, }; await updateStudio(parentUpdateData); } else { const parentID = await createNewStudio(studio.parent, parentInput); toCreate.parent_id = parentID; } } createNewStudio(studio, toCreate); } } function showStudioModal(t: GQL.ScrapedStudio) { createStudioModal(t, (toCreate, parentInput) => { studioModalCallback(t, toCreate, parentInput); }); } // constants to get around dot-notation eslint rule const fields = { cover_image: "cover_image", title: "title", date: "date", url: "url", details: "details", studio: "studio", stash_ids: "stash_ids", code: "code", director: "director", }; const maybeRenderCoverImage = () => { if (scene.image) { return (
    setExcludedField(fields.cover_image, v)} >
    ); } }; const renderTitle = () => { if (!scene.title) { return (

    ); } const url = scene.urls?.length ? scene.urls[0] : null; const sceneTitleEl = url ? ( ) : ( ); return (

    setExcludedField(fields.title, v)} > {sceneTitleEl}

    ); }; function renderStudioDate() { const text = scene.studio && scene.date ? `${scene.studio.name} • ${scene.date}` : `${scene.studio?.name ?? scene.date ?? ""}`; if (text) { return
    {text}
    ; } } const renderPerformerList = () => { if (scene.performers?.length) { return (
    {intl.formatMessage( { id: "countables.performers" }, { count: scene?.performers?.length } )} : {scene?.performers?.map((p) => p.name).join(", ")}
    ); } }; const maybeRenderStudioCode = () => { if (isActive && scene.code) { return (
    setExcludedField(fields.code, v)} > {scene.code}
    ); } }; const maybeRenderDateField = () => { if (isActive && scene.date) { return (
    setExcludedField(fields.date, v)} > {scene.date}
    ); } }; const maybeRenderDirector = () => { if (scene.director) { return (
    setExcludedField(fields.director, v)} > : {scene.director}
    ); } }; const maybeRenderURL = () => { if (scene.urls) { return (
    setExcludedField(fields.url, v)} > {scene.urls.map((url) => (
    {url}
    ))}
    ); } }; const maybeRenderDetails = () => { if (scene.details) { return (
    setExcludedField(fields.details, v)} >
    ); } }; const maybeRenderStashBoxID = () => { if (scene.remote_site_id && stashBoxURL) { return (
    setExcludedField(fields.stash_ids, v)} > {scene.remote_site_id}
    ); } }; const maybeRenderStudioField = () => { if (scene.studio) { return (
    setStudioID(id)} onCreate={() => showStudioModal(scene.studio!)} endpoint={ currentSource?.sourceInput.stash_box_endpoint ?? undefined } onLink={async () => { await linkStudio(scene.studio!, studioID!); }} />
    ); } }; function setPerformerID(performerIndex: number, id: string | undefined) { const newPerformerIDs = [...performerIDs]; newPerformerIDs[performerIndex] = id; setPerformerIDs(newPerformerIDs); } const renderPerformerField = () => (
    {performers.map((performer, performerIndex) => ( setPerformerID(performerIndex, id)} onCreate={() => showPerformerModal(performer)} onLink={async () => { await linkPerformer(performer, performerIDs[performerIndex]!); }} endpoint={ currentSource?.sourceInput.stash_box_endpoint ?? undefined } key={`${performer.name ?? performer.remote_site_id ?? ""}`} ageFromDate={ !scene.date || excludedFields.date ? stashScene.date : scene.date } /> ))}
    ); function maybeRenderTagsField() { if (!config.setTags) return; const createTags = scene.tags?.filter((t) => !t.stored_id); return (
    {FormUtils.renderLabel({ title: `${intl.formatMessage({ id: "tags" })}:`, })} { setTagIDs(items.map((i) => i.id)); }} ids={tagIDs} />
    {createTags?.map((t) => ( { onCreateTag(t); }} > {t.name} ))}
    ); } if (loading) { return ; } const stashSceneFile = stashScene.files.length > 0 ? stashScene.files[0] : undefined; return ( <>
    {maybeRenderCoverImage()}
    {renderTitle()} {!isActive && ( <> {renderStudioDate()} {renderPerformerList()} )} {maybeRenderStudioCode()} {maybeRenderDateField()} {getDurationStatus(scene, stashSceneFile?.duration)} {getFingerprintStatus(scene, stashScene)}
    {isActive && (
    {maybeRenderStashBoxID()} {maybeRenderDirector()} {maybeRenderURL()} {maybeRenderDetails()}
    )}
    {isActive && (
    {maybeRenderStudioField()} {renderPerformerField()} {maybeRenderTagsField()}
    )} ); }; export interface ISceneSearchResults { target: GQL.SlimSceneDataFragment; scenes: IScrapedScene[]; } export const SceneSearchResults: React.FC = ({ target, scenes: unsortedScenes, }) => { const [selectedResult, setSelectedResult] = useState(); const scenes = useMemo( () => unsortedScenes .slice() .sort((scrapedSceneA, scrapedSceneB) => compareScenesForSort(target, scrapedSceneA, scrapedSceneB) ), [unsortedScenes, target] ); useEffect(() => { // #3198 - if the selected result is no longer in the list, reset it if (!selectedResult || scenes?.length <= selectedResult) { if (!scenes) { setSelectedResult(undefined); } else if (scenes.length > 0 && scenes[0].resolved) { setSelectedResult(0); } } }, [scenes, selectedResult]); function getClassName(i: number) { return cx("row mx-0 mt-2 search-result", { "selected-result active": i === selectedResult, }); } return (
      {scenes.map((s, i) => ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions, react/no-array-index-key
    • setSelectedResult(i)} className={getClassName(i)} >
    • ))}
    ); }; export default StashSearchResult; ================================================ FILE: ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx ================================================ import React, { useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import cx from "classnames"; import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; import * as GQL from "src/core/generated-graphql"; import { useFindStudio } from "src/core/StashService"; import { Icon } from "src/components/Shared/Icon"; import { ModalComponent } from "src/components/Shared/Modal"; import { faCheck, faExternalLinkAlt, faTimes, } from "@fortawesome/free-solid-svg-icons"; import { Button, Form } from "react-bootstrap"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { excludeFields } from "src/utils/data"; import { ExternalLink } from "src/components/Shared/ExternalLink"; interface IStudioDetailsProps { studio: GQL.ScrapedSceneStudioDataFragment; link?: string; excluded: Record; toggleField: (field: string) => void; isNew?: boolean; } const StudioDetails: React.FC = ({ studio, link, excluded, toggleField, isNew = false, }) => { function maybeRenderImage() { if (!studio.image) return; return (
    ); } function maybeRenderField( id: string, text: string | null | undefined, isSelectable: boolean = true ) { if (!text) return; return (
    {isSelectable && ( )} :
    ); } function maybeRenderURLListField( name: string, text: string[] | null | undefined, truncate: boolean = true ) { if (!text) return; return (
    {!isNew && ( )} :
      {text.map((t, i) => (
    • {truncate ? : t}
    • ))}
    ); } function maybeRenderStashBoxLink() { if (!link) return; return (
    ); } return (
    {maybeRenderImage()}
    {maybeRenderField("name", studio.name, !isNew)} {maybeRenderURLListField("urls", studio.urls)} {maybeRenderField("details", studio.details)} {maybeRenderField("aliases", studio.aliases)} {maybeRenderField("tags", studio.tags?.map((t) => t.name).join(", "))} {maybeRenderField("parent_studio", studio.parent?.name, false)} {maybeRenderStashBoxLink()}
    ); }; interface IStudioModalProps { studio: GQL.ScrapedSceneStudioDataFragment; modalVisible: boolean; closeModal: () => void; handleStudioCreate: ( input: GQL.StudioCreateInput, parent?: GQL.StudioCreateInput ) => void; excludedStudioFields?: string[]; header: string; icon: IconDefinition; endpoint?: string; } const StudioModal: React.FC = ({ modalVisible, studio, handleStudioCreate, closeModal, excludedStudioFields = [], header, icon, endpoint, }) => { const intl = useIntl(); const [excluded, setExcluded] = useState>( excludedStudioFields.reduce( (dict, field) => ({ ...dict, [field]: true }), {} ) ); const toggleField = (name: string) => setExcluded({ ...excluded, [name]: !excluded[name], }); const [parentExcluded, setParentExcluded] = useState>( excludedStudioFields.reduce( (dict, field) => ({ ...dict, [field]: true }), {} ) ); const toggleParentField = (name: string) => setParentExcluded({ ...parentExcluded, [name]: !parentExcluded[name], }); const [createParentStudio, setCreateParentStudio] = useState( !!studio.parent ); let sendParentStudio = true; // The parent studio exists, need to check if it has a Stash ID. const queryResult = useFindStudio(studio.parent?.stored_id ?? ""); if ( queryResult.data?.findStudio?.stash_ids?.length && queryResult.data?.findStudio?.stash_ids?.length > 0 ) { // It already has a Stash ID, so we can skip worrying about it sendParentStudio = false; } const parentStudioCreateText = () => { if (studio.parent && studio.parent.stored_id) { return "actions.assign_stashid_to_parent_studio"; } return "actions.create_parent_studio"; }; function onSave() { if (!studio.name) { throw new Error("studio name must set"); } const studioData: GQL.StudioCreateInput = { name: studio.name, urls: studio.urls, image: studio.image, parent_id: studio.parent?.stored_id, details: studio.details, aliases: studio.aliases ?.split(",") .map((a) => a.trim()) .filter((a) => a), tag_ids: studio.tags?.map((t) => t.stored_id).filter((id) => id) as | string[] | undefined, }; // stashid handling code const remoteSiteID = studio.remote_site_id; const timeNow = new Date().toISOString(); if (remoteSiteID && endpoint) { studioData.stash_ids = [ { endpoint, stash_id: remoteSiteID, updated_at: timeNow, }, ]; } // handle exclusions excludeFields(studioData, excluded); let parentData: GQL.StudioCreateInput | undefined = undefined; if (createParentStudio && sendParentStudio) { if (!studio.parent?.name) { throw new Error("parent studio name must set"); } parentData = { name: studio.parent?.name, urls: studio.parent?.urls, image: studio.parent?.image, details: studio.parent?.details, aliases: studio.parent?.aliases ?.split(",") .map((a) => a.trim()) .filter((a) => a), tag_ids: studio.parent?.tags ?.map((t) => t.stored_id) .filter((id) => id) as string[] | undefined, }; // stashid handling code const parentRemoteSiteID = studio.parent?.remote_site_id; if (parentRemoteSiteID && endpoint) { parentData.stash_ids = [ { endpoint, stash_id: parentRemoteSiteID, updated_at: timeNow, }, ]; } // handle exclusions // Can't exclude parent studio name when creating a new one parentExcluded.name = false; excludeFields(parentData, parentExcluded); } handleStudioCreate(studioData, parentData); } const base = endpoint?.match(/https?:\/\/.*?\//)?.[0]; const link = base ? `${base}studios/${studio.remote_site_id}` : undefined; const parentLink = base ? `${base}studios/${studio.parent?.remote_site_id}` : undefined; function maybeRenderParentStudio() { // There is no parent studio or it already has a Stash ID if (!studio.parent || !sendParentStudio) { return; } return (
    setCreateParentStudio(!createParentStudio)} />
    {maybeRenderParentStudioDetails()}
    ); } function maybeRenderParentStudioDetails() { if (!createParentStudio || !studio.parent) { return; } return ( toggleParentField(field)} link={parentLink} isNew /> ); } return ( closeModal(), variant: "secondary" }} onHide={() => closeModal()} dialogClassName="studio-create-modal" icon={icon} header={header} > toggleField(field)} link={link} /> {maybeRenderParentStudio()} ); }; export default StudioModal; ================================================ FILE: ui/v2.5/src/components/Tagger/scenes/StudioResult.tsx ================================================ import React, { useMemo } from "react"; import { Button, ButtonGroup } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import cx from "classnames"; import { StudioSelect, SelectObject } from "src/components/Shared/Select"; import * as GQL from "src/core/generated-graphql"; import { OptionalField } from "../IncludeButton"; import { getStashboxBase } from "src/utils/stashbox"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { Link } from "react-router-dom"; import { LinkButton } from "../LinkButton"; const StudioLink: React.FC<{ studio: GQL.ScrapedStudio | GQL.SlimStudioDataFragment; url: string | undefined; internal?: boolean; }> = ({ studio, url, internal = false }) => { const name = useMemo(() => { if (!url) return studio.name; return internal ? ( {studio.name} ) : ( {studio.name} ); }, [url, studio.name, internal]); return {name}; }; interface IStudioResultProps { studio: GQL.ScrapedStudio; selectedID: string | undefined; setSelectedID: (id: string | undefined) => void; onCreate: () => void; onLink?: () => Promise; endpoint?: string; } const StudioResult: React.FC = ({ studio, selectedID, setSelectedID, onCreate, onLink, endpoint, }) => { const { data: studioData, loading: stashLoading } = GQL.useFindStudioQuery({ variables: { id: studio.stored_id ?? "" }, skip: !studio.stored_id, }); const matchedStudio = studioData?.findStudio; const matchedStashID = matchedStudio?.stash_ids.some( (stashID) => stashID.endpoint === endpoint && stashID.stash_id ); const stashboxStudioPrefix = endpoint ? `${getStashboxBase(endpoint)}studios/` : undefined; const studioURLPrefix = "/studios/"; const handleSelect = (studios: SelectObject[]) => { if (studios.length) { setSelectedID(studios[0].id); } else { setSelectedID(undefined); } }; const handleSkip = () => { setSelectedID(undefined); }; if (stashLoading) return
    Loading studio
    ; if (matchedStudio && matchedStashID) { return (
    :
    v ? handleSkip() : setSelectedID(matchedStudio.id) } >
    :
    ); } const selectedSource = !selectedID ? "skip" : "existing"; const safeBuildStudioScraperLink = (id: string | null | undefined) => { return stashboxStudioPrefix && id ? `${stashboxStudioPrefix}${id}` : undefined; }; return (
    :
    {endpoint && onLink && ( )}
    ); }; export default StudioResult; ================================================ FILE: ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx ================================================ import React, { useState, useContext, PropsWithChildren, useMemo } from "react"; import * as GQL from "src/core/generated-graphql"; import { Link, useHistory } from "react-router-dom"; import { Button, Collapse, Form, InputGroup } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import { sortPerformers } from "src/core/performers"; import { Icon } from "src/components/Shared/Icon"; import { OperationButton } from "src/components/Shared/OperationButton"; import { StashIDPill } from "src/components/Shared/StashID"; import { PerformerLink, TagLink } from "src/components/Shared/TagLink"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { parsePath, prepareQueryString } from "src/components/Tagger/utils"; import { ScenePreview, SceneSpecsOverlay, } from "src/components/Scenes/SceneCard"; import { TaggerStateContext } from "../context"; import { faChevronDown, faChevronUp, faImage, } from "@fortawesome/free-solid-svg-icons"; import { objectPath, objectTitle } from "src/core/files"; import { useConfigurationContext } from "src/hooks/Config"; import { SceneQueue } from "src/models/sceneQueue"; interface ITaggerSceneDetails { scene: GQL.SlimSceneDataFragment; } const TaggerSceneDetails: React.FC = ({ scene }) => { const [open, setOpen] = useState(false); const sorted = sortPerformers(scene.performers); return (

    {objectTitle(scene)}

    {scene.studio?.name} {scene.studio?.name && scene.date && ` • `} {scene.date}
    {sorted.map((performer) => (
    {performer.name
    ))}
    {scene.tags.map((tag) => ( ))}
    ); }; type StashID = Pick; const StashIDs: React.FC<{ stashIDs: StashID[] }> = ({ stashIDs }) => { if (!stashIDs.length) { return null; } const stashLinks = stashIDs.map((stashID) => { const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; const link = base ? ( ) : ( {stashID.stash_id} ); return
    {link}
    ; }); return
    {stashLinks}
    ; }; interface ITaggerScene { scene: GQL.SlimSceneDataFragment; url: string; errorMessage?: string; doSceneQuery?: (queryString: string) => void; scrapeSceneFragment?: (scene: GQL.SlimSceneDataFragment) => void; loading?: boolean; showLightboxImage: (imagePath: string) => void; queue?: SceneQueue; index?: number; selected?: boolean; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; } export const TaggerScene: React.FC> = ({ scene, url, loading, doSceneQuery, scrapeSceneFragment, errorMessage, children, showLightboxImage, queue, index, selected, onSelectedChanged, }) => { const { config } = useContext(TaggerStateContext); const [queryString, setQueryString] = useState(""); const [queryLoading, setQueryLoading] = useState(false); const { paths, file: basename } = parsePath(objectPath(scene)); const defaultQueryString = prepareQueryString( scene, paths, basename, config.mode, config.blacklist ); const file = useMemo( () => (scene.files.length > 0 ? scene.files[0] : undefined), [scene] ); const width = file?.width ? file.width : 0; const height = file?.height ? file.height : 0; const isPortrait = height > width; const history = useHistory(); const { configuration } = useConfigurationContext(); const cont = configuration?.interface.continuePlaylistDefault ?? false; async function query() { if (!doSceneQuery) return; try { setQueryLoading(true); await doSceneQuery(queryString || defaultQueryString); } finally { setQueryLoading(false); } } function renderQueryForm() { if (!doSceneQuery) return; return ( ) => { setQueryString(e.currentTarget.value); }} onKeyPress={(e: React.KeyboardEvent) => e.key === "Enter" && query() } /> ); } function onSpriteClick(ev: React.MouseEvent) { ev.preventDefault(); showLightboxImage(scene.paths.sprite ?? ""); } function maybeRenderSpriteIcon() { // If a scene doesn't have any files, or doesn't have a sprite generated, the // path will be http://localhost:9999/scene/_sprite.jpg if (scene.files.length > 0) { return ( ); } } function onScrubberClick(timestamp: number) { const link = queue ? queue.makeLink(scene.id, { sceneIndex: index, continue: cont, start: timestamp, }) : `/scenes/${scene.id}?t=${timestamp}`; history.push(link); } let shiftKey = false; return (
    {onSelectedChanged && (
    onSelectedChanged(!selected, shiftKey)} onClick={( event: React.MouseEvent ) => { shiftKey = event.shiftKey; event.stopPropagation(); }} />
    )}
    {maybeRenderSpriteIcon()}
    {renderQueryForm()} {scrapeSceneFragment ? (
    { await scrapeSceneFragment(scene); }} >
    ) : undefined}
    {errorMessage ? (
    {errorMessage}
    ) : undefined}
    {children}
    ); }; ================================================ FILE: ui/v2.5/src/components/Tagger/scenes/sceneTaggerModals.tsx ================================================ import React, { useState, useContext } from "react"; import * as GQL from "src/core/generated-graphql"; import StudioModal from "./StudioModal"; import PerformerModal from "../PerformerModal"; import { TaggerStateContext } from "../context"; import { useIntl } from "react-intl"; import { faTags } from "@fortawesome/free-solid-svg-icons"; import { CreateLinkTagDialog } from "src/components/Shared/ScrapeDialog/CreateLinkTagDialog"; type PerformerModalCallback = (toCreate?: GQL.PerformerCreateInput) => void; type StudioModalCallback = ( toCreate?: GQL.StudioCreateInput, parentInput?: GQL.StudioCreateInput ) => void; type TagModalCallback = (result: { create?: GQL.TagCreateInput; update?: GQL.TagUpdateInput; }) => void; export interface ISceneTaggerModalsContextState { createPerformerModal: ( performer: GQL.ScrapedPerformerDataFragment, callback: PerformerModalCallback ) => void; createStudioModal: ( studio: GQL.ScrapedSceneStudioDataFragment, callback: StudioModalCallback ) => void; createTagModal: (tag: GQL.ScrapedTag, callback: TagModalCallback) => void; } export const SceneTaggerModalsState = React.createContext({ createPerformerModal: () => {}, createStudioModal: () => {}, createTagModal: () => {}, }); export const SceneTaggerModals: React.FC = ({ children }) => { const { currentSource } = useContext(TaggerStateContext); const [performerToCreate, setPerformerToCreate] = useState< GQL.ScrapedPerformerDataFragment | undefined >(); const [performerCallback, setPerformerCallback] = useState< PerformerModalCallback | undefined >(); const [studioToCreate, setStudioToCreate] = useState< GQL.ScrapedSceneStudioDataFragment | undefined >(); const [studioCallback, setStudioCallback] = useState< StudioModalCallback | undefined >(); const [tagToCreate, setTagToCreate] = useState(); const [tagCallback, setTagCallback] = useState< | ((result: { create?: GQL.TagCreateInput; update?: GQL.TagUpdateInput; }) => void) | undefined >(); const intl = useIntl(); function handlePerformerSave(toCreate: GQL.PerformerCreateInput) { if (performerCallback) { performerCallback(toCreate); } setPerformerToCreate(undefined); setPerformerCallback(undefined); } function handlePerformerCancel() { if (performerCallback) { performerCallback(); } setPerformerToCreate(undefined); setPerformerCallback(undefined); } function createPerformerModal( performer: GQL.ScrapedPerformerDataFragment, callback: PerformerModalCallback ) { setPerformerToCreate(performer); // can't set the function directly - needs to be via a wrapping function setPerformerCallback(() => callback); } function handleStudioSave( toCreate: GQL.StudioCreateInput, parentInput?: GQL.StudioCreateInput ) { if (studioCallback) { studioCallback(toCreate, parentInput); } setStudioToCreate(undefined); setStudioCallback(undefined); } function handleStudioCancel() { if (studioCallback) { studioCallback(); } setStudioToCreate(undefined); setStudioCallback(undefined); } function createStudioModal( studio: GQL.ScrapedSceneStudioDataFragment, callback: StudioModalCallback ) { setStudioToCreate(studio); // can't set the function directly - needs to be via a wrapping function setStudioCallback(() => callback); } function handleTagSave(result: { create?: GQL.TagCreateInput; update?: GQL.TagUpdateInput; }) { if (tagCallback) { tagCallback(result); } setTagToCreate(undefined); setTagCallback(undefined); } function createTagModal(tag: GQL.ScrapedTag, callback: TagModalCallback) { setTagToCreate(tag); setTagCallback(() => callback); } const endpoint = currentSource?.sourceInput.stash_box_endpoint ?? undefined; return ( {performerToCreate && ( )} {studioToCreate && ( )} {tagToCreate && ( )} {children} ); }; ================================================ FILE: ui/v2.5/src/components/Tagger/scenes/utils.ts ================================================ import { SlimSceneDataFragment } from "src/core/generated-graphql"; import { IScrapedScene } from "../context"; import { distance } from "src/utils/hamming"; export function minDistance(hash: string, stashScene: SlimSceneDataFragment) { let ret = 9999; stashScene.files.forEach((cv) => { if (ret === 0) return; const stashHash = cv.fingerprints.find((fp) => fp.type === "phash"); if (!stashHash) { return; } const d = distance(hash, stashHash.value); if (d < ret) { ret = d; } }); return ret; } export function calculatePhashComparisonScore( stashScene: SlimSceneDataFragment, scrapedScene: IScrapedScene ) { const phashFingerprints = scrapedScene.fingerprints?.filter((f) => f.algorithm === "PHASH") ?? []; const filteredFingerprints = phashFingerprints.filter( (f) => minDistance(f.hash, stashScene) <= 8 ); if (phashFingerprints.length == 0) return [0, 0]; return [ filteredFingerprints.length, filteredFingerprints.length / phashFingerprints.length, ]; } export function minDurationDiff( stashScene: SlimSceneDataFragment, duration: number ) { let ret = 9999; stashScene.files.forEach((cv) => { if (ret === 0) return; const d = Math.abs(duration - cv.duration); if (d < ret) { ret = d; } }); return ret; } export function calculateDurationComparisonScore( stashScene: SlimSceneDataFragment, scrapedScene: IScrapedScene ) { if (scrapedScene.fingerprints && scrapedScene.fingerprints.length > 0) { const durations = scrapedScene.fingerprints.map((f) => f.duration); const diffs = durations.map((d) => minDurationDiff(stashScene, d)); const filteredDurations = diffs.filter((duration) => duration <= 5); const minDiff = Math.min(...diffs); return [ filteredDurations.length, filteredDurations.length / durations.length, minDiff, ]; } return [0, 0, 0]; } export function compareScenesForSort( stashScene: SlimSceneDataFragment, sceneA: IScrapedScene, sceneB: IScrapedScene ) { // Compare sceneA and sceneB to each other for sorting based on similarity to stashScene // Order of priority is: nb. phash match > nb. duration match > ratio duration match > ratio phash match // scenes without any fingerprints should be sorted to the end if (!sceneA.fingerprints?.length && sceneB.fingerprints?.length) { return 1; } if (!sceneB.fingerprints?.length && sceneA.fingerprints?.length) { return -1; } const [nbPhashMatchSceneA, ratioPhashMatchSceneA] = calculatePhashComparisonScore(stashScene, sceneA); const [nbPhashMatchSceneB, ratioPhashMatchSceneB] = calculatePhashComparisonScore(stashScene, sceneB); // If only one scene has matching phash, prefer that scene if ( (nbPhashMatchSceneA != nbPhashMatchSceneB && nbPhashMatchSceneA === 0) || nbPhashMatchSceneB === 0 ) { return nbPhashMatchSceneB - nbPhashMatchSceneA; } // Prefer scene with highest ratio of phash matches if (ratioPhashMatchSceneA !== ratioPhashMatchSceneB) { return ratioPhashMatchSceneB - ratioPhashMatchSceneA; } // Same ratio of phash matches, check duration const [ nbDurationMatchSceneA, ratioDurationMatchSceneA, minDurationDiffSceneA, ] = calculateDurationComparisonScore(stashScene, sceneA); const [ nbDurationMatchSceneB, ratioDurationMatchSceneB, minDurationDiffSceneB, ] = calculateDurationComparisonScore(stashScene, sceneB); if (nbDurationMatchSceneA != nbDurationMatchSceneB) { return nbDurationMatchSceneB - nbDurationMatchSceneA; } // Same number of phash & duration, check duration ratio if (ratioDurationMatchSceneA != ratioDurationMatchSceneB) { return ratioDurationMatchSceneB - ratioDurationMatchSceneA; } // fall back to duration difference - less is better return minDurationDiffSceneA - minDurationDiffSceneB; } ================================================ FILE: ui/v2.5/src/components/Tagger/studios/StashSearchResult.tsx ================================================ import React, { useState } from "react"; import { Button } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import { useUpdateStudio } from "../queries"; import StudioModal from "../scenes/StudioModal"; import { faTags } from "@fortawesome/free-solid-svg-icons"; import { useStudioCreate } from "src/core/StashService"; import { useIntl } from "react-intl"; import { apolloError } from "src/utils"; import { mergeStudioStashIDs } from "../utils"; interface IStashSearchResultProps { studio: GQL.SlimStudioDataFragment; stashboxStudios: GQL.ScrapedStudioDataFragment[]; endpoint: string; onStudioTagged: ( studio: Pick & Partial> ) => void; excludedStudioFields: string[]; } const StashSearchResult: React.FC = ({ studio, stashboxStudios, onStudioTagged, excludedStudioFields, endpoint, }) => { const intl = useIntl(); const [modalStudio, setModalStudio] = useState(); const [saveState, setSaveState] = useState(""); const [error, setError] = useState<{ message?: string; details?: string }>( {} ); const [createStudio] = useStudioCreate(); const updateStudio = useUpdateStudio(); function handleSaveError(name: string, message: string) { setError({ message: intl.formatMessage( { id: "studio_tagger.failed_to_save_studio" }, { studio: name } ), details: message === "UNIQUE constraint failed: studios.name" ? "Name already exists" : message, }); } const handleSave = async ( input: GQL.StudioCreateInput, parentInput?: GQL.StudioCreateInput ) => { setError({}); setModalStudio(undefined); if (parentInput) { setSaveState("Saving parent studio"); try { // if parent id is set, then update the existing studio if (input.parent_id) { const parentUpdateData: GQL.StudioUpdateInput = { ...parentInput, id: input.parent_id, }; parentUpdateData.stash_ids = await mergeStudioStashIDs( input.parent_id, parentInput.stash_ids ?? [] ); await updateStudio(parentUpdateData); } else { const parentRes = await createStudio({ variables: { input: parentInput }, }); input.parent_id = parentRes.data?.studioCreate?.id; } } catch (e) { handleSaveError(parentInput.name, apolloError(e)); } } setSaveState("Saving studio"); const updateData: GQL.StudioUpdateInput = { ...input, id: studio.id, }; updateData.stash_ids = await mergeStudioStashIDs( studio.id, input.stash_ids ?? [] ); const res = await updateStudio(updateData); if (!res?.data?.studioUpdate) handleSaveError(studio.name, res?.errors?.[0]?.message ?? ""); else onStudioTagged(studio); setSaveState(""); }; const studios = stashboxStudios.map((p) => ( )); return ( <> {modalStudio && ( setModalStudio(undefined)} modalVisible={modalStudio !== undefined} studio={modalStudio} handleStudioCreate={handleSave} icon={faTags} header="Update Studio" excludedStudioFields={excludedStudioFields} endpoint={endpoint} /> )}
    {studios}
    {error.message && (
    Error: {error.message}
    {error.details}
    )} {saveState && ( {saveState} )}
    ); }; export default StashSearchResult; ================================================ FILE: ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx ================================================ import React, { useEffect, useState } from "react"; import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { Link } from "react-router-dom"; import { HashLink } from "react-router-hash-link"; import * as GQL from "src/core/generated-graphql"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { stashBoxStudioQuery, useJobsSubscribe, mutateStashBoxBatchStudioTag, getClient, studioMutationImpactedQueries, useStudioCreate, evictQueries, } from "src/core/StashService"; import { useConfigurationContext } from "src/hooks/Config"; import StashSearchResult from "./StashSearchResult"; import TaggerConfig, { ConfigButton } from "../TaggerConfig"; import { ITaggerConfig, STUDIO_FIELDS } from "../constants"; import StudioModal from "../scenes/StudioModal"; import { useUpdateStudio } from "../queries"; import { apolloError } from "src/utils"; import { faTags } from "@fortawesome/free-solid-svg-icons"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { mergeStudioStashIDs } from "../utils"; import { separateNamesAndStashIds } from "src/utils/stashIds"; import { useTaggerConfig } from "../config"; import { BatchUpdateModal, BatchAddModal, } from "src/components/Shared/BatchModals"; import { StashBoxSelectorField } from "../StashBoxSelector"; type JobFragment = Pick< GQL.Job, "id" | "status" | "subTasks" | "description" | "progress" >; const CLASSNAME = "StudioTagger"; interface IStudioTaggerListProps { studios: GQL.StudioDataFragment[]; selectedEndpoint: { endpoint: string; index: number }; isIdle: boolean; config: ITaggerConfig; onBatchAdd: (studioInput: string, createParent: boolean) => void; onBatchUpdate: ( ids: string[] | undefined, refresh: boolean, createParent: boolean ) => void; } const StudioTaggerList: React.FC = ({ studios, selectedEndpoint, isIdle, config, onBatchAdd, onBatchUpdate, }) => { const intl = useIntl(); const [loading, setLoading] = useState(false); const [searchResults, setSearchResults] = useState< Record >({}); const [searchErrors, setSearchErrors] = useState< Record >({}); const [taggedStudios, setTaggedStudios] = useState< Record> >({}); const [queries, setQueries] = useState>({}); const [showBatchAdd, setShowBatchAdd] = useState(false); const [showBatchUpdate, setShowBatchUpdate] = useState(false); const [batchAddParents, setBatchAddParents] = useState( config.createParentStudios || false ); const [batchUpdateRefresh, setBatchUpdateRefresh] = useState(false); const { data: allStudios } = GQL.useFindStudiosQuery({ skip: !showBatchUpdate, variables: { studio_filter: { stash_id_endpoint: { endpoint: selectedEndpoint.endpoint, modifier: batchUpdateRefresh ? GQL.CriterionModifier.NotNull : GQL.CriterionModifier.IsNull, }, }, filter: { per_page: 0, }, }, }); const [error, setError] = useState< Record >({}); const [loadingUpdate, setLoadingUpdate] = useState(); const [modalStudio, setModalStudio] = useState< GQL.ScrapedStudioDataFragment | undefined >(); const doBoxSearch = (studioID: string, searchVal: string) => { stashBoxStudioQuery(searchVal, selectedEndpoint.endpoint) .then((queryData) => { const s = queryData.data?.scrapeSingleStudio ?? []; setSearchResults({ ...searchResults, [studioID]: s, }); setSearchErrors({ ...searchErrors, [studioID]: undefined, }); setLoading(false); }) .catch(() => { setLoading(false); // Destructure to remove existing result const { [studioID]: unassign, ...results } = searchResults; setSearchResults(results); setSearchErrors({ ...searchErrors, [studioID]: intl.formatMessage({ id: "studio_tagger.network_error", }), }); }); setLoading(true); }; const doBoxUpdate = (studioID: string, stashID: string, endpoint: string) => { setLoadingUpdate(stashID); setError({ ...error, [studioID]: undefined, }); stashBoxStudioQuery(stashID, endpoint) .then((queryData) => { const data = queryData.data?.scrapeSingleStudio ?? []; if (data.length > 0) { setModalStudio({ ...data[0], stored_id: studioID, }); } }) .finally(() => setLoadingUpdate(undefined)); }; async function handleBatchAdd(input: string) { onBatchAdd(input, batchAddParents); setShowBatchAdd(false); } const handleBatchUpdate = (queryAll: boolean, refresh: boolean) => { onBatchUpdate( !queryAll ? studios.map((p) => p.id) : undefined, refresh, batchAddParents ); setShowBatchUpdate(false); }; const handleTaggedStudio = ( studio: Pick & Partial> ) => { setTaggedStudios({ ...taggedStudios, [studio.id]: studio, }); }; const [createStudio] = useStudioCreate(); const updateStudio = useUpdateStudio(); function handleSaveError(studioID: string, name: string, message: string) { setError({ ...error, [studioID]: { message: intl.formatMessage( { id: "studio_tagger.failed_to_save_studio" }, { studio: modalStudio?.name } ), details: message === "UNIQUE constraint failed: studios.name" ? intl.formatMessage({ id: "studio_tagger.name_already_exists", }) : message, }, }); } const handleStudioUpdate = async ( input: GQL.StudioCreateInput, parentInput?: GQL.StudioCreateInput ) => { setModalStudio(undefined); const studioID = modalStudio?.stored_id; if (studioID) { if (parentInput) { try { // if parent id is set, then update the existing studio if (input.parent_id) { const parentUpdateData: GQL.StudioUpdateInput = { ...parentInput, id: input.parent_id, }; parentUpdateData.stash_ids = await mergeStudioStashIDs( input.parent_id, parentInput.stash_ids ?? [] ); await updateStudio(parentUpdateData); } else { const parentRes = await createStudio({ variables: { input: parentInput }, }); input.parent_id = parentRes.data?.studioCreate?.id; } } catch (e) { handleSaveError(studioID, parentInput.name, apolloError(e)); } } const updateData: GQL.StudioUpdateInput = { ...input, id: studioID, }; updateData.stash_ids = await mergeStudioStashIDs( studioID, input.stash_ids ?? [] ); const res = await updateStudio(updateData); if (!res.data?.studioUpdate) handleSaveError( studioID, modalStudio?.name ?? "", res?.errors?.[0]?.message ?? "" ); } }; const renderStudios = () => studios.map((studio) => { const isTagged = taggedStudios[studio.id]; const stashID = studio.stash_ids.find((s) => { return s.endpoint === selectedEndpoint.endpoint; }); let mainContent; if (!isTagged && stashID !== undefined) { mainContent = (
    ); } else if (!isTagged && !stashID) { mainContent = ( setQueries({ ...queries, [studio.id]: e.currentTarget.value, }) } onKeyPress={(e: React.KeyboardEvent) => e.key === "Enter" && doBoxSearch(studio.id, queries[studio.id] ?? studio.name ?? "") } /> ); } else if (isTagged) { mainContent = (
    ); } let subContent; if (stashID !== undefined) { const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; const link = base ? ( {stashID.stash_id} ) : (
    {stashID.stash_id}
    ); subContent = (
    {link} {error[studio.id] && (
    Error: {error[studio.id]?.message}
    {error[studio.id]?.details}
    )}
    ); } else if (searchErrors[studio.id]) { subContent = (
    {searchErrors[studio.id]}
    ); } else if (searchResults[studio.id]?.length === 0) { subContent = (
    ); } let searchResult; if (searchResults[studio.id]?.length > 0 && !isTagged) { searchResult = ( ); } return (
    {modalStudio && ( setModalStudio(undefined)} modalVisible={modalStudio.stored_id === studio.id} studio={modalStudio} handleStudioCreate={handleStudioUpdate} excludedStudioFields={config.excludedStudioFields} icon={faTags} header={intl.formatMessage({ id: "studio_tagger.update_studio", })} endpoint={selectedEndpoint.endpoint} /> )}

    {studio.name}

    {mainContent}
    {subContent}
    {searchResult}
    ); }); return ( {showBatchUpdate && ( setShowBatchUpdate(false)} isIdle={isIdle} selectedEndpoint={selectedEndpoint} entities={studios} allCount={allStudios?.findStudios.count} onBatchUpdate={handleBatchUpdate} onRefreshChange={setBatchUpdateRefresh} batchAddParents={batchAddParents} setBatchAddParents={setBatchAddParents} localePrefix="studio_tagger" entityName="studio" countVariableName="studio_count" /> )} {showBatchAdd && ( setShowBatchAdd(false)} isIdle={isIdle} onBatchAdd={handleBatchAdd} batchAddParents={batchAddParents} setBatchAddParents={setBatchAddParents} localePrefix="studio_tagger" entityName="studio" /> )}
    {renderStudios()}
    ); }; interface ITaggerProps { studios: GQL.StudioDataFragment[]; } export const StudioTagger: React.FC = ({ studios }) => { const jobsSubscribe = useJobsSubscribe(); const { configuration: stashConfig } = useConfigurationContext(); const { config, setConfig } = useTaggerConfig(); const [showConfig, setShowConfig] = useState(false); const [batchJobID, setBatchJobID] = useState(); const [batchJob, setBatchJob] = useState(); // monitor batch operation useEffect(() => { if (!jobsSubscribe.data) { return; } const event = jobsSubscribe.data.jobsSubscribe; if (event.job.id !== batchJobID) { return; } if (event.type !== GQL.JobStatusUpdateType.Remove) { setBatchJob(event.job); } else { setBatchJob(undefined); setBatchJobID(undefined); // Once the studio batch is complete, refresh all local studio data const ac = getClient(); evictQueries(ac.cache, studioMutationImpactedQueries); } }, [jobsSubscribe, batchJobID]); if (!config) return ; const savedEndpointIndex = stashConfig?.general.stashBoxes.findIndex( (s) => s.endpoint === config.selectedEndpoint ) ?? -1; const selectedEndpointIndex = savedEndpointIndex === -1 && stashConfig?.general.stashBoxes.length ? 0 : savedEndpointIndex; const selectedEndpoint = stashConfig?.general.stashBoxes[selectedEndpointIndex]; async function batchAdd(studioInput: string, createParent: boolean) { if (studioInput && selectedEndpoint) { const inputs = studioInput .split(",") .map((n) => n.trim()) .filter((n) => n.length > 0); const { names, stashIds } = separateNamesAndStashIds(inputs); if (names.length > 0 || stashIds.length > 0) { const ret = await mutateStashBoxBatchStudioTag({ names: names.length > 0 ? names : undefined, stash_ids: stashIds.length > 0 ? stashIds : undefined, endpoint: selectedEndpointIndex, refresh: false, exclude_fields: config?.excludedStudioFields ?? [], createParent: createParent, }); setBatchJobID(ret.data?.stashBoxBatchStudioTag); } } } async function batchUpdate( ids: string[] | undefined, refresh: boolean, createParent: boolean ) { if (selectedEndpoint) { const ret = await mutateStashBoxBatchStudioTag({ ids: ids, endpoint: selectedEndpointIndex, refresh, exclude_fields: config?.excludedStudioFields ?? [], createParent: createParent, }); setBatchJobID(ret.data?.stashBoxBatchStudioTag); } } // const progress = // jobStatus.data?.metadataUpdate.status === // "Stash-Box Studio Batch Operation" && // jobStatus.data.metadataUpdate.progress >= 0 // ? jobStatus.data.metadataUpdate.progress * 100 // : null; function renderStatus() { if (batchJob) { const progress = batchJob.progress !== undefined && batchJob.progress !== null ? batchJob.progress * 100 : undefined; return (
    {progress !== undefined && ( )}
    ); } if (batchJobID !== undefined) { return (
    ); } } if (selectedEndpointIndex === -1 || !selectedEndpoint) { return (

    el.scrollIntoView({ behavior: "smooth", block: "center" }) } > ), }} />
    ); } return ( <> {renderStatus()}
    setConfig({ ...config, selectedEndpoint: endpoint }) } />
    setShowConfig(!showConfig)} />
    setConfig({ ...config, excludedStudioFields: fields }) } fields={STUDIO_FIELDS} entityName="studios" extraConfig={ } checked={config.createParentStudios} onChange={(e: React.ChangeEvent) => setConfig({ ...config, createParentStudios: e.currentTarget.checked, }) } /> } />
    ); }; ================================================ FILE: ui/v2.5/src/components/Tagger/styles.scss ================================================ .tagger-container { max-width: 1600px; .tagger-container-header { background-color: rgba(0, 0, 0, 0); padding-bottom: 0; } .scene-card { position: relative; .scene-specs-overlay { bottom: 5px; right: 5px; } } .scene-card-preview { border-radius: 3px; color: $text-color; height: 100px; margin-bottom: 0; overflow: hidden; width: auto; } .sprite-button { filter: drop-shadow(1px 1px 1px #222); padding: 0; position: absolute; right: 5px; top: 5px; } .sub-content { min-height: 1.5rem; } } .tagger-table { overflow: visible; } .search-item { background-color: #495b68; border-radius: 3px; padding: 1rem; .scene-details { display: flex; flex-direction: column; overflow-wrap: anywhere; width: 100%; } .original-scene-details { align-items: center; display: flex; flex-direction: column; width: 100%; } } .search-item-check { cursor: pointer; } .search-result { background-color: rgba(61, 80, 92, 0.3); padding: 1rem 0; &:hover { background-color: hsl(204, 20%, 30%); cursor: pointer; } .performer-select, .studio-select { width: 18rem; // stylelint-disable-next-line selector-class-pattern &-active .react-select__control { background-color: #137cbd; } } .SceneTaggerIcon { margin-left: 0.25em; margin-right: 10px; width: var(--fa-fw-width, 1.25em); } } .selected-result { background-color: hsl(204, 20%, 30%); border-radius: 3px; &:hover { cursor: default; } } .scene-select { &:hover { cursor: pointer; } } .scene-image { max-height: 10rem; max-width: 14rem; min-width: 168px; object-fit: contain; } .scene-metadata { margin-left: 1rem; } .select-existing { width: 2rem; } .entity-name { flex: 1; margin-right: auto; } .scene-link { color: $text-color; font-weight: 500; } .performer-create-modal { font-size: 1.2rem; max-width: 800px; .image-selection { height: 450px; text-align: center; .performer-image { height: 85%; position: relative; &-exclude { position: absolute; right: 20px; top: 10px; } } img { max-height: 100%; max-width: 100%; } } .LoadingIndicator { height: 100%; } &-field { margin-bottom: 5px; .btn { margin-right: 5px; } .fa-icon { width: 12px; } } &-value ul { font-size: 0.8em; list-style-type: none; padding-inline-start: 0; } } .PerformerTagger { display: flex; flex-wrap: wrap; justify-content: center; max-width: 1600px; &-header { color: white; &:hover { color: white; } } &-performer { background-color: #495b68; border-radius: 3px; display: flex; margin: 1rem; max-width: 100%; padding: 1rem; .performer-card { flex-shrink: 0; width: 12rem; img { height: 100%; max-height: 18rem; object-fit: cover; object-position: top; } } } &-details { flex-grow: 1; margin-left: 1rem; width: 24rem; } &-performer-search { display: flex; flex-wrap: wrap; &-item { align-items: center; display: flex; overflow: hidden; text-align: left; } } &-thumb { height: 40px; margin-right: 10px; } &-box-link { margin-bottom: 5px; .input-group-text { font-family: monospace; } } } .studio-create-modal { font-size: 1.2rem; max-width: 800px; .image-selection { text-align: center; .studio-image { height: 85%; position: relative; &-exclude { position: absolute; right: 20px; top: 10px; } } img { max-height: 100%; max-width: 100%; } } .LoadingIndicator { height: 100%; } &-field { margin-bottom: 5px; .btn { margin-right: 5px; } .fa-icon { width: 12px; } } } .StudioTagger, .TagTagger { display: flex; flex-wrap: wrap; justify-content: center; max-width: 1600px; &-header { color: white; &:hover { color: white; } } &-studio { background-color: #495b68; border-radius: 3px; display: flex; margin: 1rem; max-width: 100%; padding: 1rem; .studio-card { box-shadow: none; flex-shrink: 0; margin: 0; padding: 0; img { background-color: #495b68; max-height: 150px; object-fit: contain; vertical-align: middle; width: 100%; } } } &-details { //flex-grow: 1; display: flex; flex-direction: column; justify-content: space-between; margin: 0.5rem; width: 24rem; } &-details-image { vertical-align: bottom; } &-details-text { vertical-align: bottom; } &-studio-search, &-tag-search { display: flex; flex-wrap: wrap; &-item { align-items: center; display: flex; overflow: hidden; text-align: left; } } &-thumb { height: 40px; margin-right: 10px; } &-box-link { margin-bottom: 5px; .input-group-text { font-family: monospace; } } } .FieldSelect { .fa-icon { width: 12px; } } .include-exclude-button { display: inline-block; margin-right: 0.38em; padding: 0.2em; } li:not(.active) { .include-exclude-button { // visibility: hidden; display: none; } .scene-image { padding-left: 1rem; } } .optional-field { align-items: center; display: inline-flex; flex-direction: row; } li.active .optional-field.missing .optional-field-content { color: #bfccd6; } li.active .optional-field.excluded .optional-field-content, li.active .optional-field.excluded .scene-link { color: #bfccd6; text-decoration: line-through; img { opacity: 0.5; } } // li.active .scene-image-container { // margin-left: 1rem; // } .tagger-container { .scene-details, .original-scene-details { margin-top: 0.5rem; > .row { width: 100%; } } } .PHashPopover { display: inline-block; text-decoration: underline dotted; } ================================================ FILE: ui/v2.5/src/components/Tagger/tags/StashSearchResult.tsx ================================================ import React, { useState } from "react"; import { Button } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import { useUpdateTag } from "../queries"; import TagModal from "./TagModal"; import { faTags } from "@fortawesome/free-solid-svg-icons"; import { useIntl } from "react-intl"; import { mergeTagStashIDs } from "../utils"; import { useTagCreate } from "src/core/StashService"; import { apolloError } from "src/utils"; interface IStashSearchResultProps { tag: GQL.TagListDataFragment; stashboxTags: GQL.ScrapedSceneTagDataFragment[]; endpoint: string; onTagTagged: ( tag: Pick & Partial> ) => void; excludedTagFields: string[]; } const StashSearchResult: React.FC = ({ tag, stashboxTags, onTagTagged, excludedTagFields, endpoint, }) => { const intl = useIntl(); const [modalTag, setModalTag] = useState(); const [saveState, setSaveState] = useState(""); const [error, setError] = useState<{ message?: string; details?: string }>( {} ); const [createTag] = useTagCreate(); const updateTag = useUpdateTag(); function handleSaveError(name: string, message: string) { setError({ message: intl.formatMessage( { id: "tag_tagger.failed_to_save_tag" }, { tag: name } ), details: message === "UNIQUE constraint failed: tags.name" ? intl.formatMessage({ id: "tag_tagger.name_already_exists", }) : message, }); } const handleSave = async ( input: GQL.TagCreateInput, parentInput?: GQL.TagCreateInput ) => { setError({}); setModalTag(undefined); if (parentInput) { setSaveState("Saving parent tag"); try { const parentRes = await createTag({ variables: { input: parentInput }, }); input.parent_ids = [parentRes.data?.tagCreate?.id].filter( Boolean ) as string[]; } catch (e) { handleSaveError(parentInput.name, apolloError(e)); setSaveState(""); return; } } setSaveState("Saving tag"); const updateData: GQL.TagUpdateInput = { ...input, id: tag.id, }; updateData.stash_ids = await mergeTagStashIDs( tag.id, input.stash_ids ?? [] ); const res = await updateTag(updateData); if (!res?.data?.tagUpdate) { handleSaveError(input.name ?? tag.name, res?.errors?.[0]?.message ?? ""); } else { onTagTagged(tag); } setSaveState(""); }; const tags = stashboxTags.map((p) => ( )); return ( <> {modalTag && ( setModalTag(undefined)} modalVisible={modalTag !== undefined} tag={modalTag} onSave={handleSave} icon={faTags} header="Update Tag" excludedTagFields={excludedTagFields} endpoint={endpoint} /> )}
    {tags}
    {error.message && (
    Error: {error.message}
    {error.details}
    )} {saveState && ( {saveState} )}
    ); }; export default StashSearchResult; ================================================ FILE: ui/v2.5/src/components/Tagger/tags/TagModal.tsx ================================================ import React, { useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "src/components/Shared/Icon"; import { ModalComponent } from "src/components/Shared/Modal"; import { faCheck, faExternalLinkAlt, faTimes, } from "@fortawesome/free-solid-svg-icons"; import { Button, Form } from "react-bootstrap"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { excludeFields } from "src/utils/data"; import { ExternalLink } from "src/components/Shared/ExternalLink"; interface ITagModalProps { tag: GQL.ScrapedSceneTagDataFragment; modalVisible: boolean; closeModal: () => void; onSave: (input: GQL.TagCreateInput, parentInput?: GQL.TagCreateInput) => void; excludedTagFields?: string[]; header: string; icon: IconDefinition; endpoint?: string; } const TagModal: React.FC = ({ modalVisible, tag, onSave, closeModal, excludedTagFields = [], header, icon, endpoint, }) => { const intl = useIntl(); const [excluded, setExcluded] = useState>( excludedTagFields.reduce((dict, field) => ({ ...dict, [field]: true }), {}) ); const toggleField = (name: string) => setExcluded({ ...excluded, [name]: !excluded[name], }); const [createParentTag, setCreateParentTag] = useState( !!tag.parent && !tag.parent.stored_id ); // Check if a tag with the parent name already exists locally. // Categories don't have stash IDs, so stored_id may be null even when the // parent tag has already been created (e.g. by tagging a sibling tag first). const parentNameQuery = GQL.useFindTagsQuery({ skip: !tag.parent || !!tag.parent.stored_id, variables: { tag_filter: { name: { value: tag.parent?.name ?? "", modifier: GQL.CriterionModifier.Equals, }, }, filter: { per_page: 1 }, }, }); const existingParentId = parentNameQuery.data?.findTags.tags[0]?.id; // If the parent already exists locally, don't offer to create it const sendParentTag = !existingParentId; const [parentExcluded, setParentExcluded] = useState>( excludedTagFields.reduce((dict, field) => ({ ...dict, [field]: true }), {}) ); const toggleParentField = (name: string) => setParentExcluded({ ...parentExcluded, [name]: !parentExcluded[name], }); function maybeRenderField( id: string, text: string | null | undefined, isSelectable: boolean = true ) { if (!text) return; return (
    {isSelectable && ( )} :
    ); } function maybeRenderStashBoxLink() { const base = endpoint?.match(/https?:\/\/.*?\//)?.[0]; const link = base ? `${base}tags/${tag.remote_site_id}` : undefined; if (!link) return; return (
    ); } function maybeRenderParentField( id: string, text: string | null | undefined, isSelectable: boolean = true ) { if (!text) return; return (
    {isSelectable && ( )} :
    ); } function maybeRenderParentTagDetails() { if (!createParentTag || !tag.parent) { return; } return (
    {maybeRenderParentField("name", tag.parent.name, false)} {maybeRenderParentField("description", tag.parent.description)}
    ); } function maybeRenderParentTag() { // No parent tag, or parent already exists locally if (!tag.parent || tag.parent.stored_id || !sendParentTag) { return; } return (
    setCreateParentTag(!createParentTag)} />
    {maybeRenderParentTagDetails()}
    ); } function handleSave() { if (!tag.name) { throw new Error("tag name must be set"); } const parentId = tag.parent?.stored_id ?? existingParentId; const tagData: GQL.TagCreateInput = { name: tag.name, description: tag.description ?? undefined, aliases: tag.alias_list?.filter((a) => a) ?? undefined, parent_ids: parentId ? [parentId] : undefined, }; // stashid handling code const remoteSiteID = tag.remote_site_id; if (remoteSiteID && endpoint) { tagData.stash_ids = [ { endpoint, stash_id: remoteSiteID, updated_at: new Date().toISOString(), }, ]; } // handle exclusions excludeFields(tagData, excluded); let parentData: GQL.TagCreateInput | undefined = undefined; // Categories don't have stash IDs, so we only create new parent tags if ( createParentTag && sendParentTag && tag.parent && !tag.parent.stored_id ) { parentData = { name: tag.parent.name, description: tag.parent.description ?? undefined, }; // handle exclusions // Can't exclude parent tag name when creating a new one parentExcluded.name = false; excludeFields(parentData, parentExcluded); } onSave(tagData, parentData); } return ( closeModal(), variant: "secondary" }} onHide={() => closeModal()} dialogClassName="studio-create-modal" icon={icon} header={header} >
    {maybeRenderField("name", tag.name)} {maybeRenderField("description", tag.description)} {maybeRenderField("aliases", tag.alias_list?.join(", "))} {maybeRenderField("parent_tags", tag.parent?.name, false)} {maybeRenderStashBoxLink()}
    {maybeRenderParentTag()}
    ); }; export default TagModal; ================================================ FILE: ui/v2.5/src/components/Tagger/tags/TagTagger.tsx ================================================ import React, { useEffect, useState } from "react"; import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { Link } from "react-router-dom"; import { HashLink } from "react-router-hash-link"; import * as GQL from "src/core/generated-graphql"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { stashBoxTagQuery, useJobsSubscribe, mutateStashBoxBatchTagTag, getClient, } from "src/core/StashService"; import { useConfigurationContext } from "src/hooks/Config"; import StashSearchResult from "./StashSearchResult"; import TaggerConfig, { ConfigButton } from "../TaggerConfig"; import { ITaggerConfig, TAG_FIELDS } from "../constants"; import { useUpdateTag } from "../queries"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { mergeTagStashIDs } from "../utils"; import { separateNamesAndStashIds } from "src/utils/stashIds"; import { useTaggerConfig } from "../config"; import { BatchUpdateModal, BatchAddModal, } from "src/components/Shared/BatchModals"; import { StashBoxSelectorField } from "../StashBoxSelector"; type JobFragment = Pick< GQL.Job, "id" | "status" | "subTasks" | "description" | "progress" >; const CLASSNAME = "TagTagger"; interface ITagTaggerListProps { tags: GQL.TagListDataFragment[]; selectedEndpoint: { endpoint: string; index: number }; isIdle: boolean; config: ITaggerConfig; onBatchAdd: (tagInput: string, createParent: boolean) => void; onBatchUpdate: ( ids: string[] | undefined, refresh: boolean, createParent: boolean ) => void; } const TagTaggerList: React.FC = ({ tags, selectedEndpoint, isIdle, config, onBatchAdd, onBatchUpdate, }) => { const intl = useIntl(); const [loading, setLoading] = useState(false); const [searchResults, setSearchResults] = useState< Record >({}); const [searchErrors, setSearchErrors] = useState< Record >({}); const [taggedTags, setTaggedTags] = useState< Record> >({}); const [queries, setQueries] = useState>({}); const [showBatchAdd, setShowBatchAdd] = useState(false); const [showBatchUpdate, setShowBatchUpdate] = useState(false); const [batchAddParents, setBatchAddParents] = useState( config.createParentTags || false ); const [batchUpdateRefresh, setBatchUpdateRefresh] = useState(false); const { data: allTags } = GQL.useFindTagsQuery({ skip: !showBatchUpdate, variables: { tag_filter: { stash_id_endpoint: { endpoint: selectedEndpoint.endpoint, modifier: batchUpdateRefresh ? GQL.CriterionModifier.NotNull : GQL.CriterionModifier.IsNull, }, }, filter: { per_page: 0, }, }, }); const [error, setError] = useState< Record >({}); const [loadingUpdate, setLoadingUpdate] = useState(); const doBoxSearch = (tagID: string, searchVal: string) => { stashBoxTagQuery(searchVal, selectedEndpoint.endpoint) .then((queryData) => { const s = queryData.data?.scrapeSingleTag ?? []; setSearchResults({ ...searchResults, [tagID]: s, }); setSearchErrors({ ...searchErrors, [tagID]: undefined, }); setLoading(false); }) .catch(() => { setLoading(false); const { [tagID]: unassign, ...results } = searchResults; setSearchResults(results); setSearchErrors({ ...searchErrors, [tagID]: intl.formatMessage({ id: "tag_tagger.network_error", }), }); }); setLoading(true); }; const updateTag = useUpdateTag(); const doBoxUpdate = (tagID: string, stashID: string, endpoint: string) => { setLoadingUpdate(stashID); setError({ ...error, [tagID]: undefined, }); stashBoxTagQuery(stashID, endpoint) .then(async (queryData) => { const data = queryData.data?.scrapeSingleTag ?? []; if (data.length > 0) { const stashboxTag = data[0]; const updateData: GQL.TagUpdateInput = { id: tagID, }; if ( !(config.excludedTagFields ?? []).includes("name") && stashboxTag.name ) { updateData.name = stashboxTag.name; } if ( stashboxTag.description && !(config.excludedTagFields ?? []).includes("description") ) { updateData.description = stashboxTag.description; } if ( stashboxTag.alias_list && stashboxTag.alias_list.length > 0 && !(config.excludedTagFields ?? []).includes("aliases") ) { updateData.aliases = stashboxTag.alias_list; } if (stashboxTag.remote_site_id) { updateData.stash_ids = await mergeTagStashIDs(tagID, [ { endpoint, stash_id: stashboxTag.remote_site_id, }, ]); } const res = await updateTag(updateData); if (!res?.data?.tagUpdate) { setError({ ...error, [tagID]: { message: `Failed to update tag`, details: res?.errors?.[0]?.message ?? "", }, }); } } }) .finally(() => setLoadingUpdate(undefined)); }; async function handleBatchAdd(input: string) { onBatchAdd(input, batchAddParents); setShowBatchAdd(false); } const handleBatchUpdate = (queryAll: boolean, refresh: boolean) => { onBatchUpdate( !queryAll ? tags.map((t) => t.id) : undefined, refresh, batchAddParents ); setShowBatchUpdate(false); }; const handleTaggedTag = ( tag: Pick & Partial> ) => { setTaggedTags({ ...taggedTags, [tag.id]: tag, }); }; const renderTags = () => tags.map((tag) => { const isTagged = taggedTags[tag.id]; const stashID = tag.stash_ids.find((s) => { return s.endpoint === selectedEndpoint.endpoint; }); let mainContent; if (!isTagged && stashID !== undefined) { mainContent = (
    ); } else if (!isTagged && !stashID) { mainContent = ( setQueries({ ...queries, [tag.id]: e.currentTarget.value, }) } onKeyPress={(e: React.KeyboardEvent) => e.key === "Enter" && doBoxSearch(tag.id, queries[tag.id] ?? tag.name ?? "") } /> ); } else if (isTagged) { mainContent = (
    ); } let subContent; if (stashID !== undefined) { const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; const link = base ? ( {stashID.stash_id} ) : (
    {stashID.stash_id}
    ); subContent = (
    {link} {error[tag.id] && (
    Error: {error[tag.id]?.message}
    {error[tag.id]?.details}
    )}
    ); } else if (searchErrors[tag.id]) { subContent = (
    {searchErrors[tag.id]}
    ); } else if (searchResults[tag.id]?.length === 0) { subContent = (
    ); } let searchResult; if (searchResults[tag.id]?.length > 0 && !isTagged) { searchResult = ( ); } return (

    {tag.name}

    {mainContent}
    {subContent}
    {searchResult}
    ); }); return ( {showBatchUpdate && ( setShowBatchUpdate(false)} isIdle={isIdle} selectedEndpoint={selectedEndpoint} entities={tags} allCount={allTags?.findTags.count} onBatchUpdate={handleBatchUpdate} onRefreshChange={setBatchUpdateRefresh} batchAddParents={batchAddParents} setBatchAddParents={setBatchAddParents} localePrefix="tag_tagger" entityName="tag" countVariableName="tag_count" /> )} {showBatchAdd && ( setShowBatchAdd(false)} isIdle={isIdle} onBatchAdd={handleBatchAdd} batchAddParents={batchAddParents} setBatchAddParents={setBatchAddParents} localePrefix="tag_tagger" entityName="tag" /> )}
    {renderTags()}
    ); }; interface ITaggerProps { tags: GQL.TagListDataFragment[]; } export const TagTagger: React.FC = ({ tags }) => { const jobsSubscribe = useJobsSubscribe(); const { configuration: stashConfig } = useConfigurationContext(); const { config, setConfig } = useTaggerConfig(); const [showConfig, setShowConfig] = useState(false); const [batchJobID, setBatchJobID] = useState(); const [batchJob, setBatchJob] = useState(); useEffect(() => { if (!jobsSubscribe.data) { return; } const event = jobsSubscribe.data.jobsSubscribe; if (event.job.id !== batchJobID) { return; } if (event.type !== GQL.JobStatusUpdateType.Remove) { setBatchJob(event.job); } else { setBatchJob(undefined); setBatchJobID(undefined); const ac = getClient(); ac.cache.evict({ fieldName: "findTags" }); ac.cache.gc(); } }, [jobsSubscribe, batchJobID]); if (!config) return ; const savedEndpointIndex = stashConfig?.general.stashBoxes.findIndex( (s) => s.endpoint === config.selectedEndpoint ) ?? -1; const selectedEndpointIndex = savedEndpointIndex === -1 && stashConfig?.general.stashBoxes.length ? 0 : savedEndpointIndex; const selectedEndpoint = stashConfig?.general.stashBoxes[selectedEndpointIndex]; async function batchAdd(tagInput: string, createParent: boolean) { if (tagInput && selectedEndpoint) { const inputs = tagInput .split(",") .map((n) => n.trim()) .filter((n) => n.length > 0); const { names, stashIds } = separateNamesAndStashIds(inputs); if (names.length > 0 || stashIds.length > 0) { const ret = await mutateStashBoxBatchTagTag({ names: names.length > 0 ? names : undefined, stash_ids: stashIds.length > 0 ? stashIds : undefined, endpoint: selectedEndpointIndex, refresh: false, createParent: createParent, exclude_fields: config?.excludedTagFields ?? [], }); setBatchJobID(ret.data?.stashBoxBatchTagTag); } } } async function batchUpdate( ids: string[] | undefined, refresh: boolean, createParent: boolean ) { if (selectedEndpoint) { const ret = await mutateStashBoxBatchTagTag({ ids: ids, endpoint: selectedEndpointIndex, refresh, createParent: createParent, exclude_fields: config?.excludedTagFields ?? [], }); setBatchJobID(ret.data?.stashBoxBatchTagTag); } } function renderStatus() { if (batchJob) { const progress = batchJob.progress !== undefined && batchJob.progress !== null ? batchJob.progress * 100 : undefined; return (
    {progress !== undefined && ( )}
    ); } if (batchJobID !== undefined) { return (
    ); } } if (selectedEndpointIndex === -1 || !selectedEndpoint) { return (

    el.scrollIntoView({ behavior: "smooth", block: "center" }) } > ), }} />
    ); } return ( <> {renderStatus()}
    setConfig({ ...config, selectedEndpoint: endpoint }) } />
    setShowConfig(!showConfig)} />
    setConfig({ ...config, excludedTagFields: fields }) } fields={TAG_FIELDS} entityName="tags" extraConfig={ } checked={config.createParentTags} onChange={(e: React.ChangeEvent) => setConfig({ ...config, createParentTags: e.currentTarget.checked, }) } /> } />
    ); }; ================================================ FILE: ui/v2.5/src/components/Tagger/utils.ts ================================================ import * as GQL from "src/core/generated-graphql"; import { ParseMode } from "./constants"; import { queryFindStudio, queryFindTag } from "src/core/StashService"; import { mergeStashIDs } from "src/utils/stashbox"; const months = [ "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec", ]; const ddmmyyRegex = /\.(\d\d)\.(\d\d)\.(\d\d)\./; const yyyymmddRegex = /(\d{4})[-.](\d{2})[-.](\d{2})/; const mmddyyRegex = /(\d{2})[-.](\d{2})[-.](\d{4})/; const ddMMyyRegex = new RegExp( `(\\d{1,2}).(${months.join("|")})\\.?.(\\d{4})`, "i" ); const MMddyyRegex = new RegExp( `(${months.join("|")})\\.?.(\\d{1,2}),?.(\\d{4})`, "i" ); const javcodeRegex = /([a-zA-Z|tT28|tT38]+-\d+[zZeE]?)/; const handleSpecialStrings = (input: string): string => { let output = input; const ddmmyy = output.match(ddmmyyRegex); if (ddmmyy) { output = output.replace( ddmmyy[0], ` 20${ddmmyy[1]}-${ddmmyy[2]}-${ddmmyy[3]} ` ); } const mmddyy = output.match(mmddyyRegex); if (mmddyy) { output = output.replace( mmddyy[0], ` ${mmddyy[1]}-${mmddyy[2]}-${mmddyy[3]} ` ); } const ddMMyy = output.match(ddMMyyRegex); if (ddMMyy) { const month = (months.indexOf(ddMMyy[2].toLowerCase()) + 1) .toString() .padStart(2, "0"); output = output.replace( ddMMyy[0], ` ${ddMMyy[3]}-${month}-${ddMMyy[1].padStart(2, "0")} ` ); } const MMddyy = output.match(MMddyyRegex); if (MMddyy) { const month = (months.indexOf(MMddyy[1].toLowerCase()) + 1) .toString() .padStart(2, "0"); output = output.replace( MMddyy[0], ` ${MMddyy[3]}-${month}-${MMddyy[2].padStart(2, "0")} ` ); } const yyyymmdd = output.search(yyyymmddRegex); // if we find a date, then replace hyphens with spaces outside of the date // replace dots with hyphens in the date if (yyyymmdd !== -1) return ( output.slice(0, yyyymmdd).replace(/-/g, " ") + output.slice(yyyymmdd, yyyymmdd + 10).replace(/\./g, "-") + output.slice(yyyymmdd + 10).replace(/-/g, " ") ); const javcodeIndex = output.search(javcodeRegex); // if we find a javcode, then replace hyphens with spaces outside of the javcode if (javcodeIndex !== -1) { const javcodeLength = output.match(javcodeRegex)![1].length; return ( output.slice(0, javcodeIndex).replace(/-/g, " ") + output.slice(javcodeIndex, javcodeIndex + javcodeLength) + output.slice(javcodeIndex + javcodeLength).replace(/-/g, " ") ); } // otherwise just replace hyphens with spaces return output.replace(/-/g, " "); }; export function prepareQueryString( scene: Partial, paths: string[], filename: string, mode: ParseMode, blacklist: string[] ) { const regexs = blacklist .map((b) => { try { return new RegExp(b, "gi"); } catch { // ignore return null; } }) .filter((r) => r !== null) as RegExp[]; if ((mode === "auto" && scene.date && scene.studio) || mode === "metadata") { let str = [ scene.date, scene.studio?.name ?? "", (scene?.performers ?? []).map((p) => p.name).join(" "), scene?.title ? scene.title.replace(/[^a-zA-Z0-9 ]+/g, "") : "", ] .filter((s) => s !== "") .join(" "); regexs.forEach((re) => { str = str.replace(re, " "); }); return str; } let s = ""; if (mode === "auto" || mode === "filename") { s = filename; } else if (mode === "path") { s = [...paths, filename].join(" "); } else if (mode === "dir" && paths.length) { s = paths[paths.length - 1]; } regexs.forEach((re) => { s = s.replace(re, " "); }); s = handleSpecialStrings(s); return s.replace(/\./g, " ").replace(/ +/g, " "); } export const parsePath = (filePath: string) => { if (!filePath) { return { paths: [], file: "", ext: "", }; } const path = filePath.toLowerCase(); // Absolute paths on Windows start with a drive letter (e.g. C:\) // Alternatively, they may start with a UNC path (e.g. \\server\share) // Remove the drive letter/UNC and replace backslashes with forward slashes const normalizedPath = path.replace(/^[a-z]:|\\\\/, "").replace(/\\/g, "/"); const pathComponents = normalizedPath .split("/") .filter((component) => component.trim().length > 0); const fileName = pathComponents[pathComponents.length - 1]; const ext = fileName.match(/\.[a-z0-9]*$/)?.[0] ?? ""; const file = fileName.slice(0, ext.length * -1); // remove any .. or . paths const paths = ( pathComponents.length >= 1 ? pathComponents.slice(0, pathComponents.length - 1) : [] ).filter((p) => p !== ".." && p !== "."); return { paths, file, ext }; }; async function mergeEntityStashIDs( fetchExisting: (id: string) => Promise, id: string, newStashIDs: GQL.StashIdInput[] ) { const existing = await fetchExisting(id); if (existing) { return mergeStashIDs(existing, newStashIDs); } return newStashIDs; } export const mergeStudioStashIDs = ( id: string, newStashIDs: GQL.StashIdInput[] ) => mergeEntityStashIDs( async (studioId) => (await queryFindStudio(studioId))?.data?.findStudio?.stash_ids, id, newStashIDs ); export const mergeTagStashIDs = (id: string, newStashIDs: GQL.StashIdInput[]) => mergeEntityStashIDs( async (tagId) => (await queryFindTag(tagId))?.data?.findTag?.stash_ids, id, newStashIDs ); ================================================ FILE: ui/v2.5/src/components/Tags/EditTagsDialog.tsx ================================================ import React, { useEffect, useState } from "react"; import { Form } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { useBulkTagUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "../Shared/Modal"; import { useToast } from "src/hooks/Toast"; import { MultiSet } from "../Shared/MultiSet"; import { getAggregateState, getAggregateStateObject, } from "src/utils/bulkUpdate"; import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; function Tags(props: { isUpdating: boolean; controlId: string; messageId: string; existingTagIds: string[] | undefined; tagIDs: GQL.BulkUpdateIds; setTagIDs: (value: React.SetStateAction) => void; }) { const { isUpdating, controlId, messageId, existingTagIds, tagIDs, setTagIDs, } = props; return ( setTagIDs((existing) => ({ ...existing, ids: itemIDs })) } onSetMode={(newMode) => setTagIDs((existing) => ({ ...existing, mode: newMode })) } existingIds={existingTagIds ?? []} ids={tagIDs.ids ?? []} mode={tagIDs.mode} menuPortalTarget={document.body} /> ); } interface IListOperationProps { selected: (GQL.TagDataFragment | GQL.TagListDataFragment)[]; onClose: (applied: boolean) => void; } const tagFields = ["favorite", "description", "ignore_auto_tag"]; export const EditTagsDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); const [parentTagIDs, setParentTagIDs_] = useState({ mode: GQL.BulkUpdateIdMode.Add, }); function setParentTagIDs(value: React.SetStateAction) { console.log(value); setParentTagIDs_(value); } const [existingParentTagIds, setExistingParentTagIds] = useState(); const [childTagIDs, setChildTagIDs] = useState({ mode: GQL.BulkUpdateIdMode.Add, }); const [existingChildTagIds, setExistingChildTagIds] = useState(); const [updateInput, setUpdateInput] = useState({}); const unsetDisabled = props.selected.length < 2; const [updateTags] = useBulkTagUpdate(getTagInput()); // Network state const [isUpdating, setIsUpdating] = useState(false); function setUpdateField(input: Partial) { setUpdateInput({ ...updateInput, ...input }); } function getTagInput(): GQL.BulkTagUpdateInput { const tagInput: GQL.BulkTagUpdateInput = { ids: props.selected.map((tag) => { return tag.id; }), ...updateInput, parent_ids: parentTagIDs, child_ids: childTagIDs, }; return tagInput; } async function onSave() { setIsUpdating(true); try { await updateTags(); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl.formatMessage({ id: "tags" }).toLocaleLowerCase(), } ) ); props.onClose(true); } catch (e) { Toast.error(e); } setIsUpdating(false); } useEffect(() => { const updateState: GQL.BulkTagUpdateInput = {}; const state = props.selected; let updateParentTagIds: string[] = []; let updateChildTagIds: string[] = []; let first = true; state.forEach((tag: GQL.TagDataFragment | GQL.TagListDataFragment) => { getAggregateStateObject(updateState, tag, tagFields, first); const thisParents = (tag.parents ?? []).map((t) => t.id).sort(); updateParentTagIds = getAggregateState(updateParentTagIds, thisParents, first) ?? []; const thisChildren = (tag.children ?? []).map((t) => t.id).sort(); updateChildTagIds = getAggregateState(updateChildTagIds, thisChildren, first) ?? []; first = false; }); setExistingParentTagIds(updateParentTagIds); setExistingChildTagIds(updateChildTagIds); setUpdateInput(updateState); }, [props.selected]); return ( props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} isRunning={isUpdating} >
    setUpdateField({ favorite: checked })} checked={updateInput.favorite ?? undefined} label={intl.formatMessage({ id: "favourite" })} /> setUpdateField({ description: newValue }) } unsetDisabled={unsetDisabled} as="textarea" /> setUpdateField({ ignore_auto_tag: checked }) } checked={updateInput.ignore_auto_tag ?? undefined} />
    ); }; ================================================ FILE: ui/v2.5/src/components/Tags/TagCard.tsx ================================================ import { PatchComponent } from "src/patch"; import { Button, ButtonGroup } from "react-bootstrap"; import React from "react"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import NavUtils from "src/utils/navigation"; import { FormattedMessage } from "react-intl"; import { TruncatedText } from "../Shared/TruncatedText"; import { GridCard } from "../Shared/GridCard/GridCard"; import { PopoverCountButton } from "../Shared/PopoverCountButton"; import { Icon } from "../Shared/Icon"; import { faHeart } from "@fortawesome/free-solid-svg-icons"; import cx from "classnames"; import { useTagUpdate } from "src/core/StashService"; interface IProps { tag: GQL.TagDataFragment | GQL.TagListDataFragment; cardWidth?: number; zoomIndex: number; selecting?: boolean; selected?: boolean; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; } const TagCardPopovers: React.FC = PatchComponent( "TagCard.Popovers", ({ tag }) => { return ( <>
    ); } ); const TagCardOverlays: React.FC = PatchComponent( "TagCard.Overlays", ({ tag }) => { const [updateTag] = useTagUpdate(); function renderFavoriteIcon() { return ( e.preventDefault()}> ); } function onToggleFavorite(v: boolean) { if (tag.id) { updateTag({ variables: { input: { id: tag.id, favorite: v, }, }, }); } } return <>{renderFavoriteIcon()}; } ); const TagCardDetails: React.FC = PatchComponent( "TagCard.Details", ({ tag }) => { function maybeRenderDescription() { if (tag.description) { return ( ); } } function maybeRenderParents() { if (tag.parents.length === 1) { const parent = tag.parents[0]; return (
    {parent.name}, }} />
    ); } if (tag.parents.length > 1) { return (
    {tag.parents.length}  ), }} />
    ); } } function maybeRenderChildren() { if (tag.children.length > 0) { return (
    {tag.children.length}  ), }} />
    ); } } return ( <> {maybeRenderDescription()} {maybeRenderParents()} {maybeRenderChildren()} ); } ); const TagCardImage: React.FC = PatchComponent( "TagCard.Image", ({ tag }) => { return ( <> {tag.name} ); } ); const TagCardTitle: React.FC = PatchComponent( "TagCard.Title", ({ tag }) => { return <>{tag.name ?? ""}; } ); export const TagCard: React.FC = PatchComponent("TagCard", (props) => { const { tag, cardWidth, zoomIndex, selecting, selected, onSelectedChanged } = props; return ( } linkClassName="tag-card-header" image={} details={} overlays={} popovers={} selected={selected} selecting={selecting} onSelectedChanged={onSelectedChanged} /> ); }); ================================================ FILE: ui/v2.5/src/components/Tags/TagCardGrid.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useCardWidth, useContainerDimensions, } from "../Shared/GridCard/GridCard"; import { TagCard } from "./TagCard"; import { PatchComponent } from "src/patch"; interface ITagCardGrid { tags: (GQL.TagDataFragment | GQL.TagListDataFragment)[]; selectedIds: Set; zoomIndex: number; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; } const zoomWidths = [280, 340, 480, 640]; export const TagCardGrid: React.FC = PatchComponent( "TagCardGrid", ({ tags, selectedIds, zoomIndex, onSelectChange }) => { const [componentRef, { width: containerWidth }] = useContainerDimensions(); const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); return (
    {tags.map((tag) => ( 0} selected={selectedIds.has(tag.id)} onSelectedChanged={(selected: boolean, shiftKey: boolean) => onSelectChange(tag.id, selected, shiftKey) } /> ))}
    ); } ); ================================================ FILE: ui/v2.5/src/components/Tags/TagDetails/Tag.tsx ================================================ import { Button, Tabs, Tab, Form } from "react-bootstrap"; import React, { useEffect, useMemo, useState } from "react"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom"; import { FormattedMessage, useIntl } from "react-intl"; import { Helmet } from "react-helmet"; import cx from "classnames"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { useFindTag, useTagUpdate, useTagDestroy, mutateMetadataAutoTag, } from "src/core/StashService"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { ModalComponent } from "src/components/Shared/Modal"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useToast } from "src/hooks/Toast"; import { useConfigurationContext } from "src/hooks/Config"; import { tagRelationHook } from "src/core/tags"; import { TagScenesPanel } from "./TagScenesPanel"; import { TagMarkersPanel } from "./TagMarkersPanel"; import { TagImagesPanel } from "./TagImagesPanel"; import { TagPerformersPanel } from "./TagPerformersPanel"; import { TagStudiosPanel } from "./TagStudiosPanel"; import { TagGalleriesPanel } from "./TagGalleriesPanel"; import { CompressedTagDetailsPanel, TagDetailsPanel } from "./TagDetailsPanel"; import { TagEditPanel } from "./TagEditPanel"; import { TagMergeModal } from "../TagMergeDialog"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; import { DetailImage } from "src/components/Shared/DetailImage"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { TagGroupsPanel } from "./TagGroupsPanel"; import { BackgroundImage } from "src/components/Shared/DetailsPage/BackgroundImage"; import { TabTitleCounter, useTabKey, } from "src/components/Shared/DetailsPage/Tabs"; import { DetailTitle } from "src/components/Shared/DetailsPage/DetailTitle"; import { ExpandCollapseButton } from "src/components/Shared/CollapseButton"; import { FavoriteIcon } from "src/components/Shared/FavoriteIcon"; import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; import { goBackOrReplace } from "src/utils/history"; interface IProps { tag: GQL.TagDataFragment; tabKey?: TabKey; } interface ITagParams { id: string; tab?: string; } const validTabs = [ "default", "scenes", "images", "galleries", "groups", "markers", "performers", "studios", ] as const; type TabKey = (typeof validTabs)[number]; function isTabKey(tab: string): tab is TabKey { return validTabs.includes(tab as TabKey); } const TagTabs: React.FC<{ tabKey?: TabKey; tag: GQL.TagDataFragment; abbreviateCounter: boolean; showAllCounts?: boolean; }> = ({ tabKey, tag, abbreviateCounter, showAllCounts = false }) => { const [showAllDetails, setShowAllDetails] = useState( showAllCounts && tag.children.length > 0 ); const sceneCount = (showAllDetails ? tag.scene_count_all : tag.scene_count) ?? 0; const imageCount = (showAllDetails ? tag.image_count_all : tag.image_count) ?? 0; const galleryCount = (showAllDetails ? tag.gallery_count_all : tag.gallery_count) ?? 0; const groupCount = (showAllDetails ? tag.group_count_all : tag.group_count) ?? 0; const sceneMarkerCount = (showAllDetails ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0; const performerCount = (showAllDetails ? tag.performer_count_all : tag.performer_count) ?? 0; const studioCount = (showAllDetails ? tag.studio_count_all : tag.studio_count) ?? 0; const populatedDefaultTab = useMemo(() => { let ret: TabKey = "scenes"; if (sceneCount == 0) { if (imageCount != 0) { ret = "images"; } else if (galleryCount != 0) { ret = "galleries"; } else if (groupCount != 0) { ret = "groups"; } else if (sceneMarkerCount != 0) { ret = "markers"; } else if (performerCount != 0) { ret = "performers"; } else if (studioCount != 0) { ret = "studios"; } } return ret; }, [ sceneCount, imageCount, galleryCount, sceneMarkerCount, performerCount, studioCount, groupCount, ]); const { setTabKey } = useTabKey({ tabKey, validTabs, defaultTabKey: populatedDefaultTab, baseURL: `/tags/${tag.id}`, }); const contentSwitch = useMemo(() => { if (tag.children.length === 0) { return null; } return (
    setShowAllDetails(!showAllDetails)} type="switch" label={} />
    ); }, [showAllDetails, tag.children.length]); return ( } > {contentSwitch} } > {contentSwitch} } > {contentSwitch} } > {contentSwitch} } > {contentSwitch} } > {contentSwitch} } > {contentSwitch} ); }; const TagPage: React.FC = ({ tag, tabKey }) => { const history = useHistory(); const Toast = useToast(); const intl = useIntl(); // Configuration settings const { configuration } = useConfigurationContext(); const uiConfig = configuration?.ui; const abbreviateCounter = uiConfig?.abbreviateCounters ?? false; const enableBackgroundImage = uiConfig?.enableTagBackgroundImage ?? false; const showAllDetails = uiConfig?.showAllDetails ?? true; const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false; const [collapsed, setCollapsed] = useState(!showAllDetails); const loadStickyHeader = useLoadStickyHeader(); // Editing state const [isEditing, setIsEditing] = useState(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); const [isMerging, setIsMerging] = useState(false); // Editing tag state const [image, setImage] = useState(); const [encodingImage, setEncodingImage] = useState(false); const [updateTag] = useTagUpdate(); const [deleteTag] = useTagDestroy({ id: tag.id }); const showAllCounts = uiConfig?.showChildTagContent; const tagImage = useMemo(() => { let existingImage = tag.image_path; if (isEditing) { if (image === null && existingImage) { const tagImageURL = new URL(existingImage); tagImageURL.searchParams.set("default", "true"); return tagImageURL.toString(); } else if (image) { return image; } } return existingImage; }, [isEditing, tag.image_path, image]); function setFavorite(v: boolean) { if (tag.id) { updateTag({ variables: { input: { id: tag.id, favorite: v, }, }, }); } } // set up hotkeys useEffect(() => { Mousetrap.bind("e", () => toggleEditing()); Mousetrap.bind("d d", () => { setIsDeleteAlertOpen(true); }); Mousetrap.bind(",", () => setCollapsed(!collapsed)); Mousetrap.bind("f", () => setFavorite(!tag.favorite)); return () => { if (isEditing) { Mousetrap.unbind("s s"); } Mousetrap.unbind("e"); Mousetrap.unbind("d d"); Mousetrap.unbind(","); Mousetrap.unbind("f"); }; }); async function onSave(input: GQL.TagCreateInput) { const oldRelations = { parents: tag.parents ?? [], children: tag.children ?? [], }; const result = await updateTag({ variables: { input: { id: tag.id, ...input, }, }, }); if (result.data?.tagUpdate) { toggleEditing(false); const updated = result.data.tagUpdate; tagRelationHook(updated, oldRelations, { parents: updated.parents, children: updated.children, }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl.formatMessage({ id: "tag" }).toLocaleLowerCase() } ) ); } } async function onAutoTag() { if (!tag.id) return; try { await mutateMetadataAutoTag({ tags: [tag.id] }); Toast.success(intl.formatMessage({ id: "toast.started_auto_tagging" })); } catch (e) { Toast.error(e); } } async function onDelete() { try { const oldRelations = { parents: tag.parents ?? [], children: tag.children ?? [], }; await deleteTag(); tagRelationHook(tag as GQL.TagDataFragment, oldRelations, { parents: [], children: [], }); } catch (e) { Toast.error(e); return; } goBackOrReplace(history, "/tags"); } function renderDeleteAlert() { return ( setIsDeleteAlertOpen(false) }} >

    ); } function toggleEditing(value?: boolean) { if (value !== undefined) { setIsEditing(value); } else { setIsEditing((e) => !e); } setImage(undefined); } function renderMergeButton() { return ( ); } function renderMergeDialog() { if (!tag.id) return; return ( { setIsMerging(false); if (mergedId !== undefined && mergedId !== tag.id) { // By default, the merge destination is the current tag, but // the user can change it, in which case we need to redirect. history.replace(`/tags/${mergedId}`); } }} tags={[tag]} /> ); } const headerClassName = cx("detail-header", { edit: isEditing, collapsed, "full-width": !collapsed && !compactExpandedDetails, }); return (
    {tag.name}
    {tagImage && ( )}
    {!isEditing && ( setCollapsed(v)} /> )} setFavorite(v)} /> {!isEditing && ( )} {isEditing ? ( toggleEditing()} onDelete={onDelete} setImage={setImage} setEncodingImage={setEncodingImage} /> ) : ( toggleEditing()} onSave={() => {}} onImageChange={() => {}} onClearImage={() => {}} onAutoTag={onAutoTag} autoTagDisabled={tag.ignore_auto_tag} onDelete={onDelete} classNames="mb-2" customButtons={renderMergeButton()} /> )}
    {!isEditing && loadStickyHeader && ( )}
    {!isEditing && ( )}
    {renderDeleteAlert()} {renderMergeDialog()}
    ); }; const TagLoader: React.FC> = ({ location, match, }) => { const { id, tab } = match.params; const { data, loading, error } = useFindTag(id); useScrollToTopOnMount(); if (loading) return ; if (error) return ; if (!data?.findTag) return ; if (tab && !isTabKey(tab)) { return ( ); } return ; }; export default TagLoader; ================================================ FILE: ui/v2.5/src/components/Tags/TagDetails/TagCreate.tsx ================================================ import React, { useMemo, useState } from "react"; import { useHistory, useLocation } from "react-router-dom"; import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { useTagCreate } from "src/core/StashService"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useToast } from "src/hooks/Toast"; import { tagRelationHook } from "src/core/tags"; import { TagEditPanel } from "./TagEditPanel"; const TagCreate: React.FC = () => { const intl = useIntl(); const history = useHistory(); const Toast = useToast(); const location = useLocation(); const query = useMemo(() => new URLSearchParams(location.search), [location]); const tag = { name: query.get("q") ?? undefined, }; // Editing tag state const [image, setImage] = useState(); const [encodingImage, setEncodingImage] = useState(false); const [createTag] = useTagCreate(); async function onSave(input: GQL.TagCreateInput, andNew?: boolean) { const oldRelations = { parents: [], children: [], }; const result = await createTag({ variables: { input }, }); if (result.data?.tagCreate?.id) { const created = result.data.tagCreate; tagRelationHook(created, oldRelations, { parents: created.parents, children: created.children, }); if (!andNew) { history.push(`/tags/${created.id}`); } Toast.success( intl.formatMessage( { id: "toast.created_entity" }, { entity: intl.formatMessage({ id: "tag" }).toLocaleLowerCase() } ) ); } } function renderImage() { if (image) { return ; } } return (
    {encodingImage ? ( ) : ( renderImage() )}
    history.push("/tags")} onDelete={() => {}} setImage={setImage} setEncodingImage={setEncodingImage} />
    ); }; export default TagCreate; ================================================ FILE: ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx ================================================ import React from "react"; import { TagLink } from "src/components/Shared/TagLink"; import { DetailItem } from "src/components/Shared/DetailItem"; import { StashIDPill } from "src/components/Shared/StashID"; import * as GQL from "src/core/generated-graphql"; import { CustomFields } from "src/components/Shared/CustomFields"; interface ITagDetails { tag: GQL.TagDataFragment; fullWidth?: boolean; } export const TagDetailsPanel: React.FC = ({ tag, fullWidth }) => { function renderParentsField() { if (!tag.parents?.length) { return; } return ( <> {tag.parents.map((p) => ( ))} ); } function renderChildrenField() { if (!tag.children?.length) { return; } return ( <> {tag.children.map((c) => ( ))} ); } function renderStashIDs() { if (!tag.stash_ids?.length) { return; } return (
      {tag.stash_ids.map((stashID) => (
    • ))}
    ); } return (
    ); }; export const CompressedTagDetailsPanel: React.FC = ({ tag }) => { function scrollToTop() { window.scrollTo({ top: 0, behavior: "smooth" }); } return (
    scrollToTop()}> {tag.name} {tag.description ? ( <> / {tag.description} ) : ( "" )}
    ); }; ================================================ FILE: ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx ================================================ import React, { useEffect, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { Button, Form } from "react-bootstrap"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; import ImageUtils from "src/utils/image"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; import Mousetrap from "mousetrap"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import isEqual from "lodash-es/isEqual"; import { useToast } from "src/hooks/Toast"; import { useConfigurationContext } from "src/hooks/Config"; import { handleUnsavedChanges } from "src/utils/navigation"; import { formikUtils } from "src/utils/form"; import { yupFormikValidate, yupRequiredStringArray } from "src/utils/yup"; import { addUpdateStashID, getStashIDs } from "src/utils/stashIds"; import { Tag, TagSelect } from "../TagSelect"; import { Icon } from "src/components/Shared/Icon"; import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; import { CustomFieldsInput, formatCustomFieldInput, } from "src/components/Shared/CustomFields"; import { cloneDeep } from "@apollo/client/utilities"; interface ITagEditPanel { tag: Partial; onSubmit: (tag: GQL.TagCreateInput, andNew?: boolean) => Promise; onCancel: () => void; onDelete: () => void; setImage: (image?: string | null) => void; setEncodingImage: (loading: boolean) => void; } export const TagEditPanel: React.FC = ({ tag, onSubmit, onCancel, onDelete, setImage, setEncodingImage, }) => { const intl = useIntl(); const Toast = useToast(); const { configuration: stashConfig } = useConfigurationContext(); const isNew = tag.id === undefined; // Editing state const [isStashIDSearchOpen, setIsStashIDSearchOpen] = useState(false); // Network state const [isLoading, setIsLoading] = useState(false); const [childTags, setChildTags] = useState([]); const [parentTags, setParentTags] = useState([]); const schema = yup.object({ name: yup.string().required(), sort_name: yup.string().ensure(), aliases: yupRequiredStringArray(intl).defined(), description: yup.string().ensure(), parent_ids: yup.array(yup.string().required()).defined(), child_ids: yup.array(yup.string().required()).defined(), ignore_auto_tag: yup.boolean().defined(), stash_ids: yup.mixed().defined(), image: yup.string().nullable().optional(), custom_fields: yup.object().required().defined(), }); const initialValues = { name: tag?.name ?? "", sort_name: tag?.sort_name ?? "", aliases: tag?.aliases ?? [], description: tag?.description ?? "", parent_ids: (tag?.parents ?? []).map((t) => t.id), child_ids: (tag?.children ?? []).map((t) => t.id), ignore_auto_tag: tag?.ignore_auto_tag ?? false, stash_ids: getStashIDs(tag?.stash_ids), custom_fields: cloneDeep(tag?.custom_fields ?? {}), }; type InputValues = yup.InferType; const [customFieldsError, setCustomFieldsError] = useState(); function submit(values: InputValues) { const input = { ...schema.cast(values), custom_fields: formatCustomFieldInput(isNew, values.custom_fields), }; onSave(input); } const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), onSubmit: submit, }); function onSetParentTags(items: Tag[]) { setParentTags(items); formik.setFieldValue( "parent_ids", items.map((item) => item.id) ); } function onSetChildTags(items: Tag[]) { setChildTags(items); formik.setFieldValue( "child_ids", items.map((item) => item.id) ); } useEffect(() => { setParentTags(tag.parents ?? []); }, [tag.parents]); useEffect(() => { setChildTags(tag.children ?? []); }, [tag.children]); // set up hotkeys useEffect(() => { Mousetrap.bind("s s", () => { if (formik.dirty) { formik.submitForm(); } }); return () => { Mousetrap.unbind("s s"); }; }); async function onSave(input: InputValues, andNew?: boolean) { setIsLoading(true); try { await onSubmit(input, andNew); formik.resetForm(); } catch (e) { Toast.error(e); } setIsLoading(false); } async function onSaveAndNewClick() { const input = { ...schema.cast(formik.values), custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), }; onSave(input, true); } const encodingImage = ImageUtils.usePasteImage(onImageLoad); useEffect(() => { setImage(formik.values.image); }, [formik.values.image, setImage]); useEffect(() => { setEncodingImage(encodingImage); }, [setEncodingImage, encodingImage]); function onImageLoad(imageData: string | null) { formik.setFieldValue("image", imageData); } function onImageChange(event: React.FormEvent) { ImageUtils.onImageChange(event, onImageLoad); } function onStashIDSelected(item?: GQL.StashIdInput) { if (!item) return; const allowMultiple = true; formik.setFieldValue( "stash_ids", addUpdateStashID(formik.values.stash_ids, item, allowMultiple) ); } const { renderField, renderInputField, renderStringListField, renderStashIDsField, } = formikUtils(intl, formik); function renderParentTagsField() { const title = intl.formatMessage({ id: "parent_tags" }); const control = ( ); return renderField("parent_ids", title, control); } function renderSubTagsField() { const title = intl.formatMessage({ id: "sub_tags" }); const control = ( ); return renderField("child_ids", title, control); } if (isLoading) return ; // TODO: CSS class return ( <> {/* allow many stash-ids from the same stash box */} {isStashIDSearchOpen && ( { onStashIDSelected(item); setIsStashIDSearchOpen(false); }} initialQuery={tag?.name ?? ""} /> )}
    {isNew && (

    )} { // Check if it's a redirect after tag creation if (action === "PUSH" && location.pathname.startsWith("/tags/")) { return true; } return handleUnsavedChanges(intl, "tags", tag.id)(location); }} />
    {renderInputField("name")} {renderInputField("sort_name", "text")} {renderStringListField("aliases", "aliases", { orderable: false })} {renderInputField("description", "textarea")} {renderParentTagsField()} {renderSubTagsField()} {renderStashIDsField( "stash_ids", "tags", "stash_ids", undefined, )} formik.setFieldValue("custom_fields", v)} error={customFieldsError} setError={(e) => setCustomFieldsError(e)} />
    {renderInputField("ignore_auto_tag", "checkbox")} onImageLoad(null)} onDelete={onDelete} acceptSVG />
    ); }; ================================================ FILE: ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useTagFilterHook } from "src/core/tags"; import { FilteredGalleryList } from "src/components/Galleries/GalleryList"; import { View } from "src/components/List/views"; interface ITagGalleriesPanel { active: boolean; tag: GQL.TagDataFragment; showSubTagContent?: boolean; } export const TagGalleriesPanel: React.FC = ({ active, tag, showSubTagContent, }) => { const filterHook = useTagFilterHook(tag, showSubTagContent); return ( ); }; ================================================ FILE: ui/v2.5/src/components/Tags/TagDetails/TagGroupsPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useTagFilterHook } from "src/core/tags"; import { FilteredGroupList } from "src/components/Groups/GroupList"; import { View } from "src/components/List/views"; export const TagGroupsPanel: React.FC<{ active: boolean; tag: GQL.TagDataFragment; showSubTagContent?: boolean; }> = ({ active, tag, showSubTagContent }) => { const filterHook = useTagFilterHook(tag, showSubTagContent); return ( ); }; ================================================ FILE: ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useTagFilterHook } from "src/core/tags"; import { FilteredImageList } from "src/components/Images/ImageList"; import { View } from "src/components/List/views"; interface ITagImagesPanel { active: boolean; tag: GQL.TagDataFragment; showSubTagContent?: boolean; } export const TagImagesPanel: React.FC = ({ active, tag, showSubTagContent, }) => { const filterHook = useTagFilterHook(tag, showSubTagContent); return ( ); }; ================================================ FILE: ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { ListFilterModel } from "src/models/list-filter/filter"; import { TagsCriterion, TagsCriterionOption, } from "src/models/list-filter/criteria/tags"; import { FilteredSceneMarkerList } from "src/components/Scenes/SceneMarkerList"; import { View } from "src/components/List/views"; function useFilterHook(tag: GQL.TagDataFragment, showSubTagContent?: boolean) { return (filter: ListFilterModel) => { const tagValue = { id: tag.id, label: tag.name }; // if tag is already present, then we modify it, otherwise add let tagCriterion = filter.criteria.find((c) => { return c.criterionOption.type === "tags"; }) as TagsCriterion | undefined; if ( tagCriterion && (tagCriterion.modifier === GQL.CriterionModifier.IncludesAll || tagCriterion.modifier === GQL.CriterionModifier.Includes) ) { // add the tag if not present if ( !tagCriterion.value.items.find((p) => { return p.id === tag.id; }) ) { tagCriterion.value.items.push(tagValue); } tagCriterion.modifier = GQL.CriterionModifier.IncludesAll; } else { // overwrite tagCriterion = new TagsCriterion(TagsCriterionOption); tagCriterion.value = { items: [tagValue], excluded: [], depth: showSubTagContent ? -1 : 0, }; filter.criteria.push(tagCriterion); } return filter; }; } interface ITagMarkersPanel { active: boolean; tag: GQL.TagDataFragment; showSubTagContent?: boolean; } export const TagMarkersPanel: React.FC = ({ active, tag, showSubTagContent, }) => { const filterHook = useFilterHook(tag, showSubTagContent); return ( ); }; ================================================ FILE: ui/v2.5/src/components/Tags/TagDetails/TagPerformersPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useTagFilterHook } from "src/core/tags"; import { FilteredPerformerList } from "src/components/Performers/PerformerList"; import { View } from "src/components/List/views"; interface ITagPerformersPanel { active: boolean; tag: GQL.TagDataFragment; showSubTagContent?: boolean; } export const TagPerformersPanel: React.FC = ({ active, tag, showSubTagContent, }) => { const filterHook = useTagFilterHook(tag, showSubTagContent); return ( ); }; ================================================ FILE: ui/v2.5/src/components/Tags/TagDetails/TagScenesPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { FilteredSceneList } from "src/components/Scenes/SceneList"; import { useTagFilterHook } from "src/core/tags"; import { View } from "src/components/List/views"; interface ITagScenesPanel { active: boolean; tag: GQL.TagDataFragment; showSubTagContent?: boolean; } export const TagScenesPanel: React.FC = ({ active, tag, showSubTagContent, }) => { const filterHook = useTagFilterHook(tag, showSubTagContent); return ( ); }; ================================================ FILE: ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx ================================================ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useTagFilterHook } from "src/core/tags"; import { FilteredStudioList } from "src/components/Studios/StudioList"; interface ITagStudiosPanel { active: boolean; tag: GQL.TagDataFragment; showSubTagContent?: boolean; } export const TagStudiosPanel: React.FC = ({ active, tag, showSubTagContent, }) => { const filterHook = useTagFilterHook(tag, showSubTagContent); return ; }; ================================================ FILE: ui/v2.5/src/components/Tags/TagList.tsx ================================================ import React, { useCallback, useEffect } from "react"; import cloneDeep from "lodash-es/cloneDeep"; import Mousetrap from "mousetrap"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { useFilteredItemList } from "../List/ItemList"; import { Button } from "react-bootstrap"; import { useHistory } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import { queryFindTagsForList, useFindTagsForList, useTagsDestroy, } from "src/core/StashService"; import { FormattedMessage, useIntl } from "react-intl"; import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; import { ExportDialog } from "../Shared/ExportDialog"; import { tagRelationHook } from "../../core/tags"; import { TagMergeModal } from "./TagMergeDialog"; import { TagCardGrid } from "./TagCardGrid"; import { EditTagsDialog } from "./EditTagsDialog"; import { View } from "../List/views"; import { FilteredListToolbar, IItemListOperation, } from "../List/FilteredListToolbar"; import { PatchComponent, PatchContainerComponent } from "src/patch"; import { TagTagger } from "../Tagger/tags/TagTagger"; import useFocus from "src/utils/focus"; import { Sidebar, SidebarPane, SidebarPaneContent, SidebarStateContext, useSidebarState, } from "../Shared/Sidebar"; import { useCloseEditDelete, useFilterOperations } from "../List/util"; import { FilteredSidebarHeader, useFilteredSidebarKeybinds, } from "../List/Filters/FilterSidebar"; import { ListOperations } from "../List/ListOperationButtons"; import cx from "classnames"; import { FilterTags } from "../List/FilterTags"; import { Pagination, PaginationIndex } from "../List/Pagination"; import { LoadedContent } from "../List/PagedList"; import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; import { FavoriteTagCriterionOption } from "src/models/list-filter/criteria/favorite"; import { TagListTable } from "./TagListTable"; const TagList: React.FC<{ tags: GQL.TagListDataFragment[]; filter: ListFilterModel; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; }> = PatchComponent( "TagList", ({ tags, filter, selectedIds, onSelectChange }) => { if (tags.length === 0 && filter.displayMode !== DisplayMode.Tagger) { return null; } if (filter.displayMode === DisplayMode.Grid) { return ( ); } if (filter.displayMode === DisplayMode.List) { return ( ); } if (filter.displayMode === DisplayMode.Tagger) { return ; } return null; } ); const TagFilterSidebarSections = PatchContainerComponent( "FilteredTagList.SidebarSections" ); const SidebarContent: React.FC<{ filter: ListFilterModel; setFilter: (filter: ListFilterModel) => void; filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; sidebarOpen: boolean; onClose?: () => void; showEditFilter: (editingCriterion?: string) => void; count?: number; focus?: ReturnType; }> = ({ filter, setFilter, // filterHook, view, showEditFilter, sidebarOpen, onClose, count, focus, }) => { const showResultsId = count !== undefined ? "actions.show_count_results" : "actions.show_results"; return ( <> {/* */} } filter={filter} setFilter={setFilter} option={FavoriteTagCriterionOption} sectionID="favourite" />
    ); }; function useViewRandom(filter: ListFilterModel, count: number) { const history = useHistory(); const viewRandom = useCallback(async () => { // query for a random tag if (count === 0) { return; } const index = Math.floor(Math.random() * count); const filterCopy = cloneDeep(filter); filterCopy.itemsPerPage = 1; filterCopy.currentPage = index + 1; const singleResult = await queryFindTagsForList(filterCopy); if (singleResult.data.findTags.tags.length === 1) { const { id } = singleResult.data.findTags.tags[0]; // navigate to the tag page history.push(`/tags/${id}`); } }, [history, filter, count]); return viewRandom; } function useAddKeybinds(filter: ListFilterModel, count: number) { const viewRandom = useViewRandom(filter, count); useEffect(() => { Mousetrap.bind("p r", () => { viewRandom(); }); return () => { Mousetrap.unbind("p r"); }; }, [viewRandom]); } interface ITagList { filterHook?: (filter: ListFilterModel) => ListFilterModel; alterQuery?: boolean; extraOperations?: IItemListOperation[]; } export const FilteredTagList = PatchComponent( "FilteredTagList", (props: ITagList) => { const intl = useIntl(); const history = useHistory(); const searchFocus = useFocus(); const { filterHook, alterQuery, extraOperations = [] } = props; const view = View.Tags; // States const { showSidebar, setShowSidebar, sectionOpen, setSectionOpen, loading: sidebarStateLoading, } = useSidebarState(view); const { filterState, queryResult, modalState, listSelect, showEditFilter } = useFilteredItemList({ filterStateProps: { filterMode: GQL.FilterMode.Tags, view, useURL: alterQuery, }, queryResultProps: { useResult: useFindTagsForList, getCount: (r) => r.data?.findTags.count ?? 0, getItems: (r) => r.data?.findTags.tags ?? [], filterHook, }, }); const { filter, setFilter } = filterState; const { effectiveFilter, result, cachedResult, items, totalCount } = queryResult; const { selectedIds, selectedItems, onSelectChange, onSelectAll, onSelectNone, onInvertSelection, hasSelection, } = listSelect; const { modal, showModal, closeModal } = modalState; // Utility hooks const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ filter, setFilter, }); useAddKeybinds(effectiveFilter, totalCount); useFilteredSidebarKeybinds({ showSidebar, setShowSidebar, }); useEffect(() => { Mousetrap.bind("e", () => { if (hasSelection) { onEdit?.(); } }); Mousetrap.bind("d d", () => { if (hasSelection) { onDelete?.(); } }); return () => { Mousetrap.unbind("e"); Mousetrap.unbind("d d"); }; }); const onCloseEditDelete = useCloseEditDelete({ closeModal, onSelectNone, result, }); const viewRandom = useViewRandom(effectiveFilter, totalCount); function onExport(all: boolean) { showModal( closeModal()} /> ); } function onEdit() { showModal( ); } function onDelete(tag?: GQL.TagListDataFragment) { const itemsToDelete = tag ? [tag] : selectedItems; showModal( { itemsToDelete.forEach((t) => tagRelationHook( t, { parents: t.parents ?? [], children: t.children ?? [] }, { parents: [], children: [] } ) ); }} /> ); } function onMerge() { showModal( { onCloseEditDelete(); if (mergedId) { history.push(`/tags/${mergedId}`); } }} show /> ); } const convertedExtraOperations = extraOperations.map((op) => ({ text: op.text, onClick: () => op.onClick(result, filter, selectedIds), isDisplayed: () => op.isDisplayed?.(result, filter, selectedIds) ?? true, })); const otherOperations = [ ...convertedExtraOperations, { text: intl.formatMessage({ id: "actions.select_all" }), onClick: () => onSelectAll(), isDisplayed: () => totalCount > 0, }, { text: intl.formatMessage({ id: "actions.select_none" }), onClick: () => onSelectNone(), isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.invert_selection" }), onClick: () => onInvertSelection(), isDisplayed: () => totalCount > 0, }, { text: intl.formatMessage({ id: "actions.view_random" }), onClick: viewRandom, }, { text: `${intl.formatMessage({ id: "actions.merge" })}…`, onClick: () => onMerge(), isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export" }), onClick: () => onExport(false), isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export_all" }), onClick: () => onExport(true), }, ]; // render if (sidebarStateLoading) return null; const operations = ( ); return (
    {modal} setShowSidebar(false)}> setShowSidebar(false)} count={cachedResult.loading ? undefined : totalCount} focus={searchFocus} /> setShowSidebar(!showSidebar)} > showEditFilter(c.criterionOption.type)} onRemoveCriterion={removeCriterion} onRemoveAll={clearAllCriteria} />
    setFilter(filter.changePage(page))} />
    {totalCount > filter.itemsPerPage && (
    )}
    ); } ); ================================================ FILE: ui/v2.5/src/components/Tags/TagListTable.tsx ================================================ /* eslint-disable jsx-a11y/control-has-associated-label */ import React from "react"; import { useIntl } from "react-intl"; import { Button } from "react-bootstrap"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; import NavUtils from "src/utils/navigation"; import { faHeart } from "@fortawesome/free-solid-svg-icons"; import { useTagUpdate } from "src/core/StashService"; import { useTableColumns } from "src/hooks/useTableColumns"; import cx from "classnames"; import { IColumn, ListTable } from "../List/ListTable"; interface ITagListTableProps { tags: GQL.TagListDataFragment[]; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; } const TABLE_NAME = "tags"; export const TagListTable: React.FC = ( props: ITagListTableProps ) => { const intl = useIntl(); const [updateTag] = useTagUpdate(); function setFavorite(v: boolean, tagId: string) { if (tagId) { updateTag({ variables: { input: { id: tagId, favorite: v, }, }, }); } } const ImageCell = (tag: GQL.TagListDataFragment) => ( {tag.name ); const NameCell = (tag: GQL.TagListDataFragment) => (
    {tag.name}
    ); const AliasesCell = (tag: GQL.TagListDataFragment) => { let aliases = tag.aliases ? tag.aliases.join(", ") : ""; return ( {aliases} ); }; const FavoriteCell = (tag: GQL.TagListDataFragment) => ( ); const SceneCountCell = (tag: GQL.TagListDataFragment) => ( {tag.scene_count} ); const GalleryCountCell = (tag: GQL.TagListDataFragment) => ( {tag.gallery_count} ); const ImageCountCell = (tag: GQL.TagListDataFragment) => ( {tag.image_count} ); const GroupCountCell = (tag: GQL.TagListDataFragment) => ( {tag.group_count} ); const StudioCountCell = (tag: GQL.TagListDataFragment) => ( {tag.studio_count} ); const PerformerCountCell = (tag: GQL.TagListDataFragment) => ( {tag.performer_count} ); interface IColumnSpec { value: string; label: string; defaultShow?: boolean; mandatory?: boolean; render?: (tag: GQL.TagListDataFragment, index: number) => React.ReactNode; } const allColumns: IColumnSpec[] = [ { value: "image", label: intl.formatMessage({ id: "image" }), defaultShow: true, render: ImageCell, }, { value: "name", label: intl.formatMessage({ id: "name" }), mandatory: true, defaultShow: true, render: NameCell, }, { value: "aliases", label: intl.formatMessage({ id: "aliases" }), defaultShow: true, render: AliasesCell, }, { value: "favourite", label: intl.formatMessage({ id: "favourite" }), defaultShow: true, render: FavoriteCell, }, { value: "scene_count", label: intl.formatMessage({ id: "scenes" }), defaultShow: true, render: SceneCountCell, }, { value: "gallery_count", label: intl.formatMessage({ id: "galleries" }), defaultShow: true, render: GalleryCountCell, }, { value: "image_count", label: intl.formatMessage({ id: "images" }), defaultShow: true, render: ImageCountCell, }, { value: "group_count", label: intl.formatMessage({ id: "groups" }), defaultShow: true, render: GroupCountCell, }, { value: "performer_count", label: intl.formatMessage({ id: "performers" }), defaultShow: true, render: PerformerCountCell, }, { value: "studio_count", label: intl.formatMessage({ id: "studios" }), defaultShow: true, render: StudioCountCell, }, ]; const defaultColumns = allColumns .filter((col) => col.defaultShow) .map((col) => col.value); const { selectedColumns, saveColumns } = useTableColumns( TABLE_NAME, defaultColumns ); const columnRenderFuncs: Record< string, (tag: GQL.TagListDataFragment, index: number) => React.ReactNode > = {}; allColumns.forEach((col) => { if (col.render) { columnRenderFuncs[col.value] = col.render; } }); function renderCell( column: IColumn, tag: GQL.TagListDataFragment, index: number ) { const render = columnRenderFuncs[column.value]; if (render) return render(tag, index); } return ( saveColumns(c)} selectedIds={props.selectedIds} onSelectChange={props.onSelectChange} renderCell={renderCell} /> ); }; ================================================ FILE: ui/v2.5/src/components/Tags/TagMergeDialog.tsx ================================================ import { Button, Form, Col, Row } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Icon } from "../Shared/Icon"; import { ModalComponent } from "src/components/Shared/Modal"; import * as FormUtils from "src/utils/form"; import { queryFindTagsByID, useTagsMerge } from "src/core/StashService"; import { FormattedMessage, useIntl } from "react-intl"; import { useToast } from "src/hooks/Toast"; import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons"; import { Tag, TagSelect } from "./TagSelect"; import { CustomFieldScrapeResults, hasScrapedValues, ObjectListScrapeResult, ScrapeResult, } from "../Shared/ScrapeDialog/scrapeResult"; import { sortStoredIdObjects } from "src/utils/data"; import ImageUtils from "src/utils/image"; import { uniq } from "lodash-es"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { ScrapedCustomFieldRows, ScrapeDialogRow, ScrapedImageRow, ScrapedInputGroupRow, ScrapedStringListRow, ScrapedTextAreaRow, } from "../Shared/ScrapeDialog/ScrapeDialogRow"; import { ScrapedTagsRow } from "../Shared/ScrapeDialog/ScrapedObjectsRow"; import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog"; import { StashIDsField } from "../Shared/StashID"; interface ITagMergeDetailsProps { sources: GQL.TagDataFragment[]; dest: GQL.TagDataFragment; onClose: (values?: GQL.TagUpdateInput) => void; } const TagMergeDetails: React.FC = ({ sources, dest, onClose, }) => { const intl = useIntl(); const [loading, setLoading] = useState(true); const filterCandidates = useCallback( (t: { stored_id: string }) => t.stored_id !== dest.id && sources.every((s) => s.id !== t.stored_id), [dest.id, sources] ); const [name, setName] = useState>( new ScrapeResult(dest.name) ); const [sortName, setSortName] = useState>( new ScrapeResult(dest.sort_name) ); const [aliases, setAliases] = useState>( new ScrapeResult(dest.aliases) ); const [description, setDescription] = useState>( new ScrapeResult(dest.description) ); const [parentTags, setParentTags] = useState< ObjectListScrapeResult >( new ObjectListScrapeResult( sortStoredIdObjects( dest.parents.map(idToStoredID).filter(filterCandidates) ) ) ); const [childTags, setChildTags] = useState< ObjectListScrapeResult >( new ObjectListScrapeResult( sortStoredIdObjects( dest.children.map(idToStoredID).filter(filterCandidates) ) ) ); const [stashIDs, setStashIDs] = useState(new ScrapeResult([])); const [image, setImage] = useState>( new ScrapeResult(dest.image_path) ); const [customFields, setCustomFields] = useState( new Map() ); function idToStoredID(o: { id: string; name: string }) { return { stored_id: o.id, name: o.name, }; } // calculate the values for everything // uses the first set value for single value fields, and combines all useEffect(() => { async function loadImages() { const src = sources.find((s) => s.image_path); if (!dest.image_path || !src) return; setLoading(true); const destData = await ImageUtils.imageToDataURL(dest.image_path); const srcData = await ImageUtils.imageToDataURL(src.image_path!); // keep destination image by default const useNewValue = false; setImage(new ScrapeResult(destData, srcData, useNewValue)); setLoading(false); } // append dest to all so that if dest has stash_ids with the same // endpoint, then it will be excluded first const all = sources.concat(dest); setName( new ScrapeResult(dest.name, sources.find((s) => s.name)?.name, !dest.name) ); setSortName( new ScrapeResult( dest.sort_name, sources.find((s) => s.sort_name)?.sort_name, !dest.sort_name ) ); setDescription( new ScrapeResult( dest.description, sources.find((s) => s.description)?.description, !dest.description ) ); // default alias list should be the existing aliases, plus the names of all sources, // plus all source aliases, deduplicated const allAliases = uniq( dest.aliases.concat( sources.map((s) => s.name), sources.flatMap((s) => s.aliases) ) ); setAliases(new ScrapeResult(dest.aliases, allAliases, !!allAliases.length)); // default parent/child tags should be the existing tags, plus all source parent/child tags, deduplicated const allParentTags = uniq(all.flatMap((s) => s.parents)) .map(idToStoredID) .filter(filterCandidates); // exclude self and sources setParentTags( new ObjectListScrapeResult( sortStoredIdObjects(dest.parents.map(idToStoredID)), sortStoredIdObjects(allParentTags), !!allParentTags.length ) ); const allChildTags = uniq(all.flatMap((s) => s.children)) .map(idToStoredID) .filter(filterCandidates); // exclude self and sources setChildTags( new ObjectListScrapeResult( sortStoredIdObjects( dest.children.map(idToStoredID).filter(filterCandidates) ), sortStoredIdObjects(allChildTags), !!allChildTags.length ) ); setStashIDs( new ScrapeResult( dest.stash_ids, all .map((s) => s.stash_ids) .flat() .filter((s, index, a) => { // remove entries with duplicate endpoints return index === a.findIndex((ss) => ss.endpoint === s.endpoint); }) ) ); setImage( new ScrapeResult( dest.image_path, sources.find((s) => s.image_path)?.image_path, !dest.image_path ) ); const customFieldNames = new Set(Object.keys(dest.custom_fields)); for (const s of sources) { for (const n of Object.keys(s.custom_fields)) { customFieldNames.add(n); } } setCustomFields( new Map( Array.from(customFieldNames) .sort() .map((field) => { return [ field, new ScrapeResult( dest.custom_fields?.[field], sources.find((s) => s.custom_fields?.[field])?.custom_fields?.[ field ], dest.custom_fields?.[field] === undefined ), ]; }) ) ); loadImages(); }, [sources, dest, filterCandidates]); const hasCustomFieldValues = useMemo(() => { return hasScrapedValues(Array.from(customFields.values())); }, [customFields]); // ensure this is updated if fields are changed const hasValues = useMemo(() => { return ( hasCustomFieldValues || hasScrapedValues([ name, sortName, aliases, description, parentTags, childTags, stashIDs, image, ]) ); }, [ name, sortName, aliases, description, parentTags, childTags, stashIDs, image, hasCustomFieldValues, ]); function renderScrapeRows() { if (loading) { return (
    ); } if (!hasValues) { return (
    ); } return ( <> setName(value)} /> setSortName(value)} /> setAliases(value)} /> setParentTags(value)} /> setChildTags(value)} /> setDescription(value)} /> } newField={} onChange={(value) => setStashIDs(value)} alwaysShow={ !!stashIDs.originalValue?.length || !!stashIDs.newValue?.length } /> setImage(value)} /> {hasCustomFieldValues && ( setCustomFields(newCustomFields)} /> )} ); } function createValues(): GQL.TagUpdateInput { // only set the cover image if it's different from the existing cover image const coverImage = image.useNewValue ? image.getNewValue() : undefined; return { id: dest.id, name: name.getNewValue(), sort_name: sortName.getNewValue(), aliases: aliases .getNewValue() ?.map((s) => s.trim()) .filter((s) => s.length > 0), parent_ids: parentTags.getNewValue()?.map((t) => t.stored_id!), child_ids: childTags.getNewValue()?.map((t) => t.stored_id!), description: description.getNewValue(), stash_ids: stashIDs.getNewValue(), image: coverImage, custom_fields: { partial: Object.fromEntries( Array.from(customFields.entries()).flatMap(([field, v]) => v.useNewValue ? [[field, v.getNewValue()]] : [] ) ), }, }; } const dialogTitle = intl.formatMessage({ id: "actions.merge", }); const destinationLabel = !hasValues ? "" : intl.formatMessage({ id: "dialogs.merge.destination" }); const sourceLabel = !hasValues ? "" : intl.formatMessage({ id: "dialogs.merge.combined" }); return ( { if (!apply) { onClose(); } else { onClose(createValues()); } }} > {renderScrapeRows()} ); }; interface ITagMergeModalProps { show: boolean; onClose: (mergedID?: string) => void; tags: Tag[]; } export const TagMergeModal: React.FC = ({ show, onClose, tags, }) => { const [src, setSrc] = useState([]); const [dest, setDest] = useState(null); const [loadedSources, setLoadedSources] = useState([]); const [loadedDest, setLoadedDest] = useState(); const [secondStep, setSecondStep] = useState(false); const [running, setRunning] = useState(false); const [mergeTags] = useTagsMerge(); const intl = useIntl(); const Toast = useToast(); const title = intl.formatMessage({ id: "actions.merge", }); useEffect(() => { if (tags.length > 0) { setDest(tags[0]); setSrc(tags.slice(1)); } }, [tags]); async function loadTags() { try { const tagIDs = src.map((s) => s.id); tagIDs.push(dest!.id); const query = await queryFindTagsByID(tagIDs); const { tags: loadedTags } = query.data.findTags; setLoadedDest(loadedTags.find((s) => s.id === dest!.id)); setLoadedSources(loadedTags.filter((s) => s.id !== dest!.id)); setSecondStep(true); } catch (e) { Toast.error(e); return; } } async function onMerge(values: GQL.TagUpdateInput) { if (!dest) return; const source = src.map((s) => s.id); const destination = dest.id; try { setRunning(true); const result = await mergeTags({ variables: { source, destination, values, }, }); if (result.data?.tagsMerge) { Toast.success(intl.formatMessage({ id: "toast.merged_tags" })); onClose(dest.id); } } catch (e) { Toast.error(e); } finally { setRunning(false); } } function canMerge() { return src.length > 0 && dest !== null; } function switchTags() { if (src.length && dest !== null) { const newDest = src[0]; setSrc([...src.slice(1), dest]); setDest(newDest); } } if (secondStep && dest) { return ( { setSecondStep(false); if (values) { onMerge(values); } else { onClose(); } }} /> ); } return ( loadTags(), }} disabled={!canMerge()} cancel={{ variant: "secondary", onClick: () => onClose(), }} isRunning={running} >
    {FormUtils.renderLabel({ title: intl.formatMessage({ id: "dialogs.merge.source" }), labelProps: { column: true, sm: 3, xl: 12, }, })} setSrc(items)} values={src} menuPortalTarget={document.body} /> {FormUtils.renderLabel({ title: intl.formatMessage({ id: "dialogs.merge.destination", }), labelProps: { column: true, sm: 3, xl: 12, }, })} setDest(items[0])} values={dest ? [dest] : undefined} menuPortalTarget={document.body} />
    ); }; ================================================ FILE: ui/v2.5/src/components/Tags/TagPopover.tsx ================================================ import React from "react"; import { ErrorMessage } from "../Shared/ErrorMessage"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { HoverPopover } from "../Shared/HoverPopover"; import { useFindTag } from "../../core/StashService"; import { TagCard } from "./TagCard"; import { useConfigurationContext } from "../../hooks/Config"; import { Placement } from "react-bootstrap/esm/Overlay"; interface ITagPopoverCardProps { id: string; } export const TagPopoverCard: React.FC = ({ id }) => { const { data, loading, error } = useFindTag(id); if (loading) return (
    ); if (error) return ; if (!data?.findTag) return ; const tag = data.findTag; return (
    ); }; interface ITagPopoverProps { id: string; hide?: boolean; placement?: Placement; target?: React.RefObject; } export const TagPopover: React.FC = ({ id, hide, children, placement = "top", target, }) => { const { configuration: config } = useConfigurationContext(); const showTagCardOnHover = config?.ui.showTagCardOnHover ?? true; if (hide || !showTagCardOnHover) { return <>{children}; } return ( } > {children} ); }; ================================================ FILE: ui/v2.5/src/components/Tags/TagRecommendationRow.tsx ================================================ import React from "react"; import { useFindTags } from "src/core/StashService"; import { TagCard } from "./TagCard"; import { ListFilterModel } from "src/models/list-filter/filter"; import { PatchComponent } from "src/patch"; import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow"; interface IProps { isTouch: boolean; filter: ListFilterModel; header: string; } export const TagRecommendationRow: React.FC = PatchComponent( "TagRecommendationRow", (props) => { const result = useFindTags(props.filter); const count = result.data?.findTags.count ?? 0; return ( {result.loading ? [...Array(props.filter.itemsPerPage)].map((i) => (
    )) : result.data?.findTags.tags.map((p) => ( ))}
    ); } ); ================================================ FILE: ui/v2.5/src/components/Tags/TagSelect.tsx ================================================ import React, { useEffect, useMemo, useState } from "react"; import { OptionProps, components as reactSelectComponents, MultiValueGenericProps, SingleValueProps, } from "react-select"; import cx from "classnames"; import * as GQL from "src/core/generated-graphql"; import { useTagCreate, queryFindTagsByIDForSelect, queryFindTagsForSelect, } from "src/core/StashService"; import { useConfigurationContext } from "src/hooks/Config"; import { useIntl } from "react-intl"; import { defaultMaxOptionsShown } from "src/core/config"; import { ListFilterModel } from "src/models/list-filter/filter"; import { FilterSelectComponent, IFilterIDProps, IFilterProps, IFilterValueProps, Option as SelectOption, toOption, } from "../Shared/FilterSelect"; import { useCompare } from "src/hooks/state"; import { TagPopover } from "./TagPopover"; import { Placement } from "react-bootstrap/esm/Overlay"; import { sortByRelevance } from "src/utils/query"; import { PatchComponent, PatchFunction } from "src/patch"; import { isUUID } from "src/utils/stashIds"; import { filterByStashID } from "src/models/list-filter/utils"; export type SelectObject = { id: string; name?: string | null; title?: string | null; }; export type Tag = Pick< GQL.Tag, "id" | "name" | "sort_name" | "aliases" | "image_path" | "stash_ids" >; type Option = SelectOption; type FindTagsResult = Awaited< ReturnType >["data"]["findTags"]["tags"]; function sortTagsByRelevance(input: string, tags: FindTagsResult) { return sortByRelevance( input, tags, (t) => t.name, (t) => t.aliases ); } const tagSelectSort = PatchFunction("TagSelect.sort", sortTagsByRelevance); export type TagSelectProps = IFilterProps & IFilterValueProps & { hoverPlacement?: Placement; hoverPlacementLabel?: Placement; excludeIds?: string[]; }; const _TagSelect: React.FC = (props) => { const [createTag] = useTagCreate(); const { configuration } = useConfigurationContext(); const intl = useIntl(); const maxOptionsShown = configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown; const defaultCreatable = !configuration?.interface.disableDropdownCreate.tag; const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); function filterExcluded(tag: Tag) { // HACK - we should probably exclude these in the backend query, but // this will do in the short-term return !exclude.includes(tag.id.toString()); } async function loadTags(input: string): Promise { const filter = new ListFilterModel(GQL.FilterMode.Tags); filter.currentPage = 1; filter.itemsPerPage = maxOptionsShown; filter.sortBy = "name"; filter.sortDirection = GQL.SortDirectionEnum.Asc; if (isUUID(input)) { filterByStashID(filter, input); const query = await queryFindTagsForSelect(filter); const matches = query.data.findTags.tags.filter(filterExcluded); if (matches.length > 0) { // Matches found, return them immediately. return matches.map(toOption); } // If no stash_id matches found, continue with standard name/alias search. filter.criteria = []; // Clear stash_id criterion to search by name/alias below. } filter.searchTerm = input; const query = await queryFindTagsForSelect(filter); const ret = query.data.findTags.tags.filter(filterExcluded); return tagSelectSort(input, ret).map(toOption); } const TagOption: React.FC> = (optionProps) => { let thisOptionProps = optionProps; const { object } = optionProps.data; let { name } = object; // if name does not match the input value but an alias does, show the alias const { inputValue } = optionProps.selectProps; let alias: string | undefined = ""; if (!name.toLowerCase().includes(inputValue.toLowerCase())) { alias = object.aliases?.find((a) => a.toLowerCase().includes(inputValue.toLowerCase()) ); } thisOptionProps = { ...optionProps, children: ( {/* the following code causes re-rendering issues when selecting tags */} {/* */} {name} {alias &&  ({alias})} ), }; return ; }; const TagMultiValueLabel: React.FC< MultiValueGenericProps > = (optionProps) => { let thisOptionProps = optionProps; const { object } = optionProps.data; thisOptionProps = { ...optionProps, children: ( {object.name} ), }; return ; }; const TagValueLabel: React.FC> = ( optionProps ) => { let thisOptionProps = optionProps; const { object } = optionProps.data; thisOptionProps = { ...optionProps, children: <>{object.name}, }; return ; }; const onCreate = async (name: string) => { const result = await createTag({ variables: { input: { name } }, }); return { value: result.data!.tagCreate!.id, item: result.data!.tagCreate!, message: "Created tag", }; }; const getNamedObject = (id: string, name: string) => { return { id, name, aliases: [], stash_ids: [], }; }; const isValidNewOption = (inputValue: string, options: Tag[]) => { if (!inputValue) { return false; } if ( options.some((o) => { return ( o.name.toLowerCase() === inputValue.toLowerCase() || o.aliases?.some((a) => a.toLowerCase() === inputValue.toLowerCase()) ); }) ) { return false; } return true; }; return ( {...props} className={cx( "tag-select", { "tag-select-active": props.active, }, props.className )} loadOptions={loadTags} getNamedObject={getNamedObject} isValidNewOption={isValidNewOption} components={{ Option: TagOption, MultiValueLabel: TagMultiValueLabel, SingleValue: TagValueLabel, }} isMulti={props.isMulti ?? false} creatable={props.creatable ?? defaultCreatable} onCreate={onCreate} placeholder={ props.noSelectionString ?? intl.formatMessage( { id: "actions.select_entity" }, { entityType: intl.formatMessage({ id: props.isMulti ? "tags" : "tag", }), } ) } closeMenuOnSelect={!props.isMulti} /> ); }; export const TagSelect = PatchComponent("TagSelect", _TagSelect); const _TagIDSelect: React.FC> = (props) => { const { ids, onSelect: onSelectValues } = props; const [values, setValues] = useState([]); const idsChanged = useCompare(ids); function onSelect(items: Tag[]) { setValues(items); onSelectValues?.(items); } async function loadObjectsByID(idsToLoad: string[]): Promise { const query = await queryFindTagsByIDForSelect(idsToLoad); const { tags: loadedTags } = query.data.findTags; return loadedTags; } useEffect(() => { if (!idsChanged) { return; } if (!ids || ids?.length === 0) { setValues([]); return; } // load the values if we have ids and they haven't been loaded yet const filteredValues = values.filter((v) => ids.includes(v.id.toString())); if (filteredValues.length === ids.length) { return; } const load = async () => { const items = await loadObjectsByID(ids); // #4684 - sort items by sort name/name const sortedItems = [...items]; sortedItems.sort((a, b) => { const aName = a.sort_name || a.name; const bName = b.sort_name || b.name; if (aName && bName) { return aName.localeCompare(bName); } return 0; }); setValues(sortedItems); }; load(); }, [ids, idsChanged, values]); return ; }; export const TagIDSelect = PatchComponent("TagIDSelect", _TagIDSelect); ================================================ FILE: ui/v2.5/src/components/Tags/Tags.tsx ================================================ import React from "react"; import { Route, Switch } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import Tag from "./TagDetails/Tag"; import TagCreate from "./TagDetails/TagCreate"; import { FilteredTagList } from "./TagList"; const Tags: React.FC = () => { return ; }; const TagRoutes: React.FC = () => { const titleProps = useTitleProps({ id: "tags" }); return ( <> ); }; export default TagRoutes; ================================================ FILE: ui/v2.5/src/components/Tags/styles.scss ================================================ .tag-list { &-row { margin: 0.5rem 0; } &-button { margin: 0 0.5rem; width: 8rem; } &-anchor { color: $text-color; } &-count { display: inline-block; margin: 0 0.5rem; min-width: 6rem; } } .tag-card { padding: 0.5rem; @media (max-width: 576px) { width: 100%; } &-image { display: block; margin: 0 auto; object-fit: contain; } button.btn.favorite-button { opacity: 1; padding: 0; position: absolute; right: 5px; top: 10px; transition: opacity 0.5s; svg.fa-icon { margin-left: 0.4rem; margin-right: 0.4rem; } &.not-favorite { color: rgba(191, 204, 214, 0.5); filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9)); opacity: 0; } &.favorite { color: #ff7373; filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9)); } &:hover, &:active, &:focus, &:active:focus { background: none; box-shadow: none; } } &:hover button.btn.favorite-button.not-favorite { opacity: 1; } } #tag-page { .tag-head { .name-icons { .not-favorite { color: rgba(191, 204, 214, 0.5); } .favorite { color: #ff7373; } } } } .tag-details { .logo { max-height: 50vh; max-width: 100%; } .logo-container { margin-bottom: 4rem; } } #tag-merge-menu .dropdown-item { align-items: center; } .tag-card { .tag-description + div { margin-top: 1rem; } } .tag-popover-card-placeholder { display: flex; max-width: 240px; min-height: 314px; width: calc(100vw - 2rem); } .tag-popover-card { padding: 0.5rem; text-align: left; .card { background: transparent; box-shadow: none; max-width: calc(100vw - 2rem); padding: 0; width: 240px; } } .tag-item { .icon-wrapper { color: #202b33; opacity: 0.5; padding-left: 6px; } } .tag-item { .tag-icon { color: #202b33; margin: 0; opacity: 0.5; padding-left: 3px; transform: scale(0.7); } } .tag-select { .alias { font-weight: bold; white-space: pre; } } .tag-select-image { height: 25px; margin-right: 0.5em; width: 25px; } ================================================ FILE: ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeButton.tsx ================================================ import React, { useState } from "react"; import { Button } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { faBug } from "@fortawesome/free-solid-svg-icons"; import { ModalComponent } from "src/components/Shared/Modal"; import { useTroubleshootingMode } from "./useTroubleshootingMode"; const DIALOG_ITEMS = [ "config.ui.troubleshooting_mode.dialog_item_plugins", "config.ui.troubleshooting_mode.dialog_item_css", "config.ui.troubleshooting_mode.dialog_item_js", "config.ui.troubleshooting_mode.dialog_item_locales", ] as const; export const TroubleshootingModeButton: React.FC = () => { const intl = useIntl(); const [showDialog, setShowDialog] = useState(false); const { enable, isLoading } = useTroubleshootingMode(); return ( <>
    setShowDialog(false)} header={intl.formatMessage({ id: "config.ui.troubleshooting_mode.dialog_title", })} icon={faBug} accept={{ text: intl.formatMessage({ id: "config.ui.troubleshooting_mode.enable", }), variant: "primary", onClick: enable, }} cancel={{ onClick: () => setShowDialog(false), variant: "secondary", }} isRunning={isLoading} >

      {DIALOG_ITEMS.map((id) => (
    • ))}

    ); }; ================================================ FILE: ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeOverlay.tsx ================================================ import React from "react"; import { Button } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import { faBug } from "@fortawesome/free-solid-svg-icons"; import { Icon } from "src/components/Shared/Icon"; import { useTroubleshootingMode } from "./useTroubleshootingMode"; export const TroubleshootingModeOverlay: React.FC = () => { const { isActive, isLoading, disable } = useTroubleshootingMode(); if (!isActive) { return null; } return (
    ); }; ================================================ FILE: ui/v2.5/src/components/TroubleshootingMode/useTroubleshootingMode.ts ================================================ import { useState, useRef, useEffect } from "react"; import { useConfigureInterface, useConfigureGeneral, useConfiguration, } from "src/core/StashService"; const ORIGINAL_LOG_LEVEL_KEY = "troubleshootingMode_originalLogLevel"; export function useTroubleshootingMode() { const [isLoading, setIsLoading] = useState(false); const isMounted = useRef(true); const { data: config } = useConfiguration(); const [configureInterface] = useConfigureInterface(); const [configureGeneral] = useConfigureGeneral(); const isActive = config?.configuration?.interface?.disableCustomizations ?? false; const currentLogLevel = config?.configuration?.general?.logLevel || "Info"; useEffect(() => { return () => { isMounted.current = false; }; }, []); async function enable() { setIsLoading(true); try { // Store original log level for restoration later localStorage.setItem(ORIGINAL_LOG_LEVEL_KEY, currentLogLevel); // Enable troubleshooting mode and set log level to Debug await Promise.all([ configureInterface({ variables: { input: { disableCustomizations: true } }, }), configureGeneral({ variables: { input: { logLevel: "Debug" } }, }), ]); window.location.reload(); } catch (e) { if (isMounted.current) { setIsLoading(false); } throw e; } } async function disable() { setIsLoading(true); try { // Restore original log level const originalLogLevel = localStorage.getItem(ORIGINAL_LOG_LEVEL_KEY) || "Info"; // Disable troubleshooting mode and restore log level await Promise.all([ configureInterface({ variables: { input: { disableCustomizations: false } }, }), configureGeneral({ variables: { input: { logLevel: originalLogLevel } }, }), ]); // Clean up localStorage localStorage.removeItem(ORIGINAL_LOG_LEVEL_KEY); window.location.reload(); } catch (e) { if (isMounted.current) { setIsLoading(false); } throw e; } } return { isActive, isLoading, enable, disable }; } ================================================ FILE: ui/v2.5/src/components/Wall/WallItem.tsx ================================================ import React, { useRef, useState, useEffect, useCallback, MouseEvent, useMemo, } from "react"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; import NavUtils from "src/utils/navigation"; import cx from "classnames"; import { SceneQueue } from "src/models/sceneQueue"; import { useConfigurationContext } from "src/hooks/Config"; import { markerTitle } from "src/core/markers"; import { objectTitle } from "src/core/files"; export type WallItemType = keyof WallItemData; export type WallItemData = { scene: GQL.SlimSceneDataFragment; sceneMarker: GQL.SceneMarkerDataFragment; image: GQL.SlimImageDataFragment; }; interface IWallItemProps { type: T; index?: number; data: WallItemData[T]; sceneQueue?: SceneQueue; clickHandler?: (e: MouseEvent, item: WallItemData[T]) => void; className: string; } interface IPreviews { video?: string; animation?: string; image?: string; } const Preview: React.FC<{ previews: IPreviews; config?: GQL.ConfigDataFragment; active: boolean; }> = ({ previews, config, active }) => { const videoEl = useRef(null); const [isMissing, setIsMissing] = useState(false); const previewType = config?.interface?.wallPlayback; const soundOnPreview = config?.interface?.soundOnPreview ?? false; useEffect(() => { const video = videoEl.current; if (!video) return; video.muted = !(soundOnPreview && active); if (previewType !== "video") { if (active) { video.play(); } else { video.pause(); } } }, [previewType, soundOnPreview, active]); const image = ( ); const video = (