mirror of
https://github.com/stashapp/stash.git
synced 2026-06-10 21:42:03 -05:00
Compare commits
27 Commits
ai-and-con
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a31ccef98d | ||
|
|
f76f66b725 | ||
|
|
45ca5a4d27 | ||
|
|
763f6903bc | ||
|
|
759ec317cd | ||
|
|
f3bfd8db75 | ||
|
|
e470ee34d8 | ||
|
|
e28ab14c6e | ||
|
|
c39e1657ae | ||
|
|
58f8b17196 | ||
|
|
6958bdd66a | ||
|
|
608bb97e6c | ||
|
|
20df97e361 | ||
|
|
50fc593177 | ||
|
|
2ad3724dee | ||
|
|
4bd16c29b1 | ||
|
|
9353d862ea | ||
|
|
5ed738558e | ||
|
|
e0fd439c02 | ||
|
|
b9381872f1 | ||
|
|
c637b2931a | ||
|
|
3050d900b0 | ||
|
|
64a104d905 | ||
|
|
6bd0123edf | ||
|
|
b8c17f780f | ||
|
|
294651ebb8 | ||
|
|
c2902233ad |
12
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
12
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -4,14 +4,14 @@ labels: ["bug report"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
value: Thanks for taking the time to fill out this bug report! Make sure to read [Contributing](https://github.com/stashapp/stash/blob/develop/docs/CONTRIBUTING.md) document before submitting.
|
||||
- type: checkboxes
|
||||
id: confirm-troubleshooting
|
||||
attributes:
|
||||
label: Have you enabled troubleshooting mode?
|
||||
description: |
|
||||
To ensure the bug is not caused by custom modifications or plugins make sure to enable troubleshooting mode before filing the report. In Stash go to Settings and click **Troubleshooting mode** and then retest to see if the bug still occurs.
|
||||
To ensure the bug is not caused by custom modifications or plugins make sure to enable troubleshooting mode before filing the report. In Stash go to Settings and click **Troubleshooting mode** and then retest to see if the bug still occurs.
|
||||
It's important to note that troubleshooting mode only affects UI modifications and plugins.
|
||||
options:
|
||||
- label: I confirm that the troubleshooting mode is enabled.
|
||||
required: true
|
||||
@@ -56,13 +56,13 @@ body:
|
||||
placeholder: (e.g. v0.28.1)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
- type: textarea
|
||||
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)
|
||||
Please provide details about the device you are using, including the operating system and browser (if applicable).
|
||||
placeholder: Firefox 97 (64-bit) on Windows 11
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
3
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -2,6 +2,9 @@ name: Feature Request
|
||||
description: Request a new feature or idea to be added to Stash
|
||||
labels: ["feature request"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Thank you for taking the time to submit a feature request! Make sure to read [Contributing](https://github.com/stashapp/stash/blob/develop/docs/CONTRIBUTING.md) document before submitting.
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
|
||||
18
.github/PULL_REQUEST_TEMPLATE/BugFix.md
vendored
18
.github/PULL_REQUEST_TEMPLATE/BugFix.md
vendored
@@ -1,18 +0,0 @@
|
||||
---
|
||||
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'
|
||||
|
||||
---
|
||||
<!-- Please make sure to read https://github.com/stashapp/stash/docs/CONTRIBUTING.md and check that you understand and have followed it as best as possible -->
|
||||
<!-- Explain what your bugfix seeks to remedy in a short paragraph. -->
|
||||
# Scope
|
||||
|
||||
<!-- Declare any issues by typing `fixes #1` or `closes #1` for example so that the automation can kick in when this is merged -->
|
||||
## Closes/Fixes Issues
|
||||
|
||||
<!-- What have you tested specifically and what possible impacts/areas there are that may need retesting by others. -->
|
||||
## Other testing QA Notes
|
||||
|
||||
17
.github/PULL_REQUEST_TEMPLATE/Feature.md
vendored
17
.github/PULL_REQUEST_TEMPLATE/Feature.md
vendored
@@ -1,17 +0,0 @@
|
||||
---
|
||||
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'
|
||||
|
||||
---
|
||||
<!-- Please make sure to read https://github.com/stashapp/stash/docs/CONTRIBUTING.md and check that you understand and have followed it as best as possible
|
||||
Explain what your feature does in a short paragraph. -->
|
||||
# Scope
|
||||
|
||||
<!-- Declare any issues by typing `fixes #1` or `closes #1` for example so that the automation can kick in when this is merged -->
|
||||
## Closes/Fixes Issues
|
||||
|
||||
<!-- What have you tested specifically and what possible impacts/areas there are that may need retesting by others. -->
|
||||
## Other testing QA Notes
|
||||
39
.github/pull_request_template.md
vendored
Normal file
39
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
<!-- Thank you for submitting a pull request! Make sure to follow the contributing guidelines and this template. -->
|
||||
|
||||
## Description
|
||||
<!-- Please write a clear and concise description of what the pull request does. -->
|
||||
|
||||
|
||||
|
||||
## Related Issue
|
||||
<!-- Please link the issue your pull request is referring to. -->
|
||||
|
||||
|
||||
|
||||
## Testing
|
||||
<!-- Describe the testing steps you have performed. -->
|
||||
|
||||
|
||||
|
||||
## Screenshots
|
||||
<!-- For visual changes, please add before and after screenshots. -->
|
||||
|
||||
|
||||
|
||||
## Checklist
|
||||
<!-- Mark [x] to indicate completion. -->
|
||||
|
||||
- [ ] I have read and understood the [Contributing](https://github.com/stashapp/stash/blob/develop/docs/CONTRIBUTING.md) document.
|
||||
- [ ] I have read and understood the [AI Usage Policy](https://github.com/stashapp/stash/blob/develop/docs/AI_POLICY.md) document.
|
||||
- [ ] I have made corresponding changes to the documentation (if applicable).
|
||||
|
||||
## AI Usage Disclosure
|
||||
<!-- Mark [x] to indicate completion. -->
|
||||
- [ ] I have used AI tools to assist with this pull request, and I have disclosed the tools and how I used them below.
|
||||
<!-- If you used AI to assist with this pull request, please disclose what tools you used and how you used them. -->
|
||||
|
||||
|
||||
|
||||
## Additional Context
|
||||
<!-- Add any other context about the pull request here. -->
|
||||
|
||||
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
@@ -40,6 +40,16 @@ jobs:
|
||||
with:
|
||||
package_json_file: ui/v2.5/package.json
|
||||
|
||||
# ensure pnpm store path exists to fix post setup node.js error
|
||||
# https://github.com/actions/setup-node/issues/1137#issuecomment-2508963254
|
||||
- name: Ensure pnpm store path exists
|
||||
run: |
|
||||
PNPM_STORE_PATH="$( pnpm store path --silent )"
|
||||
if [ ! -d "$PNPM_STORE_PATH" ]; then
|
||||
echo "PNPM store directory does not exist, creating it."
|
||||
mkdir -p "$PNPM_STORE_PATH"
|
||||
fi
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -63,4 +63,4 @@ node_modules
|
||||
/phasher
|
||||
dist
|
||||
.DS_Store
|
||||
/.local*
|
||||
/.local*
|
||||
10
Makefile
10
Makefile
@@ -388,7 +388,7 @@ zip-ui:
|
||||
|
||||
.PHONY: ui-start
|
||||
ui-start: ui-env
|
||||
cd ui/v2.5 && pnpm run start -- --host
|
||||
cd ui/v2.5 && pnpm run start --host
|
||||
|
||||
.PHONY: fmt-ui
|
||||
fmt-ui:
|
||||
@@ -404,7 +404,7 @@ fmt-ui-quick:
|
||||
cd ui/v2.5 && \
|
||||
files=$$(git diff --name-only --relative --diff-filter d . ../../graphql); \
|
||||
if [ -n "$$files" ]; then \
|
||||
pnpm run prettier -- --write $$files; \
|
||||
pnpm exec biome format --write $$files; \
|
||||
fi
|
||||
|
||||
# does not run tsc checks, as they are slow
|
||||
@@ -413,9 +413,9 @@ validate-ui-quick:
|
||||
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 pnpm run eslint -- $$tsfiles; fi && \
|
||||
if [ -n "$$scssfiles" ]; then pnpm run stylelint -- $$scssfiles; fi && \
|
||||
if [ -n "$$prettyfiles" ]; then pnpm run prettier -- --check $$prettyfiles; fi
|
||||
if [ -n "$$tsfiles" ]; then pnpm exec biome check $$tsfiles; fi && \
|
||||
if [ -n "$$scssfiles" ]; then pnpm exec stylelint $$scssfiles; fi && \
|
||||
if [ -n "$$prettyfiles" ]; then pnpm exec biome format $$prettyfiles; fi
|
||||
|
||||
# runs all of the backend PR-acceptance steps
|
||||
.PHONY: validate-backend
|
||||
|
||||
59
README.md
59
README.md
@@ -1,13 +1,14 @@
|
||||
# Stash
|
||||
|
||||
[](https://github.com/stashapp/stash/actions/workflows/build.yml)
|
||||
[](https://hub.docker.com/r/stashapp/stash 'DockerHub')
|
||||
[](https://hub.docker.com/r/stashapp/stash 'DockerHub')
|
||||
[](https://github.com/sponsors/stashapp)
|
||||
[](https://opencollective.com/stashapp)
|
||||
[](https://goreportcard.com/report/github.com/stashapp/stash)
|
||||
[](https://discord.gg/2TsNFKt)
|
||||
[](https://github.com/stashapp/stash/releases/latest)
|
||||
[](https://github.com/stashapp/stash/labels/bounty)
|
||||
[](https://translate.codeberg.org/engage/stash/)
|
||||
[](https://github.com/stashapp/stash/labels/bounty)
|
||||
|
||||
<h3>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.</h3>
|
||||
|
||||
@@ -20,9 +21,9 @@
|
||||
|
||||
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)).
|
||||
For further information see [Support & Resources](#support--resources) section.
|
||||
|
||||
# Installing Stash
|
||||
## Installing Stash
|
||||
|
||||
> [!tip]
|
||||
Step-by-step instructions are available at [docs.stashapp.cc/installation](https://docs.stashapp.cc/installation/).
|
||||
@@ -37,7 +38,7 @@ Step-by-step instructions are available at [docs.stashapp.cc/installation](https
|
||||
>
|
||||
> As of version 0.29.0, Stash requires _macOS 11 Big Sur_ or later.
|
||||
> As of version 0.32.0, Stash requires _macOS 12 Monterey_ or later.
|
||||
> Stash can still be run through Docker on older versions of macOS.
|
||||
> Older versions can still be run through Docker.
|
||||
|
||||
<img src="docs/readme_assets/windows_logo.svg" width="100%" height="75"> Windows | <img src="docs/readme_assets/mac_logo.svg" width="100%" height="75"> macOS | <img src="docs/readme_assets/linux_logo.svg" width="100%" height="75"> Linux | <img src="docs/readme_assets/docker_logo.svg" width="100%" height="75"> Docker
|
||||
:---:|:---:|:---:|:---:
|
||||
@@ -45,7 +46,7 @@ Step-by-step instructions are available at [docs.stashapp.cc/installation](https
|
||||
|
||||
Download links for other platforms and architectures are available on the [Releases](https://github.com/stashapp/stash/releases) page.
|
||||
|
||||
## First Run
|
||||
### First Run
|
||||
|
||||
#### Windows/macOS Users: Security Prompt
|
||||
|
||||
@@ -58,9 +59,9 @@ On Windows or macOS, running the app might present a security prompt since the a
|
||||
|
||||
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
|
||||
## Usage
|
||||
|
||||
## Quickstart Guide
|
||||
### Quickstart Guide
|
||||
|
||||
Stash is a web-based application. Once the application is running, the interface is available (by default) from `http://localhost:9999`.
|
||||
|
||||
@@ -68,59 +69,51 @@ On first run, Stash will prompt you for some configuration options and media dir
|
||||
|
||||
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/).
|
||||
- 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 the [Metadata Sources](https://docs.stashapp.cc/metadata-sources/stash-box-instances/) section of the documentation.
|
||||
- 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/).
|
||||
|
||||
<sub>[StashDB](http://stashdb.org) is the canonical instance of our open source metadata API, [stash-box](https://github.com/stashapp/stash-box).</sub>
|
||||
|
||||
# Translation
|
||||
|
||||
[](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:
|
||||
|
||||
[](https://translate.codeberg.org/engage/stash/)
|
||||
|
||||
# Support & Resources
|
||||
## 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 documentation](https://docs.stashapp.cc) - official guides guides and troubleshooting.
|
||||
- [In-app manual](https://docs.stashapp.cc/in-app-manual) press <kbd>Shift</kbd> + <kbd>?</kbd> in the app or view the manual online.
|
||||
- [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 & 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) - board-style community space.
|
||||
|
||||
### Community scrapers & plugins
|
||||
### 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/)
|
||||
|
||||
# Architecture
|
||||
## Architecture
|
||||
|
||||
## Backend
|
||||
You can find an overview of Stash's architecture in the [ARCHITECTURE.md](docs/ARCHITECTURE.md) document.
|
||||
|
||||
- Go
|
||||
- GraphQL API
|
||||
- SQLite
|
||||
## Contributing
|
||||
|
||||
## Frontend
|
||||
We welcome contributions and help from all humans who want to improve the project.
|
||||
|
||||
- React
|
||||
- TypeScript
|
||||
Before contributing, please read the [Contributing](docs/CONTRIBUTING.md) document to understand our guidelines and processes for contributing to the project.
|
||||
|
||||
# For Developers
|
||||
You can learn about setting up a local development environment in the [Development](docs/DEVELOPMENT.md) document.
|
||||
|
||||
Pull requests are welcome!
|
||||
## Translation
|
||||
|
||||
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.
|
||||
The widget below shows the current translation status of Stash across all supported languages. If you want to help us translate Stash, you can make an account at [Codeberg Translate](https://translate.codeberg.org/projects/stash/stash/) to contribute to new or existing languages. Thanks!
|
||||
|
||||
[](https://translate.codeberg.org/engage/stash/)
|
||||
22
docs/AI_POLICY.md
Normal file
22
docs/AI_POLICY.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# AI Usage Policy
|
||||
|
||||
- AI agents are not welcome to contribute to this project.
|
||||
- All issues, pull request descriptions and comments must be written by humans.
|
||||
- Fully AI-generated contributions will be closed without comment.
|
||||
|
||||
## AI-Assisted Code Contributions
|
||||
|
||||
AI-assisted code contributions generated with the use of LLMs are permitted under the following conditions:
|
||||
|
||||
- AI usage and scope must be openly disclosed in the PR description.
|
||||
- You must be able to explain any line of code and design decision during the review process.
|
||||
- You must perform manual testing and describe the steps taken to sufficiently verify the changes.
|
||||
- You must take full responsibility for the code, including license compliance.
|
||||
|
||||
We are not accepting large, complex features generated by LLMs by outside contributors at this time.
|
||||
|
||||
## Respect Maintainers Time
|
||||
|
||||
Reviewing PRs takes a significant amount of time. Anyone with zero effort can generate code with an LLM, but it takes a human to understand it, test it, and ensure it fits with the overall design of the project.
|
||||
|
||||
We ask that contributors respect this by ensuring their PRs meet these standards before submitting them for review.
|
||||
@@ -1,35 +1,48 @@
|
||||
## Goals and design vision
|
||||
# Contributing to Stash
|
||||
|
||||
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
|
||||
## AI Usage Policy
|
||||
|
||||
The core stash system is not intended for:
|
||||
- managing downloading of content
|
||||
- managing content on external websites
|
||||
- publically sharing content
|
||||
Please see our [AI Usage Policy](/docs/AI_POLICY.md) for guidelines on the use of AI in contributions to this project.
|
||||
|
||||
## Issues
|
||||
|
||||
Bug reports and feature requests must use descriptive and concise titles and follow the provided templates. Please use the search function to make sure that you are not submitting duplicates, and that a similar report or request has not already been resolved or rejected.
|
||||
|
||||
All issues must be written by humans. Fully AI-generated issues will be closed without comment.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
All pull requests must use descriptive and concise titles and follow the provided templates. In addition, they must follow the the following guidelines:
|
||||
|
||||
- You must link to an open issue that pull request addresses (see [GitHub documentation](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue) on how to do that).
|
||||
- Pull requests must be focused on a single issue or feature. Large, multi-purpose pull requests will be rejected.
|
||||
- Large features must be discussed with maintainers before submitting a pull request to ensure it fits with the overall design vision of the project. Failure to do so may result in the pull request being rejected.
|
||||
- Pull requests must include code tests that sufficiently cover the changes made.
|
||||
- You must detail the manual testing done and describe the steps taken to sufficiently verify the changes.
|
||||
- You must be able to explain any line of code and design decision during the review process.
|
||||
|
||||
By submitting a pull request, you agree that you have read and understood and that you are in compliance with the guidelines outlined here, including the [AI Usage Policy](docs/AI_POLICY.md).
|
||||
|
||||
You also agree to license your contribution under the [AGPL](/LICENSE.md) license, and that all of your previous contributions to the project are also licensed under the AGPL.
|
||||
|
||||
## Goals and Design Vision
|
||||
|
||||
The goal of Stash is to be:
|
||||
- An application for organising and viewing NSFW and SFW 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
|
||||
- Publicly 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.
|
||||
- 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.**
|
||||
- Features are easy to add and difficult to remove. Large superfluous features should be scrutinised and avoided where possible (e.g. DLNA, filename parser). Such features should be considered for third-party plugins instead.
|
||||
|
||||
@@ -51,6 +51,7 @@ type Query {
|
||||
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_filter: SceneFilterType
|
||||
): [[Scene!]!]!
|
||||
|
||||
"Return valid stream paths"
|
||||
|
||||
@@ -302,6 +302,8 @@ input StashBoxBatchTagInput {
|
||||
stash_box_endpoint: String
|
||||
"Fields to exclude when executing the tagging"
|
||||
exclude_fields: [String!]
|
||||
"Collection fields to merge (add to existing) instead of overwriting when executing the tagging"
|
||||
merge_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?"
|
||||
|
||||
@@ -36,6 +36,7 @@ input StudioCreateInput {
|
||||
url: String @deprecated(reason: "Use urls")
|
||||
urls: [String!]
|
||||
parent_id: ID
|
||||
child_ids: [ID!]
|
||||
"This should be a URL or a base64 encoded data URL"
|
||||
image: String
|
||||
stash_ids: [StashIDInput!]
|
||||
@@ -58,6 +59,7 @@ input StudioUpdateInput {
|
||||
url: String @deprecated(reason: "Use urls")
|
||||
urls: [String!]
|
||||
parent_id: ID
|
||||
child_ids: [ID!]
|
||||
"This should be a URL or a base64 encoded data URL"
|
||||
image: String
|
||||
stash_ids: [StashIDInput!]
|
||||
|
||||
@@ -5,7 +5,7 @@ package api
|
||||
type key int
|
||||
|
||||
const (
|
||||
galleryKey key = 0
|
||||
galleryKey key = iota
|
||||
performerKey
|
||||
sceneKey
|
||||
studioKey
|
||||
|
||||
@@ -14,6 +14,63 @@ import (
|
||||
)
|
||||
|
||||
// used to refetch studio after hooks run
|
||||
|
||||
func clearRemovedChildStudios(ctx context.Context, qb models.StudioReaderWriter, parentStudioID int, childStudioIDs []int) error {
|
||||
currentChildren, err := qb.FindChildren(ctx, parentStudioID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newChildStudioIDs := make(map[int]struct{}, len(childStudioIDs))
|
||||
for _, childStudioID := range childStudioIDs {
|
||||
newChildStudioIDs[childStudioID] = struct{}{}
|
||||
}
|
||||
|
||||
for _, currentChild := range currentChildren {
|
||||
if _, keep := newChildStudioIDs[currentChild.ID]; keep {
|
||||
continue
|
||||
}
|
||||
|
||||
clearParentPartial := models.NewStudioPartial()
|
||||
clearParentPartial.ID = currentChild.ID
|
||||
clearParentPartial.ParentID = models.NewOptionalIntPtr(nil)
|
||||
|
||||
if _, err := qb.UpdatePartial(ctx, clearParentPartial); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setChildStudios(ctx context.Context, qb models.StudioReaderWriter, parentStudioID int, childStudioIDs []int) error {
|
||||
if err := clearRemovedChildStudios(ctx, qb, parentStudioID, childStudioIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newChildStudioIDs := make(map[int]struct{}, len(childStudioIDs))
|
||||
for _, childStudioID := range childStudioIDs {
|
||||
if _, found := newChildStudioIDs[childStudioID]; found {
|
||||
continue
|
||||
}
|
||||
newChildStudioIDs[childStudioID] = struct{}{}
|
||||
|
||||
childPartial := models.NewStudioPartial()
|
||||
childPartial.ID = childStudioID
|
||||
childPartial.ParentID = models.NewOptionalInt(parentStudioID)
|
||||
|
||||
if err := studio.ValidateModify(ctx, childPartial, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := qb.UpdatePartial(ctx, childPartial); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -62,6 +119,11 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting tag ids: %w", err)
|
||||
}
|
||||
|
||||
childStudioIDs, err := stringslice.StringSliceToIntSlice(input.ChildIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting child ids: %w", err)
|
||||
}
|
||||
newStudio.CustomFields = convertMapJSONNumbers(input.CustomFields)
|
||||
|
||||
// Process the base 64 encoded image string
|
||||
@@ -93,6 +155,12 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
|
||||
}
|
||||
}
|
||||
|
||||
if input.ChildIds != nil {
|
||||
if err := setChildStudios(ctx, qb, newStudio.ID, childStudioIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
@@ -135,6 +203,11 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
||||
return nil, fmt.Errorf("converting tag ids: %w", err)
|
||||
}
|
||||
|
||||
childStudioIDs, err := stringslice.StringSliceToIntSlice(input.ChildIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting child ids: %w", err)
|
||||
}
|
||||
|
||||
if translator.hasField("urls") {
|
||||
// ensure url not included in the input
|
||||
if err := validateNoLegacyURLs(translator); err != nil {
|
||||
@@ -197,6 +270,12 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
||||
}
|
||||
}
|
||||
|
||||
if translator.hasField("child_ids") {
|
||||
if err := clearRemovedChildStudios(ctx, qb, studioID, childStudioIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := studio.ValidateModify(ctx, updatedStudio, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -212,6 +291,12 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
||||
}
|
||||
}
|
||||
|
||||
if translator.hasField("child_ids") {
|
||||
if err := setChildStudios(ctx, qb, studioID, childStudioIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -227,7 +227,7 @@ func (r *queryResolver) ParseSceneFilenames(ctx context.Context, filter *models.
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindDuplicateScenes(ctx context.Context, distance *int, durationDiff *float64) (ret [][]*models.Scene, err error) {
|
||||
func (r *queryResolver) FindDuplicateScenes(ctx context.Context, distance *int, durationDiff *float64, sceneFilter *models.SceneFilterType) (ret [][]*models.Scene, err error) {
|
||||
dist := 0
|
||||
durDiff := -1.
|
||||
if distance != nil {
|
||||
@@ -237,7 +237,7 @@ func (r *queryResolver) FindDuplicateScenes(ctx context.Context, distance *int,
|
||||
durDiff = *durationDiff
|
||||
}
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.Scene.FindDuplicates(ctx, dist, durDiff)
|
||||
ret, err = r.repository.Scene.FindDuplicates(ctx, dist, durDiff, sceneFilter)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -24,17 +24,30 @@ func NewImageURLBuilder(baseURL string, image *models.Image) ImageURLBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// cacheBuster returns the value used to bust client-side caches. The image
|
||||
// content is immutable for a given file, so the checksum of the primary file
|
||||
// is preferred: this keeps the URL stable across metadata edits (rating,
|
||||
// o-counter, tags, etc.) so the browser can reuse its cached copy. When no
|
||||
// primary file is present the checksum is empty, in which case we fall back to
|
||||
// the updated timestamp.
|
||||
func (b ImageURLBuilder) cacheBuster() string {
|
||||
if b.Checksum != "" {
|
||||
return b.Checksum
|
||||
}
|
||||
return b.UpdatedAt
|
||||
}
|
||||
|
||||
func (b ImageURLBuilder) GetImageURL() string {
|
||||
return b.BaseURL + "/image/" + b.ImageID + "/image?t=" + b.UpdatedAt
|
||||
return b.BaseURL + "/image/" + b.ImageID + "/image?t=" + b.cacheBuster()
|
||||
}
|
||||
|
||||
func (b ImageURLBuilder) GetThumbnailURL() string {
|
||||
return b.BaseURL + "/image/" + b.ImageID + "/thumbnail?t=" + b.UpdatedAt
|
||||
return b.BaseURL + "/image/" + b.ImageID + "/thumbnail?t=" + b.cacheBuster()
|
||||
}
|
||||
|
||||
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
|
||||
return b.BaseURL + "/image/" + b.ImageID + "/preview?t=" + b.cacheBuster()
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
|
||||
106
internal/api/urlbuilders/image_test.go
Normal file
106
internal/api/urlbuilders/image_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package urlbuilders
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestImageURLBuilder_cacheBuster(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
checksum string
|
||||
updatedAt string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "prefers checksum when present",
|
||||
checksum: "abc123",
|
||||
updatedAt: "1700000000",
|
||||
want: "abc123",
|
||||
},
|
||||
{
|
||||
name: "falls back to updatedAt when checksum is empty",
|
||||
checksum: "",
|
||||
updatedAt: "1700000000",
|
||||
want: "1700000000",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
b := ImageURLBuilder{Checksum: tt.checksum, UpdatedAt: tt.updatedAt}
|
||||
if got := b.cacheBuster(); got != tt.want {
|
||||
t.Errorf("cacheBuster() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageURLBuilder_GetImageURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
checksum string
|
||||
updatedAt string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "uses checksum so the URL is stable across metadata edits",
|
||||
checksum: "abc123",
|
||||
updatedAt: "1700000000",
|
||||
want: "http://localhost:9999/image/42/image?t=abc123",
|
||||
},
|
||||
{
|
||||
name: "falls back to updatedAt when there is no primary file",
|
||||
checksum: "",
|
||||
updatedAt: "1700000000",
|
||||
want: "http://localhost:9999/image/42/image?t=1700000000",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
b := ImageURLBuilder{
|
||||
BaseURL: "http://localhost:9999",
|
||||
ImageID: "42",
|
||||
Checksum: tt.checksum,
|
||||
UpdatedAt: tt.updatedAt,
|
||||
}
|
||||
if got := b.GetImageURL(); got != tt.want {
|
||||
t.Errorf("GetImageURL() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageURLBuilder_GetThumbnailURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
checksum string
|
||||
updatedAt string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "uses checksum so the URL is stable across metadata edits",
|
||||
checksum: "abc123",
|
||||
updatedAt: "1700000000",
|
||||
want: "http://localhost:9999/image/42/thumbnail?t=abc123",
|
||||
},
|
||||
{
|
||||
name: "falls back to updatedAt when there is no primary file",
|
||||
checksum: "",
|
||||
updatedAt: "1700000000",
|
||||
want: "http://localhost:9999/image/42/thumbnail?t=1700000000",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
b := ImageURLBuilder{
|
||||
BaseURL: "http://localhost:9999",
|
||||
ImageID: "42",
|
||||
Checksum: tt.checksum,
|
||||
UpdatedAt: tt.updatedAt,
|
||||
}
|
||||
if got := b.GetThumbnailURL(); got != tt.want {
|
||||
t.Errorf("GetThumbnailURL() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,12 @@ type Action struct {
|
||||
Speed float64
|
||||
}
|
||||
|
||||
var utf8BOM = []byte{0xEF, 0xBB, 0xBF}
|
||||
|
||||
func unmarshalFunscriptData(data []byte, funscript *Script) error {
|
||||
return json.Unmarshal(bytes.TrimPrefix(data, utf8BOM), funscript)
|
||||
}
|
||||
|
||||
type GradientTable []struct {
|
||||
Col colorful.Color
|
||||
Pos float64
|
||||
@@ -96,7 +102,7 @@ func (g *InteractiveHeatmapSpeedGenerator) LoadFunscriptData(path string, sceneD
|
||||
}
|
||||
|
||||
var funscript Script
|
||||
err = json.Unmarshal(data, &funscript)
|
||||
err = unmarshalFunscriptData(data, &funscript)
|
||||
if err != nil {
|
||||
return Script{}, err
|
||||
}
|
||||
@@ -370,7 +376,7 @@ func LoadFunscriptData(path string) (Script, error) {
|
||||
}
|
||||
|
||||
var funscript Script
|
||||
err = json.Unmarshal(data, &funscript)
|
||||
err = unmarshalFunscriptData(data, &funscript)
|
||||
if err != nil {
|
||||
return Script{}, err
|
||||
}
|
||||
|
||||
53
internal/manager/generator_interactive_heatmap_speed_test.go
Normal file
53
internal/manager/generator_interactive_heatmap_speed_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadFunscriptDataHandlesUTF8BOM(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "test.funscript")
|
||||
data := append([]byte{0xEF, 0xBB, 0xBF}, []byte(`{"actions":[{"at":100,"pos":40},{"at":50,"pos":20}]}`)...)
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
load func() (Script, error)
|
||||
}{
|
||||
{
|
||||
name: "load funscript data",
|
||||
load: func() (Script, error) {
|
||||
return LoadFunscriptData(path)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "heatmap generator load funscript data",
|
||||
load: func() (Script, error) {
|
||||
return NewInteractiveHeatmapSpeedGenerator(false).LoadFunscriptData(path, 1)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
funscript, err := tt.load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(funscript.Actions) != 2 {
|
||||
t.Fatalf("expected 2 actions, got %d", len(funscript.Actions))
|
||||
}
|
||||
|
||||
if funscript.Actions[0].At != 50 || funscript.Actions[0].Pos != 20 {
|
||||
t.Errorf("expected first action to be sorted to at=50 pos=20, got at=%v pos=%d", funscript.Actions[0].At, funscript.Actions[0].Pos)
|
||||
}
|
||||
if funscript.Actions[1].At != 100 || funscript.Actions[1].Pos != 40 {
|
||||
t.Errorf("expected second action to be sorted to at=100 pos=40, got at=%v pos=%d", funscript.Actions[1].At, funscript.Actions[1].Pos)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -431,6 +431,8 @@ type StashBoxBatchTagInput struct {
|
||||
StashBoxEndpoint *string `json:"stash_box_endpoint"`
|
||||
// Fields to exclude when executing the tagging
|
||||
ExcludeFields []string `json:"exclude_fields"`
|
||||
// Collection fields to merge (add to existing) instead of overwriting when executing the tagging
|
||||
MergeFields []string `json:"merge_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?
|
||||
@@ -480,6 +482,7 @@ func (s *Manager) batchTagPerformersByIds(ctx context.Context, input StashBoxBat
|
||||
performer: performer,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
mergeFields: input.MergeFields,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -500,6 +503,7 @@ func (s *Manager) batchTagPerformersByNamesOrStashIds(input StashBoxBatchTagInpu
|
||||
stashID: &stashID,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
mergeFields: input.MergeFields,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -516,6 +520,7 @@ func (s *Manager) batchTagPerformersByNamesOrStashIds(input StashBoxBatchTagInpu
|
||||
name: &name,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
mergeFields: input.MergeFields,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -546,6 +551,7 @@ func (s *Manager) batchTagAllPerformers(ctx context.Context, input StashBoxBatch
|
||||
performer: performer,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
mergeFields: input.MergeFields,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -381,9 +381,11 @@ func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress *
|
||||
// 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 a force rescan was requested, or if the handler was required because
|
||||
// a related object (e.g. a deleted gallery) is missing and needs to be
|
||||
// recreated from the contents.
|
||||
|
||||
if j.scanner.IsZipFile(f.Info.Name()) && (r.New || r.FingerprintChanged || j.scanner.Rescan) {
|
||||
if j.scanner.IsZipFile(f.Info.Name()) && (r.New || r.FingerprintChanged || j.scanner.Rescan || r.HandlerRequired) {
|
||||
ff := r.File
|
||||
f.BaseFile = ff.Base()
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ type stashBoxBatchPerformerTagTask struct {
|
||||
stashID *string
|
||||
performer *models.Performer
|
||||
excludedFields []string
|
||||
mergeFields []string
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchPerformerTagTask) getName() string {
|
||||
@@ -54,8 +55,13 @@ func (t *stashBoxBatchPerformerTagTask) Start(ctx context.Context) {
|
||||
excluded[field] = true
|
||||
}
|
||||
|
||||
merge := map[string]bool{}
|
||||
for _, field := range t.mergeFields {
|
||||
merge[field] = true
|
||||
}
|
||||
|
||||
if performer != nil {
|
||||
t.processMatchedPerformer(ctx, performer, excluded)
|
||||
t.processMatchedPerformer(ctx, performer, excluded, merge)
|
||||
} else {
|
||||
logger.Infof("No match found for %s", t.getName())
|
||||
}
|
||||
@@ -157,7 +163,7 @@ func (t *stashBoxBatchPerformerTagTask) handleMergedPerformer(ctx context.Contex
|
||||
return mergedPerformer, nil
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchPerformerTagTask) processMatchedPerformer(ctx context.Context, p *models.ScrapedPerformer, excluded map[string]bool) {
|
||||
func (t *stashBoxBatchPerformerTagTask) processMatchedPerformer(ctx context.Context, p *models.ScrapedPerformer, excluded map[string]bool, merge map[string]bool) {
|
||||
if t.performer != nil {
|
||||
storedID, _ := strconv.Atoi(*p.StoredID)
|
||||
|
||||
@@ -176,7 +182,7 @@ func (t *stashBoxBatchPerformerTagTask) processMatchedPerformer(ctx context.Cont
|
||||
return err
|
||||
}
|
||||
|
||||
partial := p.ToPartial(t.box.Endpoint, excluded, existingStashIDs)
|
||||
partial := p.ToPartial(t.box.Endpoint, excluded, merge, existingStashIDs)
|
||||
|
||||
// if we're setting the performer's aliases, and not the name, then filter out the name
|
||||
// from the aliases to avoid duplicates
|
||||
|
||||
@@ -348,6 +348,7 @@ type ScanFileResult struct {
|
||||
Renamed bool
|
||||
Updated bool
|
||||
FingerprintChanged bool
|
||||
HandlerRequired bool
|
||||
}
|
||||
|
||||
func (r ScanFileResult) IsUnchanged() bool {
|
||||
@@ -842,6 +843,14 @@ func (s *Scanner) removeOutdatedFingerprints(existing models.File, fp models.Fin
|
||||
return
|
||||
}
|
||||
|
||||
b := existing.Base()
|
||||
|
||||
// oshash has changed - drop phash in case file contents are different
|
||||
if b.Fingerprints.For(models.FingerprintTypePhash) != nil {
|
||||
logger.Infof("Removing outdated phash from %s", b.Path)
|
||||
b.Fingerprints = b.Fingerprints.Remove(models.FingerprintTypePhash)
|
||||
}
|
||||
|
||||
md5 := fp.For(models.FingerprintTypeMD5)
|
||||
|
||||
if md5 != nil {
|
||||
@@ -850,8 +859,7 @@ func (s *Scanner) removeOutdatedFingerprints(existing models.File, fp models.Fin
|
||||
}
|
||||
|
||||
// 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()
|
||||
logger.Infof("Removing outdated checksum from %s", b.Path)
|
||||
b.Fingerprints = b.Fingerprints.Remove(models.FingerprintTypeMD5)
|
||||
}
|
||||
|
||||
@@ -911,7 +919,8 @@ func (s *Scanner) onUnchangedFile(ctx context.Context, f ScannedFile, existing m
|
||||
// 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,
|
||||
File: existing,
|
||||
Updated: true,
|
||||
HandlerRequired: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -664,13 +664,13 @@ func (_m *SceneReaderWriter) FindByPrimaryFileID(ctx context.Context, fileID mod
|
||||
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)
|
||||
// FindDuplicates provides a mock function with given fields: ctx, distance, durationDiff, filter
|
||||
func (_m *SceneReaderWriter) FindDuplicates(ctx context.Context, distance int, durationDiff float64, filter *models.SceneFilterType) ([][]*models.Scene, error) {
|
||||
ret := _m.Called(ctx, distance, durationDiff, filter)
|
||||
|
||||
var r0 [][]*models.Scene
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int, float64) [][]*models.Scene); ok {
|
||||
r0 = rf(ctx, distance, durationDiff)
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int, float64, *models.SceneFilterType) [][]*models.Scene); ok {
|
||||
r0 = rf(ctx, distance, durationDiff, filter)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([][]*models.Scene)
|
||||
@@ -678,8 +678,8 @@ func (_m *SceneReaderWriter) FindDuplicates(ctx context.Context, distance int, d
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, int, float64) error); ok {
|
||||
r1 = rf(ctx, distance, durationDiff)
|
||||
if rf, ok := ret.Get(1).(func(context.Context, int, float64, *models.SceneFilterType) error); ok {
|
||||
r1 = rf(ctx, distance, durationDiff, filter)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
@@ -348,13 +348,17 @@ func (p *ScrapedPerformer) GetImage(ctx context.Context, excluded map[string]boo
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, existingStashIDs []StashID) PerformerPartial {
|
||||
func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, merge map[string]bool, existingStashIDs []StashID) PerformerPartial {
|
||||
ret := NewPerformerPartial()
|
||||
|
||||
if p.Aliases != nil && !excluded["aliases"] {
|
||||
mode := RelationshipUpdateModeSet
|
||||
if merge["aliases"] {
|
||||
mode = RelationshipUpdateModeAdd
|
||||
}
|
||||
ret.Aliases = &UpdateStrings{
|
||||
Values: stringslice.FromString(*p.Aliases, ","),
|
||||
Mode: RelationshipUpdateModeSet,
|
||||
Mode: mode,
|
||||
}
|
||||
}
|
||||
if p.Birthdate != nil && !excluded["birthdate"] {
|
||||
@@ -430,12 +434,17 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool,
|
||||
ret.Tattoos = NewOptionalString(*p.Tattoos)
|
||||
}
|
||||
|
||||
urlMode := RelationshipUpdateModeSet
|
||||
if merge["urls"] {
|
||||
urlMode = RelationshipUpdateModeAdd
|
||||
}
|
||||
|
||||
// if URLs are provided, only use those
|
||||
if len(p.URLs) > 0 {
|
||||
if !excluded["urls"] {
|
||||
ret.URLs = &UpdateStrings{
|
||||
Values: p.URLs,
|
||||
Mode: RelationshipUpdateModeSet,
|
||||
Mode: urlMode,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -453,7 +462,7 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool,
|
||||
if len(urls) > 0 {
|
||||
ret.URLs = &UpdateStrings{
|
||||
Values: urls,
|
||||
Mode: RelationshipUpdateModeSet,
|
||||
Mode: urlMode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ type SceneFinder interface {
|
||||
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)
|
||||
FindDuplicates(ctx context.Context, distance int, durationDiff float64, filter *SceneFilterType) ([][]*Scene, error)
|
||||
}
|
||||
|
||||
// SceneQueryer provides methods to query scenes.
|
||||
|
||||
@@ -62,6 +62,7 @@ type StudioCreateInput struct {
|
||||
URL *string `json:"url"` // deprecated
|
||||
Urls []string `json:"urls"`
|
||||
ParentID *string `json:"parent_id"`
|
||||
ChildIds []string `json:"child_ids"`
|
||||
// This should be a URL or a base64 encoded data URL
|
||||
Image *string `json:"image"`
|
||||
StashIds []StashIDInput `json:"stash_ids"`
|
||||
@@ -82,6 +83,7 @@ type StudioUpdateInput struct {
|
||||
URL *string `json:"url"` // deprecated
|
||||
Urls []string `json:"urls"`
|
||||
ParentID *string `json:"parent_id"`
|
||||
ChildIds []string `json:"child_ids"`
|
||||
// This should be a URL or a base64 encoded data URL
|
||||
Image *string `json:"image"`
|
||||
StashIds []StashIDInput `json:"stash_ids"`
|
||||
|
||||
@@ -89,6 +89,49 @@ func autotagMatchTags(ctx context.Context, path string, tagReader models.TagAuto
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (s autotagScraper) viaImage(ctx context.Context, _client *http.Client, image *models.Image) (*models.ScrapedImage, error) {
|
||||
path := image.Path
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var ret *models.ScrapedImage
|
||||
|
||||
// only trim extension if image is file-based
|
||||
trimExt := image.PrimaryFileID != nil
|
||||
|
||||
// populate performers, studio and tags based on image path
|
||||
if err := txn.WithReadTxn(ctx, s.txnManager, func(ctx context.Context) error {
|
||||
performers, err := autotagMatchPerformers(ctx, path, s.performerReader, trimExt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("autotag scraper viaImage: %w", err)
|
||||
}
|
||||
studio, err := autotagMatchStudio(ctx, path, s.studioReader, trimExt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("autotag scraper viaImage: %w", err)
|
||||
}
|
||||
|
||||
tags, err := autotagMatchTags(ctx, path, s.tagReader, trimExt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("autotag scraper viaImage: %w", err)
|
||||
}
|
||||
|
||||
if len(performers) > 0 || studio != nil || len(tags) > 0 {
|
||||
ret = &models.ScrapedImage{
|
||||
Performers: performers,
|
||||
Studio: studio,
|
||||
Tags: tags,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
@@ -181,6 +224,8 @@ func (s autotagScraper) supports(ty ScrapeContentType) bool {
|
||||
return true
|
||||
case ScrapeContentTypeGallery:
|
||||
return true
|
||||
case ScrapeContentTypeImage:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -204,6 +249,9 @@ func (s autotagScraper) spec() Scraper {
|
||||
Gallery: &ScraperSpec{
|
||||
SupportedScrapes: supportedScrapes,
|
||||
},
|
||||
Image: &ScraperSpec{
|
||||
SupportedScrapes: supportedScrapes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -205,6 +205,52 @@ func galleryInputFromGallery(gallery *models.Gallery) galleryInput {
|
||||
return ret
|
||||
}
|
||||
|
||||
type imageInput 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"`
|
||||
}
|
||||
|
||||
func imageInputFromImage(image *models.Image) imageInput {
|
||||
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 := image.GetTitle()
|
||||
urls := image.URLs.List()
|
||||
|
||||
ret := imageInput{
|
||||
ID: strconv.Itoa(image.ID),
|
||||
Title: title,
|
||||
Urls: urls,
|
||||
Details: image.Details,
|
||||
Date: dateToStringPtr(image.Date),
|
||||
|
||||
Code: image.Code,
|
||||
Photographer: image.Photographer,
|
||||
}
|
||||
|
||||
for _, f := range image.Files.List() {
|
||||
fi := fileInputFromFile(*f.Base())
|
||||
ret.Files = append(ret.Files, fi)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
var ErrScraperScript = errors.New("scraper script error")
|
||||
|
||||
type scriptScraper struct {
|
||||
@@ -392,6 +438,9 @@ func (s *scriptFragmentScraper) scrapeByFragment(ctx context.Context, input Inpu
|
||||
case input.Scene != nil:
|
||||
inString, err = json.Marshal(*input.Scene)
|
||||
ty = ScrapeContentTypeScene
|
||||
case input.Image != nil:
|
||||
inString, err = json.Marshal(*input.Image)
|
||||
ty = ScrapeContentTypeImage
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -430,7 +479,7 @@ func (s *scriptFragmentScraper) scrapeGalleryByGallery(ctx context.Context, gall
|
||||
}
|
||||
|
||||
func (s *scriptFragmentScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) {
|
||||
inString, err := json.Marshal(imageToUpdateInput(image))
|
||||
inString, err := json.Marshal(imageInputFromImage(image))
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
52
pkg/scraper/script_test.go
Normal file
52
pkg/scraper/script_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package scraper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_imageInputFromImage_worksWithMultipleFiles(t *testing.T) {
|
||||
|
||||
date, _ := models.ParseDate("2020-01-01")
|
||||
model := models.Image{
|
||||
ID: 1,
|
||||
Title: "Test Image",
|
||||
URLs: models.NewRelatedStrings([]string{"https://example.com/image.png"}),
|
||||
Date: &date,
|
||||
Code: "Code",
|
||||
Photographer: "Photographer",
|
||||
Files: models.NewRelatedFiles([]models.File{
|
||||
makeImageFile(1),
|
||||
makeImageFile(2),
|
||||
}),
|
||||
}
|
||||
|
||||
input := imageInputFromImage(&model)
|
||||
|
||||
assert.Equal(t, "1", input.ID)
|
||||
assert.Equal(t, "Test Image", input.Title)
|
||||
assert.Equal(t, "https://example.com/image.png", input.Urls[0])
|
||||
assert.Equal(t, "2020-01-01", *input.Date)
|
||||
assert.Equal(t, "Code", input.Code)
|
||||
assert.Equal(t, "Photographer", input.Photographer)
|
||||
assert.Equal(t, "/data/images/image_0001_.png", input.Files[0].Path)
|
||||
assert.Equal(t, "/data/images/image_0002_.png", input.Files[1].Path)
|
||||
}
|
||||
|
||||
func getImageStringValue(index int, field string) string {
|
||||
return fmt.Sprintf("image_%04d_%s", index, field)
|
||||
}
|
||||
|
||||
func makeImageFile(i int) *models.ImageFile {
|
||||
return &models.ImageFile{
|
||||
BaseFile: &models.BaseFile{
|
||||
Path: "/data/images/" + getImageStringValue(i, ".png"),
|
||||
Basename: getImageStringValue(i, ".png"),
|
||||
},
|
||||
Height: 200,
|
||||
Width: 300,
|
||||
}
|
||||
}
|
||||
@@ -438,26 +438,3 @@ func (s *stashScraper) scrapeImageByImage(ctx context.Context, image *models.Ima
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -829,6 +829,7 @@ var gallerySortOptions = sortOptions{
|
||||
"id",
|
||||
"images_count",
|
||||
"path",
|
||||
"performer_age",
|
||||
"performer_count",
|
||||
"random",
|
||||
"rating",
|
||||
@@ -890,6 +891,34 @@ func (qb *GalleryStore) setGallerySort(query *queryBuilder, findFilter *models.F
|
||||
query.sortAndPagination += getCountSort(galleryTable, galleriesTagsTable, galleryIDColumn, direction)
|
||||
case "performer_count":
|
||||
query.sortAndPagination += getCountSort(galleryTable, performersGalleriesTable, galleryIDColumn, direction)
|
||||
case "performer_age":
|
||||
// Multi-performer semantics:
|
||||
// - ASC sorts by the youngest performer in each gallery (MIN age)
|
||||
// - DESC sorts by the oldest performer in each gallery (MAX age)
|
||||
aggregation := "MIN"
|
||||
if direction == "DESC" {
|
||||
// DESC uses oldest performer age for each gallery.
|
||||
aggregation = "MAX"
|
||||
}
|
||||
var fallback string
|
||||
if direction == "ASC" {
|
||||
// ASC puts NULL first by default, so coalesce to sqlite max int.
|
||||
fallback = "9223372036854775807"
|
||||
} else {
|
||||
// DESC puts larger values first; coalesce NULL to sqlite min int to keep NULLs last.
|
||||
fallback = "-9223372036854775808"
|
||||
}
|
||||
query.sortAndPagination += fmt.Sprintf(
|
||||
" ORDER BY (SELECT COALESCE(%s(JulianDay(galleries.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,
|
||||
performersGalleriesTable,
|
||||
performerIDColumn,
|
||||
galleryIDColumn,
|
||||
galleryTable,
|
||||
getSortDirection(direction),
|
||||
)
|
||||
case "path":
|
||||
// special handling for path
|
||||
addFileTable()
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var invalidID = -1
|
||||
@@ -2825,6 +2826,20 @@ func TestGalleryQuerySorting(t *testing.T) {
|
||||
-1,
|
||||
-1,
|
||||
},
|
||||
{
|
||||
"performer age asc",
|
||||
"performer_age",
|
||||
models.SortDirectionEnumAsc,
|
||||
-1,
|
||||
-1,
|
||||
},
|
||||
{
|
||||
"performer age desc",
|
||||
"performer_age",
|
||||
models.SortDirectionEnumDesc,
|
||||
-1,
|
||||
-1,
|
||||
},
|
||||
}
|
||||
|
||||
qb := db.Gallery
|
||||
@@ -2862,6 +2877,163 @@ func TestGalleryQuerySorting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGalleryQuerySortingPerformerAgeNullHandling(t *testing.T) {
|
||||
runWithRollbackTxn(t, "performer age null handling", func(t *testing.T, ctx context.Context) {
|
||||
assert := assert.New(t)
|
||||
|
||||
knownBirthdate, err := models.ParseDate("1990-01-01")
|
||||
require.NoError(t, err)
|
||||
galleryDate, err := models.ParseDate("2020-01-01")
|
||||
require.NoError(t, err)
|
||||
|
||||
knownPerformer := models.Performer{
|
||||
Name: "performer-known-birthdate",
|
||||
Birthdate: &knownBirthdate,
|
||||
}
|
||||
require.NoError(t, db.Performer.Create(ctx, &models.CreatePerformerInput{Performer: &knownPerformer}))
|
||||
|
||||
unknownPerformer := models.Performer{
|
||||
Name: "performer-unknown-birthdate",
|
||||
}
|
||||
require.NoError(t, db.Performer.Create(ctx, &models.CreatePerformerInput{Performer: &unknownPerformer}))
|
||||
|
||||
knownOnlyGallery := models.Gallery{
|
||||
Title: "gallery-known-only",
|
||||
Date: &galleryDate,
|
||||
PerformerIDs: models.NewRelatedIDs([]int{
|
||||
knownPerformer.ID,
|
||||
}),
|
||||
}
|
||||
require.NoError(t, db.Gallery.Create(ctx, &models.CreateGalleryInput{Gallery: &knownOnlyGallery}))
|
||||
|
||||
mixedGallery := models.Gallery{
|
||||
Title: "gallery-known-and-unknown",
|
||||
Date: &galleryDate,
|
||||
PerformerIDs: models.NewRelatedIDs([]int{
|
||||
knownPerformer.ID,
|
||||
unknownPerformer.ID,
|
||||
}),
|
||||
}
|
||||
require.NoError(t, db.Gallery.Create(ctx, &models.CreateGalleryInput{Gallery: &mixedGallery}))
|
||||
|
||||
unknownOnlyGallery := models.Gallery{
|
||||
Title: "gallery-unknown-only",
|
||||
Date: &galleryDate,
|
||||
PerformerIDs: models.NewRelatedIDs([]int{
|
||||
unknownPerformer.ID,
|
||||
}),
|
||||
}
|
||||
require.NoError(t, db.Gallery.Create(ctx, &models.CreateGalleryInput{Gallery: &unknownOnlyGallery}))
|
||||
|
||||
findIndex := func(galleries []*models.Gallery, id int) int {
|
||||
for i, g := range galleries {
|
||||
if g.ID == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
asc := models.SortDirectionEnumAsc
|
||||
sortBy := "performer_age"
|
||||
ascGot, _, err := db.Gallery.Query(ctx, nil, &models.FindFilterType{Sort: &sortBy, Direction: &asc})
|
||||
require.NoError(t, err)
|
||||
|
||||
ascKnownOnly := findIndex(ascGot, knownOnlyGallery.ID)
|
||||
ascMixed := findIndex(ascGot, mixedGallery.ID)
|
||||
ascUnknownOnly := findIndex(ascGot, unknownOnlyGallery.ID)
|
||||
assert.NotEqual(-1, ascKnownOnly)
|
||||
assert.NotEqual(-1, ascMixed)
|
||||
assert.NotEqual(-1, ascUnknownOnly)
|
||||
assert.Less(ascKnownOnly, ascUnknownOnly)
|
||||
assert.Less(ascMixed, ascUnknownOnly)
|
||||
|
||||
desc := models.SortDirectionEnumDesc
|
||||
descGot, _, err := db.Gallery.Query(ctx, nil, &models.FindFilterType{Sort: &sortBy, Direction: &desc})
|
||||
require.NoError(t, err)
|
||||
|
||||
descKnownOnly := findIndex(descGot, knownOnlyGallery.ID)
|
||||
descMixed := findIndex(descGot, mixedGallery.ID)
|
||||
descUnknownOnly := findIndex(descGot, unknownOnlyGallery.ID)
|
||||
assert.NotEqual(-1, descKnownOnly)
|
||||
assert.NotEqual(-1, descMixed)
|
||||
assert.NotEqual(-1, descUnknownOnly)
|
||||
assert.Less(descKnownOnly, descUnknownOnly)
|
||||
assert.Less(descMixed, descUnknownOnly)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGalleryQuerySortingPerformerAgeMultiPerformerAggregation(t *testing.T) {
|
||||
runWithRollbackTxn(t, "performer age multi performer aggregation", func(t *testing.T, ctx context.Context) {
|
||||
assert := assert.New(t)
|
||||
|
||||
youngBirthdate, err := models.ParseDate("2000-01-01")
|
||||
require.NoError(t, err)
|
||||
midBirthdate, err := models.ParseDate("1990-01-01")
|
||||
require.NoError(t, err)
|
||||
oldBirthdate, err := models.ParseDate("1980-01-01")
|
||||
require.NoError(t, err)
|
||||
galleryDate, err := models.ParseDate("2020-01-01")
|
||||
require.NoError(t, err)
|
||||
|
||||
young := models.Performer{Name: "performer-young", Birthdate: &youngBirthdate}
|
||||
mid := models.Performer{Name: "performer-mid", Birthdate: &midBirthdate}
|
||||
old := models.Performer{Name: "performer-old", Birthdate: &oldBirthdate}
|
||||
require.NoError(t, db.Performer.Create(ctx, &models.CreatePerformerInput{Performer: &young}))
|
||||
require.NoError(t, db.Performer.Create(ctx, &models.CreatePerformerInput{Performer: &mid}))
|
||||
require.NoError(t, db.Performer.Create(ctx, &models.CreatePerformerInput{Performer: &old}))
|
||||
|
||||
galleryYoungAndOld := models.Gallery{
|
||||
Title: "gallery-young-and-old",
|
||||
Date: &galleryDate,
|
||||
PerformerIDs: models.NewRelatedIDs([]int{
|
||||
young.ID,
|
||||
old.ID,
|
||||
}),
|
||||
}
|
||||
require.NoError(t, db.Gallery.Create(ctx, &models.CreateGalleryInput{Gallery: &galleryYoungAndOld}))
|
||||
|
||||
galleryMidOnly := models.Gallery{
|
||||
Title: "gallery-mid-only",
|
||||
Date: &galleryDate,
|
||||
PerformerIDs: models.NewRelatedIDs([]int{
|
||||
mid.ID,
|
||||
}),
|
||||
}
|
||||
require.NoError(t, db.Gallery.Create(ctx, &models.CreateGalleryInput{Gallery: &galleryMidOnly}))
|
||||
|
||||
findIndex := func(galleries []*models.Gallery, id int) int {
|
||||
for i, g := range galleries {
|
||||
if g.ID == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
sortBy := "performer_age"
|
||||
asc := models.SortDirectionEnumAsc
|
||||
ascGot, _, err := db.Gallery.Query(ctx, nil, &models.FindFilterType{Sort: &sortBy, Direction: &asc})
|
||||
require.NoError(t, err)
|
||||
ascYoungAndOld := findIndex(ascGot, galleryYoungAndOld.ID)
|
||||
ascMidOnly := findIndex(ascGot, galleryMidOnly.ID)
|
||||
assert.NotEqual(-1, ascYoungAndOld)
|
||||
assert.NotEqual(-1, ascMidOnly)
|
||||
// ASC uses MIN(age), so gallery with youngest performer should come first.
|
||||
assert.Less(ascYoungAndOld, ascMidOnly)
|
||||
|
||||
desc := models.SortDirectionEnumDesc
|
||||
descGot, _, err := db.Gallery.Query(ctx, nil, &models.FindFilterType{Sort: &sortBy, Direction: &desc})
|
||||
require.NoError(t, err)
|
||||
descYoungAndOld := findIndex(descGot, galleryYoungAndOld.ID)
|
||||
descMidOnly := findIndex(descGot, galleryMidOnly.ID)
|
||||
assert.NotEqual(-1, descYoungAndOld)
|
||||
assert.NotEqual(-1, descMidOnly)
|
||||
// DESC uses MAX(age), so gallery with oldest performer should come first.
|
||||
assert.Less(descYoungAndOld, descMidOnly)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGalleryStore_AddImages(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -500,6 +500,7 @@ var groupSortOptions = sortOptions{
|
||||
"rating",
|
||||
"scenes_count",
|
||||
"o_counter",
|
||||
"sub_group_description",
|
||||
"sub_group_order",
|
||||
"tag_count",
|
||||
"updated_at",
|
||||
@@ -532,6 +533,15 @@ func (qb *GroupStore) setGroupSort(query *queryBuilder, findFilter *models.FindF
|
||||
query.joinSort(groupRelationsTable, "", "groups.id = groups_relations.sub_id")
|
||||
query.sortAndPagination += getSort("order_index", direction, groupRelationsTable)
|
||||
}
|
||||
case "sub_group_description":
|
||||
// as above, we need to handle parent groups differently here
|
||||
const clause = " ORDER BY COALESCE(%s.description, '') COLLATE NATURAL_CI %s"
|
||||
if query.hasJoin("groups_parents") {
|
||||
query.sortAndPagination += fmt.Sprintf(clause, "groups_parents", direction)
|
||||
} else {
|
||||
query.joinSort(groupRelationsTable, "", "groups.id = groups_relations.sub_id")
|
||||
query.sortAndPagination += fmt.Sprintf(clause, groupRelationsTable, direction)
|
||||
}
|
||||
case "tag_count":
|
||||
query.sortAndPagination += getCountSort(groupTable, groupsTagsTable, groupIDColumn, direction)
|
||||
case "scenes_count": // generic getSort won't work for this
|
||||
|
||||
@@ -1124,6 +1124,90 @@ func TestGroupQuerySortOrderIndex(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGroupQuerySortSubGroupDescription(t *testing.T) {
|
||||
runWithRollbackTxn(t, "sort subgroup description", func(t *testing.T, ctx context.Context) {
|
||||
assert := assert.New(t)
|
||||
|
||||
cEmpty := models.Group{Name: "sort-desc-child-empty"}
|
||||
c01 := models.Group{Name: "sort-desc-child-01"}
|
||||
c2 := models.Group{Name: "sort-desc-child-2"}
|
||||
c10 := models.Group{Name: "sort-desc-child-10"}
|
||||
assert.NoError(db.Group.Create(ctx, &cEmpty))
|
||||
assert.NoError(db.Group.Create(ctx, &c01))
|
||||
assert.NoError(db.Group.Create(ctx, &c2))
|
||||
assert.NoError(db.Group.Create(ctx, &c10))
|
||||
|
||||
parent := models.Group{
|
||||
Name: "sort-desc-parent",
|
||||
SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
|
||||
{GroupID: cEmpty.ID, Description: ""},
|
||||
{GroupID: c10.ID, Description: "10"},
|
||||
{GroupID: c2.ID, Description: "2"},
|
||||
{GroupID: c01.ID, Description: "01"},
|
||||
}),
|
||||
}
|
||||
assert.NoError(db.Group.Create(ctx, &parent))
|
||||
|
||||
sortKey := "sub_group_description"
|
||||
dirAsc := models.SortDirectionEnumAsc
|
||||
findFilter := models.FindFilterType{
|
||||
Sort: &sortKey,
|
||||
Direction: &dirAsc,
|
||||
}
|
||||
groupFilter := models.GroupFilterType{
|
||||
ContainingGroups: &models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{strconv.Itoa(parent.ID)},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
}
|
||||
|
||||
groups, _, err := db.Group.Query(ctx, &groupFilter, &findFilter)
|
||||
assert.NoError(err)
|
||||
assert.Len(groups, 4)
|
||||
assert.Equal(cEmpty.ID, groups[0].ID)
|
||||
assert.Equal(c01.ID, groups[1].ID)
|
||||
assert.Equal(c2.ID, groups[2].ID)
|
||||
assert.Equal(c10.ID, groups[3].ID)
|
||||
|
||||
dirDesc := models.SortDirectionEnumDesc
|
||||
findFilter.Direction = &dirDesc
|
||||
groups, _, err = db.Group.Query(ctx, &groupFilter, &findFilter)
|
||||
assert.NoError(err)
|
||||
assert.Len(groups, 4)
|
||||
assert.Equal(c10.ID, groups[0].ID)
|
||||
assert.Equal(c2.ID, groups[1].ID)
|
||||
assert.Equal(c01.ID, groups[2].ID)
|
||||
assert.Equal(cEmpty.ID, groups[3].ID)
|
||||
|
||||
// Exercise the non-groups_parents code path by filtering on name only.
|
||||
nameCriterion := models.StringCriterionInput{
|
||||
Value: "sort-desc-child-",
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
}
|
||||
nameFilter := models.GroupFilterType{
|
||||
Name: &nameCriterion,
|
||||
}
|
||||
|
||||
findFilter.Direction = &dirAsc
|
||||
groups, _, err = db.Group.Query(ctx, &nameFilter, &findFilter)
|
||||
assert.NoError(err)
|
||||
assert.Len(groups, 4)
|
||||
assert.Equal(cEmpty.ID, groups[0].ID)
|
||||
assert.Equal(c01.ID, groups[1].ID)
|
||||
assert.Equal(c2.ID, groups[2].ID)
|
||||
assert.Equal(c10.ID, groups[3].ID)
|
||||
|
||||
findFilter.Direction = &dirDesc
|
||||
groups, _, err = db.Group.Query(ctx, &nameFilter, &findFilter)
|
||||
assert.NoError(err)
|
||||
assert.Len(groups, 4)
|
||||
assert.Equal(c10.ID, groups[0].ID)
|
||||
assert.Equal(c2.ID, groups[1].ID)
|
||||
assert.Equal(c01.ID, groups[2].ID)
|
||||
assert.Equal(cEmpty.ID, groups[3].ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGroupUpdateFrontImage(t *testing.T) {
|
||||
if err := withRollbackTxn(func(ctx context.Context) error {
|
||||
qb := db.Group
|
||||
|
||||
@@ -41,41 +41,6 @@ const (
|
||||
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"`
|
||||
@@ -1462,11 +1427,61 @@ func (qb *SceneStore) GetStashIDs(ctx context.Context, sceneID int) ([]models.St
|
||||
return sceneRepository.stashIDs.get(ctx, sceneID)
|
||||
}
|
||||
|
||||
func (qb *SceneStore) FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*models.Scene, error) {
|
||||
func (qb *SceneStore) FindDuplicates(ctx context.Context, distance int, durationDiff float64, filter *models.SceneFilterType) ([][]*models.Scene, error) {
|
||||
var dupeIds [][]int
|
||||
|
||||
query, err := qb.makeQuery(ctx, filter, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add necessary joins for duplicate checking
|
||||
query.addJoins(
|
||||
join{
|
||||
table: scenesFilesTable,
|
||||
onClause: "scenes.id = scenes_files.scene_id",
|
||||
},
|
||||
join{
|
||||
table: fileTable,
|
||||
onClause: "scenes_files.file_id = files.id",
|
||||
},
|
||||
join{
|
||||
table: fingerprintTable,
|
||||
onClause: "scenes_files.file_id = files_fingerprints.file_id AND files_fingerprints.type = 'phash'",
|
||||
},
|
||||
join{
|
||||
table: videoFileTable,
|
||||
onClause: "files.id = video_files.file_id",
|
||||
},
|
||||
)
|
||||
|
||||
if distance == 0 {
|
||||
query.columns = []string{
|
||||
"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",
|
||||
}
|
||||
|
||||
sqlStr := query.toSQL(false)
|
||||
|
||||
finalQuery := `
|
||||
SELECT GROUP_CONCAT(DISTINCT scene_id) as ids
|
||||
FROM (` + sqlStr + `)
|
||||
WHERE phash IS NOT NULL
|
||||
AND (durationDiff <= ?
|
||||
OR ? < 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 ids []string
|
||||
if err := dbWrapper.Select(ctx, &ids, findExactDuplicateQuery, durationDiff); err != nil {
|
||||
args := append(query.allArgs(), durationDiff, durationDiff)
|
||||
if err := dbWrapper.Select(ctx, &ids, finalQuery, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1484,9 +1499,19 @@ func (qb *SceneStore) FindDuplicates(ctx context.Context, distance int, duration
|
||||
}
|
||||
}
|
||||
} else {
|
||||
query.columns = []string{
|
||||
"scenes.id as id",
|
||||
"files_fingerprints.fingerprint as phash",
|
||||
"video_files.duration as duration",
|
||||
}
|
||||
query.addWhere("files_fingerprints.fingerprint IS NOT NULL")
|
||||
query.sortAndPagination = " ORDER BY files.size DESC"
|
||||
|
||||
sqlStr := query.toSQL(true)
|
||||
|
||||
var hashes []*utils.Phash
|
||||
|
||||
if err := sceneRepository.queryFunc(ctx, findAllPhashesQuery, nil, false, func(rows *sqlx.Rows) error {
|
||||
if err := sceneRepository.queryFunc(ctx, sqlStr, query.allArgs(), false, func(rows *sqlx.Rows) error {
|
||||
phash := utils.Phash{
|
||||
Bucket: -1,
|
||||
Duration: -1,
|
||||
|
||||
@@ -4631,7 +4631,7 @@ func TestSceneStore_FindDuplicates(t *testing.T) {
|
||||
withRollbackTxn(func(ctx context.Context) error {
|
||||
distance := 0
|
||||
durationDiff := -1.
|
||||
got, err := qb.FindDuplicates(ctx, distance, durationDiff)
|
||||
got, err := qb.FindDuplicates(ctx, distance, durationDiff, nil)
|
||||
if err != nil {
|
||||
t.Errorf("SceneStore.FindDuplicates() error = %v", err)
|
||||
return nil
|
||||
@@ -4641,7 +4641,7 @@ func TestSceneStore_FindDuplicates(t *testing.T) {
|
||||
|
||||
distance = 1
|
||||
durationDiff = -1.
|
||||
got, err = qb.FindDuplicates(ctx, distance, durationDiff)
|
||||
got, err = qb.FindDuplicates(ctx, distance, durationDiff, nil)
|
||||
if err != nil {
|
||||
t.Errorf("SceneStore.FindDuplicates() error = %v", err)
|
||||
return nil
|
||||
@@ -4653,6 +4653,214 @@ func TestSceneStore_FindDuplicates(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestSceneStore_FindDuplicatesWithFilter(t *testing.T) {
|
||||
qb := db.Scene
|
||||
|
||||
// Helper to create a scene with a specific phash and optional title prefix
|
||||
createDupeScene := func(ctx context.Context, name string, phash int64) (*models.Scene, error) {
|
||||
sceneFile := &models.VideoFile{
|
||||
BaseFile: &models.BaseFile{
|
||||
Basename: name,
|
||||
ParentFolderID: folderIDs[folderIdxWithSceneFiles],
|
||||
Fingerprints: models.Fingerprints{
|
||||
{Type: models.FingerprintTypeMD5, Fingerprint: name + "_md5"},
|
||||
{Type: models.FingerprintTypeOshash, Fingerprint: name + "_oshash"},
|
||||
{Type: models.FingerprintTypePhash, Fingerprint: phash},
|
||||
},
|
||||
},
|
||||
Duration: 100.0,
|
||||
Width: 1920,
|
||||
Height: 1080,
|
||||
}
|
||||
|
||||
if err := db.File.Create(ctx, sceneFile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scene := &models.Scene{
|
||||
Title: name,
|
||||
}
|
||||
|
||||
if err := qb.Create(ctx, scene, []models.FileID{sceneFile.ID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return scene, nil
|
||||
}
|
||||
|
||||
// Helper to add tags to a scene
|
||||
addSceneTags := func(ctx context.Context, sceneID int, tagIDsToAdd []int) error {
|
||||
_, err := qb.UpdatePartial(ctx, sceneID, models.ScenePartial{
|
||||
TagIDs: &models.UpdateIDs{
|
||||
Mode: models.RelationshipUpdateModeSet,
|
||||
IDs: tagIDsToAdd,
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
withRollbackTxn(func(ctx context.Context) error {
|
||||
// Create a test tag to use for filtering
|
||||
err := db.Tag.Create(ctx, &models.CreateTagInput{
|
||||
Tag: &models.Tag{
|
||||
Name: "FindDuplicatesFilterTestTag",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("failed to create test tag: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetch the tag we just created
|
||||
tagName := "FindDuplicatesFilterTestTag"
|
||||
tags, _, err := db.Tag.Query(ctx, &models.TagFilterType{
|
||||
Name: &models.StringCriterionInput{
|
||||
Value: tagName,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
},
|
||||
}, &models.FindFilterType{
|
||||
PerPage: intPtr(1),
|
||||
})
|
||||
if err != nil || len(tags) == 0 {
|
||||
t.Errorf("failed to find test tag: %v", err)
|
||||
return nil
|
||||
}
|
||||
testTagID := tags[0].ID
|
||||
|
||||
// Create two pairs of duplicate scenes:
|
||||
// Pair A: sceneA1 and sceneA2 have the same phash and share a tag
|
||||
// Pair B: sceneB1 and sceneB2 have the same phash but no tag
|
||||
|
||||
const sharedPhash int64 = 999999
|
||||
|
||||
sceneA1, err := createDupeScene(ctx, "FilterTest_A1", sharedPhash)
|
||||
if err != nil {
|
||||
t.Errorf("failed to create sceneA1: %v", err)
|
||||
return nil
|
||||
}
|
||||
sceneA2, err := createDupeScene(ctx, "FilterTest_A2", sharedPhash)
|
||||
if err != nil {
|
||||
t.Errorf("failed to create sceneA2: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
const otherPhash int64 = 888888
|
||||
|
||||
sceneB1, err := createDupeScene(ctx, "FilterTest_B1", otherPhash)
|
||||
if err != nil {
|
||||
t.Errorf("failed to create sceneB1: %v", err)
|
||||
return nil
|
||||
}
|
||||
sceneB2, err := createDupeScene(ctx, "FilterTest_B2", otherPhash)
|
||||
if err != nil {
|
||||
t.Errorf("failed to create sceneB2: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add tag only to pair A
|
||||
if err := addSceneTags(ctx, sceneA1.ID, []int{testTagID}); err != nil {
|
||||
t.Errorf("failed to add tag to sceneA1: %v", err)
|
||||
return nil
|
||||
}
|
||||
if err := addSceneTags(ctx, sceneA2.ID, []int{testTagID}); err != nil {
|
||||
t.Errorf("failed to add tag to sceneA2: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Test 1: No filter - should find all duplicates (2 pairs: original + our new ones)
|
||||
distance := 0
|
||||
durationDiff := -1.0
|
||||
got, err := qb.FindDuplicates(ctx, distance, durationDiff, nil)
|
||||
if err != nil {
|
||||
t.Errorf("FindDuplicates(nil filter) error = %v", err)
|
||||
return nil
|
||||
}
|
||||
// Should find at least our 2 new pairs (may find more from pre-populated data)
|
||||
assert.GreaterOrEqual(t, len(got), 2, "nil filter should find at least our 2 new duplicate pairs")
|
||||
|
||||
// Test 2: Filter by tag - should only find pair A (the tagged pair)
|
||||
got, err = qb.FindDuplicates(ctx, distance, durationDiff, &models.SceneFilterType{
|
||||
Tags: &models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{strconv.Itoa(testTagID)},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("FindDuplicates(tag filter) error = %v", err)
|
||||
return nil
|
||||
}
|
||||
// Should find exactly 1 duplicate pair (pair A)
|
||||
assert.Len(t, got, 1, "tag filter should find exactly 1 duplicate pair")
|
||||
|
||||
// Verify the found pair contains our tagged scenes
|
||||
if len(got) == 1 {
|
||||
foundIDs := map[int]bool{}
|
||||
for _, s := range got[0] {
|
||||
foundIDs[s.ID] = true
|
||||
}
|
||||
assert.True(t, foundIDs[sceneA1.ID], "pair A scene 1 should be in results")
|
||||
assert.True(t, foundIDs[sceneA2.ID], "pair A scene 2 should be in results")
|
||||
// Pair B (untagged) should NOT be in the results
|
||||
assert.False(t, foundIDs[sceneB1.ID], "pair B scene 1 should NOT be in tag-filtered results")
|
||||
assert.False(t, foundIDs[sceneB2.ID], "pair B scene 2 should NOT be in tag-filtered results")
|
||||
}
|
||||
|
||||
// Test 3: Filter by tag that no duplicate scene has - should find nothing
|
||||
err = db.Tag.Create(ctx, &models.CreateTagInput{
|
||||
Tag: &models.Tag{
|
||||
Name: "FindDuplicatesFilterTestTag_NonExistent",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("failed to create non-existent tag: %v", err)
|
||||
return nil
|
||||
}
|
||||
nonExistentTagName := "FindDuplicatesFilterTestTag_NonExistent"
|
||||
tags2, _, err := db.Tag.Query(ctx, &models.TagFilterType{
|
||||
Name: &models.StringCriterionInput{
|
||||
Value: nonExistentTagName,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
},
|
||||
}, &models.FindFilterType{
|
||||
PerPage: intPtr(1),
|
||||
})
|
||||
if err != nil || len(tags2) == 0 {
|
||||
t.Errorf("failed to find non-existent tag: %v", err)
|
||||
return nil
|
||||
}
|
||||
nonExistentTagID := tags2[0].ID
|
||||
|
||||
got, err = qb.FindDuplicates(ctx, distance, durationDiff, &models.SceneFilterType{
|
||||
Tags: &models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{strconv.Itoa(nonExistentTagID)},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("FindDuplicates(non-existent tag filter) error = %v", err)
|
||||
return nil
|
||||
}
|
||||
assert.Len(t, got, 0, "non-existent tag filter should find no duplicates")
|
||||
|
||||
// Test 4: Fuzzy match (distance=1) with filter
|
||||
distance = 1
|
||||
got, err = qb.FindDuplicates(ctx, distance, durationDiff, &models.SceneFilterType{
|
||||
Tags: &models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{strconv.Itoa(testTagID)},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("FindDuplicates(fuzzy + tag filter) error = %v", err)
|
||||
return nil
|
||||
}
|
||||
// Should still find pair A with fuzzy matching
|
||||
assert.Len(t, got, 1, "fuzzy + tag filter should find exactly 1 duplicate pair")
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestSceneStore_AssignFiles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -588,6 +588,10 @@ func indexesToIDPtrs[T any](ids []T, indexes []int) []*T {
|
||||
return ret
|
||||
}
|
||||
|
||||
func intPtr(i int) *int {
|
||||
return &i
|
||||
}
|
||||
|
||||
func indexToIDPtr[T any](ids []T, idx int) *T {
|
||||
if idx < 0 {
|
||||
return nil
|
||||
|
||||
@@ -88,6 +88,7 @@ func getSortDirection(direction string) string {
|
||||
return direction
|
||||
}
|
||||
}
|
||||
|
||||
func getSort(sort string, direction string, tableName string) string {
|
||||
direction = getSortDirection(direction)
|
||||
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
BROWSER=none
|
||||
ESLINT_NO_DEV_ERRORS=true
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
{
|
||||
"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": ["/"] }]
|
||||
}
|
||||
}
|
||||
8
ui/v2.5/.gitignore
vendored
8
ui/v2.5/.gitignore
vendored
@@ -3,8 +3,6 @@ src/core/generated-graphql.ts
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
@@ -20,9 +18,5 @@ src/core/generated-graphql.ts
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
.stylelintcache
|
||||
@@ -1,18 +0,0 @@
|
||||
*.md
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
|
||||
# locales
|
||||
src/locales/**/*.json
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# generated
|
||||
src/core/generated-graphql.ts
|
||||
@@ -29,7 +29,7 @@ See the section about [deployment](https://facebook.github.io/create-react-app/d
|
||||
|
||||
### `npm run format`
|
||||
|
||||
Formats the whitespace of all typescript and scss code with prettier, to ease editing and ensure a common code style.
|
||||
Formats the whitespace of all typescript and scss code with biome, to ease editing and ensure a common code style.
|
||||
|
||||
Should ideally be run before all frontend PRs.
|
||||
|
||||
@@ -39,7 +39,7 @@ Should ideally be run before all frontend PRs.
|
||||
|
||||
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.
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, 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.
|
||||
|
||||
|
||||
57
ui/v2.5/biome.jsonc
Normal file
57
ui/v2.5/biome.jsonc
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
|
||||
"json": {
|
||||
"parser": {
|
||||
"allowComments": true
|
||||
}
|
||||
},
|
||||
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": [
|
||||
"**",
|
||||
"../../graphql/**",
|
||||
"!src/core/generated-graphql.ts",
|
||||
"!src/pluginApi.d.ts",
|
||||
"!src/@types/videojs-vr.d.ts",
|
||||
"!src/locales/*.json"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"domains": {
|
||||
"react": "recommended",
|
||||
"types": "recommended"
|
||||
},
|
||||
"rules": {
|
||||
"a11y": "off",
|
||||
"style": {
|
||||
// used where the type is known to be non-null, but the compiler can't figure it out
|
||||
"noNonNullAssertion": "off",
|
||||
// preferable, but not something we want to enforce right now
|
||||
"useTemplate": "off",
|
||||
// remove the following after fixing
|
||||
"useImportType": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
// this is used extensively, and there's no obvious fix
|
||||
"noArrayIndexKey": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
// es5 requires the least amount of changes and (imo) looks better than "all"
|
||||
"trailingCommas": "es5"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": { "source": { "organizeImports": "off" } }
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,16 @@
|
||||
"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",
|
||||
"build-ci": "pnpm run validate && pnpm run build",
|
||||
"validate": "pnpm run lint && pnpm run check && pnpm run format-check",
|
||||
"lint": "pnpm run lint:js && pnpm run lint:css",
|
||||
"lint:css": "stylelint --cache \"src/**/*.scss\"",
|
||||
"lint:js": "eslint --cache src/",
|
||||
"lint:js": "biome lint",
|
||||
"lint:js:fix": "biome lint --write",
|
||||
"check": "tsc --noEmit",
|
||||
"eslint": "eslint",
|
||||
"prettier": "prettier",
|
||||
"stylelint": "stylelint",
|
||||
"format": "prettier --write . ../../graphql",
|
||||
"format-check": "prettier --check . ../../graphql",
|
||||
"format": "biome format --write",
|
||||
"format-check": "biome format",
|
||||
"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'"
|
||||
},
|
||||
@@ -86,6 +85,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
"@biomejs/biome": "2.4.16",
|
||||
"@graphql-codegen/cli": "^5.0.0",
|
||||
"@graphql-codegen/time": "^5.0.0",
|
||||
"@graphql-codegen/typescript": "^4.0.1",
|
||||
@@ -107,22 +107,9 @@
|
||||
"@types/video.js": "^7.3.58",
|
||||
"@types/videojs-mobile-ui": "^0.8.3",
|
||||
"@types/videojs-seek-buttons": "^2.1.3",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"eslint-config-prettier": "^8.10.2",
|
||||
"eslint-plugin-deprecation": "^3.0.0",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"extract-react-intl-messages": "^4.1.1",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-scss": "^4.0.9",
|
||||
"prettier": "^2.8.8",
|
||||
"sass": "^1.98.0",
|
||||
"stylelint": "^17.6.0",
|
||||
"stylelint-order": "^8.1.1",
|
||||
|
||||
2191
ui/v2.5/pnpm-lock.yaml
generated
2191
ui/v2.5/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
ui/v2.5/src/@types/mousetrap-pause.d.ts
vendored
2
ui/v2.5/src/@types/mousetrap-pause.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
/* XXbiome-ignore @typescript-eslint/naming-convention */
|
||||
|
||||
declare module "mousetrap-pause" {
|
||||
import { MousetrapStatic } from "mousetrap";
|
||||
|
||||
2
ui/v2.5/src/@types/videojs-abloop.d.ts
vendored
2
ui/v2.5/src/@types/videojs-abloop.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
/* XXbiome-ignore @typescript-eslint/naming-convention */
|
||||
|
||||
declare module "videojs-abloop" {
|
||||
import videojs from "video.js";
|
||||
|
||||
2
ui/v2.5/src/@types/videojs-vr.d.ts
vendored
2
ui/v2.5/src/@types/videojs-vr.d.ts
vendored
@@ -1,9 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
declare module "@blaineam/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;
|
||||
|
||||
2
ui/v2.5/src/@types/videojs-vtt.d.ts
vendored
2
ui/v2.5/src/@types/videojs-vtt.d.ts
vendored
@@ -1,5 +1,3 @@
|
||||
/* 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.
|
||||
|
||||
@@ -14,7 +14,7 @@ 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 locales, { NestedMessage, registerCountry } from "src/locales";
|
||||
import {
|
||||
useConfiguration,
|
||||
useConfigureUI,
|
||||
@@ -101,9 +101,9 @@ function languageMessageString(language: string) {
|
||||
return language.replace(/-/, "");
|
||||
}
|
||||
|
||||
const AppContainer: React.FC<React.PropsWithChildren<{}>> = PatchFunction(
|
||||
const AppContainer: React.FC<React.PropsWithChildren<unknown>> = PatchFunction(
|
||||
"App",
|
||||
(props: React.PropsWithChildren<{}>) => {
|
||||
(props: React.PropsWithChildren<unknown>) => {
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
) as React.FC;
|
||||
@@ -147,8 +147,8 @@ export const App: React.FC = () => {
|
||||
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<{}>();
|
||||
const [messages, setMessages] = useState<Record<string, string>>();
|
||||
const [customMessages, setCustomMessages] = useState<NestedMessage>();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -233,7 +233,7 @@ export const App: React.FC = () => {
|
||||
// redirect to migrate page
|
||||
history.replace("/migrate");
|
||||
}
|
||||
}, [systemStatusData, setupMatch, history, location]);
|
||||
}, [systemStatusData, history, location.pathname]);
|
||||
|
||||
function maybeRenderNavbar() {
|
||||
// don't render navbar for setup views
|
||||
|
||||
@@ -48,12 +48,12 @@ const Changelog: React.FC = () => {
|
||||
const stashVersion = import.meta.env.VITE_APP_STASH_VERSION;
|
||||
const buildTime = import.meta.env.VITE_APP_DATE;
|
||||
|
||||
let buildDate;
|
||||
let buildDate: string | undefined;
|
||||
if (buildTime) {
|
||||
buildDate = buildTime.substring(0, buildTime.indexOf(" "));
|
||||
}
|
||||
|
||||
if (loading) return <></>;
|
||||
if (loading) return null;
|
||||
|
||||
const openState = data?.versions ?? {};
|
||||
|
||||
|
||||
@@ -238,11 +238,9 @@ const FieldOptionsEditor: React.FC<IFieldOptionsEditor> = ({
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button className="minimal" onClick={() => editField()}>
|
||||
<Icon icon={faPencilAlt} />
|
||||
</Button>
|
||||
</>
|
||||
<Button className="minimal" onClick={() => editField()}>
|
||||
<Icon icon={faPencilAlt} />
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -312,7 +310,7 @@ export const FieldOptionsList: React.FC<IFieldOptionsList> = ({
|
||||
}
|
||||
|
||||
if (!localFieldOptions) {
|
||||
return <></>;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -332,7 +330,6 @@ export const FieldOptionsList: React.FC<IFieldOptionsList> = ({
|
||||
<th className="w-25">
|
||||
<FormattedMessage id="config.tasks.identify.create_missing" />
|
||||
</th>
|
||||
{/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}
|
||||
<th className="w-25" />
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -215,7 +215,7 @@ export const IdentifyDialog: React.FC<IIdentifyDialogProps> = ({
|
||||
ss.stash_box_endpoint === s.source.stash_box_endpoint
|
||||
);
|
||||
|
||||
if (!found) return;
|
||||
if (!found) return undefined;
|
||||
|
||||
const ret: IScraperSource = {
|
||||
...found,
|
||||
@@ -317,9 +317,9 @@ export const IdentifyDialog: React.FC<IIdentifyDialogProps> = ({
|
||||
// only include scrapers not already present
|
||||
return !editingSource?.id === undefined
|
||||
? []
|
||||
: allSources?.filter((s) => {
|
||||
: (allSources?.filter((s) => {
|
||||
return !sources.some((ss) => ss.id === s.id);
|
||||
}) ?? [];
|
||||
}) ?? []);
|
||||
}
|
||||
|
||||
function onEditSource(s?: IScraperSource) {
|
||||
|
||||
@@ -42,6 +42,7 @@ export const SubmitStashBoxDraft: React.FC<IProps> = ({
|
||||
boxes[selectedBoxIndex];
|
||||
|
||||
// #4354: reset state when shown, or if any props change
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally resetting when any prop changes
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
setSelectedBoxIndex(0);
|
||||
|
||||
@@ -94,7 +94,7 @@ const RecommendationRow: React.FC<IFilter> = ({ mode, filter, header }) => {
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <></>;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -121,7 +121,7 @@ const SavedFilterResults: React.FC<ISavedFilterResults> = ({
|
||||
}, [data?.findSavedFilter, config]);
|
||||
|
||||
if (loading || !data?.findSavedFilter || !filter) {
|
||||
return <></>;
|
||||
return null;
|
||||
}
|
||||
|
||||
const { name, mode } = data.findSavedFilter;
|
||||
@@ -155,7 +155,7 @@ const CustomFilterResults: React.FC<ICustomFilterProps> = ({
|
||||
{ id: customFilter.message.id },
|
||||
customFilter.message.values
|
||||
)
|
||||
: customFilter.title ?? "";
|
||||
: (customFilter.title ?? "");
|
||||
|
||||
return (
|
||||
<RecommendationRow
|
||||
@@ -183,6 +183,6 @@ export const Control: React.FC<IProps> = ({ content }) => {
|
||||
case "CustomFilter":
|
||||
return <CustomFilterResults customFilter={content} />;
|
||||
default:
|
||||
return <></>;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -147,7 +147,7 @@ const AddContentModal: React.FC<IAddSavedFilterModalProps> = ({
|
||||
<Form.Control
|
||||
as="select"
|
||||
value={premadeFilterIndex}
|
||||
onChange={(e) => setPremadeFilterIndex(parseInt(e.target.value))}
|
||||
onChange={(e) => setPremadeFilterIndex(parseInt(e.target.value, 10))}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{premadeFilterOptions.map((c, i) => (
|
||||
@@ -191,7 +191,7 @@ const AddContentModal: React.FC<IAddSavedFilterModalProps> = ({
|
||||
case "front_page.types.saved_filter":
|
||||
onClose({
|
||||
__typename: "SavedFilter",
|
||||
savedFilterId: parseInt(savedFilter!),
|
||||
savedFilterId: parseInt(savedFilter!, 10),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -234,14 +234,15 @@ const ContentRow: React.FC<IFilterRowProps> = (props: IFilterRowProps) => {
|
||||
|
||||
function title() {
|
||||
switch (props.content.__typename) {
|
||||
case "SavedFilter":
|
||||
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":
|
||||
}
|
||||
case "CustomFilter": {
|
||||
const asCustomFilter = props.content as ICustomFilter;
|
||||
if (asCustomFilter.message)
|
||||
return intl.formatMessage(
|
||||
@@ -249,6 +250,7 @@ const ContentRow: React.FC<IFilterRowProps> = (props: IFilterRowProps) => {
|
||||
asCustomFilter.message.values
|
||||
);
|
||||
return asCustomFilter.title ?? "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,7 +362,7 @@ export const FrontPageConfig: React.FC<IFrontPageConfigProps> = ({
|
||||
}
|
||||
|
||||
function deleteSavedFilter(index: number) {
|
||||
setCurrentContent(currentContent.filter((f, i) => i !== index));
|
||||
setCurrentContent(currentContent.filter((_f, i) => i !== index));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -20,14 +20,14 @@ const GalleryImage: React.FC<RouteComponentProps<IGalleryImageParams>> = ({
|
||||
}) => {
|
||||
const { id, index: indexStr } = match.params;
|
||||
|
||||
let index = parseInt(indexStr);
|
||||
if (isNaN(index)) {
|
||||
let index = parseInt(indexStr, 10);
|
||||
if (Number.isNaN(index)) {
|
||||
index = 0;
|
||||
}
|
||||
|
||||
const { data, loading, error } = useFindGalleryImageID(id, index);
|
||||
|
||||
if (isNaN(index)) {
|
||||
if (Number.isNaN(index)) {
|
||||
return <Redirect to={`/galleries/${id}`} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,8 +63,8 @@ export const GalleryAddPanel: React.FC<IGalleryAddProps> = PatchComponent(
|
||||
);
|
||||
|
||||
async function addImages(
|
||||
result: GQL.FindImagesQueryResult,
|
||||
filter: ListFilterModel,
|
||||
_result: GQL.FindImagesQueryResult,
|
||||
_filter: ListFilterModel,
|
||||
selectedIds: Set<string>
|
||||
) {
|
||||
try {
|
||||
|
||||
@@ -214,12 +214,12 @@ export const GalleryEditPanel: React.FC<IProps> = ({
|
||||
}
|
||||
|
||||
async function onScrapeClicked(s: GQL.ScraperSourceInput) {
|
||||
if (!gallery || !gallery.id) return;
|
||||
if (!gallery?.id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await queryScrapeGallery(s.scraper_id!, gallery.id);
|
||||
if (!result.data || !result.data.scrapeSingleGallery?.length) {
|
||||
if (!result.data?.scrapeSingleGallery?.length) {
|
||||
Toast.success("No galleries found");
|
||||
return;
|
||||
}
|
||||
@@ -342,7 +342,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await queryScrapeGalleryURL(url);
|
||||
if (!result || !result.data || !result.data.scrapeGalleryURL) {
|
||||
if (!result.data?.scrapeGalleryURL) {
|
||||
return;
|
||||
}
|
||||
setScrapedGallery(result.data.scrapeGalleryURL);
|
||||
@@ -420,7 +420,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
|
||||
const date = (() => {
|
||||
try {
|
||||
return schema.validateSyncAt("date", formik.values);
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -24,7 +24,7 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
||||
props: IFileInfoPanelProps
|
||||
) => {
|
||||
const checksum = props.file?.fingerprints.find((f) => f.type === "md5");
|
||||
const path = props.folder ? props.folder.path : props.file?.path ?? "";
|
||||
const path = props.folder ? props.folder.path : (props.file?.path ?? "");
|
||||
const id = props.folder ? "folder" : "path";
|
||||
|
||||
return (
|
||||
@@ -99,7 +99,7 @@ export const GalleryFileInfoPanel: React.FC<IGalleryFileInfoPanelProps> = (
|
||||
}
|
||||
|
||||
if (props.gallery.files.length === 0) {
|
||||
return <></>;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (props.gallery.files.length === 1) {
|
||||
|
||||
@@ -71,8 +71,8 @@ export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> =
|
||||
);
|
||||
|
||||
async function setCover(
|
||||
result: GQL.FindImagesQueryResult,
|
||||
filter: ListFilterModel,
|
||||
_result: GQL.FindImagesQueryResult,
|
||||
_filter: ListFilterModel,
|
||||
selectedIds: Set<string>
|
||||
) {
|
||||
const coverImageID = selectedIds.values().next();
|
||||
@@ -102,8 +102,8 @@ export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> =
|
||||
}
|
||||
|
||||
async function removeImages(
|
||||
result: GQL.FindImagesQueryResult,
|
||||
filter: ListFilterModel,
|
||||
_result: GQL.FindImagesQueryResult,
|
||||
_filter: ListFilterModel,
|
||||
selectedIds: Set<string>
|
||||
) {
|
||||
try {
|
||||
|
||||
@@ -138,7 +138,7 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = ({
|
||||
newTags.length === 0
|
||||
) {
|
||||
onClose();
|
||||
return <></>;
|
||||
return null;
|
||||
}
|
||||
|
||||
function makeNewScrapedItem(): GQL.ScrapedGalleryDataFragment {
|
||||
|
||||
@@ -95,7 +95,7 @@ const _GallerySelect: React.FC<
|
||||
}
|
||||
|
||||
const query = await queryFindGalleriesForSelect(filter);
|
||||
let ret = query.data.findGalleries.galleries.filter((gallery) => {
|
||||
const 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());
|
||||
@@ -296,14 +296,14 @@ const _GalleryIDSelect: React.FC<
|
||||
onSelectValues?.(items);
|
||||
}
|
||||
|
||||
async function loadObjectsByID(idsToLoad: string[]): Promise<Gallery[]> {
|
||||
const query = await queryFindGalleriesByIDForSelect(idsToLoad);
|
||||
const { galleries: loadedGalleries } = query.data.findGalleries;
|
||||
|
||||
return loadedGalleries;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function loadObjectsByID(idsToLoad: string[]): Promise<Gallery[]> {
|
||||
const query = await queryFindGalleriesByIDForSelect(idsToLoad);
|
||||
const { galleries: loadedGalleries } = query.data.findGalleries;
|
||||
|
||||
return loadedGalleries;
|
||||
}
|
||||
|
||||
if (!idsChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export const GalleryViewer: React.FC<IProps> = ({ galleryId }) => {
|
||||
per_page: pageSize,
|
||||
sort: "path",
|
||||
};
|
||||
}, [pageSize]);
|
||||
}, []);
|
||||
|
||||
const { data, loading } = useFindImagesQuery({
|
||||
variables: {
|
||||
@@ -46,7 +46,7 @@ export const GalleryViewer: React.FC<IProps> = ({ galleryId }) => {
|
||||
|
||||
const showLightbox = useLightbox(lightboxState);
|
||||
const showLightboxOnClick: PhotoClickHandler = useCallback(
|
||||
(event, { index }) => {
|
||||
(_event, { index }) => {
|
||||
showLightbox({ initialIndex: index });
|
||||
},
|
||||
[showLightbox]
|
||||
@@ -54,7 +54,7 @@ export const GalleryViewer: React.FC<IProps> = ({ galleryId }) => {
|
||||
|
||||
if (loading) return <LoadingIndicator />;
|
||||
|
||||
let photos: {
|
||||
const photos: {
|
||||
src: string;
|
||||
srcSet?: string | string[] | undefined;
|
||||
sizes?: string | string[] | undefined;
|
||||
@@ -65,7 +65,7 @@ export const GalleryViewer: React.FC<IProps> = ({ galleryId }) => {
|
||||
}[] = [];
|
||||
|
||||
images.forEach((image, index) => {
|
||||
let imageData = {
|
||||
const imageData = {
|
||||
src: image.paths.thumbnail!,
|
||||
width: image.visual_files[0]?.width ?? 0,
|
||||
height: image.visual_files[0]?.height ?? 0,
|
||||
|
||||
@@ -96,75 +96,71 @@ const GalleryWallCard: React.FC<IProps> = ({
|
||||
let shiftKey = false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
className={`${CLASSNAME} ${CLASSNAME}-${coverOrientation} wall-item`}
|
||||
onClick={handleCardClick}
|
||||
onKeyPress={() => showLightboxStart()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
{...dragProps}
|
||||
>
|
||||
{onSelectedChanged && (
|
||||
<Form.Control
|
||||
type="checkbox"
|
||||
className="wall-item-check mousetrap"
|
||||
checked={selected}
|
||||
onChange={() => onSelectedChanged(!selected, shiftKey)}
|
||||
onClick={(
|
||||
event: React.MouseEvent<HTMLInputElement, MouseEvent>
|
||||
) => {
|
||||
shiftKey = event.shiftKey;
|
||||
event.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<RatingSystem value={gallery.rating100} disabled withoutContext />
|
||||
<img
|
||||
loading="lazy"
|
||||
src={imgSrc}
|
||||
alt=""
|
||||
className={cx(CLASSNAME_IMG, imgClassname)}
|
||||
// set orientation based on cover only
|
||||
onLoad={imgSrc === cover ? onCoverLoad : onNonCoverLoad}
|
||||
<section
|
||||
className={`${CLASSNAME} ${CLASSNAME}-${coverOrientation} wall-item`}
|
||||
onClick={handleCardClick}
|
||||
onKeyPress={() => showLightboxStart()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
{...dragProps}
|
||||
>
|
||||
{onSelectedChanged && (
|
||||
<Form.Control
|
||||
type="checkbox"
|
||||
className="wall-item-check mousetrap"
|
||||
checked={selected}
|
||||
onChange={() => onSelectedChanged(!selected, shiftKey)}
|
||||
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
|
||||
shiftKey = event.shiftKey;
|
||||
event.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
<div className="lineargradient">
|
||||
<footer className={CLASSNAME_FOOTER}>
|
||||
<Link
|
||||
to={`/galleries/${gallery.id}`}
|
||||
onClick={(e) => {
|
||||
if (selecting) {
|
||||
e.preventDefault();
|
||||
handleCardClick(e);
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{title && (
|
||||
<TruncatedText
|
||||
text={title}
|
||||
lineCount={1}
|
||||
className={CLASSNAME_TITLE}
|
||||
/>
|
||||
)}
|
||||
<TruncatedText text={performers.join(", ")} />
|
||||
<div>
|
||||
{gallery.date && TextUtils.formatFuzzyDate(intl, gallery.date)}
|
||||
</div>
|
||||
</Link>
|
||||
</footer>
|
||||
<GalleryPreviewScrubber
|
||||
previewPath={gallery.paths.preview}
|
||||
defaultPath={cover ?? ""}
|
||||
imageCount={gallery.image_count}
|
||||
onClick={(i) => {
|
||||
showLightbox(i);
|
||||
)}
|
||||
<RatingSystem value={gallery.rating100} disabled withoutContext />
|
||||
<img
|
||||
loading="lazy"
|
||||
src={imgSrc}
|
||||
alt=""
|
||||
className={cx(CLASSNAME_IMG, imgClassname)}
|
||||
// set orientation based on cover only
|
||||
onLoad={imgSrc === cover ? onCoverLoad : onNonCoverLoad}
|
||||
/>
|
||||
<div className="lineargradient">
|
||||
<footer className={CLASSNAME_FOOTER}>
|
||||
<Link
|
||||
to={`/galleries/${gallery.id}`}
|
||||
onClick={(e) => {
|
||||
if (selecting) {
|
||||
e.preventDefault();
|
||||
handleCardClick(e);
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onPathChanged={setImgSrc}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
>
|
||||
{title && (
|
||||
<TruncatedText
|
||||
text={title}
|
||||
lineCount={1}
|
||||
className={CLASSNAME_TITLE}
|
||||
/>
|
||||
)}
|
||||
<TruncatedText text={performers.join(", ")} />
|
||||
<div>
|
||||
{gallery.date && TextUtils.formatFuzzyDate(intl, gallery.date)}
|
||||
</div>
|
||||
</Link>
|
||||
</footer>
|
||||
<GalleryPreviewScrubber
|
||||
previewPath={gallery.paths.preview}
|
||||
defaultPath={cover ?? ""}
|
||||
imageCount={gallery.image_count}
|
||||
onClick={(i) => {
|
||||
showLightbox(i);
|
||||
}}
|
||||
onPathChanged={setImgSrc}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import { GroupSubGroupsPanel } from "./GroupSubGroupsPanel";
|
||||
import { GroupPerformersPanel } from "./GroupPerformersPanel";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
import { goBackOrReplace } from "src/utils/history";
|
||||
import { PatchComponent } from "src/patch";
|
||||
|
||||
const validTabs = ["default", "scenes", "performers", "subgroups"] as const;
|
||||
type TabKey = (typeof validTabs)[number];
|
||||
@@ -64,8 +65,8 @@ const GroupTabs: React.FC<{
|
||||
} = group;
|
||||
|
||||
const populatedDefaultTab = useMemo(() => {
|
||||
if (sceneCount == 0) {
|
||||
if (performerCount != 0) {
|
||||
if (sceneCount === 0) {
|
||||
if (performerCount !== 0) {
|
||||
return "performers";
|
||||
} else if (groupCount !== 0) {
|
||||
return "subgroups";
|
||||
@@ -140,327 +141,330 @@ interface IGroupParams {
|
||||
tab?: string;
|
||||
}
|
||||
|
||||
const GroupPage: React.FC<IProps> = ({ group, tabKey }) => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const Toast = useToast();
|
||||
const GroupPage: React.FC<IProps> = PatchComponent(
|
||||
"GroupPage",
|
||||
({ 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;
|
||||
// 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<boolean>(true);
|
||||
const [focusedOnFront, setFocusedOnFront] = useState<boolean>(true);
|
||||
|
||||
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
|
||||
const loadStickyHeader = useLoadStickyHeader();
|
||||
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
|
||||
const loadStickyHeader = useLoadStickyHeader();
|
||||
|
||||
// Editing state
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
||||
// Editing state
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
||||
|
||||
// Editing group state
|
||||
const [frontImage, setFrontImage] = useState<string | null>();
|
||||
const [backImage, setBackImage] = useState<string | null>();
|
||||
const [encodingImage, setEncodingImage] = useState<boolean>(false);
|
||||
// Editing group state
|
||||
const [frontImage, setFrontImage] = useState<string | null>();
|
||||
const [backImage, setBackImage] = useState<string | null>();
|
||||
const [encodingImage, setEncodingImage] = useState<boolean>(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() }
|
||||
)
|
||||
const aliases = useMemo(
|
||||
() => (group.aliases ? [group.aliases] : []),
|
||||
[group.aliases]
|
||||
);
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
try {
|
||||
await deleteGroup();
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
return;
|
||||
}
|
||||
const isDefaultImage = group.front_image_path?.includes("default=true");
|
||||
|
||||
goBackOrReplace(history, "/groups");
|
||||
}
|
||||
const lightboxImages = useMemo(() => {
|
||||
const covers = [];
|
||||
|
||||
function toggleEditing(value?: boolean) {
|
||||
if (value !== undefined) {
|
||||
setIsEditing(value);
|
||||
} else {
|
||||
setIsEditing((e) => !e);
|
||||
}
|
||||
setFrontImage(undefined);
|
||||
setBackImage(undefined);
|
||||
}
|
||||
if (group.front_image_path && !isDefaultImage) {
|
||||
covers.push({
|
||||
paths: {
|
||||
thumbnail: group.front_image_path,
|
||||
image: group.front_image_path,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderDeleteAlert() {
|
||||
return (
|
||||
<ModalComponent
|
||||
show={isDeleteAlertOpen}
|
||||
icon={faTrashAlt}
|
||||
accept={{
|
||||
text: intl.formatMessage({ id: "actions.delete" }),
|
||||
variant: "danger",
|
||||
onClick: onDelete,
|
||||
}}
|
||||
cancel={{ onClick: () => setIsDeleteAlertOpen(false) }}
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="dialogs.delete_confirm"
|
||||
values={{
|
||||
entityName:
|
||||
group.name ??
|
||||
intl.formatMessage({ id: "group" }).toLocaleLowerCase(),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</ModalComponent>
|
||||
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(() => {
|
||||
const 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(() => {
|
||||
const 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");
|
||||
Mousetrap.unbind(",");
|
||||
};
|
||||
});
|
||||
|
||||
useRatingKeybinds(
|
||||
true,
|
||||
configuration?.ui.ratingSystemOptions?.type,
|
||||
setRating
|
||||
);
|
||||
}
|
||||
|
||||
function setRating(v: number | null) {
|
||||
if (group.id) {
|
||||
updateGroup({
|
||||
async function onSave(input: GQL.GroupCreateInput) {
|
||||
await updateGroup({
|
||||
variables: {
|
||||
input: {
|
||||
id: group.id,
|
||||
rating100: v,
|
||||
...input,
|
||||
},
|
||||
},
|
||||
});
|
||||
toggleEditing(false);
|
||||
Toast.success(
|
||||
intl.formatMessage(
|
||||
{ id: "toast.updated_entity" },
|
||||
{ entity: intl.formatMessage({ id: "group" }).toLocaleLowerCase() }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (updating || deleting) return <LoadingIndicator />;
|
||||
async function onDelete() {
|
||||
try {
|
||||
await deleteGroup();
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
return;
|
||||
}
|
||||
|
||||
const headerClassName = cx("detail-header", {
|
||||
edit: isEditing,
|
||||
collapsed,
|
||||
"full-width": !collapsed && !compactExpandedDetails,
|
||||
});
|
||||
goBackOrReplace(history, "/groups");
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="group-page" className="row">
|
||||
<Helmet>
|
||||
<title>{group?.name}</title>
|
||||
</Helmet>
|
||||
function toggleEditing(value?: boolean) {
|
||||
if (value !== undefined) {
|
||||
setIsEditing(value);
|
||||
} else {
|
||||
setIsEditing((e) => !e);
|
||||
}
|
||||
setFrontImage(undefined);
|
||||
setBackImage(undefined);
|
||||
}
|
||||
|
||||
<div className={headerClassName}>
|
||||
<BackgroundImage
|
||||
imagePath={group.front_image_path ?? undefined}
|
||||
show={enableBackgroundImage && !isEditing}
|
||||
/>
|
||||
<div className="detail-container">
|
||||
<HeaderImage encodingImage={encodingImage}>
|
||||
<div className="group-images">
|
||||
{!!activeFrontImage && (
|
||||
<LightboxLink images={lightboxImages}>
|
||||
<DetailImage
|
||||
className={`front-cover ${
|
||||
focusedOnFront ? "active" : "inactive"
|
||||
}`}
|
||||
alt="Front Cover"
|
||||
src={activeFrontImage}
|
||||
/>
|
||||
</LightboxLink>
|
||||
)}
|
||||
{!!activeBackImage && (
|
||||
<LightboxLink
|
||||
images={lightboxImages}
|
||||
index={lightboxImages.length - 1}
|
||||
>
|
||||
<DetailImage
|
||||
className={`back-cover ${
|
||||
!focusedOnFront ? "active" : "inactive"
|
||||
}`}
|
||||
alt="Back Cover"
|
||||
src={activeBackImage}
|
||||
/>
|
||||
</LightboxLink>
|
||||
)}
|
||||
{!!(activeFrontImage && activeBackImage) && (
|
||||
<Button
|
||||
className="flip"
|
||||
onClick={() => setFocusedOnFront(!focusedOnFront)}
|
||||
>
|
||||
<Icon icon={faRefresh} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</HeaderImage>
|
||||
<div className="row">
|
||||
<div className="group-head col">
|
||||
<DetailTitle name={group.name} classNamePrefix="group">
|
||||
function renderDeleteAlert() {
|
||||
return (
|
||||
<ModalComponent
|
||||
show={isDeleteAlertOpen}
|
||||
icon={faTrashAlt}
|
||||
accept={{
|
||||
text: intl.formatMessage({ id: "actions.delete" }),
|
||||
variant: "danger",
|
||||
onClick: onDelete,
|
||||
}}
|
||||
cancel={{ onClick: () => setIsDeleteAlertOpen(false) }}
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="dialogs.delete_confirm"
|
||||
values={{
|
||||
entityName:
|
||||
group.name ??
|
||||
intl.formatMessage({ id: "group" }).toLocaleLowerCase(),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</ModalComponent>
|
||||
);
|
||||
}
|
||||
|
||||
function setRating(v: number | null) {
|
||||
if (group.id) {
|
||||
updateGroup({
|
||||
variables: {
|
||||
input: {
|
||||
id: group.id,
|
||||
rating100: v,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (updating || deleting) return <LoadingIndicator />;
|
||||
|
||||
const headerClassName = cx("detail-header", {
|
||||
edit: isEditing,
|
||||
collapsed,
|
||||
"full-width": !collapsed && !compactExpandedDetails,
|
||||
});
|
||||
|
||||
return (
|
||||
<div id="group-page" className="row">
|
||||
<Helmet>
|
||||
<title>{group?.name}</title>
|
||||
</Helmet>
|
||||
|
||||
<div className={headerClassName}>
|
||||
<BackgroundImage
|
||||
imagePath={group.front_image_path ?? undefined}
|
||||
show={enableBackgroundImage && !isEditing}
|
||||
/>
|
||||
<div className="detail-container">
|
||||
<HeaderImage encodingImage={encodingImage}>
|
||||
<div className="group-images">
|
||||
{!!activeFrontImage && (
|
||||
<LightboxLink images={lightboxImages}>
|
||||
<DetailImage
|
||||
className={`front-cover ${
|
||||
focusedOnFront ? "active" : "inactive"
|
||||
}`}
|
||||
alt="Front Cover"
|
||||
src={activeFrontImage}
|
||||
/>
|
||||
</LightboxLink>
|
||||
)}
|
||||
{!!activeBackImage && (
|
||||
<LightboxLink
|
||||
images={lightboxImages}
|
||||
index={lightboxImages.length - 1}
|
||||
>
|
||||
<DetailImage
|
||||
className={`back-cover ${
|
||||
!focusedOnFront ? "active" : "inactive"
|
||||
}`}
|
||||
alt="Back Cover"
|
||||
src={activeBackImage}
|
||||
/>
|
||||
</LightboxLink>
|
||||
)}
|
||||
{!!(activeFrontImage && activeBackImage) && (
|
||||
<Button
|
||||
className="flip"
|
||||
onClick={() => setFocusedOnFront(!focusedOnFront)}
|
||||
>
|
||||
<Icon icon={faRefresh} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</HeaderImage>
|
||||
<div className="row">
|
||||
<div className="group-head col">
|
||||
<DetailTitle name={group.name} classNamePrefix="group">
|
||||
{!isEditing && (
|
||||
<ExpandCollapseButton
|
||||
collapsed={collapsed}
|
||||
setCollapsed={(v) => setCollapsed(v)}
|
||||
/>
|
||||
)}
|
||||
<span className="name-icons">
|
||||
<ExternalLinkButtons urls={group.urls ?? undefined} />
|
||||
</span>
|
||||
</DetailTitle>
|
||||
|
||||
<AliasList aliases={aliases} />
|
||||
<RatingSystem
|
||||
value={group.rating100}
|
||||
onSetRating={(value) => setRating(value)}
|
||||
clickToRate
|
||||
withoutContext
|
||||
/>
|
||||
{!isEditing && (
|
||||
<ExpandCollapseButton
|
||||
<GroupDetailsPanel
|
||||
group={group}
|
||||
collapsed={collapsed}
|
||||
setCollapsed={(v) => setCollapsed(v)}
|
||||
fullWidth={!collapsed && !compactExpandedDetails}
|
||||
/>
|
||||
)}
|
||||
<span className="name-icons">
|
||||
<ExternalLinkButtons urls={group.urls ?? undefined} />
|
||||
</span>
|
||||
</DetailTitle>
|
||||
{isEditing ? (
|
||||
<GroupEditPanel
|
||||
group={group}
|
||||
onSubmit={onSave}
|
||||
onCancel={() => toggleEditing()}
|
||||
onDelete={onDelete}
|
||||
setFrontImage={setFrontImage}
|
||||
setBackImage={setBackImage}
|
||||
setEncodingImage={setEncodingImage}
|
||||
/>
|
||||
) : (
|
||||
<DetailsEditNavbar
|
||||
objectName={group.name}
|
||||
isNew={false}
|
||||
isEditing={isEditing}
|
||||
onToggleEdit={() => toggleEditing()}
|
||||
onSave={() => {}}
|
||||
onImageChange={() => {}}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AliasList aliases={aliases} />
|
||||
<RatingSystem
|
||||
value={group.rating100}
|
||||
onSetRating={(value) => setRating(value)}
|
||||
clickToRate
|
||||
withoutContext
|
||||
/>
|
||||
{!isEditing && loadStickyHeader && (
|
||||
<CompressedGroupDetailsPanel group={group} />
|
||||
)}
|
||||
|
||||
<div className="detail-body">
|
||||
<div className="group-body">
|
||||
<div className="group-tabs">
|
||||
{!isEditing && (
|
||||
<GroupDetailsPanel
|
||||
<GroupTabs
|
||||
group={group}
|
||||
collapsed={collapsed}
|
||||
fullWidth={!collapsed && !compactExpandedDetails}
|
||||
/>
|
||||
)}
|
||||
{isEditing ? (
|
||||
<GroupEditPanel
|
||||
group={group}
|
||||
onSubmit={onSave}
|
||||
onCancel={() => toggleEditing()}
|
||||
onDelete={onDelete}
|
||||
setFrontImage={setFrontImage}
|
||||
setBackImage={setBackImage}
|
||||
setEncodingImage={setEncodingImage}
|
||||
/>
|
||||
) : (
|
||||
<DetailsEditNavbar
|
||||
objectName={group.name}
|
||||
isNew={false}
|
||||
isEditing={isEditing}
|
||||
onToggleEdit={() => toggleEditing()}
|
||||
onSave={() => {}}
|
||||
onImageChange={() => {}}
|
||||
onDelete={onDelete}
|
||||
tabKey={tabKey}
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderDeleteAlert()}
|
||||
</div>
|
||||
|
||||
{!isEditing && loadStickyHeader && (
|
||||
<CompressedGroupDetailsPanel group={group} />
|
||||
)}
|
||||
|
||||
<div className="detail-body">
|
||||
<div className="group-body">
|
||||
<div className="group-tabs">
|
||||
{!isEditing && (
|
||||
<GroupTabs
|
||||
group={group}
|
||||
tabKey={tabKey}
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderDeleteAlert()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const GroupLoader: React.FC<RouteComponentProps<IGroupParams>> = ({
|
||||
location,
|
||||
|
||||
@@ -196,7 +196,7 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
|
||||
formik.setFieldValue("date", state.date);
|
||||
}
|
||||
|
||||
if (state.studio && state.studio.stored_id) {
|
||||
if (state.studio?.stored_id) {
|
||||
onSetStudio({
|
||||
id: state.studio.stored_id,
|
||||
name: state.studio.name ?? "",
|
||||
@@ -250,7 +250,7 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
|
||||
|
||||
try {
|
||||
const result = await queryScrapeGroupURL(url);
|
||||
if (!result.data || !result.data.scrapeGroupURL) {
|
||||
if (!result.data?.scrapeGroupURL) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ export const GroupScenesPanel: React.FC<IGroupScenesPanel> = ({
|
||||
}) => {
|
||||
const filterHook = useFilterHook(group, showSubGroupContent);
|
||||
|
||||
if (group && group.id) {
|
||||
if (group?.id) {
|
||||
return (
|
||||
<FilteredSceneList
|
||||
filterHook={filterHook}
|
||||
@@ -73,5 +73,5 @@ export const GroupScenesPanel: React.FC<IGroupScenesPanel> = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <></>;
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -31,8 +31,8 @@ interface IGroupScrapeDialogProps {
|
||||
|
||||
export const GroupScrapeDialog: React.FC<IGroupScrapeDialogProps> = ({
|
||||
group,
|
||||
groupStudio: groupStudio,
|
||||
groupTags: groupTags,
|
||||
groupStudio,
|
||||
groupTags,
|
||||
scraped,
|
||||
onClose,
|
||||
}) => {
|
||||
@@ -48,7 +48,7 @@ export const GroupScrapeDialog: React.FC<IGroupScrapeDialogProps> = ({
|
||||
new ScrapeResult<string>(
|
||||
TextUtils.secondsToTimestamp(group.duration || 0),
|
||||
// convert seconds to string if it's a number
|
||||
scraped.duration && !isNaN(+scraped.duration)
|
||||
scraped.duration && !Number.isNaN(Number(scraped.duration))
|
||||
? TextUtils.secondsToTimestamp(parseInt(scraped.duration, 10))
|
||||
: scraped.duration
|
||||
)
|
||||
@@ -123,7 +123,7 @@ export const GroupScrapeDialog: React.FC<IGroupScrapeDialogProps> = ({
|
||||
newTags.length === 0
|
||||
) {
|
||||
onClose();
|
||||
return <></>;
|
||||
return null;
|
||||
}
|
||||
|
||||
function makeNewScrapedItem(): GQL.ScrapedGroup {
|
||||
|
||||
@@ -54,9 +54,6 @@ const useContainingGroupFilterHook = (
|
||||
filter.criteria.push(groupCriterion);
|
||||
}
|
||||
|
||||
filter.sortBy = "sub_group_order";
|
||||
filter.sortDirection = GQL.SortDirectionEnum.Asc;
|
||||
|
||||
return filter;
|
||||
};
|
||||
};
|
||||
@@ -67,18 +64,6 @@ interface IGroupSubGroupsPanel {
|
||||
extraOperations?: IItemListOperation<GQL.FindGroupsQueryResult>[];
|
||||
}
|
||||
|
||||
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<IGroupSubGroupsPanel> =
|
||||
PatchComponent(
|
||||
"GroupSubGroupsPanel",
|
||||
@@ -93,8 +78,8 @@ export const GroupSubGroupsPanel: React.FC<IGroupSubGroupsPanel> =
|
||||
const filterHook = useContainingGroupFilterHook(group);
|
||||
|
||||
async function removeSubGroups(
|
||||
result: GQL.FindGroupsQueryResult,
|
||||
filter: ListFilterModel,
|
||||
_result: GQL.FindGroupsQueryResult,
|
||||
_filter: ListFilterModel,
|
||||
selectedIds: Set<string>
|
||||
) {
|
||||
try {
|
||||
@@ -163,7 +148,8 @@ export const GroupSubGroupsPanel: React.FC<IGroupSubGroupsPanel> =
|
||||
<>
|
||||
{modal}
|
||||
<FilteredGroupList
|
||||
defaultFilter={defaultFilter}
|
||||
defaultSort="sub_group_order"
|
||||
manualSortBy="sub_group_order"
|
||||
filterHook={filterHook}
|
||||
alterQuery={active}
|
||||
fromGroupId={group.id}
|
||||
|
||||
@@ -147,13 +147,15 @@ const SidebarContent: React.FC<{
|
||||
|
||||
interface IGroupListContext {
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
defaultFilter?: ListFilterModel;
|
||||
view?: View;
|
||||
alterQuery?: boolean;
|
||||
}
|
||||
|
||||
interface IGroupList extends IGroupListContext {
|
||||
defaultSort?: string;
|
||||
fromGroupId?: string;
|
||||
// specifies the sort by value that allows reordering
|
||||
manualSortBy?: string;
|
||||
onMove?: (srcIds: string[], targetId: string, after: boolean) => void;
|
||||
otherOperations?: IItemListOperation<GQL.FindGroupsQueryResult>[];
|
||||
}
|
||||
@@ -204,19 +206,16 @@ export const FilteredGroupList = PatchComponent(
|
||||
const searchFocus = useFocus();
|
||||
|
||||
const {
|
||||
defaultSort,
|
||||
filterHook,
|
||||
view,
|
||||
alterQuery,
|
||||
manualSortBy,
|
||||
onMove,
|
||||
fromGroupId,
|
||||
otherOperations: providedOperations = [],
|
||||
defaultFilter,
|
||||
} = props;
|
||||
|
||||
const withSidebar = view !== View.GroupSubGroups;
|
||||
const filterable = view !== View.GroupSubGroups;
|
||||
const sortable = view !== View.GroupSubGroups;
|
||||
|
||||
// States
|
||||
const {
|
||||
showSidebar,
|
||||
@@ -230,7 +229,7 @@ export const FilteredGroupList = PatchComponent(
|
||||
useFilteredItemList({
|
||||
filterStateProps: {
|
||||
filterMode: GQL.FilterMode.Groups,
|
||||
defaultFilter,
|
||||
defaultSort,
|
||||
view,
|
||||
useURL: alterQuery,
|
||||
},
|
||||
@@ -379,6 +378,8 @@ export const FilteredGroupList = PatchComponent(
|
||||
// render
|
||||
if (sidebarStateLoading) return null;
|
||||
|
||||
const canMove = manualSortBy && onMove && filter.sortBy === manualSortBy;
|
||||
|
||||
const operations = (
|
||||
<ListOperations
|
||||
items={items.length}
|
||||
@@ -402,8 +403,6 @@ export const FilteredGroupList = PatchComponent(
|
||||
operationComponent={operations}
|
||||
view={view}
|
||||
zoomable
|
||||
filterable={filterable}
|
||||
sortable={sortable}
|
||||
/>
|
||||
|
||||
<FilterTags
|
||||
@@ -435,7 +434,7 @@ export const FilteredGroupList = PatchComponent(
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
fromGroupId={fromGroupId}
|
||||
onMove={onMove}
|
||||
onMove={canMove ? onMove : undefined}
|
||||
/>
|
||||
</LoadedContent>
|
||||
|
||||
@@ -455,10 +454,6 @@ export const FilteredGroupList = PatchComponent(
|
||||
</>
|
||||
);
|
||||
|
||||
if (!withSidebar) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx("item-list-container group-list", {
|
||||
|
||||
@@ -88,7 +88,7 @@ export const GroupSelect: React.FC<
|
||||
}
|
||||
|
||||
const query = await queryFindGroupsForSelect(filter);
|
||||
let ret = query.data.findGroups.groups.filter((group) => {
|
||||
const 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());
|
||||
@@ -276,14 +276,14 @@ const _GroupIDSelect: React.FC<IFilterProps & IFilterIDProps<Group>> = (
|
||||
onSelectValues?.(items);
|
||||
}
|
||||
|
||||
async function loadObjectsByID(idsToLoad: string[]): Promise<Group[]> {
|
||||
const query = await queryFindGroupsByIDForSelect(idsToLoad);
|
||||
const { groups: loadedGroups } = query.data.findGroups;
|
||||
|
||||
return loadedGroups;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function loadObjectsByID(idsToLoad: string[]): Promise<Group[]> {
|
||||
const query = await queryFindGroupsByIDForSelect(idsToLoad);
|
||||
const { groups: loadedGroups } = query.data.findGroups;
|
||||
|
||||
return loadedGroups;
|
||||
}
|
||||
|
||||
if (!idsChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ export const Manual: React.FC<IManualProps> = ({
|
||||
) {
|
||||
if (event.target instanceof HTMLAnchorElement) {
|
||||
const href = event.target.getAttribute("href");
|
||||
if (href && href.startsWith("/help")) {
|
||||
if (href?.startsWith("/help")) {
|
||||
const newKey = event.target.pathname.substring("/help/".length);
|
||||
setActiveTab(newKey);
|
||||
event.preventDefault();
|
||||
|
||||
@@ -27,7 +27,7 @@ export const ManualProvider: React.FC = ({ children }) => {
|
||||
openManual,
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<></>}>
|
||||
<Suspense fallback={null}>
|
||||
{showManual && (
|
||||
<Manual
|
||||
show={showManual}
|
||||
|
||||
@@ -176,9 +176,9 @@ const ImageCardImage = PatchComponent(
|
||||
}
|
||||
|
||||
const source =
|
||||
props.image.paths.preview != ""
|
||||
? props.image.paths.preview ?? ""
|
||||
: props.image.paths.thumbnail ?? "";
|
||||
props.image.paths.preview !== ""
|
||||
? (props.image.paths.preview ?? "")
|
||||
: (props.image.paths.thumbnail ?? "");
|
||||
const video = source.includes("preview");
|
||||
const ImagePreview = video ? "video" : "img";
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ const ImagePage: React.FC<IProps> = ({ image }) => {
|
||||
}
|
||||
|
||||
async function onRescan() {
|
||||
if (!image || !image.visual_files.length) {
|
||||
if (!image?.visual_files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -379,13 +379,13 @@ const ImagePage: React.FC<IProps> = ({ image }) => {
|
||||
<div className="image-container">
|
||||
{image.visual_files.length > 0 && (
|
||||
<ImageView
|
||||
loop={image.visual_files[0].__typename == "VideoFile"}
|
||||
autoPlay={image.visual_files[0].__typename == "VideoFile"}
|
||||
playsInline={image.visual_files[0].__typename == "VideoFile"}
|
||||
controls={image.visual_files[0].__typename == "VideoFile"}
|
||||
loop={image.visual_files[0].__typename === "VideoFile"}
|
||||
autoPlay={image.visual_files[0].__typename === "VideoFile"}
|
||||
playsInline={image.visual_files[0].__typename === "VideoFile"}
|
||||
controls={image.visual_files[0].__typename === "VideoFile"}
|
||||
className="m-sm-auto no-gutter image-image"
|
||||
style={
|
||||
image.visual_files[0].__typename == "VideoFile"
|
||||
image.visual_files[0].__typename === "VideoFile"
|
||||
? { width: "100%", height: "100%" }
|
||||
: {}
|
||||
}
|
||||
|
||||
@@ -200,12 +200,12 @@ export const ImageEditPanel: React.FC<IProps> = ({
|
||||
}
|
||||
|
||||
async function onScrapeClicked(s: GQL.ScraperSourceInput) {
|
||||
if (!image || !image.id) return;
|
||||
if (!image?.id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await queryScrapeImage(s.scraper_id!, image.id);
|
||||
if (!result.data || !result.data.scrapeSingleImage?.length) {
|
||||
if (!result.data?.scrapeSingleImage?.length) {
|
||||
Toast.success("No images found");
|
||||
return;
|
||||
}
|
||||
@@ -304,7 +304,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await queryScrapeImageURL(url);
|
||||
if (!result || !result.data || !result.data.scrapeImageURL) {
|
||||
if (!result.data?.scrapeImageURL) {
|
||||
return;
|
||||
}
|
||||
setScrapedImage(result.data.scrapeImageURL);
|
||||
@@ -383,7 +383,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
|
||||
const date = (() => {
|
||||
try {
|
||||
return schema.validateSyncAt("date", formik.values);
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -109,7 +109,7 @@ export const ImageFileInfoPanel: React.FC<IImageFileInfoPanelProps> = (
|
||||
>();
|
||||
|
||||
if (props.image.visual_files.length === 0) {
|
||||
return <></>;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (props.image.visual_files.length === 1) {
|
||||
|
||||
@@ -140,7 +140,7 @@ export const ImageScrapeDialog: React.FC<IImageScrapeDialogProps> = ({
|
||||
newTags.length === 0
|
||||
) {
|
||||
onClose();
|
||||
return <></>;
|
||||
return null;
|
||||
}
|
||||
|
||||
function makeNewScrapedItem(): GQL.ScrapedImageDataFragment {
|
||||
|
||||
@@ -101,7 +101,7 @@ const ImageWall: React.FC<IImageWallProps> = ({
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
let photos: {
|
||||
const photos: {
|
||||
src: string;
|
||||
srcSet?: string | string[] | undefined;
|
||||
sizes?: string | string[] | undefined;
|
||||
@@ -112,9 +112,9 @@ const ImageWall: React.FC<IImageWallProps> = ({
|
||||
}[] = [];
|
||||
|
||||
images.forEach((image, index) => {
|
||||
let imageData = {
|
||||
const imageData = {
|
||||
src:
|
||||
image.paths.preview != ""
|
||||
image.paths.preview !== ""
|
||||
? image.paths.preview!
|
||||
: image.paths.thumbnail!,
|
||||
width: image.visual_files?.[0]?.width ?? 0,
|
||||
@@ -129,15 +129,15 @@ const ImageWall: React.FC<IImageWallProps> = ({
|
||||
});
|
||||
|
||||
const showLightboxOnClick = useCallback(
|
||||
(event, { index }) => {
|
||||
(_event, { index }) => {
|
||||
handleImageOpen(index);
|
||||
},
|
||||
[handleImageOpen]
|
||||
);
|
||||
|
||||
function columns(containerWidth: number) {
|
||||
let preferredSize = zoomWidths[zoomIndex];
|
||||
let columnCount = containerWidth / preferredSize;
|
||||
const preferredSize = zoomWidths[zoomIndex];
|
||||
const columnCount = containerWidth / preferredSize;
|
||||
return Math.round(columnCount);
|
||||
}
|
||||
|
||||
@@ -196,8 +196,8 @@ const ImageWall: React.FC<IImageWallProps> = ({
|
||||
photos={photos}
|
||||
renderImage={renderImage}
|
||||
onClick={showLightboxOnClick}
|
||||
margin={uiConfig?.imageWallOptions?.margin!}
|
||||
direction={uiConfig?.imageWallOptions?.direction!}
|
||||
margin={uiConfig?.imageWallOptions?.margin}
|
||||
direction={uiConfig?.imageWallOptions?.direction}
|
||||
columns={columns}
|
||||
targetRowHeight={targetRowHeight}
|
||||
/>
|
||||
@@ -331,7 +331,7 @@ const ImageList: React.FC<IImageListImages> = PatchComponent(
|
||||
}
|
||||
|
||||
// should not happen
|
||||
return <></>;
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ const CriterionOptionList: React.FC<ICriterionList> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally triggering on criterion change
|
||||
useEffect(() => {
|
||||
// scrolling to the current criterion doesn't work well when the
|
||||
// dialog is already open, so limit to when we click on the
|
||||
@@ -151,7 +152,9 @@ const CriterionOptionList: React.FC<ICriterionList> = ({
|
||||
icon={type === c.type ? faChevronDown : faChevronRight}
|
||||
/>
|
||||
<FormattedMessage
|
||||
id={!sfwContentMode ? c.messageID : c.sfwMessageID ?? c.messageID}
|
||||
id={
|
||||
!sfwContentMode ? c.messageID : (c.sfwMessageID ?? c.messageID)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
{criteria.some((cc) => c.type === cc) && (
|
||||
@@ -271,11 +274,13 @@ export const EditFilterDialog: React.FC<IEditFilterProps> = ({
|
||||
.sort((a, b) => {
|
||||
return intl
|
||||
.formatMessage({
|
||||
id: !sfwContentMode ? a.messageID : a.sfwMessageID ?? a.messageID,
|
||||
id: !sfwContentMode ? a.messageID : (a.sfwMessageID ?? a.messageID),
|
||||
})
|
||||
.localeCompare(
|
||||
intl.formatMessage({
|
||||
id: !sfwContentMode ? b.messageID : b.sfwMessageID ?? b.messageID,
|
||||
id: !sfwContentMode
|
||||
? b.messageID
|
||||
: (b.sfwMessageID ?? b.messageID),
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -314,7 +319,7 @@ export const EditFilterDialog: React.FC<IEditFilterProps> = ({
|
||||
return criterionOptions.filter((c) => {
|
||||
return intl
|
||||
.formatMessage({
|
||||
id: !sfwContentMode ? c.messageID : c.sfwMessageID ?? c.messageID,
|
||||
id: !sfwContentMode ? c.messageID : (c.sfwMessageID ?? c.messageID),
|
||||
})
|
||||
.toLowerCase()
|
||||
.includes(trimmedSearch);
|
||||
|
||||
@@ -182,7 +182,7 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
|
||||
}, [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 */
|
||||
/* XXbiome-ignore useExhaustiveDependencies: intentional */
|
||||
useLayoutEffect(() => {
|
||||
if (!truncateOnOverflow) {
|
||||
setCutoff(undefined);
|
||||
@@ -308,7 +308,7 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
|
||||
|
||||
const className = "wrap-tags filter-tags";
|
||||
|
||||
const filterTags = criteria.map((c) => getFilterTags(c)).flat();
|
||||
const filterTags = criteria.flatMap((c) => getFilterTags(c));
|
||||
|
||||
if (searchTerm && searchTerm.length > 0) {
|
||||
filterTags.unshift(
|
||||
|
||||
@@ -99,6 +99,10 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
|
||||
sortable = true,
|
||||
}) => {
|
||||
const filterOptions = filter.options;
|
||||
// Something in the popper layout for groups.sub-groups tab to double calculates the offset
|
||||
// causing the dropdown to be misaligned. Portal to document.body to fix this.
|
||||
const menuPortalTarget =
|
||||
typeof document !== "undefined" ? document.body : undefined;
|
||||
const { setDisplayMode, setZoom } = useFilterOperations({
|
||||
filter,
|
||||
setFilter,
|
||||
@@ -142,6 +146,7 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
|
||||
filter={filter}
|
||||
onSetFilter={setFilter}
|
||||
view={view}
|
||||
menuPortalTarget={menuPortalTarget}
|
||||
/>
|
||||
<FilterButton
|
||||
onClick={() => showEditFilter()}
|
||||
|
||||
@@ -121,16 +121,14 @@ export const SidebarBooleanFilter: React.FC<ISidebarFilter> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarListFilter
|
||||
title={title}
|
||||
candidates={options}
|
||||
onSelect={onSelect}
|
||||
onUnselect={onUnselect}
|
||||
selected={selected}
|
||||
singleValue
|
||||
sectionID={sectionID}
|
||||
/>
|
||||
</>
|
||||
<SidebarListFilter
|
||||
title={title}
|
||||
candidates={options}
|
||||
onSelect={onSelect}
|
||||
onUnselect={onUnselect}
|
||||
selected={selected}
|
||||
singleValue
|
||||
sectionID={sectionID}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ interface ICustomFieldCriterionEditor {
|
||||
function getValue(v: string) {
|
||||
// if the value is numeric, convert it to a number
|
||||
const num = Number(v);
|
||||
if (!isNaN(num)) {
|
||||
if (!Number.isNaN(num)) {
|
||||
return num;
|
||||
} else {
|
||||
return v;
|
||||
|
||||
@@ -19,15 +19,13 @@ export const InputFilter: React.FC<IInputFilterProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
className="btn-secondary"
|
||||
type={criterion.modifierCriterionOption().inputType}
|
||||
onChange={onChanged}
|
||||
value={criterion.value ? criterion.value.toString() : ""}
|
||||
/>
|
||||
</Form.Group>
|
||||
</>
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
className="btn-secondary"
|
||||
type={criterion.modifierCriterionOption().inputType}
|
||||
onChange={onChanged}
|
||||
value={criterion.value ? criterion.value.toString() : ""}
|
||||
/>
|
||||
</Form.Group>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -223,10 +223,10 @@ export function useSelectionState(props: {
|
||||
}));
|
||||
}, [criterion.value.excluded]);
|
||||
|
||||
const includingOnly = modifier == CriterionModifier.Equals;
|
||||
const includingOnly = modifier === CriterionModifier.Equals;
|
||||
const excludingOnly =
|
||||
modifier == CriterionModifier.Excludes ||
|
||||
modifier == CriterionModifier.NotEquals;
|
||||
modifier === CriterionModifier.Excludes ||
|
||||
modifier === CriterionModifier.NotEquals;
|
||||
|
||||
const onSelect = useCallback(
|
||||
(v: Option, exclude: boolean) => {
|
||||
@@ -508,7 +508,7 @@ export function useLabeledIdFilterState(props: {
|
||||
};
|
||||
}
|
||||
|
||||
export function makeQueryVariables(query: string, extraProps: {}) {
|
||||
export function makeQueryVariables(query: string, extraProps: object) {
|
||||
return {
|
||||
filter: {
|
||||
q: query,
|
||||
|
||||
@@ -219,16 +219,14 @@ export const SidebarOptionFilter: React.FC<ISidebarFilter> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarListFilter
|
||||
title={title}
|
||||
candidates={options}
|
||||
onSelect={onSelect}
|
||||
onUnselect={onUnselect}
|
||||
selected={selected}
|
||||
singleValue
|
||||
sectionID={sectionID}
|
||||
/>
|
||||
</>
|
||||
<SidebarListFilter
|
||||
title={title}
|
||||
candidates={options}
|
||||
onSelect={onSelect}
|
||||
onUnselect={onUnselect}
|
||||
selected={selected}
|
||||
singleValue
|
||||
sectionID={sectionID}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,8 +26,8 @@ export const PhashFilter: React.FC<IPhashFilterProps> = ({
|
||||
}
|
||||
|
||||
function distanceChanged(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
let distance = parseInt(event.target.value);
|
||||
if (distance < 0 || isNaN(distance)) {
|
||||
let distance = parseInt(event.target.value, 10);
|
||||
if (distance < 0 || Number.isNaN(distance)) {
|
||||
distance = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export const RatingFilter: React.FC<IRatingFilterProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
return null;
|
||||
};
|
||||
|
||||
interface ISidebarFilter {
|
||||
|
||||
@@ -197,10 +197,10 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
|
||||
);
|
||||
}, [modifier, queryResults, selected, excluded]);
|
||||
|
||||
const includingOnly = modifier == CriterionModifier.Equals;
|
||||
const includingOnly = modifier === CriterionModifier.Equals;
|
||||
const excludingOnly =
|
||||
modifier == CriterionModifier.Excludes ||
|
||||
modifier == CriterionModifier.NotEquals;
|
||||
modifier === CriterionModifier.Excludes ||
|
||||
modifier === CriterionModifier.NotEquals;
|
||||
|
||||
const modifierValues = useMemo(() => {
|
||||
return {
|
||||
@@ -296,27 +296,24 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{showModifierValues && (
|
||||
<>
|
||||
{Object.entries(availableModifierValues).map(([key, value]) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
{showModifierValues &&
|
||||
Object.entries(availableModifierValues).map(([key, value]) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<UnselectedItem
|
||||
key={key}
|
||||
onSelect={() => onModifierValueSelect(key as SpecialValue)}
|
||||
label={`(${intl.formatMessage({
|
||||
id: `criterion_modifier_values.${key}`,
|
||||
})})`}
|
||||
canExclude={false}
|
||||
modifier
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
return (
|
||||
<UnselectedItem
|
||||
key={key}
|
||||
onSelect={() => onModifierValueSelect(key as SpecialValue)}
|
||||
label={`(${intl.formatMessage({
|
||||
id: `criterion_modifier_values.${key}`,
|
||||
})})`}
|
||||
canExclude={false}
|
||||
modifier
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{objects.map((p) => (
|
||||
<UnselectedItem
|
||||
key={p.id}
|
||||
@@ -338,7 +335,7 @@ interface IObjectsFilter<T extends ModifierCriterion<ILabeledValueListValue>> {
|
||||
}
|
||||
|
||||
export const ObjectsFilter = <
|
||||
T extends ModifierCriterion<ILabeledValueListValue | IHierarchicalLabelValue>
|
||||
T extends ModifierCriterion<ILabeledValueListValue | IHierarchicalLabelValue>,
|
||||
>({
|
||||
criterion,
|
||||
setCriterion,
|
||||
@@ -354,7 +351,7 @@ export const ObjectsFilter = <
|
||||
setDisplayQuery(input);
|
||||
debouncedSetQuery(input);
|
||||
},
|
||||
[debouncedSetQuery, setDisplayQuery]
|
||||
[debouncedSetQuery]
|
||||
);
|
||||
|
||||
const [queryResults, setQueryResults] = useState<ILabeledId[]>([]);
|
||||
@@ -369,7 +366,7 @@ export const ObjectsFilter = <
|
||||
const [, setInputFocus] = inputFocus;
|
||||
|
||||
function onSelect(value: ILabeledId, newExclude: boolean) {
|
||||
let newCriterion: T = cloneDeep(criterion);
|
||||
const newCriterion: T = cloneDeep(criterion);
|
||||
|
||||
if (newExclude) {
|
||||
if (newCriterion.value.excluded) {
|
||||
@@ -399,7 +396,7 @@ export const ObjectsFilter = <
|
||||
(value: ILabeledId) => {
|
||||
if (!criterion) return;
|
||||
|
||||
let newCriterion: T = cloneDeep(criterion);
|
||||
const newCriterion: T = cloneDeep(criterion);
|
||||
|
||||
newCriterion.value.items = criterion.value.items.filter(
|
||||
(v) => v.id !== value.id
|
||||
@@ -418,7 +415,7 @@ export const ObjectsFilter = <
|
||||
|
||||
const onSetModifier = useCallback(
|
||||
(modifier: CriterionModifier) => {
|
||||
let newCriterion: T = criterion.clone();
|
||||
const newCriterion: T = criterion.clone();
|
||||
newCriterion.modifier = modifier;
|
||||
setCriterion(newCriterion);
|
||||
},
|
||||
@@ -504,7 +501,7 @@ interface IHierarchicalObjectsFilter<T extends IHierarchicalLabeledIdCriterion>
|
||||
extends IObjectsFilter<T> {}
|
||||
|
||||
export const HierarchicalObjectsFilter = <
|
||||
T extends IHierarchicalLabeledIdCriterion
|
||||
T extends IHierarchicalLabeledIdCriterion,
|
||||
>(
|
||||
props: IHierarchicalObjectsFilter<T>
|
||||
) => {
|
||||
@@ -519,7 +516,7 @@ export const HierarchicalObjectsFilter = <
|
||||
});
|
||||
|
||||
function onDepthChanged(depth: number) {
|
||||
let newCriterion: T = cloneDeep(criterion);
|
||||
const newCriterion: T = cloneDeep(criterion);
|
||||
newCriterion.value.depth = depth;
|
||||
setCriterion(newCriterion);
|
||||
}
|
||||
@@ -539,8 +536,8 @@ export const HierarchicalObjectsFilter = <
|
||||
criterion.criterionOption.type === "studios"
|
||||
? "include_sub_studios"
|
||||
: criterion.criterionOption.type === "children"
|
||||
? "include_parent_tags"
|
||||
: "include_sub_tags";
|
||||
? "include_parent_tags"
|
||||
: "include_sub_tags";
|
||||
return {
|
||||
id: optionType,
|
||||
};
|
||||
|
||||
@@ -169,8 +169,8 @@ export const SidebarAgeFilter: React.FC<ISidebarFilter> = ({
|
||||
return MAX_AGE;
|
||||
}
|
||||
|
||||
const age = parseInt(trimmed);
|
||||
if (isNaN(age) || age < 18 || age > MAX_AGE) {
|
||||
const age = parseInt(trimmed, 10);
|
||||
if (Number.isNaN(age) || age < 18 || age > MAX_AGE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -202,13 +202,13 @@ export const SidebarDurationFilter: React.FC<ISidebarFilter> = ({
|
||||
|
||||
// Try to parse as pure number (minutes)
|
||||
const minutesOnly = parseFloat(trimmed);
|
||||
if (!isNaN(minutesOnly) && trimmed.indexOf(":") === -1) {
|
||||
if (!Number.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)) {
|
||||
const parts = trimmed.split(":").map((p) => parseInt(p, 10));
|
||||
if (parts.some(Number.isNaN)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@ const QueryField: React.FC<{
|
||||
setDisplayQuery(input);
|
||||
debouncedSetQuery(input);
|
||||
},
|
||||
[debouncedSetQuery, setDisplayQuery]
|
||||
[debouncedSetQuery]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useConfigurationContext } from "src/hooks/Config";
|
||||
interface IFilteredItemList<
|
||||
T extends QueryResult,
|
||||
E extends IHasID = IHasID,
|
||||
M = unknown
|
||||
M = unknown,
|
||||
> {
|
||||
filterStateProps: IFilterStateHook;
|
||||
queryResultProps: IQueryResultHook<T, E, M>;
|
||||
@@ -29,7 +29,7 @@ interface IFilteredItemList<
|
||||
export function useFilteredItemList<
|
||||
T extends QueryResult,
|
||||
E extends IHasID = IHasID,
|
||||
M = unknown
|
||||
M = unknown,
|
||||
>(props: IFilteredItemList<T, E, M>) {
|
||||
const { configuration: config } = useConfigurationContext();
|
||||
|
||||
@@ -90,24 +90,24 @@ export function useFilteredItemList<
|
||||
}
|
||||
|
||||
export const showWhenSelected = <T extends QueryResult>(
|
||||
result: T,
|
||||
filter: ListFilterModel,
|
||||
_result: T,
|
||||
_filter: ListFilterModel,
|
||||
selectedIds: Set<string>
|
||||
) => {
|
||||
return selectedIds.size > 0;
|
||||
};
|
||||
|
||||
export const showWhenSingleSelection = <T extends QueryResult>(
|
||||
result: T,
|
||||
filter: ListFilterModel,
|
||||
_result: T,
|
||||
_filter: ListFilterModel,
|
||||
selectedIds: Set<string>
|
||||
) => {
|
||||
return selectedIds.size == 1;
|
||||
return selectedIds.size === 1;
|
||||
};
|
||||
|
||||
export const showWhenNoneSelected = <T extends QueryResult>(
|
||||
result: T,
|
||||
filter: ListFilterModel,
|
||||
_result: T,
|
||||
_filter: ListFilterModel,
|
||||
selectedIds: Set<string>
|
||||
) => {
|
||||
return selectedIds.size === 0;
|
||||
|
||||
@@ -160,7 +160,7 @@ export const PageSizeSelector: React.FC<{
|
||||
|
||||
setCustomPageSizeShowing(false);
|
||||
|
||||
let pp = parseInt(val, 10);
|
||||
const pp = parseInt(val, 10);
|
||||
if (Number.isNaN(pp) || pp <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -253,7 +253,7 @@ export const SortBySelect: React.FC<{
|
||||
const currentSortByMessageID = currentSortBy
|
||||
? !sfwContentMode
|
||||
? currentSortBy.messageID
|
||||
: currentSortBy.sfwMessageID ?? currentSortBy.messageID
|
||||
: (currentSortBy.sfwMessageID ?? currentSortBy.messageID)
|
||||
: "";
|
||||
|
||||
function renderSortByOptions() {
|
||||
@@ -261,7 +261,7 @@ export const SortBySelect: React.FC<{
|
||||
.map((o) => {
|
||||
const messageID = !sfwContentMode
|
||||
? o.messageID
|
||||
: o.sfwMessageID ?? o.messageID;
|
||||
: (o.sfwMessageID ?? o.messageID);
|
||||
return {
|
||||
message: intl.formatMessage({ id: messageID }),
|
||||
value: o.value,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user