From aecab2d131ead8cdb7ae194f549b33f0b79ccca1 Mon Sep 17 00:00:00 2001
From: gitgiggety <79809426+gitgiggety@users.noreply.github.com>
Date: Tue, 13 Jul 2021 01:59:09 +0200
Subject: [PATCH 02/51] Tag stable versions with the version number on Docker
(#1550)
Fixes #1546
---
.github/workflows/build.yml | 2 +-
docker/ci/x86_64/docker_push.sh | 10 ++++++++--
2 files changed, 9 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index c9e919e9a..e963bd17e 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -182,4 +182,4 @@ jobs:
docker buildx create --name builder --use
docker buildx inspect --bootstrap
docker buildx ls
- bash ./docker/ci/x86_64/docker_push.sh latest
+ bash ./docker/ci/x86_64/docker_push.sh latest "${{ github.event.release.tag_name }}"
diff --git a/docker/ci/x86_64/docker_push.sh b/docker/ci/x86_64/docker_push.sh
index 25008c476..8d638e0f7 100644
--- a/docker/ci/x86_64/docker_push.sh
+++ b/docker/ci/x86_64/docker_push.sh
@@ -1,8 +1,14 @@
#!/bin/bash
-DOCKER_TAG=$1
+DOCKER_TAGS=""
+
+for TAG in "$@"
+do
+ DOCKER_TAGS="$DOCKER_TAGS -t stashapp/stash:$TAG"
+done
+
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
# must build the image from dist directory
-docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push --output type=image,name=stashapp/stash:$DOCKER_TAG,push=true -f docker/ci/x86_64/Dockerfile dist/
+docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push $DOCKER_TAGS -f docker/ci/x86_64/Dockerfile dist/
From 1b20fd1ad6034531e50448c32ce5f80dc7a71f5c Mon Sep 17 00:00:00 2001
From: thatbrick <87104291+thatbrick@users.noreply.github.com>
Date: Mon, 12 Jul 2021 20:29:47 -0400
Subject: [PATCH 03/51] Fix Incorrect loading of images on iOS devices (#1562)
* Removing the height parameter seems to resolve the issue without noticeable differences in functionality
---
ui/v2.5/src/components/Changelog/versions/v090.md | 1 +
ui/v2.5/src/hooks/Lightbox/lightbox.scss | 1 -
2 files changed, 1 insertion(+), 1 deletion(-)
diff --git a/ui/v2.5/src/components/Changelog/versions/v090.md b/ui/v2.5/src/components/Changelog/versions/v090.md
index 0feb9ec2a..73b479044 100644
--- a/ui/v2.5/src/components/Changelog/versions/v090.md
+++ b/ui/v2.5/src/components/Changelog/versions/v090.md
@@ -1,2 +1,3 @@
### 🐛 Bug fixes
+* Fix rendering of carousel images on Apple devices. ([#1562](https://github.com/stashapp/stash/pull/1562))
* Show New and Delete buttons in mobile view. ([#1539](https://github.com/stashapp/stash/pull/1539))
\ No newline at end of file
diff --git a/ui/v2.5/src/hooks/Lightbox/lightbox.scss b/ui/v2.5/src/hooks/Lightbox/lightbox.scss
index 78f6c6b5e..0ac16571a 100644
--- a/ui/v2.5/src/hooks/Lightbox/lightbox.scss
+++ b/ui/v2.5/src/hooks/Lightbox/lightbox.scss
@@ -99,7 +99,6 @@
&-image {
content-visibility: auto;
display: flex;
- height: 100%;
width: 100vw;
picture {
From a13f43c13bbe3efb261e7c8b5427027b43b1de5a Mon Sep 17 00:00:00 2001
From: gitgiggety <79809426+gitgiggety@users.noreply.github.com>
Date: Wed, 14 Jul 2021 10:29:59 +0200
Subject: [PATCH 04/51] Always wrap filter conditions in parentheses (#1577)
* Always wrap filter conditions in parentheses
Fixes #1571
---
pkg/sqlite/filter.go | 2 +-
pkg/sqlite/filter_internal_test.go | 32 +++++++++----------
.../src/components/Changelog/versions/v090.md | 1 +
3 files changed, 18 insertions(+), 17 deletions(-)
diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go
index dce1b11d3..1f1f6c13d 100644
--- a/pkg/sqlite/filter.go
+++ b/pkg/sqlite/filter.go
@@ -310,7 +310,7 @@ func (f *filterBuilder) andClauses(input []sqlClause) (string, []interface{}) {
}
if len(clauses) > 0 {
- c := strings.Join(clauses, " AND ")
+ c := "(" + strings.Join(clauses, ") AND (") + ")"
if len(clauses) > 1 {
c = "(" + c + ")"
}
diff --git a/pkg/sqlite/filter_internal_test.go b/pkg/sqlite/filter_internal_test.go
index e7a39521d..9a5042ba1 100644
--- a/pkg/sqlite/filter_internal_test.go
+++ b/pkg/sqlite/filter_internal_test.go
@@ -252,14 +252,14 @@ func TestGenerateWhereClauses(t *testing.T) {
// ensure single where clause is generated correctly
f.addWhere(clause1)
r, rArgs := f.generateWhereClauses()
- assert.Equal(clause1, r)
+ assert.Equal(fmt.Sprintf("(%s)", clause1), r)
assert.Len(rArgs, 0)
// ensure multiple where clauses are surrounded with parenthesis and
// ANDed together
f.addWhere(clause2, arg1, arg2)
r, rArgs = f.generateWhereClauses()
- assert.Equal(fmt.Sprintf("(%s AND %s)", clause1, clause2), r)
+ assert.Equal(fmt.Sprintf("((%s) AND (%s))", clause1, clause2), r)
assert.Len(rArgs, 2)
// ensure empty subfilter is not added to generated where clause
@@ -267,13 +267,13 @@ func TestGenerateWhereClauses(t *testing.T) {
f.and(sf)
r, rArgs = f.generateWhereClauses()
- assert.Equal(fmt.Sprintf("(%s AND %s)", clause1, clause2), r)
+ assert.Equal(fmt.Sprintf("((%s) AND (%s))", clause1, clause2), r)
assert.Len(rArgs, 2)
// ensure sub-filter is generated correctly
sf.addWhere(clause3, arg3)
r, rArgs = f.generateWhereClauses()
- assert.Equal(fmt.Sprintf("(%s AND %s) AND (%s)", clause1, clause2, clause3), r)
+ assert.Equal(fmt.Sprintf("((%s) AND (%s)) AND ((%s))", clause1, clause2, clause3), r)
assert.Len(rArgs, 3)
// ensure OR sub-filter is generated correctly
@@ -283,7 +283,7 @@ func TestGenerateWhereClauses(t *testing.T) {
f.or(sf)
r, rArgs = f.generateWhereClauses()
- assert.Equal(fmt.Sprintf("(%s AND %s) OR (%s)", clause1, clause2, clause3), r)
+ assert.Equal(fmt.Sprintf("((%s) AND (%s)) OR ((%s))", clause1, clause2, clause3), r)
assert.Len(rArgs, 3)
// ensure NOT sub-filter is generated correctly
@@ -293,7 +293,7 @@ func TestGenerateWhereClauses(t *testing.T) {
f.not(sf)
r, rArgs = f.generateWhereClauses()
- assert.Equal(fmt.Sprintf("(%s AND %s) AND NOT (%s)", clause1, clause2, clause3), r)
+ assert.Equal(fmt.Sprintf("((%s) AND (%s)) AND NOT ((%s))", clause1, clause2, clause3), r)
assert.Len(rArgs, 3)
// ensure empty filter with ANDed sub-filter does not include AND
@@ -301,7 +301,7 @@ func TestGenerateWhereClauses(t *testing.T) {
f.and(sf)
r, rArgs = f.generateWhereClauses()
- assert.Equal(fmt.Sprintf("(%s)", clause3), r)
+ assert.Equal(fmt.Sprintf("((%s))", clause3), r)
assert.Len(rArgs, 1)
// ensure empty filter with ORed sub-filter does not include OR
@@ -309,7 +309,7 @@ func TestGenerateWhereClauses(t *testing.T) {
f.or(sf)
r, rArgs = f.generateWhereClauses()
- assert.Equal(fmt.Sprintf("(%s)", clause3), r)
+ assert.Equal(fmt.Sprintf("((%s))", clause3), r)
assert.Len(rArgs, 1)
// ensure empty filter with NOTed sub-filter does not include AND
@@ -317,7 +317,7 @@ func TestGenerateWhereClauses(t *testing.T) {
f.not(sf)
r, rArgs = f.generateWhereClauses()
- assert.Equal(fmt.Sprintf("NOT (%s)", clause3), r)
+ assert.Equal(fmt.Sprintf("NOT ((%s))", clause3), r)
assert.Len(rArgs, 1)
// (clause1) AND ((clause2) OR (clause3))
@@ -328,7 +328,7 @@ func TestGenerateWhereClauses(t *testing.T) {
f.and(sf2)
sf2.or(sf)
r, rArgs = f.generateWhereClauses()
- assert.Equal(fmt.Sprintf("%s AND (%s OR (%s))", clause1, clause2, clause3), r)
+ assert.Equal(fmt.Sprintf("(%s) AND ((%s) OR ((%s)))", clause1, clause2, clause3), r)
assert.Len(rArgs, 3)
}
@@ -348,14 +348,14 @@ func TestGenerateHavingClauses(t *testing.T) {
// ensure single Having clause is generated correctly
f.addHaving(clause1)
r, rArgs := f.generateHavingClauses()
- assert.Equal(clause1, r)
+ assert.Equal(fmt.Sprintf("(%s)", clause1), r)
assert.Len(rArgs, 0)
// ensure multiple Having clauses are surrounded with parenthesis and
// ANDed together
f.addHaving(clause2, arg1, arg2)
r, rArgs = f.generateHavingClauses()
- assert.Equal("("+clause1+" AND "+clause2+")", r)
+ assert.Equal("(("+clause1+") AND ("+clause2+"))", r)
assert.Len(rArgs, 2)
// ensure empty subfilter is not added to generated Having clause
@@ -363,13 +363,13 @@ func TestGenerateHavingClauses(t *testing.T) {
f.and(sf)
r, rArgs = f.generateHavingClauses()
- assert.Equal("("+clause1+" AND "+clause2+")", r)
+ assert.Equal("(("+clause1+") AND ("+clause2+"))", r)
assert.Len(rArgs, 2)
// ensure sub-filter is generated correctly
sf.addHaving(clause3, arg3)
r, rArgs = f.generateHavingClauses()
- assert.Equal("("+clause1+" AND "+clause2+") AND ("+clause3+")", r)
+ assert.Equal("(("+clause1+") AND ("+clause2+")) AND (("+clause3+"))", r)
assert.Len(rArgs, 3)
// ensure OR sub-filter is generated correctly
@@ -379,7 +379,7 @@ func TestGenerateHavingClauses(t *testing.T) {
f.or(sf)
r, rArgs = f.generateHavingClauses()
- assert.Equal("("+clause1+" AND "+clause2+") OR ("+clause3+")", r)
+ assert.Equal("(("+clause1+") AND ("+clause2+")) OR (("+clause3+"))", r)
assert.Len(rArgs, 3)
// ensure NOT sub-filter is generated correctly
@@ -389,7 +389,7 @@ func TestGenerateHavingClauses(t *testing.T) {
f.not(sf)
r, rArgs = f.generateHavingClauses()
- assert.Equal("("+clause1+" AND "+clause2+") AND NOT ("+clause3+")", r)
+ assert.Equal("(("+clause1+") AND ("+clause2+")) AND NOT (("+clause3+"))", r)
assert.Len(rArgs, 3)
}
diff --git a/ui/v2.5/src/components/Changelog/versions/v090.md b/ui/v2.5/src/components/Changelog/versions/v090.md
index 73b479044..c7373230f 100644
--- a/ui/v2.5/src/components/Changelog/versions/v090.md
+++ b/ui/v2.5/src/components/Changelog/versions/v090.md
@@ -1,3 +1,4 @@
### 🐛 Bug fixes
+* Fix is missing date scene criterion causing invalid SQL. ([#1577](https://github.com/stashapp/stash/pull/1577))
* Fix rendering of carousel images on Apple devices. ([#1562](https://github.com/stashapp/stash/pull/1562))
* Show New and Delete buttons in mobile view. ([#1539](https://github.com/stashapp/stash/pull/1539))
\ No newline at end of file
From 4bdd759dae643d6482a7cda562df2be96c095746 Mon Sep 17 00:00:00 2001
From: Phasetime <87231024+Phasetime@users.noreply.github.com>
Date: Sat, 17 Jul 2021 11:00:47 +0200
Subject: [PATCH 05/51] German Translation (#1578)
* initial translation pass
* New line at EOF
---
.../src/components/Changelog/versions/v090.md | 3 +
.../SettingsInterfacePanel.tsx | 1 +
ui/v2.5/src/locales/de-DE.json | 601 ++++++++++++++++++
ui/v2.5/src/locales/index.ts | 2 +
4 files changed, 607 insertions(+)
create mode 100644 ui/v2.5/src/locales/de-DE.json
diff --git a/ui/v2.5/src/components/Changelog/versions/v090.md b/ui/v2.5/src/components/Changelog/versions/v090.md
index c7373230f..9c9a1c74d 100644
--- a/ui/v2.5/src/components/Changelog/versions/v090.md
+++ b/ui/v2.5/src/components/Changelog/versions/v090.md
@@ -1,3 +1,6 @@
+### 🎨 Improvements
+* Added de-DE language option. ([#1578](https://github.com/stashapp/stash/pull/1578))
+
### 🐛 Bug fixes
* Fix is missing date scene criterion causing invalid SQL. ([#1577](https://github.com/stashapp/stash/pull/1577))
* Fix rendering of carousel images on Apple devices. ([#1562](https://github.com/stashapp/stash/pull/1562))
diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx
index e12b858e3..b09af3342 100644
--- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx
+++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx
@@ -119,6 +119,7 @@ export const SettingsInterfacePanel: React.FC = () => {
>
English (United States)
English (United Kingdom)
+
German (Germany)
繁體中文 (台灣)
diff --git a/ui/v2.5/src/locales/de-DE.json b/ui/v2.5/src/locales/de-DE.json
new file mode 100644
index 000000000..e60a157c2
--- /dev/null
+++ b/ui/v2.5/src/locales/de-DE.json
@@ -0,0 +1,601 @@
+{
+ "actions": {
+ "add": "Hinzufügen",
+ "add_directory": "Ordner hinzufügen",
+ "add_entity": "Füge {entityType} hinzu",
+ "add_to_entity": "Hinzufügen zu {entityType}",
+ "allow": "Erlauben",
+ "allow_temporarily": "Vorübergehend erlauben",
+ "apply": "Anwenden",
+ "auto_tag": "Auto Tag",
+ "backup": "Backup",
+ "cancel": "Abbrechen",
+ "clean": "Aufräumen",
+ "clear_back_image": "Rückseite entfernen",
+ "clear_front_image": "Vorderseite entfernen",
+ "clear_image": "Bild entfernen",
+ "close": "Schließen",
+ "create": "Erstellen",
+ "create_entity": "Erstelle {entityType}",
+ "create_marker": "Erstelle Marke",
+ "created_entity": "{entity_type} erstellt: {entity_name}",
+ "delete": "Löschen",
+ "delete_entity": "Lösche {entityType}",
+ "delete_file": "Lösche Datei",
+ "delete_generated_supporting_files": "Lösche generierte Hilfsdaten",
+ "disallow": "Nicht erlauben",
+ "download": "Herunterladen",
+ "download_backup": "Lade Backup herunter",
+ "edit": "Bearbeiten",
+ "export": "Exportieren…",
+ "export_all": "Alles exportieren…",
+ "find": "Suchen",
+ "from_file": "Aus Datei…",
+ "from_url": "Von URL…",
+ "full_export": "Vollständiger Export",
+ "full_import": "Vollständiger Import",
+ "generate": "Generieren",
+ "generate_thumb_default": "Erstelle voreingestelltes Vorschaubild",
+ "generate_thumb_from_current": "Erstelle Vorschaubild vom Gegenwärtigen",
+ "hash_migration": "Hash Umwandlung",
+ "hide": "Verstecken",
+ "import": "Importieren…",
+ "import_from_file": "Importieren aus Datei",
+ "merge": "Zusammenführen",
+ "merge_from": "Zusammenführen aus",
+ "merge_into": "Zusammenführen in",
+ "not_running": "wird nicht ausgeführt",
+ "overwrite": "Überschreiben",
+ "play_random": "Spiele zufällig ab",
+ "play_selected": "Spiele ausgewählte",
+ "preview": "Vorschau",
+ "refresh": "Aktualisieren",
+ "reload_plugins": "Plugins neu laden",
+ "reload_scrapers": "Scraper neu laden",
+ "remove": "Entfernen",
+ "rename_gen_files": "Hilfsdaten umbenennen",
+ "rescan": "Erneut scannen",
+ "reshuffle": "Neu mischen",
+ "running": "wird ausgeführt",
+ "save": "Speichern",
+ "save_filter": "Filter speichern",
+ "scan": "Scannen",
+ "scrape_with": "Scrape mit…",
+ "search": "Suchen",
+ "select_all": "Alle auswählen",
+ "select_none": "Nichts auswählen",
+ "selective_auto_tag": "Selektives Auto Tag",
+ "selective_scan": "Selektives Scannen",
+ "set_as_default": "Als Voreinstellung festlegen",
+ "set_back_image": "Rückseite…",
+ "set_front_image": "Vorderseite…",
+ "set_image": "Bild festlegen…",
+ "show": "Anzeigen",
+ "skip": "Überspringen",
+ "tasks": {
+ "clean_confirm_message": "Wollen Sie wirklich die Datenbank aufräumen? Dies wird alle Informationen und Hilfsdaten für Szenen und Galerien löschen, die nicht mehr auf dem Dateisystem vorhanden sind.",
+ "dry_mode_selected": "Trockenmodus ausgewählt. Es findet keine Löschung der Daten statt, lediglich Protokollierung.",
+ "import_warning": "Wollen Sie wirklich die Datenbank importieren? Dies wird die aktuelle Datenbank mit der importierten Datenbank überschreiben."
+ },
+ "temp_disable": "Vorübergehend deaktivieren…",
+ "temp_enable": "Vörübergehend aktivieren…",
+ "view_random": "Zeige Zufällige"
+ },
+ "actions_name": "Aktionen",
+ "age": "Alter",
+ "aliases": "Aliase",
+ "also_known_as": "Auch bekannt als",
+ "ascending": "Aufsteigend",
+ "average_resolution": "Durchschnittliche Auflösung",
+ "birth_year": "Geburtsjahr",
+ "birthdate": "Geburtsdatum",
+ "bitrate": "Bitrate",
+ "career_length": "Länge der Karriere",
+ "child_studios": "Tochterstudios",
+ "component_tagger": {
+ "config": {
+ "active_instance": "Aktive stash-box Instanz:",
+ "blacklist_desc": "Auf der Blacklist befindliche Objekte sind von Anfragen ausgenommen. Objekte sind reguläre Ausdrücke und Groß-/Kleinschreibung wird nicht beachtet. Manchen Zeichen muss ein Fluchtsymbol (Backslash) vorangestellt werden: {chars_require_escape}",
+ "blacklist_label": "Blacklist",
+ "query_mode_auto": "Automatisch",
+ "query_mode_auto_desc": "Nutzt Metadaten sofern verfügbar bzw. Dateinamen",
+ "query_mode_dir": "Verzeichnis",
+ "query_mode_dir_desc": "Nutzt nur den übergeordneten Ornder ",
+ "query_mode_filename": "Dateiname",
+ "query_mode_filename_desc": "Nutzt nur den Dateinamen",
+ "query_mode_label": "Anfragemodus",
+ "query_mode_metadata": "Metadaten",
+ "query_mode_metadata_desc": "Nutzt nur Metadaten",
+ "query_mode_path": "Pfad ",
+ "query_mode_path_desc": "Nutzt vollständigen Dateipfad",
+ "set_cover_desc": "Überschreibe Cover-Bild, sofern verfügbar",
+ "set_cover_label": "Setze Cover-Bild",
+ "set_tag_desc": "Hänge Tags der Szene an, entweder durch Überschreiben oder Zusammenführen mit bereits angehängten Tags.",
+ "set_tag_label": "Tags anhängen",
+ "show_male_desc": "Auswahl ob männliche Darsteller der Szene hinzugefügt werden können.",
+ "show_male_label": "Männliche Darsteller anzeigen"
+ },
+ "noun_query": "Anfrage",
+ "results": {
+ "fp_found": "{fpCount, plural, =0 {Keine neuen Fingerabdruckübereinstimmungen gefunden} other {# neue Fingerabdruckübereinstimmungen gefunden}}",
+ "fp_matches": "Übereinstimmung der Laufzeit",
+ "fp_matches_multi": "Laufzeit stimmt mit {matchCount}/{durationsLength} Fingerabdrücken überein",
+ "hash_matches": "Übereinstimmung bei {hash_type}",
+ "match_failed_already_tagged": "Szene bereits getagged",
+ "match_failed_no_result": "Keine Übereinstimmungen gefunden",
+ "match_success": "Szene erfolgreich getagged",
+ "duration_off": "Laufzeitunterschied bei mindestens {number}sek",
+ "duration_unknown": "Laufzeit unbekannt"
+ },
+ "verb_match_fp": "Fingerabdrücke zuordnen",
+ "verb_matched": "zugeordnet",
+ "verb_submit_fp": "Übermittele {fpCount, plural, one{# Fingerabdruck} other{# Fingerabdrücke}}",
+ "verb_toggle_config": "{toggle} {configuration}",
+ "verb_toggle_unmatched": "{toggle} nicht zugeordnete Szenen"
+ },
+ "config": {
+ "about": {
+ "build_hash": "Hash des Builds:",
+ "build_time": "Zeitpunkt des Builds:",
+ "check_for_new_version": "Suche nach Updates",
+ "latest_version_build_hash": "Neuester Build Hash:",
+ "new_version_notice": "[NEU]",
+ "stash_discord": "Komm in unseren {url} Kanal",
+ "stash_home": "Stash ist beheimatet auf {url}",
+ "stash_open_collective": "Unterstütze uns über {url}",
+ "stash_wiki": "Stash {url} Seite",
+ "version": "Version"
+ },
+ "categories": {
+ "about": "Über",
+ "interface": "Oberfläche",
+ "logs": "Protokoll",
+ "plugins": "Plugins",
+ "scrapers": "Scraper",
+ "tasks": "Aufgaben",
+ "tools": "Werkzeuge"
+ },
+ "dlna": {
+ "allow_temp_ip": "Erlaube {tempIP}",
+ "allowed_ip_addresses": "Erlaubte IP Adressen",
+ "default_ip_whitelist": "Standard IP Whitelist",
+ "default_ip_whitelist_desc": "Standard IP Adressen, welche DLNA nutzen dürfen. Nutze {wildcard} um alle IP Adressen zu erlauben.",
+ "enabled_by_default": "Standardmäßig aktiviert",
+ "network_interfaces": "Netzwerkoberflächen",
+ "network_interfaces_desc": "Netzwerkoberflächen auf denen DLNA sichtbar ist. Eine leere Liste führt dazu, dass DLNA auf allen Oberflächen ausgeführt wird. Benötigt Neustart des DLNA nach Änderungen.",
+ "recent_ip_addresses": "Letzte IP Adressen",
+ "server_display_name": "Server Anzeigename",
+ "server_display_name_desc": "Anzeigename des DLNA-Servers. Standardmäßig {server_name} bei leerem Feld.",
+ "until_restart": "bis Neustart"
+ },
+ "general": {
+ "auth": {
+ "api_key": "API-Schlüssel",
+ "api_key_desc": "API-Schlüssel für externe Systeme. Nur nötig, falls Benutzer/Password konfiguriert. Benutzername muss vor Erzeugung des API Schlüssels gespeichert worden sein.",
+ "authentication": "Authentifizierung",
+ "clear_api_key": "API-Schlüssel löschen",
+ "generate_api_key": "API-Schlüssel erzeugen",
+ "log_file": "Protokolldatei",
+ "log_file_desc": "Pfad zur Protokolldatei. Feld leer lassen, um Protokollierung zu deaktivieren. Benötigt Neustart.",
+ "log_http": "Protokolliere HTTP Zugriffe",
+ "log_http_desc": "Protokolliert HTTP Zugriffe im Terminal. Benötigt Neustart.",
+ "log_to_terminal": "Protokolliere zu Terminal",
+ "log_to_terminal_desc": "Protokolliert zusätzlich zur Protokolldatei auch zum Terminal. Gilt automatisch, sofern Protokolldatei deaktiviert. Benötigt Neustart.",
+ "maximum_session_age": "Maximale Sitzungsdauer",
+ "maximum_session_age_desc": "Maximale Wartezeit bis eine Login-Sitzung ausläuft, in Sekunden.",
+ "password": "Passwort",
+ "password_desc": "Passwort für den Zugriff auf Stash. Feld leer lassen, um Benutzerauthentifizierung zu deaktivieren.",
+ "stash-box_integration": "Stash-box Einbindung",
+ "username": "Benutzername",
+ "username_desc": "Benutzername für den Zugriff auf Stash. Feld leer lassen, um Benutzerauthentifizierung zu deaktivieren."
+ },
+ "cache_location": "Verzeichnis für den Cache.",
+ "cache_path_head": "Cache Pfad",
+ "calculate_md5_and_ohash_desc": "Berechne MD5 Prüfsumme zusätzlich zu oshash. Aktivierung führt dazu, dass erstmalige Scans mehr Zeit benötigen. Dateibenennungshash muss auf oshash gesetzt sein, um Berechnung des MD5 zu unterbinden.",
+ "calculate_md5_and_ohash_label": "Berechne MD5 für Videodateien",
+ "check_for_insecure_certificates": "Überprüfe auf unsichere Zertifikate",
+ "check_for_insecure_certificates_desc": "Manche Seiten nutzen unsichere SSL Zertifikate. Wenn diese Option nicht ausgewählt ist, überspringt der Scraper die Überprüfung und erlaubt das Scrapen dieser Seiten. Entfernen Sie das Häkchen, falls Sie Zertifikatsfehler beim Scrapen erhalten.",
+ "chrome_cdp_path": "Chrome CDP Pfad",
+ "chrome_cdp_path_desc": "Dateipfad zur Chrome Executable oder einer externen Adresse (beginnend mit http:// oder https://, bspw. http://localhost:9222/json/version) die auf eine Chrome Instanz zeigt.",
+ "create_galleries_from_folders_desc": "Wenn ausgewählt, erzeuge Galerien aus Verzeichnissen, welche Bilder enthalten.",
+ "create_galleries_from_folders_label": "Erzeuge Galerien aus Verzeichnissen mit Bilder darin",
+ "db_path_head": "Datenbank Pfad",
+ "directory_locations_to_your_content": "Verzeichnis zu Ihren Inhalten",
+ "exclude_image": "Bilder ausschließen",
+ "exclude_video": "Videos ausschließen",
+ "excluded_image_gallery_patterns_desc": "Reguläre Ausdrücke für Dateinamen/Pfade von Bildern/Galerien, welche von Scans ausgeschlossen werden und beim Aufräumen der Datenbank berücksichtigt werden sollen.",
+ "excluded_image_gallery_patterns_head": "Schema für ausgeschlossene Bilder/Galerien",
+ "excluded_video_patterns_desc": "Reguläre Ausdrücke für Dateinamen/Pfade von Videos, welche von Scans ausgeschlossen werden und beim Aufräumen der Datenbank berücksichtigt werden sollen.",
+ "excluded_video_patterns_head": "Schema für ausgeschlossene Videos",
+ "gallery_ext_desc": "Durch Kommas getrennte Liste von Dateiformaten, welche als Galeriecontainer gelesen werden sollen.",
+ "gallery_ext_head": "Galeriecontainer Dateiformate",
+ "generated_file_naming_hash_desc": "Verwende MD5 oder oshash für die Benennung der generierten Dateien. Um dies zu ändern, müssen für alle Szenen der entsprechende MD5/oshash berechnet werden. Nachdem dieser Wert geändert wurde, müssen vorhandene generierte Dateien migriert oder neu generiert werden. Siehe Aufgabenseite für die Migration.",
+ "generated_file_naming_hash_head": "Dateinamen-Hash für generierte Dateien",
+ "generated_files_location": "Verzeichnisspeicherort für die generierten Dateien (Szenenmarkierungen, Szenenvorschauen, Sprites usw.)",
+ "generated_path_head": "Pfad für generierte Dateien",
+ "hashing": "Hashing",
+ "image_ext_desc": "Durch Kommas getrennte Liste von Dateierweiterungen, die als Bilder identifiziert werden.",
+ "image_ext_head": "Bilderweiterungen",
+ "logging": "Protokollierung",
+ "maximum_streaming_transcode_size_desc": "Maximale Größe für transcodierte Streams",
+ "maximum_streaming_transcode_size_head": "Maximale Streaming-Transcode-Größe",
+ "maximum_transcode_size_desc": "Maximale Größe für generierte Transcodes",
+ "maximum_transcode_size_head": "Maximale Transcodierungsgröße",
+ "number_of_parallel_task_for_scan_generation_desc": "Für die automatische Erkennung auf 0 setzen. Warnung: Mehr Aufgaben auszuführen, als erforderlich ist, um eine CPU-Auslastung von 100 % zu erreichen, verringert die Leistung und verursacht möglicherweise andere Probleme.",
+ "number_of_parallel_task_for_scan_generation_head": "Anzahl paralleler Tasks für Scan/Generierung",
+ "parallel_scan_head": "Paralleler Scan/Generierung",
+ "preview_generation": "Vorschau-Generierung",
+ "scraper_user_agent": "Scraper-Benutzeragent",
+ "scraper_user_agent_desc": "User-Agent-String, der während Scrape-HTTP-Anfragen verwendet wird",
+ "scraping": "Scraping",
+ "sqlite_location": "Dateispeicherort für die SQLite-Datenbank (erfordert Neustart)",
+ "video_ext_desc": "Durch Kommas getrennte Liste von Dateierweiterungen, die als Videos identifiziert werden.",
+ "video_ext_head": "Videodateiformate",
+ "video_head": "Video"
+ },
+ "logs": {
+ "log_level": "Protokolllevel"
+ },
+ "plugins": {
+ "hooks": "Hooks",
+ "triggers_on": "Auslösen bei"
+ },
+ "scrapers": {
+ "entity_metadata": "{entityType} Metadaten",
+ "entity_scrapers": "{entityType} Scraper",
+ "search_by_name": "Suche nach Name",
+ "supported_types": "Unterstützte Typen",
+ "supported_urls": "URLs"
+ },
+ "stashbox": {
+ "add_instance": "Stash-Box-Instanz hinzufügen",
+ "api_key": "API-Schlüssel",
+ "description": "Stash-Box erleichtert das automatisierte Tagging von Szenen und Darstellern basierend auf Fingerabdrücken und Dateinamen.\nEndpunkt und API-Schlüssel finden Sie auf Ihrer Kontoseite in der stash-box-Instanz. Ein Name ist erforderlich, wenn mehr als eine Instanz hinzugefügt wird.",
+ "endpoint": "Endpunkt",
+ "graphql_endpoint": "GraphQL-Endpunkt",
+ "name": "Name",
+ "title": "Stash-Box-Endpunkte"
+ },
+ "tasks": {
+ "added_job_to_queue": "{operation_name} zur Auftragswarteschlange hinzugefügt",
+ "auto_tag_based_on_filenames": "Inhalte basierend auf Dateinamen automatisch taggen.",
+ "auto_tagging": "Automatisches Tagging",
+ "backing_up_database": "Datenbank sichern",
+ "backup_and_download": "Führt eine Sicherung der Datenbank durch und lädt die resultierende Datei herunter.",
+ "backup_database": "Führt eine Sicherung der Datenbank im selben Verzeichnis wie die Datenbank mit dem Dateinamenformat {filename_format} durch.",
+ "cleanup_desc": "Suche nach fehlenden Dateien und entfernen Sie diese aus der Datenbank. Dies ist eine destruktive Aktion.",
+ "dont_include_file_extension_as_part_of_the_title": "Füge keine Dateierweiterung als Teil des Titels hinzu",
+ "export_to_json": "Exportiert den Datenbankinhalt in JSON-Format im Metadatenverzeichnis.",
+ "generate_desc": "Generiere unterstützende Bild-, Sprite-, Video-, VTT- und andere Dateien.",
+ "generate_phashes_during_scan": "Generiere phashes während des Scans (zur Deduplizierung und Szenenidentifikation)",
+ "generate_previews_during_scan": "Generiere Bildervorschauen während des Scans (animierte WebP-Vorschauen, nur erforderlich, wenn Vorschautyp auf Animiertes Bild eingestellt ist)",
+ "generate_sprites_during_scan": "Generiere Sprites während des Scans (für den Szenen-Scrubber)",
+ "generate_video_previews_during_scan": "Generiere Vorschauen während des Scans (Videovorschauen, die abgespielt werden, wenn der Mauszeiger über eine Szene fährt)",
+ "generated_content": "Generierter Inhalt",
+ "import_from_exported_json": "Import aus exportiertem JSON im Metadatenverzeichnis. Löscht die vorhandene Datenbank.",
+ "incremental_import": "Inkrementeller Import aus einer Export-ZIP-Datei.",
+ "job_queue": "Job-Warteschlange",
+ "maintenance": "Instandhaltung",
+ "migrate_hash_files": "Wird nach dem Ändern des Dateinamen-Hashs für generierte Dateien verwendet, um vorhandene generierte Dateien in das neue Hash-Format umzubenennen.",
+ "migrations": "Migrationen",
+ "only_dry_run": "Führe nur einen Probelauf durch. Es wird noch nichts entfernt.",
+ "plugin_tasks": "Plugin-Aufgaben",
+ "scan_for_content_desc": "Suchen nach neuen Inhalten und füge sie der Datenbank hinzu.",
+ "set_name_date_details_from_metadata_if_present": "Name, Datum, Details aus Metadaten festlegen (falls vorhanden)"
+ },
+ "tools": {
+ "scene_duplicate_checker": "Szenenduplikatsprüfung",
+ "scene_filename_parser": {
+ "add_field": "Feld hinzufügen",
+ "capitalize_title": "Titel groß schreiben",
+ "display_fields": "Anzeigefelder",
+ "escape_chars": "Verwenden Sie \\, um Literale zu maskieren",
+ "filename": "Dateiname",
+ "filename_pattern": "Dateinamenmuster",
+ "ignored_words": "Ignorierte Wörter",
+ "matches_with": "Stimmt mit {i} überein",
+ "select_parser_recipe": "Parser-Rezept auswählen",
+ "title": "Szenendateinamen-Parser",
+ "whitespace_chars": "Zwischenraumzeichen",
+ "whitespace_chars_desc": "Diese Zeichen werden im Titel durch Zwischenraumzeichen ersetzt"
+ },
+ "scene_tools": "Szenen-Tools"
+ },
+ "ui": {
+ "custom_css": {
+ "description": "Die Seite muss neu geladen werden, damit die Änderungen wirksam werden.",
+ "heading": "Benutzerdefinierte CSS",
+ "option_label": "Benutzerdefiniertes CSS aktiviert"
+ },
+ "handy_connection_key": "Handy Verbindungsschlüssel",
+ "handy_connection_key_desc": "Handy Verbindungsschlüssel für interaktive Szenen.",
+ "language": {
+ "heading": "Sprache"
+ },
+ "max_loop_duration": {
+ "description": "Maximale Szenendauer, bei der der Szenenplayer das Video wiederholt - 0 zum Deaktivieren",
+ "heading": "Maximale Schleifendauer"
+ },
+ "menu_items": {
+ "description": "Anzeigen oder Ausblenden verschiedener Inhaltstypen in der Navigationsleiste",
+ "heading": "Menüpunkte"
+ },
+ "preview_type": {
+ "description": "Konfiguration für Szenenwand",
+ "heading": "Vorschautyp",
+ "options": {
+ "animated": "Animiertes Bild",
+ "static": "Statisches Bild",
+ "video": "Video"
+ }
+ },
+ "scene_list": {
+ "heading": "Szenenliste",
+ "options": {
+ "show_studio_as_text": "Studios als Text anzeigen"
+ }
+ },
+ "scene_player": {
+ "heading": "Szenenplayer",
+ "options": {
+ "auto_start_video": "Video automatisch starten"
+ }
+ },
+ "scene_wall": {
+ "heading": "Szenen-/Markierungswand",
+ "options": {
+ "display_title": "Titel und Tags anzeigen",
+ "toggle_sound": "Sound einschalten"
+ }
+ },
+ "slideshow_delay": {
+ "description": "Die Diashow ist in Galerien in der Wandansicht verfügbar",
+ "heading": "Verzögerung der Diashow"
+ },
+ "title": "Benutzeroberfläche"
+ }
+ },
+ "configuration": "Konfiguration",
+ "countables": {
+ "galleries": "{count, plural, one {Galerie} other {Galerien}}",
+ "images": "{count, plural, one {Bild} other {Bilder}}",
+ "markers": "{count, plural, one {Markierung} other {Markierungen}}",
+ "movies": "{count, plural, one {Film} other {Filme}}",
+ "performers": "{count, plural, one {Darsteller} other {Darsteller}}",
+ "scenes": "{count, plural, one {Szene} other {Szenen}}",
+ "studios": "{count, plural, one {Studio} other {Studios}}",
+ "tags": "{count, plural, one {Tag} other {Tags}}"
+ },
+ "country": "Land",
+ "cover_image": "Titelbild",
+ "created_at": "Erstellt am",
+ "criterion_modifier": {
+ "equals": "ist",
+ "excludes": "schließt aus",
+ "format_string": "{criterion} {modifierString} {valueString}",
+ "greater_than": "ist größer als",
+ "includes": "beinhaltet",
+ "includes_all": "beinhaltet alles",
+ "is_null": "ist nichts",
+ "less_than": "ist weniger als",
+ "matches_regex": "stimmt mit Regex überein",
+ "not_equals": "ist nicht",
+ "not_matches_regex": "stimmt nicht mit Regex überein",
+ "not_null": "ist nicht nichts"
+ },
+ "date": "Datum",
+ "death_date": "Todesdatum",
+ "death_year": "Todesjahr",
+ "descending": "Absteigend",
+ "detail": "Detail",
+ "details": "Details",
+ "developmentVersion": "Entwicklungsversion",
+ "dialogs": {
+ "delete_confirm": "Möchten Sie {entityName} wirklich löschen?",
+ "delete_entity_desc": "{count, plural, one {Möchten Sie {singularEntity} wirklich löschen? Sofern die Datei nicht ebenfalls gelöscht wird, wird diese {singularEntity} beim Scannen wieder hinzugefügt.} other {Möchten Sie diese {pluralEntity} wirklich löschen? Sofern die Dateien nicht ebenfalls gelöscht werden, werden diese {pluralEntity} beim Scannen wieder hinzugefügt.}}",
+ "delete_entity_title": "{count, plural, one {Lösche {singularEntity}} other {Lösche {pluralEntity}}}",
+ "delete_object_desc": "Möchten Sie {count, plural, one {diese {singularEntity}} other {diese {pluralEntity}}} wirklich löschen?",
+ "delete_object_overflow": "…und {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.",
+ "delete_object_title": "{count, plural, one {{singularEntity}} other {{pluralEntity}}} löschen",
+ "edit_entity_title": "Bearbeiten von {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
+ "export_include_related_objects": "Zugehörige Objekte in den Export einbeziehen",
+ "export_title": "Export",
+ "merge_tags": {
+ "destination": "Ziel",
+ "source": "Quelle"
+ },
+ "overwrite_filter_confirm": "Möchten Sie die vorhandene gespeicherte Anfrage {entityName} wirklich überschreiben?",
+ "scene_gen": {
+ "image_previews": "Bildvorschauen (animierte WebP-Vorschauen, nur erforderlich, wenn Vorschautyp auf Animiertes Bild eingestellt ist)",
+ "markers": "Markierungen (20-Sekunden-Videos, die mit dem angegebenen Timecode beginnen)",
+ "overwrite": "Vorhandene generierte Dateien überschreiben",
+ "phash": "Perzeptuelle Hashes (zur Deduplizierung)",
+ "preview_exclude_end_time_desc": "Schließen Sie die letzten x Sekunden von der Szenenvorschau aus. Dies kann ein Wert in Sekunden oder ein Prozentsatz (zB 2%) der gesamten Szenendauer sein.",
+ "preview_exclude_end_time_head": "Endzeit ausschließen",
+ "preview_exclude_start_time_desc": "Schließen Sie die ersten x Sekunden von der Szenenvorschau aus. Dies kann ein Wert in Sekunden oder ein Prozentsatz (zB 2%) der gesamten Szenendauer sein.",
+ "preview_exclude_start_time_head": "Startzeit ausschließen",
+ "preview_options": "Vorschauoptionen",
+ "preview_preset_desc": "Die Voreinstellung regelt Größe, Qualität und Encoding-Zeit der Vorschaugenerierung. Einstellungen jenseits von „slow“ haben vernachlässigbare Vorteile und werden nicht empfohlen.",
+ "preview_preset_head": "Vorschau-Kodierungseinstellung",
+ "preview_seg_count_desc": "Anzahl der Segmente in Vorschaudateien.",
+ "preview_seg_count_head": "Anzahl der Segmente in der Vorschau",
+ "preview_seg_duration_desc": "Dauer jedes Vorschausegments in Sekunden.",
+ "preview_seg_duration_head": "Vorschau der Segmentdauer",
+ "sprites": "Sprites (für den Szenen-Scrubber)",
+ "transcodes": "Transcodes (MP4-Konvertierungen von nicht unterstützten Videoformaten)",
+ "video_previews": "Vorschauen (Videovorschauen, die abgespielt werden, wenn Sie mit der Maus über eine Szene fahren)"
+ },
+ "scrape_entity_title": "{entity_type} Scrape-Ergebnisse",
+ "scrape_results_existing": "Vorhanden",
+ "scrape_results_scraped": "Gescraped",
+ "set_image_url_title": "Bild URL",
+ "unsaved_changes": "Nicht gespeicherte Änderungen. Bist du sicher dass du die Seite verlassen willst?"
+ },
+ "dimensions": "Maße",
+ "director": "Direktor",
+ "display_mode": {
+ "grid": "Gitter",
+ "list": "Liste",
+ "tagger": "Tagger",
+ "unknown": "Unbekannt",
+ "wall": "Wand"
+ },
+ "donate": "Spenden",
+ "dupe_check": {
+ "description": "Bei Levels unterhalb von 'Exact' kann die Berechnung länger dauern. Bei niedrigeren Genauigkeitsstufen können auch falsch positive Ergebnisse zurückgegeben werden.",
+ "found_sets": "{setCount, plural, one{# Satz von Duplikaten gefunden.} andere {# Sätze von Duplikaten gefunden.}}",
+ "options": {
+ "exact": "Genau",
+ "high": "Hoch",
+ "low": "Niedrig",
+ "medium": "Mittel"
+ },
+ "search_accuracy_label": "Suchgenauigkeit",
+ "title": "Szenen-Duplikate"
+ },
+ "duration": "Dauer",
+ "effect_filters": {
+ "aspect": "Aspekt",
+ "blue": "Blau",
+ "blur": "Blur",
+ "brightness": "Helligkeit",
+ "contrast": "Kontrast",
+ "gamma": "Gamma",
+ "green": "Grün",
+ "hue": "Farbton",
+ "name": "Filter",
+ "name_transforms": "Transformierung",
+ "red": "Rot",
+ "reset_filters": "Filter zurücksetzen",
+ "reset_transforms": "Transformationen zurücksetzen",
+ "rotate": "Drehen",
+ "rotate_left_and_scale": "Nach links drehen und skalieren",
+ "rotate_right_and_scale": "Nach rechts drehen und skalieren",
+ "saturation": "Sättigung",
+ "scale": "Skalieren",
+ "warmth": "Wärme"
+ },
+ "ethnicity": "Ethnizität",
+ "eye_color": "Augenfarbe",
+ "fake_tits": "Gemachte Brüste",
+ "favourite": "Favorit",
+ "file_info": "Dateiinformation",
+ "file_mod_time": "Dateiänderungszeit",
+ "filesize": "Dateigröße",
+ "filter": "Filter",
+ "filter_name": "Filtername",
+ "filters": "Filter",
+ "framerate": "Bildrate",
+ "galleries": "Galerien",
+ "gallery": "Galerie",
+ "gallery_count": "Galerienanzahl",
+ "gender": "Geschlecht",
+ "hair_color": "Haarfarbe",
+ "hasMarkers": "Hat Markierungen",
+ "height": "Größe",
+ "help": "Hilfe",
+ "image": "Bild",
+ "image_count": "Bilderanzahl",
+ "images": "Bilder",
+ "images-size": "Bildgröße",
+ "include_child_studios": "Tochterstudios einbeziehen",
+ "instagram": "Instagram",
+ "interactive": "Interaktiv",
+ "isMissing": "Wird vermisst",
+ "library": "Bibliothek",
+ "loading": {
+ "generic": "Wird geladen…"
+ },
+ "marker_count": "Markierungsanzahl",
+ "markers": "Markierungen",
+ "measurements": "Maße",
+ "media_info": {
+ "audio_codec": "Audio-Codec",
+ "checksum": "Prüfsumme",
+ "downloaded_from": "Heruntergeladen von",
+ "hash": "Hash",
+ "performer_card": {
+ "age": "{age} {years_old}",
+ "age_context": "{age} {years_old} in dieser Szene"
+ },
+ "phash": "PHash",
+ "stream": "Stream",
+ "video_codec": "Video-Codec"
+ },
+ "metadata": "Metadaten",
+ "movie": "Film",
+ "movie_scene_number": "Filmszenennummer",
+ "movies": "Filme",
+ "name": "Name",
+ "new": "Neu",
+ "none": "Keiner",
+ "o_counter": "O-Zähler",
+ "operations": "Operationen",
+ "organized": "Organisiert",
+ "pagination": {
+ "first": "Erste",
+ "last": "Letzte",
+ "next": "Nächster",
+ "previous": "Vorheriger"
+ },
+ "parent_studios": "Elternstudios",
+ "path": "Pfad",
+ "performer": "Darsteller",
+ "performer_count": "Darstelleranzahl",
+ "performer_image": "Darsteller-Bild",
+ "performers": "Darsteller",
+ "performerTags": "Darsteller-Tags",
+ "piercings": "Piercings",
+ "queue": "Warteschlange",
+ "random": "Zufällig",
+ "rating": "Bewertung",
+ "resolution": "Auflösung",
+ "scene": "Szene",
+ "scene_count": "Szenenanzahl",
+ "scene_id": "Szenen-ID",
+ "scenes": "Szenen",
+ "scenes-size": "Szenengröße",
+ "scenes_updated_at": "Szene aktualisiert am",
+ "sceneTagger": "Szenen-Tagger",
+ "sceneTags": "Szenen-Tags",
+ "search_filter": {
+ "add_filter": "Filter hinzufügen",
+ "name": "Filter",
+ "saved_filters": "Gespeicherte Filter",
+ "update_filter": "Filter aktualisieren"
+ },
+ "seconds": "Sekunden",
+ "settings": "Einstellungen",
+ "stash_id": "Stash-ID",
+ "status": "Status: {statusText}",
+ "studio": "Studio",
+ "studio_depth": "Ebenen (leer für alle)",
+ "studios": "Studios",
+ "synopsis": "Zusammenfassung",
+ "tag": "Tag",
+ "tag_count": "Tag-Anzahl",
+ "tags": "Tags",
+ "tattoos": "Tätowierungen",
+ "title": "Titel",
+ "toast": {
+ "added_entity": "{entity} hinzugefügt",
+ "added_generation_job_to_queue": "Generierungsaufgabe zur Warteschlange hinzugefügt",
+ "create_entity": "{entity} erstellt",
+ "default_filter_set": "Standardfiltersatz",
+ "delete_entity": "{count, plural, one {{singularEntity}} other {{pluralEntity}}} löschen",
+ "delete_past_tense": "{count, plural, one {{singularEntity}} other {{pluralEntity}}} gelöscht",
+ "generating_screenshot": "Screenshot wird erstellt…",
+ "merged_tags": "Zusammengeführte Tags",
+ "rescanning_entity": "Erneutes Scannen von {count, plural, one {{singularEntity}} other {{pluralEntity}}}…",
+ "started_auto_tagging": "Automatisches Tagging gestartet",
+ "saved_entity": "{entity} gespeichert",
+ "updated_entity": "{entity} aktualisiert"
+ },
+ "total": "Gesamt",
+ "twitter": "Twitter",
+ "up-dir": "Ein Verzeichnis hoch",
+ "updated_at": "Aktualisiert am",
+ "url": "URL",
+ "weight": "Gewicht",
+ "years_old": "Jahre alt"
+}
diff --git a/ui/v2.5/src/locales/index.ts b/ui/v2.5/src/locales/index.ts
index 554844268..57e5a9cff 100644
--- a/ui/v2.5/src/locales/index.ts
+++ b/ui/v2.5/src/locales/index.ts
@@ -1,8 +1,10 @@
+import deDE from "./de-DE.json";
import enGB from "./en-GB.json";
import enUS from "./en-US.json";
import zhTW from "./zh-TW.json";
export default {
+ deDE,
enGB,
enUS,
zhTW,
From 723446842f215645c4a468e5e120803752b19038 Mon Sep 17 00:00:00 2001
From: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
Date: Mon, 2 Aug 2021 10:32:23 +1000
Subject: [PATCH 06/51] Split up Tagger component (#1534)
---
.../src/components/Tagger/PerformerResult.tsx | 4 +
.../components/Tagger/StashSearchResult.tsx | 260 +------
ui/v2.5/src/components/Tagger/Tagger.tsx | 684 ++----------------
ui/v2.5/src/components/Tagger/TaggerList.tsx | 329 +++++++++
ui/v2.5/src/components/Tagger/TaggerScene.tsx | 233 ++++++
ui/v2.5/src/components/Tagger/styles.scss | 2 +-
.../src/components/Tagger/taggerService.ts | 260 +++++++
ui/v2.5/src/components/Tagger/utils.ts | 111 +++
8 files changed, 1026 insertions(+), 857 deletions(-)
create mode 100644 ui/v2.5/src/components/Tagger/TaggerList.tsx
create mode 100644 ui/v2.5/src/components/Tagger/TaggerScene.tsx
create mode 100644 ui/v2.5/src/components/Tagger/taggerService.ts
diff --git a/ui/v2.5/src/components/Tagger/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/PerformerResult.tsx
index d9125f974..cabc1444b 100755
--- a/ui/v2.5/src/components/Tagger/PerformerResult.tsx
+++ b/ui/v2.5/src/components/Tagger/PerformerResult.tsx
@@ -16,6 +16,10 @@ export type PerformerOperation =
| { type: "existing"; data: GQL.PerformerDataFragment }
| { type: "skip" };
+export interface IPerformerOperations {
+ [x: string]: PerformerOperation;
+}
+
interface IPerformerResultProps {
performer: IStashBoxPerformer;
setPerformer: (data: PerformerOperation) => void;
diff --git a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx
index bebb39907..ebfa55ed2 100755
--- a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx
+++ b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx
@@ -2,8 +2,6 @@ import React, { useState, useReducer } from "react";
import cx from "classnames";
import { Button } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
-import { uniq } from "lodash";
-import { blobToBase64 } from "base64-blob";
import * as GQL from "src/core/generated-graphql";
import {
@@ -14,13 +12,7 @@ import {
import PerformerResult, { PerformerOperation } from "./PerformerResult";
import StudioResult, { StudioOperation } from "./StudioResult";
import { IStashBoxScene } from "./utils";
-import {
- useCreateTag,
- useCreatePerformer,
- useCreateStudio,
- useUpdatePerformerStashID,
- useUpdateStudioStashID,
-} from "./queries";
+import { useTagScene } from "./taggerService";
const getDurationStatus = (
scene: IStashBoxScene,
@@ -140,240 +132,36 @@ const StashSearchResult: React.FC
= ({
);
const intl = useIntl();
- const createStudio = useCreateStudio();
- const createPerformer = useCreatePerformer();
- const createTag = useCreateTag();
- const updatePerformerStashID = useUpdatePerformerStashID();
- const updateStudioStashID = useUpdateStudioStashID();
- const [updateScene] = GQL.useSceneUpdateMutation({
- onError: (e) => {
- const message =
- e.message === "invalid JPEG format: short Huffman data"
- ? "Failed to save scene due to corrupted cover image"
- : "Failed to save scene";
- setError({
- message,
- details: e.message,
- });
- },
- });
- const { data: allTags } = GQL.useAllTagsForFilterQuery();
+ const tagScene = useTagScene(
+ {
+ tagOperation,
+ setCoverImage,
+ setTags,
+ },
+ setSaveState,
+ setError
+ );
+
+ async function handleSave() {
+ const updatedScene = await tagScene(
+ stashScene,
+ scene,
+ studio,
+ performers,
+ endpoint
+ );
+
+ if (updatedScene) setScene(updatedScene);
+
+ queueFingerprintSubmission(stashScene.id, endpoint);
+ }
const setPerformer = (
performerData: PerformerOperation,
performerID: string
) => dispatch({ id: performerID, data: performerData });
- const handleSave = async () => {
- setError({});
- let performerIDs = [];
- let studioID = null;
-
- if (!studio) return;
-
- if (studio.type === "create") {
- setSaveState("Creating studio");
- const newStudio = {
- name: studio.data.name,
- stash_ids: [
- {
- endpoint,
- stash_id: scene.studio.stash_id,
- },
- ],
- url: studio.data.url,
- };
- const studioCreateResult = await createStudio(
- newStudio,
- scene.studio.stash_id
- );
-
- if (!studioCreateResult?.data?.studioCreate) {
- setError({
- message: `Failed to save studio "${newStudio.name}"`,
- details: studioCreateResult?.errors?.[0].message,
- });
- return setSaveState("");
- }
- studioID = studioCreateResult.data.studioCreate.id;
- } else if (studio.type === "update") {
- setSaveState("Saving studio stashID");
- const res = await updateStudioStashID(studio.data, [
- ...studio.data.stash_ids,
- { stash_id: scene.studio.stash_id, endpoint },
- ]);
- if (!res?.data?.studioUpdate) {
- setError({
- message: `Failed to save stashID to studio "${studio.data.name}"`,
- details: res?.errors?.[0].message,
- });
- return setSaveState("");
- }
- studioID = res.data.studioUpdate.id;
- } else if (studio.type === "existing") {
- studioID = studio.data.id;
- } else if (studio.type === "skip") {
- studioID = stashScene.studio?.id;
- }
-
- setSaveState("Saving performers");
- performerIDs = await Promise.all(
- Object.keys(performers).map(async (stashID) => {
- const performer = performers[stashID];
- if (performer.type === "skip") return "Skip";
-
- let performerID = performer.data.id;
-
- if (performer.type === "create") {
- const imgurl = performer.data.images[0];
- let imgData = null;
- if (imgurl) {
- const img = await fetch(imgurl, {
- mode: "cors",
- cache: "no-store",
- });
- if (img.status === 200) {
- const blob = await img.blob();
- imgData = await blobToBase64(blob);
- }
- }
-
- const performerInput = {
- name: performer.data.name,
- gender: performer.data.gender,
- country: performer.data.country,
- height: performer.data.height,
- ethnicity: performer.data.ethnicity,
- birthdate: performer.data.birthdate,
- eye_color: performer.data.eye_color,
- fake_tits: performer.data.fake_tits,
- measurements: performer.data.measurements,
- career_length: performer.data.career_length,
- tattoos: performer.data.tattoos,
- piercings: performer.data.piercings,
- twitter: performer.data.twitter,
- instagram: performer.data.instagram,
- image: imgData,
- stash_ids: [
- {
- endpoint,
- stash_id: stashID,
- },
- ],
- details: performer.data.details,
- death_date: performer.data.death_date,
- hair_color: performer.data.hair_color,
- weight: Number(performer.data.weight),
- };
-
- const res = await createPerformer(performerInput, stashID);
- if (!res?.data?.performerCreate) {
- setError({
- message: `Failed to save performer "${performerInput.name}"`,
- details: res?.errors?.[0].message,
- });
- return null;
- }
- performerID = res.data?.performerCreate.id;
- }
-
- if (performer.type === "update") {
- const stashIDs = performer.data.stash_ids;
- await updatePerformerStashID(performer.data.id, [
- ...stashIDs,
- { stash_id: stashID, endpoint },
- ]);
- }
-
- return performerID;
- })
- );
-
- if (!performerIDs.some((id) => !id)) {
- setSaveState("Updating scene");
- const imgurl = scene.images[0];
- let imgData = null;
- if (imgurl && setCoverImage) {
- const img = await fetch(imgurl, {
- mode: "cors",
- cache: "no-store",
- });
- if (img.status === 200) {
- const blob = await img.blob();
- // Sanity check on image size since bad images will fail
- if (blob.size > 10000) imgData = await blobToBase64(blob);
- }
- }
-
- let updatedTags = stashScene?.tags?.map((t) => t.id) ?? [];
- if (setTags) {
- const newTagIDs = tagOperation === "merge" ? updatedTags : [];
- const tags = scene.tags ?? [];
- if (tags.length > 0) {
- const tagDict: Record = (allTags?.allTags ?? [])
- .filter((t) => t.name)
- .reduce(
- (dict, t) => ({ ...dict, [t.name.toLowerCase()]: t.id }),
- {}
- );
- const newTags: string[] = [];
- tags.forEach((tag) => {
- if (tagDict[tag.name.toLowerCase()])
- newTagIDs.push(tagDict[tag.name.toLowerCase()]);
- else newTags.push(tag.name);
- });
-
- const createdTags = await Promise.all(
- newTags.map((tag) => createTag(tag))
- );
- createdTags.forEach((createdTag) => {
- if (createdTag?.data?.tagCreate?.id)
- newTagIDs.push(createdTag.data.tagCreate.id);
- });
- }
- updatedTags = uniq(newTagIDs);
- }
-
- const performer_ids = performerIDs.filter(
- (id) => id !== "Skip"
- ) as string[];
-
- const sceneUpdateResult = await updateScene({
- variables: {
- input: {
- id: stashScene.id ?? "",
- title: scene.title,
- details: scene.details,
- date: scene.date,
- performer_ids:
- performer_ids.length === 0
- ? stashScene.performers.map((p) => p.id)
- : performer_ids,
- studio_id: studioID,
- cover_image: imgData,
- url: scene.url,
- tag_ids: updatedTags,
- stash_ids: [
- ...(stashScene?.stash_ids ?? []),
- {
- endpoint,
- stash_id: scene.stash_id,
- },
- ],
- },
- },
- });
-
- if (sceneUpdateResult?.data?.sceneUpdate)
- setScene(sceneUpdateResult.data.sceneUpdate);
-
- queueFingerprintSubmission(stashScene.id, endpoint);
- }
-
- setSaveState("");
- };
-
const classname = cx("row mx-0 mt-2 search-result", {
"selected-result": isActive,
});
diff --git a/ui/v2.5/src/components/Tagger/Tagger.tsx b/ui/v2.5/src/components/Tagger/Tagger.tsx
index 03ab99a5a..eaeb91656 100755
--- a/ui/v2.5/src/components/Tagger/Tagger.tsx
+++ b/ui/v2.5/src/components/Tagger/Tagger.tsx
@@ -1,621 +1,18 @@
-import React, { useEffect, useRef, useState } from "react";
-import { Button, Card, Form, InputGroup } from "react-bootstrap";
-import { Link } from "react-router-dom";
-import { FormattedMessage, useIntl } from "react-intl";
+import React, { useState } from "react";
+import { Button } from "react-bootstrap";
+import { FormattedMessage } from "react-intl";
import { HashLink } from "react-router-hash-link";
-import { uniqBy } from "lodash";
-import { ScenePreview } from "src/components/Scenes/SceneCard";
import { useLocalForage } from "src/hooks";
import * as GQL from "src/core/generated-graphql";
-import { LoadingIndicator, TruncatedText } from "src/components/Shared";
-import {
- stashBoxSceneQuery,
- stashBoxSceneBatchQuery,
- useConfiguration,
-} from "src/core/StashService";
+import { LoadingIndicator } from "src/components/Shared";
+import { stashBoxSceneQuery, useConfiguration } from "src/core/StashService";
import { Manual } from "src/components/Help/Manual";
import { SceneQueue } from "src/models/sceneQueue";
-import StashSearchResult from "./StashSearchResult";
import Config from "./Config";
-import {
- LOCAL_FORAGE_KEY,
- ITaggerConfig,
- ParseMode,
- initialConfig,
-} from "./constants";
-import {
- parsePath,
- selectScenes,
- IStashBoxScene,
- sortScenesByDuration,
-} from "./utils";
-
-const months = [
- "jan",
- "feb",
- "mar",
- "apr",
- "may",
- "jun",
- "jul",
- "aug",
- "sep",
- "oct",
- "nov",
- "dec",
-];
-
-const ddmmyyRegex = /\.(\d\d)\.(\d\d)\.(\d\d)\./;
-const yyyymmddRegex = /(\d{4})[-.](\d{2})[-.](\d{2})/;
-const mmddyyRegex = /(\d{2})[-.](\d{2})[-.](\d{4})/;
-const ddMMyyRegex = new RegExp(
- `(\\d{1,2}).(${months.join("|")})\\.?.(\\d{4})`,
- "i"
-);
-const MMddyyRegex = new RegExp(
- `(${months.join("|")})\\.?.(\\d{1,2}),?.(\\d{4})`,
- "i"
-);
-const parseDate = (input: string): string => {
- let output = input;
- const ddmmyy = output.match(ddmmyyRegex);
- if (ddmmyy) {
- output = output.replace(
- ddmmyy[0],
- ` 20${ddmmyy[1]}-${ddmmyy[2]}-${ddmmyy[3]} `
- );
- }
- const mmddyy = output.match(mmddyyRegex);
- if (mmddyy) {
- output = output.replace(
- mmddyy[0],
- ` ${mmddyy[1]}-${mmddyy[2]}-${mmddyy[3]} `
- );
- }
- const ddMMyy = output.match(ddMMyyRegex);
- if (ddMMyy) {
- const month = (months.indexOf(ddMMyy[2].toLowerCase()) + 1)
- .toString()
- .padStart(2, "0");
- output = output.replace(
- ddMMyy[0],
- ` ${ddMMyy[3]}-${month}-${ddMMyy[1].padStart(2, "0")} `
- );
- }
- const MMddyy = output.match(MMddyyRegex);
- if (MMddyy) {
- const month = (months.indexOf(MMddyy[1].toLowerCase()) + 1)
- .toString()
- .padStart(2, "0");
- output = output.replace(
- MMddyy[0],
- ` ${MMddyy[3]}-${month}-${MMddyy[2].padStart(2, "0")} `
- );
- }
-
- const yyyymmdd = output.search(yyyymmddRegex);
- if (yyyymmdd !== -1)
- return (
- output.slice(0, yyyymmdd).replace(/-/g, " ") +
- output.slice(yyyymmdd, yyyymmdd + 10).replace(/\./g, "-") +
- output.slice(yyyymmdd + 10).replace(/-/g, " ")
- );
- return output.replace(/-/g, " ");
-};
-
-function prepareQueryString(
- scene: Partial,
- paths: string[],
- filename: string,
- mode: ParseMode,
- blacklist: string[]
-) {
- if ((mode === "auto" && scene.date && scene.studio) || mode === "metadata") {
- let str = [
- scene.date,
- scene.studio?.name ?? "",
- (scene?.performers ?? []).map((p) => p.name).join(" "),
- scene?.title ? scene.title.replace(/[^a-zA-Z0-9 ]+/g, "") : "",
- ]
- .filter((s) => s !== "")
- .join(" ");
- blacklist.forEach((b) => {
- str = str.replace(new RegExp(b, "gi"), " ");
- });
- return str;
- }
- let s = "";
-
- if (mode === "auto" || mode === "filename") {
- s = filename;
- } else if (mode === "path") {
- s = [...paths, filename].join(" ");
- } else {
- s = paths[paths.length - 1];
- }
- blacklist.forEach((b) => {
- s = s.replace(new RegExp(b, "gi"), " ");
- });
- s = parseDate(s);
- return s.replace(/\./g, " ");
-}
-
-interface ITaggerListProps {
- scenes: GQL.SlimSceneDataFragment[];
- queue?: SceneQueue;
- selectedEndpoint: { endpoint: string; index: number };
- config: ITaggerConfig;
- queueFingerprintSubmission: (sceneId: string, endpoint: string) => void;
- clearSubmissionQueue: (endpoint: string) => void;
-}
-
-// Caches fingerprint lookups between page renders
-let fingerprintCache: Record = {};
-
-const TaggerList: React.FC = ({
- scenes,
- queue,
- selectedEndpoint,
- config,
- queueFingerprintSubmission,
- clearSubmissionQueue,
-}) => {
- const intl = useIntl();
- const [fingerprintError, setFingerprintError] = useState("");
- const [loading, setLoading] = useState(false);
- const queryString = useRef>({});
- const inputForm = useRef(null);
-
- const [searchResults, setSearchResults] = useState<
- Record
- >({});
- const [searchErrors, setSearchErrors] = useState<
- Record
- >({});
- const [selectedResult, setSelectedResult] = useState<
- Record
- >();
- const [selectedFingerprintResult, setSelectedFingerprintResult] = useState<
- Record
- >();
- const [taggedScenes, setTaggedScenes] = useState<
- Record>
- >({});
- const [loadingFingerprints, setLoadingFingerprints] = useState(false);
- const [fingerprints, setFingerprints] = useState<
- Record
- >(fingerprintCache);
- const [hideUnmatched, setHideUnmatched] = useState(false);
- const fingerprintQueue =
- config.fingerprintQueue[selectedEndpoint.endpoint] ?? [];
-
- useEffect(() => {
- inputForm?.current?.reset();
- }, [config.mode, config.blacklist]);
-
- function clearSceneSearchResult(sceneID: string) {
- // remove sceneID results from the results object
- const { [sceneID]: _removedResult, ...newSearchResults } = searchResults;
- const { [sceneID]: _removedError, ...newSearchErrors } = searchErrors;
- setSearchResults(newSearchResults);
- setSearchErrors(newSearchErrors);
- }
-
- const doBoxSearch = (sceneID: string, searchVal: string) => {
- clearSceneSearchResult(sceneID);
-
- stashBoxSceneQuery(searchVal, selectedEndpoint.index)
- .then((queryData) => {
- const s = selectScenes(queryData.data?.queryStashBoxScene);
- setSearchResults({
- ...searchResults,
- [sceneID]: s,
- });
- setSearchErrors({
- ...searchErrors,
- [sceneID]: undefined,
- });
- setLoading(false);
- })
- .catch(() => {
- setLoading(false);
- // Destructure to remove existing result
- const { [sceneID]: unassign, ...results } = searchResults;
- setSearchResults(results);
- setSearchErrors({
- ...searchErrors,
- [sceneID]: "Network Error",
- });
- });
-
- setLoading(true);
- };
-
- const [
- submitFingerPrints,
- { loading: submittingFingerprints },
- ] = GQL.useSubmitStashBoxFingerprintsMutation({
- onCompleted: (result) => {
- setFingerprintError("");
- if (result.submitStashBoxFingerprints)
- clearSubmissionQueue(selectedEndpoint.endpoint);
- },
- onError: () => {
- setFingerprintError("Network Error");
- },
- });
-
- const handleFingerprintSubmission = () => {
- submitFingerPrints({
- variables: {
- input: {
- stash_box_index: selectedEndpoint.index,
- scene_ids: fingerprintQueue,
- },
- },
- });
- };
-
- const handleTaggedScene = (scene: Partial) => {
- setTaggedScenes({
- ...taggedScenes,
- [scene.id as string]: scene,
- });
- };
-
- const handleFingerprintSearch = async () => {
- setLoadingFingerprints(true);
- const newFingerprints = { ...fingerprints };
-
- const sceneIDs = scenes
- .filter((s) => s.stash_ids.length === 0)
- .map((s) => s.id);
-
- const results = await stashBoxSceneBatchQuery(
- sceneIDs,
- selectedEndpoint.index
- ).catch(() => {
- setLoadingFingerprints(false);
- setFingerprintError("Network Error");
- });
-
- if (!results) return;
-
- // clear search errors
- setSearchErrors({});
-
- selectScenes(results.data?.queryStashBoxScene).forEach((scene) => {
- scene.fingerprints?.forEach((f) => {
- newFingerprints[f.hash] = newFingerprints[f.hash]
- ? [...newFingerprints[f.hash], scene]
- : [scene];
- });
- });
-
- // Null any ids that are still undefined since it means they weren't found
- sceneIDs.forEach((id) => {
- newFingerprints[id] = newFingerprints[id] ?? null;
- });
-
- setFingerprints(newFingerprints);
- fingerprintCache = newFingerprints;
- setLoadingFingerprints(false);
- setFingerprintError("");
- };
-
- const canFingerprintSearch = () =>
- scenes.some(
- (s) => s.stash_ids.length === 0 && fingerprints[s.id] === undefined
- );
-
- const getFingerprintCount = () => {
- return scenes.filter(
- (s) =>
- s.stash_ids.length === 0 &&
- ((s.checksum && fingerprints[s.checksum]) ||
- (s.oshash && fingerprints[s.oshash]) ||
- (s.phash && fingerprints[s.phash]))
- ).length;
- };
-
- const getFingerprintCountMessage = () => {
- const count = getFingerprintCount();
- return intl.formatMessage(
- { id: "component_tagger.results.fp_found" },
- { fpCount: count }
- );
- };
-
- const toggleHideUnmatchedScenes = () => {
- setHideUnmatched(!hideUnmatched);
- };
-
- function generateSceneLink(scene: GQL.SlimSceneDataFragment, index: number) {
- return queue
- ? queue.makeLink(scene.id, { sceneIndex: index })
- : `/scenes/${scene.id}`;
- }
-
- const renderScenes = () =>
- scenes.map((scene, index) => {
- const sceneLink = generateSceneLink(scene, index);
- const { paths, file, ext } = parsePath(scene.path);
- const originalDir = scene.path.slice(
- 0,
- scene.path.length - file.length - ext.length
- );
- const defaultQueryString = prepareQueryString(
- scene,
- paths,
- file,
- config.mode,
- config.blacklist
- );
-
- // Get all scenes matching one of the fingerprints, and return array of unique scenes
- const fingerprintMatches = uniqBy(
- [
- ...(fingerprints[scene.checksum ?? ""] ?? []),
- ...(fingerprints[scene.oshash ?? ""] ?? []),
- ...(fingerprints[scene.phash ?? ""] ?? []),
- ].flat(),
- (f) => f.stash_id
- );
-
- const isTagged = taggedScenes[scene.id];
- const hasStashIDs = scene.stash_ids.length > 0;
- const width = scene.file.width ? scene.file.width : 0;
- const height = scene.file.height ? scene.file.height : 0;
- const isPortrait = height > width;
-
- let mainContent;
- if (!isTagged && hasStashIDs) {
- mainContent = (
-
-
-
-
-
- );
- } else if (!isTagged && !hasStashIDs) {
- mainContent = (
-
-
-
-
-
-
- ) => {
- queryString.current[scene.id] = e.currentTarget.value;
- }}
- onKeyPress={(e: React.KeyboardEvent) =>
- e.key === "Enter" &&
- doBoxSearch(
- scene.id,
- queryString.current[scene.id] || defaultQueryString
- )
- }
- />
-
-
- doBoxSearch(
- scene.id,
- queryString.current[scene.id] || defaultQueryString
- )
- }
- >
-
-
-
-
- );
- } else if (isTagged) {
- mainContent = (
-
-
-
-
-
-
- {taggedScenes[scene.id].title}
-
-
-
- );
- }
-
- let subContent;
- if (scene.stash_ids.length > 0) {
- const stashLinks = scene.stash_ids.map((stashID) => {
- const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
- const link = base ? (
-
- {stashID.stash_id}
-
- ) : (
- {stashID.stash_id}
- );
-
- return link;
- });
- subContent = <>{stashLinks}>;
- } else if (searchErrors[scene.id]) {
- subContent = (
-
- {searchErrors[scene.id]}
-
- );
- } else if (searchResults[scene.id]?.length === 0) {
- subContent = (
-
-
-
- );
- }
-
- let searchResult;
- if (fingerprintMatches.length > 0 && !isTagged && !hasStashIDs) {
- searchResult = sortScenesByDuration(
- fingerprintMatches,
- scene.file.duration ?? 0
- ).map((match, i) => (
-
- setSelectedFingerprintResult({
- ...selectedFingerprintResult,
- [scene.id]: i,
- })
- }
- setScene={handleTaggedScene}
- scene={match}
- setCoverImage={config.setCoverImage}
- setTags={config.setTags}
- tagOperation={config.tagOperation}
- endpoint={selectedEndpoint.endpoint}
- queueFingerprintSubmission={queueFingerprintSubmission}
- key={match.stash_id}
- />
- ));
- } else if (
- searchResults[scene.id]?.length > 0 &&
- !isTagged &&
- fingerprintMatches.length === 0
- ) {
- searchResult = (
-
- {sortScenesByDuration(
- searchResults[scene.id],
- scene.file.duration ?? undefined
- ).map(
- (sceneResult, i) =>
- sceneResult && (
-
- setSelectedResult({
- ...selectedResult,
- [scene.id]: i,
- })
- }
- setCoverImage={config.setCoverImage}
- tagOperation={config.tagOperation}
- setTags={config.setTags}
- setScene={handleTaggedScene}
- endpoint={selectedEndpoint.endpoint}
- queueFingerprintSubmission={queueFingerprintSubmission}
- />
- )
- )}
-
- );
- }
-
- return hideUnmatched && fingerprintMatches.length === 0 ? null : (
-
-
-
-
- {mainContent}
-
{subContent}
-
-
- {searchResult}
-
- );
- });
-
- return (
-
-
-
{fingerprintError}
-
- {(getFingerprintCount() > 0 || hideUnmatched) && (
-
-
- ),
- }}
- />
-
- )}
-
-
- {fingerprintQueue.length > 0 && (
-
- {submittingFingerprints ? (
-
- ) : (
-
-
-
- )}
-
- )}
-
-
- {canFingerprintSearch() && (
-
- {intl.formatMessage({ id: "component_tagger.verb_match_fp" })}
-
- )}
- {!canFingerprintSearch() && getFingerprintCountMessage()}
- {loadingFingerprints && }
-
-
-
-
- );
-};
+import { LOCAL_FORAGE_KEY, ITaggerConfig, initialConfig } from "./constants";
+import { TaggerList } from "./TaggerList";
interface ITaggerProps {
scenes: GQL.SlimSceneDataFragment[];
@@ -631,6 +28,38 @@ export const Tagger: React.FC = ({ scenes, queue }) => {
const [showConfig, setShowConfig] = useState(false);
const [showManual, setShowManual] = useState(false);
+ const clearSubmissionQueue = (endpoint: string) => {
+ if (!config) return;
+
+ setConfig({
+ ...config,
+ fingerprintQueue: {
+ ...config.fingerprintQueue,
+ [endpoint]: [],
+ },
+ });
+ };
+
+ const [
+ submitFingerprints,
+ { loading: submittingFingerprints },
+ ] = GQL.useSubmitStashBoxFingerprintsMutation();
+
+ const handleFingerprintSubmission = (endpoint: string) => {
+ if (!config) return;
+
+ return submitFingerprints({
+ variables: {
+ input: {
+ stash_box_index: getEndpointIndex(endpoint),
+ scene_ids: config?.fingerprintQueue[endpoint],
+ },
+ },
+ }).then(() => {
+ clearSubmissionQueue(endpoint);
+ });
+ };
+
if (!config) return ;
const savedEndpointIndex =
@@ -645,7 +74,20 @@ export const Tagger: React.FC = ({ scenes, queue }) => {
const selectedEndpoint =
stashConfig.data?.configuration.general.stashBoxes[selectedEndpointIndex];
+ function getEndpointIndex(endpoint: string) {
+ return (
+ stashConfig.data?.configuration.general.stashBoxes.findIndex(
+ (s) => s.endpoint === endpoint
+ ) ?? -1
+ );
+ }
+
+ async function doBoxSearch(searchVal: string) {
+ return (await stashBoxSceneQuery(searchVal, selectedEndpointIndex)).data;
+ }
+
const queueFingerprintSubmission = (sceneId: string, endpoint: string) => {
+ if (!config) return;
setConfig({
...config,
fingerprintQueue: {
@@ -655,14 +97,16 @@ export const Tagger: React.FC = ({ scenes, queue }) => {
});
};
- const clearSubmissionQueue = (endpoint: string) => {
- setConfig({
- ...config,
- fingerprintQueue: {
- ...config.fingerprintQueue,
- [endpoint]: [],
- },
- });
+ const getQueue = (endpoint: string) => {
+ if (!config) return [];
+ return config.fingerprintQueue[endpoint] ?? [];
+ };
+
+ const fingerprintQueue = {
+ queueFingerprintSubmission,
+ getQueue,
+ submitFingerprints: handleFingerprintSubmission,
+ submittingFingerprints,
};
return (
@@ -708,8 +152,8 @@ export const Tagger: React.FC = ({ scenes, queue }) => {
endpoint: selectedEndpoint.endpoint,
index: selectedEndpointIndex,
}}
- queueFingerprintSubmission={queueFingerprintSubmission}
- clearSubmissionQueue={clearSubmissionQueue}
+ queryScene={doBoxSearch}
+ fingerprintQueue={fingerprintQueue}
/>
>
) : (
diff --git a/ui/v2.5/src/components/Tagger/TaggerList.tsx b/ui/v2.5/src/components/Tagger/TaggerList.tsx
new file mode 100644
index 000000000..0a885df5c
--- /dev/null
+++ b/ui/v2.5/src/components/Tagger/TaggerList.tsx
@@ -0,0 +1,329 @@
+import React, { useEffect, useRef, useState } from "react";
+import { Button, Card } from "react-bootstrap";
+import { FormattedMessage, useIntl } from "react-intl";
+
+import * as GQL from "src/core/generated-graphql";
+import { LoadingIndicator } from "src/components/Shared";
+import { stashBoxSceneBatchQuery } from "src/core/StashService";
+
+import { SceneQueue } from "src/models/sceneQueue";
+import { uniqBy } from "lodash";
+import { ITaggerConfig } from "./constants";
+import { selectScenes, IStashBoxScene } from "./utils";
+import { TaggerScene } from "./TaggerScene";
+
+interface IFingerprintQueue {
+ getQueue: (endpoint: string) => string[];
+ queueFingerprintSubmission: (sceneId: string, endpoint: string) => void;
+ submitFingerprints: (endpoint: string) => Promise | undefined;
+ submittingFingerprints: boolean;
+}
+
+interface ITaggerListProps {
+ scenes: GQL.SlimSceneDataFragment[];
+ queue?: SceneQueue;
+ selectedEndpoint: { endpoint: string; index: number };
+ config: ITaggerConfig;
+ queryScene: (searchVal: string) => Promise;
+ fingerprintQueue: IFingerprintQueue;
+}
+
+// Caches fingerprint lookups between page renders
+let fingerprintCache: Record = {};
+
+function fingerprintSearchResults(
+ scenes: GQL.SlimSceneDataFragment[],
+ fingerprints: Record
+) {
+ const ret: Record = {};
+
+ if (Object.keys(fingerprints).length === 0) {
+ return ret;
+ }
+
+ // perform matching here
+ scenes.forEach((scene) => {
+ // ignore where scene entry is not in results
+ if (
+ (scene.checksum && fingerprints[scene.checksum] !== undefined) ||
+ (scene.oshash && fingerprints[scene.oshash] !== undefined) ||
+ (scene.phash && fingerprints[scene.phash] !== undefined)
+ ) {
+ const fingerprintMatches = uniqBy(
+ [
+ ...(fingerprints[scene.checksum ?? ""] ?? []),
+ ...(fingerprints[scene.oshash ?? ""] ?? []),
+ ...(fingerprints[scene.phash ?? ""] ?? []),
+ ].flat(),
+ (f) => f.stash_id
+ );
+
+ ret[scene.id] = fingerprintMatches;
+ } else {
+ delete ret[scene.id];
+ }
+ });
+
+ return ret;
+}
+
+export const TaggerList: React.FC = ({
+ scenes,
+ queue,
+ selectedEndpoint,
+ config,
+ queryScene,
+ fingerprintQueue,
+}) => {
+ const intl = useIntl();
+ const [fingerprintError, setFingerprintError] = useState("");
+ const [loading, setLoading] = useState(false);
+ const inputForm = useRef(null);
+
+ const [searchErrors, setSearchErrors] = useState<
+ Record
+ >({});
+ const [taggedScenes, setTaggedScenes] = useState<
+ Record>
+ >({});
+ const [loadingFingerprints, setLoadingFingerprints] = useState(false);
+ const [fingerprints, setFingerprints] = useState<
+ Record
+ >(fingerprintCache);
+ const [searchResults, setSearchResults] = useState<
+ Record
+ >(fingerprintSearchResults(scenes, fingerprints));
+ const [hideUnmatched, setHideUnmatched] = useState(false);
+ const queuedFingerprints = fingerprintQueue.getQueue(
+ selectedEndpoint.endpoint
+ );
+
+ useEffect(() => {
+ inputForm?.current?.reset();
+ }, [config.mode, config.blacklist]);
+
+ function clearSceneSearchResult(sceneID: string) {
+ // remove sceneID results from the results object
+ const { [sceneID]: _removedResult, ...newSearchResults } = searchResults;
+ const { [sceneID]: _removedError, ...newSearchErrors } = searchErrors;
+ setSearchResults(newSearchResults);
+ setSearchErrors(newSearchErrors);
+ }
+
+ const doSceneQuery = (sceneID: string, searchVal: string) => {
+ clearSceneSearchResult(sceneID);
+
+ queryScene(searchVal)
+ .then((queryData) => {
+ const s = selectScenes(queryData.queryStashBoxScene);
+ setSearchResults({
+ ...searchResults,
+ [sceneID]: s,
+ });
+ setSearchErrors({
+ ...searchErrors,
+ [sceneID]: undefined,
+ });
+ setLoading(false);
+ })
+ .catch(() => {
+ setLoading(false);
+ // Destructure to remove existing result
+ const { [sceneID]: unassign, ...results } = searchResults;
+ setSearchResults(results);
+ setSearchErrors({
+ ...searchErrors,
+ [sceneID]: "Network Error",
+ });
+ });
+
+ setLoading(true);
+ };
+
+ const handleFingerprintSubmission = () => {
+ fingerprintQueue.submitFingerprints(selectedEndpoint.endpoint);
+ };
+
+ const handleTaggedScene = (scene: Partial) => {
+ setTaggedScenes({
+ ...taggedScenes,
+ [scene.id as string]: scene,
+ });
+ };
+
+ const handleFingerprintSearch = async () => {
+ setLoadingFingerprints(true);
+
+ setSearchErrors({});
+ setSearchResults({});
+
+ const newFingerprints = { ...fingerprints };
+
+ const filteredScenes = scenes.filter((s) => s.stash_ids.length === 0);
+ const sceneIDs = filteredScenes.map((s) => s.id);
+
+ const results = await stashBoxSceneBatchQuery(
+ sceneIDs,
+ selectedEndpoint.index
+ ).catch(() => {
+ setLoadingFingerprints(false);
+ setFingerprintError("Network Error");
+ });
+
+ if (!results) return;
+
+ // clear search errors
+ setSearchErrors({});
+
+ selectScenes(results.data?.queryStashBoxScene).forEach((scene) => {
+ scene.fingerprints?.forEach((f) => {
+ newFingerprints[f.hash] = newFingerprints[f.hash]
+ ? [...newFingerprints[f.hash], scene]
+ : [scene];
+ });
+ });
+
+ // Null any ids that are still undefined since it means they weren't found
+ filteredScenes.forEach((scene) => {
+ if (scene.oshash) {
+ newFingerprints[scene.oshash] = newFingerprints[scene.oshash] ?? null;
+ }
+ if (scene.checksum) {
+ newFingerprints[scene.checksum] =
+ newFingerprints[scene.checksum] ?? null;
+ }
+ if (scene.phash) {
+ newFingerprints[scene.phash] = newFingerprints[scene.phash] ?? null;
+ }
+ });
+
+ const newSearchResults = fingerprintSearchResults(scenes, newFingerprints);
+ setSearchResults(newSearchResults);
+
+ setFingerprints(newFingerprints);
+ fingerprintCache = newFingerprints;
+ setLoadingFingerprints(false);
+ setFingerprintError("");
+ };
+
+ const canFingerprintSearch = () =>
+ scenes.some(
+ (s) =>
+ s.stash_ids.length === 0 &&
+ (!s.oshash || fingerprints[s.oshash] === undefined) &&
+ (!s.checksum || fingerprints[s.checksum] === undefined) &&
+ (!s.phash || fingerprints[s.phash] === undefined)
+ );
+
+ const getFingerprintCount = () => {
+ return scenes.filter(
+ (s) =>
+ s.stash_ids.length === 0 &&
+ ((s.checksum && fingerprints[s.checksum]) ||
+ (s.oshash && fingerprints[s.oshash]) ||
+ (s.phash && fingerprints[s.phash]))
+ ).length;
+ };
+
+ const getFingerprintCountMessage = () => {
+ const count = getFingerprintCount();
+ return intl.formatMessage(
+ { id: "component_tagger.results.fp_found" },
+ { fpCount: count }
+ );
+ };
+
+ const toggleHideUnmatchedScenes = () => {
+ setHideUnmatched(!hideUnmatched);
+ };
+
+ function generateSceneLink(scene: GQL.SlimSceneDataFragment, index: number) {
+ return queue
+ ? queue.makeLink(scene.id, { sceneIndex: index })
+ : `/scenes/${scene.id}`;
+ }
+
+ const renderScenes = () =>
+ scenes.map((scene, index) => {
+ const sceneLink = generateSceneLink(scene, index);
+ const searchResult = {
+ results: searchResults[scene.id],
+ error: searchErrors[scene.id],
+ };
+
+ return (
+ doSceneQuery(scene.id, queryString)}
+ tagScene={handleTaggedScene}
+ searchResult={searchResult}
+ />
+ );
+ });
+
+ return (
+
+
+
{fingerprintError}
+
+ {(getFingerprintCount() > 0 || hideUnmatched) && (
+
+
+ ),
+ }}
+ />
+
+ )}
+
+
+ {queuedFingerprints.length > 0 && (
+
+ {fingerprintQueue.submittingFingerprints ? (
+
+ ) : (
+
+
+
+ )}
+
+ )}
+
+
+ {canFingerprintSearch() && (
+
+ {intl.formatMessage({ id: "component_tagger.verb_match_fp" })}
+
+ )}
+ {!canFingerprintSearch() && getFingerprintCountMessage()}
+ {loadingFingerprints && }
+
+
+
+
+ );
+};
diff --git a/ui/v2.5/src/components/Tagger/TaggerScene.tsx b/ui/v2.5/src/components/Tagger/TaggerScene.tsx
new file mode 100644
index 000000000..f06eb8ae0
--- /dev/null
+++ b/ui/v2.5/src/components/Tagger/TaggerScene.tsx
@@ -0,0 +1,233 @@
+import React, { useRef, useState } from "react";
+import { Button, Form, InputGroup } from "react-bootstrap";
+import { Link } from "react-router-dom";
+import { FormattedMessage } from "react-intl";
+import { ScenePreview } from "src/components/Scenes/SceneCard";
+
+import * as GQL from "src/core/generated-graphql";
+import { TruncatedText } from "src/components/Shared";
+import StashSearchResult from "./StashSearchResult";
+import { ITaggerConfig } from "./constants";
+import {
+ parsePath,
+ IStashBoxScene,
+ sortScenesByDuration,
+ prepareQueryString,
+} from "./utils";
+
+export interface ISearchResult {
+ results?: IStashBoxScene[];
+ error?: string;
+}
+
+export interface ITaggerScene {
+ scene: GQL.SlimSceneDataFragment;
+ url: string;
+ config: ITaggerConfig;
+ searchResult?: ISearchResult;
+ hideUnmatched?: boolean;
+ loading?: boolean;
+ doSceneQuery: (queryString: string) => void;
+ taggedScene?: Partial;
+ tagScene: (scene: Partial) => void;
+ endpoint: string;
+ queueFingerprintSubmission: (sceneId: string, endpoint: string) => void;
+}
+
+export const TaggerScene: React.FC = ({
+ scene,
+ url,
+ config,
+ searchResult,
+ hideUnmatched,
+ loading,
+ doSceneQuery,
+ taggedScene,
+ tagScene,
+ endpoint,
+ queueFingerprintSubmission,
+}) => {
+ const [selectedResult, setSelectedResult] = useState(0);
+
+ const queryString = useRef("");
+
+ const searchResults = searchResult?.results ?? [];
+ const searchError = searchResult?.error;
+ const emptyResults =
+ searchResult && searchResult.results && searchResult.results.length === 0;
+
+ const { paths, file, ext } = parsePath(scene.path);
+ const originalDir = scene.path.slice(
+ 0,
+ scene.path.length - file.length - ext.length
+ );
+ const defaultQueryString = prepareQueryString(
+ scene,
+ paths,
+ file,
+ config.mode,
+ config.blacklist
+ );
+
+ const hasStashIDs = scene.stash_ids.length > 0;
+ const width = scene.file.width ? scene.file.width : 0;
+ const height = scene.file.height ? scene.file.height : 0;
+ const isPortrait = height > width;
+
+ function renderMainContent() {
+ if (!taggedScene && hasStashIDs) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (!taggedScene && !hasStashIDs) {
+ return (
+
+
+
+
+
+
+ ) => {
+ queryString.current = e.currentTarget.value;
+ }}
+ onKeyPress={(e: React.KeyboardEvent) =>
+ e.key === "Enter" &&
+ doSceneQuery(queryString.current || defaultQueryString)
+ }
+ />
+
+
+ doSceneQuery(queryString.current || defaultQueryString)
+ }
+ >
+
+
+
+
+ );
+ }
+
+ if (taggedScene) {
+ return (
+
+
+
+
+
+
+ {taggedScene.title}
+
+
+
+ );
+ }
+ }
+
+ function renderSubContent() {
+ if (scene.stash_ids.length > 0) {
+ const stashLinks = scene.stash_ids.map((stashID) => {
+ const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
+ const link = base ? (
+
+ {stashID.stash_id}
+
+ ) : (
+ {stashID.stash_id}
+ );
+
+ return link;
+ });
+ return <>{stashLinks}>;
+ }
+
+ if (searchError) {
+ return {searchError}
;
+ }
+
+ if (emptyResults) {
+ return (
+
+
+
+ );
+ }
+ }
+
+ function renderSearchResult() {
+ if (searchResults.length > 0 && !taggedScene) {
+ return (
+
+ {sortScenesByDuration(
+ searchResults,
+ scene.file.duration ?? undefined
+ ).map(
+ (sceneResult, i) =>
+ sceneResult && (
+ setSelectedResult(i)}
+ setCoverImage={config.setCoverImage}
+ tagOperation={config.tagOperation}
+ setTags={config.setTags}
+ setScene={tagScene}
+ endpoint={endpoint}
+ queueFingerprintSubmission={queueFingerprintSubmission}
+ />
+ )
+ )}
+
+ );
+ }
+ }
+
+ return hideUnmatched && emptyResults ? null : (
+
+
+
+
+ {renderMainContent()}
+
{renderSubContent()}
+
+
+ {renderSearchResult()}
+
+ );
+};
diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss
index 1b42be2a7..f36d00af3 100644
--- a/ui/v2.5/src/components/Tagger/styles.scss
+++ b/ui/v2.5/src/components/Tagger/styles.scss
@@ -179,9 +179,9 @@
&-item {
align-items: center;
- align-text: left;
display: flex;
overflow: hidden;
+ text-align: left;
}
}
diff --git a/ui/v2.5/src/components/Tagger/taggerService.ts b/ui/v2.5/src/components/Tagger/taggerService.ts
new file mode 100644
index 000000000..59f22dce7
--- /dev/null
+++ b/ui/v2.5/src/components/Tagger/taggerService.ts
@@ -0,0 +1,260 @@
+import * as GQL from "src/core/generated-graphql";
+import { blobToBase64 } from "base64-blob";
+import { uniq } from "lodash";
+import {
+ useCreatePerformer,
+ useCreateStudio,
+ useCreateTag,
+ useUpdatePerformerStashID,
+ useUpdateStudioStashID,
+} from "./queries";
+import { IPerformerOperations } from "./PerformerResult";
+import { StudioOperation } from "./StudioResult";
+import { IStashBoxScene } from "./utils";
+
+export interface ITagSceneOptions {
+ setCoverImage?: boolean;
+ setTags?: boolean;
+ tagOperation: string;
+}
+
+export function useTagScene(
+ options: ITagSceneOptions,
+ setSaveState: (state: string) => void,
+ setError: (err: { message?: string; details?: string }) => void
+) {
+ const createStudio = useCreateStudio();
+ const createPerformer = useCreatePerformer();
+ const createTag = useCreateTag();
+ const updatePerformerStashID = useUpdatePerformerStashID();
+ const updateStudioStashID = useUpdateStudioStashID();
+ const [updateScene] = GQL.useSceneUpdateMutation({
+ onError: (e) => {
+ const message =
+ e.message === "invalid JPEG format: short Huffman data"
+ ? "Failed to save scene due to corrupted cover image"
+ : "Failed to save scene";
+ setError({
+ message,
+ details: e.message,
+ });
+ },
+ });
+
+ const { data: allTags } = GQL.useAllTagsForFilterQuery();
+
+ const handleSave = async (
+ stashScene: GQL.SlimSceneDataFragment,
+ scene: IStashBoxScene,
+ studio: StudioOperation | undefined,
+ performers: IPerformerOperations,
+ endpoint: string
+ ) => {
+ setError({});
+ let performerIDs = [];
+ let studioID = null;
+
+ if (!studio) return;
+
+ if (studio.type === "create") {
+ setSaveState("Creating studio");
+ const newStudio = {
+ name: studio.data.name,
+ stash_ids: [
+ {
+ endpoint,
+ stash_id: scene.studio.stash_id,
+ },
+ ],
+ url: studio.data.url,
+ };
+ const studioCreateResult = await createStudio(
+ newStudio,
+ scene.studio.stash_id
+ );
+
+ if (!studioCreateResult?.data?.studioCreate) {
+ setError({
+ message: `Failed to save studio "${newStudio.name}"`,
+ details: studioCreateResult?.errors?.[0].message,
+ });
+ return setSaveState("");
+ }
+ studioID = studioCreateResult.data.studioCreate.id;
+ } else if (studio.type === "update") {
+ setSaveState("Saving studio stashID");
+ const res = await updateStudioStashID(studio.data, [
+ ...studio.data.stash_ids,
+ { stash_id: scene.studio.stash_id, endpoint },
+ ]);
+ if (!res?.data?.studioUpdate) {
+ setError({
+ message: `Failed to save stashID to studio "${studio.data.name}"`,
+ details: res?.errors?.[0].message,
+ });
+ return setSaveState("");
+ }
+ studioID = res.data.studioUpdate.id;
+ } else if (studio.type === "existing") {
+ studioID = studio.data.id;
+ } else if (studio.type === "skip") {
+ studioID = stashScene.studio?.id;
+ }
+
+ setSaveState("Saving performers");
+ performerIDs = await Promise.all(
+ Object.keys(performers).map(async (stashID) => {
+ const performer = performers[stashID];
+ if (performer.type === "skip") return "Skip";
+
+ let performerID = performer.data.id;
+
+ if (performer.type === "create") {
+ const imgurl = performer.data.images[0];
+ let imgData = null;
+ if (imgurl) {
+ const img = await fetch(imgurl, {
+ mode: "cors",
+ cache: "no-store",
+ });
+ if (img.status === 200) {
+ const blob = await img.blob();
+ imgData = await blobToBase64(blob);
+ }
+ }
+
+ const performerInput = {
+ name: performer.data.name,
+ gender: performer.data.gender,
+ country: performer.data.country,
+ height: performer.data.height,
+ ethnicity: performer.data.ethnicity,
+ birthdate: performer.data.birthdate,
+ eye_color: performer.data.eye_color,
+ fake_tits: performer.data.fake_tits,
+ measurements: performer.data.measurements,
+ career_length: performer.data.career_length,
+ tattoos: performer.data.tattoos,
+ piercings: performer.data.piercings,
+ twitter: performer.data.twitter,
+ instagram: performer.data.instagram,
+ image: imgData,
+ stash_ids: [
+ {
+ endpoint,
+ stash_id: stashID,
+ },
+ ],
+ details: performer.data.details,
+ death_date: performer.data.death_date,
+ hair_color: performer.data.hair_color,
+ weight: Number(performer.data.weight),
+ };
+
+ const res = await createPerformer(performerInput, stashID);
+ if (!res?.data?.performerCreate) {
+ setError({
+ message: `Failed to save performer "${performerInput.name}"`,
+ details: res?.errors?.[0].message,
+ });
+ return null;
+ }
+ performerID = res.data?.performerCreate.id;
+ }
+
+ if (performer.type === "update") {
+ const stashIDs = performer.data.stash_ids;
+ await updatePerformerStashID(performer.data.id, [
+ ...stashIDs,
+ { stash_id: stashID, endpoint },
+ ]);
+ }
+
+ return performerID;
+ })
+ );
+
+ if (!performerIDs.some((id) => !id)) {
+ setSaveState("Updating scene");
+ const imgurl = scene.images[0];
+ let imgData = null;
+ if (imgurl && options.setCoverImage) {
+ const img = await fetch(imgurl, {
+ mode: "cors",
+ cache: "no-store",
+ });
+ if (img.status === 200) {
+ const blob = await img.blob();
+ // Sanity check on image size since bad images will fail
+ if (blob.size > 10000) imgData = await blobToBase64(blob);
+ }
+ }
+
+ let updatedTags = stashScene?.tags?.map((t) => t.id) ?? [];
+ if (options.setTags) {
+ const newTagIDs = options.tagOperation === "merge" ? updatedTags : [];
+ const tags = scene.tags ?? [];
+ if (tags.length > 0) {
+ const tagDict: Record = (allTags?.allTags ?? [])
+ .filter((t) => t.name)
+ .reduce(
+ (dict, t) => ({ ...dict, [t.name.toLowerCase()]: t.id }),
+ {}
+ );
+ const newTags: string[] = [];
+ tags.forEach((tag) => {
+ if (tagDict[tag.name.toLowerCase()])
+ newTagIDs.push(tagDict[tag.name.toLowerCase()]);
+ else newTags.push(tag.name);
+ });
+
+ const createdTags = await Promise.all(
+ newTags.map((tag) => createTag(tag))
+ );
+ createdTags.forEach((createdTag) => {
+ if (createdTag?.data?.tagCreate?.id)
+ newTagIDs.push(createdTag.data.tagCreate.id);
+ });
+ }
+ updatedTags = uniq(newTagIDs);
+ }
+
+ const performer_ids = performerIDs.filter(
+ (id) => id !== "Skip"
+ ) as string[];
+
+ const sceneUpdateResult = await updateScene({
+ variables: {
+ input: {
+ id: stashScene.id ?? "",
+ title: scene.title,
+ details: scene.details,
+ date: scene.date,
+ performer_ids:
+ performer_ids.length === 0
+ ? stashScene.performers.map((p) => p.id)
+ : performer_ids,
+ studio_id: studioID,
+ cover_image: imgData,
+ url: scene.url,
+ tag_ids: updatedTags,
+ stash_ids: [
+ ...(stashScene?.stash_ids ?? []),
+ {
+ endpoint,
+ stash_id: scene.stash_id,
+ },
+ ],
+ },
+ },
+ });
+
+ setSaveState("");
+ return sceneUpdateResult?.data?.sceneUpdate;
+ }
+
+ setSaveState("");
+ };
+
+ return handleSave;
+}
diff --git a/ui/v2.5/src/components/Tagger/utils.ts b/ui/v2.5/src/components/Tagger/utils.ts
index b14d6bb0f..d403e1156 100644
--- a/ui/v2.5/src/components/Tagger/utils.ts
+++ b/ui/v2.5/src/components/Tagger/utils.ts
@@ -1,5 +1,116 @@
import * as GQL from "src/core/generated-graphql";
import { getCountryByISO } from "src/utils/country";
+import { ParseMode } from "./constants";
+
+const months = [
+ "jan",
+ "feb",
+ "mar",
+ "apr",
+ "may",
+ "jun",
+ "jul",
+ "aug",
+ "sep",
+ "oct",
+ "nov",
+ "dec",
+];
+
+const ddmmyyRegex = /\.(\d\d)\.(\d\d)\.(\d\d)\./;
+const yyyymmddRegex = /(\d{4})[-.](\d{2})[-.](\d{2})/;
+const mmddyyRegex = /(\d{2})[-.](\d{2})[-.](\d{4})/;
+const ddMMyyRegex = new RegExp(
+ `(\\d{1,2}).(${months.join("|")})\\.?.(\\d{4})`,
+ "i"
+);
+const MMddyyRegex = new RegExp(
+ `(${months.join("|")})\\.?.(\\d{1,2}),?.(\\d{4})`,
+ "i"
+);
+const parseDate = (input: string): string => {
+ let output = input;
+ const ddmmyy = output.match(ddmmyyRegex);
+ if (ddmmyy) {
+ output = output.replace(
+ ddmmyy[0],
+ ` 20${ddmmyy[1]}-${ddmmyy[2]}-${ddmmyy[3]} `
+ );
+ }
+ const mmddyy = output.match(mmddyyRegex);
+ if (mmddyy) {
+ output = output.replace(
+ mmddyy[0],
+ ` ${mmddyy[1]}-${mmddyy[2]}-${mmddyy[3]} `
+ );
+ }
+ const ddMMyy = output.match(ddMMyyRegex);
+ if (ddMMyy) {
+ const month = (months.indexOf(ddMMyy[2].toLowerCase()) + 1)
+ .toString()
+ .padStart(2, "0");
+ output = output.replace(
+ ddMMyy[0],
+ ` ${ddMMyy[3]}-${month}-${ddMMyy[1].padStart(2, "0")} `
+ );
+ }
+ const MMddyy = output.match(MMddyyRegex);
+ if (MMddyy) {
+ const month = (months.indexOf(MMddyy[1].toLowerCase()) + 1)
+ .toString()
+ .padStart(2, "0");
+ output = output.replace(
+ MMddyy[0],
+ ` ${MMddyy[3]}-${month}-${MMddyy[2].padStart(2, "0")} `
+ );
+ }
+
+ const yyyymmdd = output.search(yyyymmddRegex);
+ if (yyyymmdd !== -1)
+ return (
+ output.slice(0, yyyymmdd).replace(/-/g, " ") +
+ output.slice(yyyymmdd, yyyymmdd + 10).replace(/\./g, "-") +
+ output.slice(yyyymmdd + 10).replace(/-/g, " ")
+ );
+ return output.replace(/-/g, " ");
+};
+
+export function prepareQueryString(
+ scene: Partial,
+ paths: string[],
+ filename: string,
+ mode: ParseMode,
+ blacklist: string[]
+) {
+ if ((mode === "auto" && scene.date && scene.studio) || mode === "metadata") {
+ let str = [
+ scene.date,
+ scene.studio?.name ?? "",
+ (scene?.performers ?? []).map((p) => p.name).join(" "),
+ scene?.title ? scene.title.replace(/[^a-zA-Z0-9 ]+/g, "") : "",
+ ]
+ .filter((s) => s !== "")
+ .join(" ");
+ blacklist.forEach((b) => {
+ str = str.replace(new RegExp(b, "gi"), " ");
+ });
+ return str;
+ }
+ let s = "";
+
+ if (mode === "auto" || mode === "filename") {
+ s = filename;
+ } else if (mode === "path") {
+ s = [...paths, filename].join(" ");
+ } else {
+ s = paths[paths.length - 1];
+ }
+ blacklist.forEach((b) => {
+ s = s.replace(new RegExp(b, "gi"), " ");
+ });
+ s = parseDate(s);
+ return s.replace(/\./g, " ");
+}
const toTitleCase = (phrase: string) => {
return phrase
From ede8cca63163dc242c2a4186afa7b43fbe2e75e6 Mon Sep 17 00:00:00 2001
From: Jekora
Date: Mon, 2 Aug 2021 05:22:39 +0200
Subject: [PATCH 07/51] [Feature] Better resolution search (#1568)
* Fix width in database test setup
* Added more filters on resolution field
* added test to verify resolution range is defined for every resolution
* Refactor UI code
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
---
graphql/schema/types/filters.graphql | 11 ++-
pkg/models/extension_resolution.go | 74 ++++++------------
pkg/sqlite/gallery.go | 23 +++---
pkg/sqlite/gallery_test.go | 5 +-
pkg/sqlite/image_test.go | 5 +-
pkg/sqlite/scene.go | 22 +++---
pkg/sqlite/scene_test.go | 75 ++++++++++++++++++-
pkg/sqlite/setup_test.go | 1 +
.../src/components/Changelog/versions/v090.md | 3 +
.../models/list-filter/criteria/resolution.ts | 62 +++++----------
ui/v2.5/src/utils/resolution.ts | 42 +++++++++++
11 files changed, 200 insertions(+), 123 deletions(-)
create mode 100644 ui/v2.5/src/utils/resolution.ts
diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql
index 18bcc9dff..06187a81f 100644
--- a/graphql/schema/types/filters.graphql
+++ b/graphql/schema/types/filters.graphql
@@ -28,6 +28,11 @@ enum ResolutionEnum {
"8k", EIGHT_K
}
+input ResolutionCriterionInput {
+ value: ResolutionEnum!
+ modifier: CriterionModifier!
+}
+
input PerformerFilterType {
AND: PerformerFilterType
OR: PerformerFilterType
@@ -126,7 +131,7 @@ input SceneFilterType {
"""Filter by o-counter"""
o_counter: IntCriterionInput
"""Filter by resolution"""
- resolution: ResolutionEnum
+ resolution: ResolutionCriterionInput
"""Filter by duration (in seconds)"""
duration: IntCriterionInput
"""Filter to only include scenes which have markers. `true` or `false`"""
@@ -215,7 +220,7 @@ input GalleryFilterType {
"""Filter by organized"""
organized: Boolean
"""Filter by average image resolution"""
- average_resolution: ResolutionEnum
+ average_resolution: ResolutionCriterionInput
"""Filter to only include galleries with this studio"""
studios: HierarchicalMultiCriterionInput
"""Filter to only include galleries with these tags"""
@@ -282,7 +287,7 @@ input ImageFilterType {
"""Filter by o-counter"""
o_counter: IntCriterionInput
"""Filter by resolution"""
- resolution: ResolutionEnum
+ resolution: ResolutionCriterionInput
"""Filter to only include images missing this property"""
is_missing: String
"""Filter to only include images with this studio"""
diff --git a/pkg/models/extension_resolution.go b/pkg/models/extension_resolution.go
index 864fd4421..6890ddac3 100644
--- a/pkg/models/extension_resolution.go
+++ b/pkg/models/extension_resolution.go
@@ -1,65 +1,33 @@
package models
-var resolutionMax = []int{
- 240,
- 360,
- 480,
- 540,
- 720,
- 1080,
- 1440,
- 1920,
- 2160,
- 2880,
- 3384,
- 4320,
- 0,
+type ResolutionRange struct {
+ min, max int
+}
+
+var resolutionRanges = map[ResolutionEnum]ResolutionRange{
+ ResolutionEnum("VERY_LOW"): {144, 239},
+ ResolutionEnum("LOW"): {240, 359},
+ ResolutionEnum("R360P"): {360, 479},
+ ResolutionEnum("STANDARD"): {480, 539},
+ ResolutionEnum("WEB_HD"): {540, 719},
+ ResolutionEnum("STANDARD_HD"): {720, 1079},
+ ResolutionEnum("FULL_HD"): {1080, 1439},
+ ResolutionEnum("QUAD_HD"): {1440, 1919},
+ ResolutionEnum("VR_HD"): {1920, 2159},
+ ResolutionEnum("FOUR_K"): {2160, 2879},
+ ResolutionEnum("FIVE_K"): {2880, 3383},
+ ResolutionEnum("SIX_K"): {3384, 4319},
+ ResolutionEnum("EIGHT_K"): {4320, 8639},
}
// GetMaxResolution returns the maximum width or height that media must be
-// to qualify as this resolution. A return value of 0 means that there is no
-// maximum.
+// to qualify as this resolution.
func (r *ResolutionEnum) GetMaxResolution() int {
- if !r.IsValid() {
- return 0
- }
-
- // sanity check - length of arrays must be the same
- if len(resolutionMax) != len(AllResolutionEnum) {
- panic("resolutionMax array length != AllResolutionEnum array length")
- }
-
- for i, rr := range AllResolutionEnum {
- if rr == *r {
- return resolutionMax[i]
- }
- }
-
- return 0
+ return resolutionRanges[*r].max
}
// GetMinResolution returns the minimum width or height that media must be
// to qualify as this resolution.
func (r *ResolutionEnum) GetMinResolution() int {
- if !r.IsValid() {
- return 0
- }
-
- // sanity check - length of arrays must be the same
- if len(resolutionMax) != len(AllResolutionEnum) {
- panic("resolutionMax array length != AllResolutionEnum array length")
- }
-
- // use the previous resolution max as this resolution min
- for i, rr := range AllResolutionEnum {
- if rr == *r {
- if i > 0 {
- return resolutionMax[i-1]
- }
-
- return 0
- }
- }
-
- return 0
+ return resolutionRanges[*r].min
}
diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go
index de46f1d96..66bf2032a 100644
--- a/pkg/sqlite/gallery.go
+++ b/pkg/sqlite/gallery.go
@@ -3,7 +3,6 @@ package sqlite
import (
"database/sql"
"fmt"
- "strconv"
"github.com/stashapp/stash/pkg/models"
)
@@ -426,23 +425,25 @@ func galleryPerformerTagsCriterionHandler(qb *galleryQueryBuilder, performerTags
}
}
-func galleryAverageResolutionCriterionHandler(qb *galleryQueryBuilder, resolution *models.ResolutionEnum) criterionHandlerFunc {
+func galleryAverageResolutionCriterionHandler(qb *galleryQueryBuilder, resolution *models.ResolutionCriterionInput) criterionHandlerFunc {
return func(f *filterBuilder) {
- if resolution != nil && resolution.IsValid() {
+ if resolution != nil && resolution.Value.IsValid() {
qb.imagesRepository().join(f, "images_join", "galleries.id")
f.addJoin("images", "", "images_join.image_id = images.id")
- min := resolution.GetMinResolution()
- max := resolution.GetMaxResolution()
+ min := resolution.Value.GetMinResolution()
+ max := resolution.Value.GetMaxResolution()
const widthHeight = "avg(MIN(images.width, images.height))"
- if min > 0 {
- f.addHaving(widthHeight + " >= " + strconv.Itoa(min))
- }
-
- if max > 0 {
- f.addHaving(widthHeight + " < " + strconv.Itoa(max))
+ if resolution.Modifier == models.CriterionModifierEquals {
+ f.addHaving(fmt.Sprintf("%s BETWEEN %d AND %d", widthHeight, min, max))
+ } else if resolution.Modifier == models.CriterionModifierNotEquals {
+ f.addHaving(fmt.Sprintf("%s NOT BETWEEN %d AND %d", widthHeight, min, max))
+ } else if resolution.Modifier == models.CriterionModifierLessThan {
+ f.addHaving(fmt.Sprintf("%s < %d", widthHeight, min))
+ } else if resolution.Modifier == models.CriterionModifierGreaterThan {
+ f.addHaving(fmt.Sprintf("%s > %d", widthHeight, max))
}
}
}
diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go
index 9e409029d..5ba8db446 100644
--- a/pkg/sqlite/gallery_test.go
+++ b/pkg/sqlite/gallery_test.go
@@ -914,7 +914,10 @@ func TestGalleryQueryAverageResolution(t *testing.T) {
qb := r.Gallery()
resolution := models.ResolutionEnumLow
galleryFilter := models.GalleryFilterType{
- AverageResolution: &resolution,
+ AverageResolution: &models.ResolutionCriterionInput{
+ Value: resolution,
+ Modifier: models.CriterionModifierEquals,
+ },
}
// not verifying average - just ensure we get at least one
diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go
index cd225fc82..50006685b 100644
--- a/pkg/sqlite/image_test.go
+++ b/pkg/sqlite/image_test.go
@@ -389,7 +389,10 @@ func verifyImagesResolution(t *testing.T, resolution models.ResolutionEnum) {
withTxn(func(r models.Repository) error {
sqb := r.Image()
imageFilter := models.ImageFilterType{
- Resolution: &resolution,
+ Resolution: &models.ResolutionCriterionInput{
+ Value: resolution,
+ Modifier: models.CriterionModifierEquals,
+ },
}
images, _, err := sqb.Query(&imageFilter, nil)
diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go
index 4aec58ab9..2c34936a0 100644
--- a/pkg/sqlite/scene.go
+++ b/pkg/sqlite/scene.go
@@ -495,20 +495,22 @@ func getDurationWhereClause(durationFilter models.IntCriterionInput, column stri
return clause, args
}
-func resolutionCriterionHandler(resolution *models.ResolutionEnum, heightColumn string, widthColumn string) criterionHandlerFunc {
+func resolutionCriterionHandler(resolution *models.ResolutionCriterionInput, heightColumn string, widthColumn string) criterionHandlerFunc {
return func(f *filterBuilder) {
- if resolution != nil && resolution.IsValid() {
- min := resolution.GetMinResolution()
- max := resolution.GetMaxResolution()
+ if resolution != nil && resolution.Value.IsValid() {
+ min := resolution.Value.GetMinResolution()
+ max := resolution.Value.GetMaxResolution()
widthHeight := fmt.Sprintf("MIN(%s, %s)", widthColumn, heightColumn)
- if min > 0 {
- f.addWhere(widthHeight + " >= " + strconv.Itoa(min))
- }
-
- if max > 0 {
- f.addWhere(widthHeight + " < " + strconv.Itoa(max))
+ if resolution.Modifier == models.CriterionModifierEquals {
+ f.addWhere(fmt.Sprintf("%s BETWEEN %d AND %d", widthHeight, min, max))
+ } else if resolution.Modifier == models.CriterionModifierNotEquals {
+ f.addWhere(fmt.Sprintf("%s NOT BETWEEN %d AND %d", widthHeight, min, max))
+ } else if resolution.Modifier == models.CriterionModifierLessThan {
+ f.addWhere(fmt.Sprintf("%s < %d", widthHeight, min))
+ } else if resolution.Modifier == models.CriterionModifierGreaterThan {
+ f.addWhere(fmt.Sprintf("%s > %d", widthHeight, max))
}
}
}
diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go
index fa43e53f9..ec95447da 100644
--- a/pkg/sqlite/scene_test.go
+++ b/pkg/sqlite/scene_test.go
@@ -648,7 +648,10 @@ func verifyScenesResolution(t *testing.T, resolution models.ResolutionEnum) {
withTxn(func(r models.Repository) error {
sqb := r.Scene()
sceneFilter := models.SceneFilterType{
- Resolution: &resolution,
+ Resolution: &models.ResolutionCriterionInput{
+ Value: resolution,
+ Modifier: models.CriterionModifierEquals,
+ },
}
scenes := queryScene(t, sqb, &sceneFilter, nil)
@@ -679,6 +682,76 @@ func verifySceneResolution(t *testing.T, height sql.NullInt64, resolution models
}
}
+func TestAllResolutionsHaveResolutionRange(t *testing.T) {
+ for _, resolution := range models.AllResolutionEnum {
+ assert.NotZero(t, resolution.GetMinResolution(), "Define resolution range for %s in extension_resolution.go", resolution)
+ assert.NotZero(t, resolution.GetMaxResolution(), "Define resolution range for %s in extension_resolution.go", resolution)
+ }
+}
+
+func TestSceneQueryResolutionModifiers(t *testing.T) {
+ if err := withRollbackTxn(func(r models.Repository) error {
+ qb := r.Scene()
+ sceneNoResolution, _ := createScene(qb, 0, 0)
+ firstScene540P, _ := createScene(qb, 960, 540)
+ secondScene540P, _ := createScene(qb, 1280, 719)
+ firstScene720P, _ := createScene(qb, 1280, 720)
+ secondScene720P, _ := createScene(qb, 1280, 721)
+ thirdScene720P, _ := createScene(qb, 1920, 1079)
+ scene1080P, _ := createScene(qb, 1920, 1080)
+
+ scenesEqualTo720P := queryScenes(t, qb, models.ResolutionEnumStandardHd, models.CriterionModifierEquals)
+ scenesNotEqualTo720P := queryScenes(t, qb, models.ResolutionEnumStandardHd, models.CriterionModifierNotEquals)
+ scenesGreaterThan720P := queryScenes(t, qb, models.ResolutionEnumStandardHd, models.CriterionModifierGreaterThan)
+ scenesLessThan720P := queryScenes(t, qb, models.ResolutionEnumStandardHd, models.CriterionModifierLessThan)
+
+ assert.Subset(t, scenesEqualTo720P, []*models.Scene{firstScene720P, secondScene720P, thirdScene720P})
+ assert.NotSubset(t, scenesEqualTo720P, []*models.Scene{sceneNoResolution, firstScene540P, secondScene540P, scene1080P})
+
+ assert.Subset(t, scenesNotEqualTo720P, []*models.Scene{sceneNoResolution, firstScene540P, secondScene540P, scene1080P})
+ assert.NotSubset(t, scenesNotEqualTo720P, []*models.Scene{firstScene720P, secondScene720P, thirdScene720P})
+
+ assert.Subset(t, scenesGreaterThan720P, []*models.Scene{scene1080P})
+ assert.NotSubset(t, scenesGreaterThan720P, []*models.Scene{sceneNoResolution, firstScene540P, secondScene540P, firstScene720P, secondScene720P, thirdScene720P})
+
+ assert.Subset(t, scenesLessThan720P, []*models.Scene{sceneNoResolution, firstScene540P, secondScene540P})
+ assert.NotSubset(t, scenesLessThan720P, []*models.Scene{scene1080P, firstScene720P, secondScene720P, thirdScene720P})
+
+ return nil
+ }); err != nil {
+ t.Error(err.Error())
+ }
+}
+
+func queryScenes(t *testing.T, queryBuilder models.SceneReaderWriter, resolution models.ResolutionEnum, modifier models.CriterionModifier) []*models.Scene {
+ sceneFilter := models.SceneFilterType{
+ Resolution: &models.ResolutionCriterionInput{
+ Value: resolution,
+ Modifier: modifier,
+ },
+ }
+
+ return queryScene(t, queryBuilder, &sceneFilter, nil)
+}
+
+func createScene(queryBuilder models.SceneReaderWriter, width int64, height int64) (*models.Scene, error) {
+ name := fmt.Sprintf("TestSceneQueryResolutionModifiers %d %d", width, height)
+ scene := models.Scene{
+ Path: name,
+ Width: sql.NullInt64{
+ Int64: width,
+ Valid: true,
+ },
+ Height: sql.NullInt64{
+ Int64: height,
+ Valid: true,
+ },
+ Checksum: sql.NullString{String: utils.MD5FromString(name), Valid: true},
+ }
+
+ return queryBuilder.Create(scene)
+}
+
func TestSceneQueryHasMarkers(t *testing.T) {
withTxn(func(r models.Repository) error {
sqb := r.Scene()
diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go
index 1cd30fd5f..d25e3c4e4 100644
--- a/pkg/sqlite/setup_test.go
+++ b/pkg/sqlite/setup_test.go
@@ -605,6 +605,7 @@ func createScenes(sqb models.SceneReaderWriter, n int) error {
OCounter: getOCounter(i),
Duration: getSceneDuration(i),
Height: getHeight(i),
+ Width: getWidth(i),
Date: getSceneDate(i),
}
diff --git a/ui/v2.5/src/components/Changelog/versions/v090.md b/ui/v2.5/src/components/Changelog/versions/v090.md
index 9c9a1c74d..7c87b7a62 100644
--- a/ui/v2.5/src/components/Changelog/versions/v090.md
+++ b/ui/v2.5/src/components/Changelog/versions/v090.md
@@ -1,3 +1,6 @@
+### ✨ New Features
+* Added not equals/greater than/less than modifiers for resolution criteria. ([#1568](https://github.com/stashapp/stash/pull/1568))
+
### 🎨 Improvements
* Added de-DE language option. ([#1578](https://github.com/stashapp/stash/pull/1578))
diff --git a/ui/v2.5/src/models/list-filter/criteria/resolution.ts b/ui/v2.5/src/models/list-filter/criteria/resolution.ts
index a5fb54de0..a669a6f86 100644
--- a/ui/v2.5/src/models/list-filter/criteria/resolution.ts
+++ b/ui/v2.5/src/models/list-filter/criteria/resolution.ts
@@ -1,37 +1,20 @@
-import { ResolutionEnum } from "src/core/generated-graphql";
+import {
+ ResolutionCriterionInput,
+ CriterionModifier,
+} from "src/core/generated-graphql";
+import { stringToResolution, resolutionStrings } from "src/utils/resolution";
import { CriterionType } from "../types";
import { CriterionOption, StringCriterion } from "./criterion";
abstract class AbstractResolutionCriterion extends StringCriterion {
- protected toCriterionInput(): ResolutionEnum | undefined {
- switch (this.value) {
- case "144p":
- return ResolutionEnum.VeryLow;
- case "240p":
- return ResolutionEnum.Low;
- case "360p":
- return ResolutionEnum.R360P;
- case "480p":
- return ResolutionEnum.Standard;
- case "540p":
- return ResolutionEnum.WebHd;
- case "720p":
- return ResolutionEnum.StandardHd;
- case "1080p":
- return ResolutionEnum.FullHd;
- case "1440p":
- return ResolutionEnum.QuadHd;
- case "1920p":
- return ResolutionEnum.VrHd;
- case "4k":
- return ResolutionEnum.FourK;
- case "5k":
- return ResolutionEnum.FiveK;
- case "6k":
- return ResolutionEnum.SixK;
- case "8k":
- return ResolutionEnum.EightK;
- // no default
+ protected toCriterionInput(): ResolutionCriterionInput | undefined {
+ const value = stringToResolution(this.value);
+
+ if (value !== undefined) {
+ return {
+ value,
+ modifier: this.modifier,
+ };
}
}
}
@@ -42,20 +25,13 @@ class ResolutionCriterionOptionType extends CriterionOption {
messageID: value,
type: value,
parameterName: value,
- options: [
- "144p",
- "240p",
- "360p",
- "480p",
- "540p",
- "720p",
- "1080p",
- "1440p",
- "4k",
- "5k",
- "6k",
- "8k",
+ modifierOptions: [
+ CriterionModifier.Equals,
+ CriterionModifier.NotEquals,
+ CriterionModifier.GreaterThan,
+ CriterionModifier.LessThan,
],
+ options: resolutionStrings,
});
}
}
diff --git a/ui/v2.5/src/utils/resolution.ts b/ui/v2.5/src/utils/resolution.ts
new file mode 100644
index 000000000..8f8327206
--- /dev/null
+++ b/ui/v2.5/src/utils/resolution.ts
@@ -0,0 +1,42 @@
+import { ResolutionEnum } from "src/core/generated-graphql";
+
+const stringResolutionMap = new Map([
+ ["144p", ResolutionEnum.VeryLow],
+ ["240p", ResolutionEnum.Low],
+ ["360p", ResolutionEnum.R360P],
+ ["480p", ResolutionEnum.Standard],
+ ["540p", ResolutionEnum.WebHd],
+ ["720p", ResolutionEnum.StandardHd],
+ ["1080p", ResolutionEnum.FullHd],
+ ["1440p", ResolutionEnum.QuadHd],
+ ["1920p", ResolutionEnum.VrHd],
+ ["4k", ResolutionEnum.FourK],
+ ["5k", ResolutionEnum.FiveK],
+ ["6k", ResolutionEnum.SixK],
+ ["8k", ResolutionEnum.EightK],
+]);
+
+export const stringToResolution = (
+ value?: string | null,
+ caseInsensitive?: boolean
+) => {
+ if (!value) {
+ return undefined;
+ }
+
+ const ret = stringResolutionMap.get(value);
+ if (ret || !caseInsensitive) {
+ return ret;
+ }
+
+ const asUpper = value.toUpperCase();
+ const foundEntry = Array.from(stringResolutionMap.entries()).find((e) => {
+ return e[0].toUpperCase() === asUpper;
+ });
+
+ if (foundEntry) {
+ return foundEntry[1];
+ }
+};
+
+export const resolutionStrings = Array.from(stringResolutionMap.keys());
From 8f3036b3517377435cb6f85921b76958460ecd6b Mon Sep 17 00:00:00 2001
From: Felipe Fernandes Leandro
Date: Mon, 2 Aug 2021 21:58:46 -0300
Subject: [PATCH 08/51] Portuguese translation (#1587)
Co-authored-by: kermieisinthehouse
---
.../src/components/Changelog/versions/v090.md | 1 +
.../SettingsInterfacePanel.tsx | 1 +
ui/v2.5/src/locales/index.ts | 2 +
ui/v2.5/src/locales/pt-BR.json | 601 ++++++++++++++++++
4 files changed, 605 insertions(+)
create mode 100644 ui/v2.5/src/locales/pt-BR.json
diff --git a/ui/v2.5/src/components/Changelog/versions/v090.md b/ui/v2.5/src/components/Changelog/versions/v090.md
index 7c87b7a62..cbb8b0f3f 100644
--- a/ui/v2.5/src/components/Changelog/versions/v090.md
+++ b/ui/v2.5/src/components/Changelog/versions/v090.md
@@ -2,6 +2,7 @@
* Added not equals/greater than/less than modifiers for resolution criteria. ([#1568](https://github.com/stashapp/stash/pull/1568))
### 🎨 Improvements
+* Added pt-BR language option. ([#1587](https://github.com/stashapp/stash/pull/1587))
* Added de-DE language option. ([#1578](https://github.com/stashapp/stash/pull/1578))
### 🐛 Bug fixes
diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx
index b09af3342..ec7f5f313 100644
--- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx
+++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx
@@ -120,6 +120,7 @@ export const SettingsInterfacePanel: React.FC = () => {
English (United States)
English (United Kingdom)
German (Germany)
+ Portuguese (Brazil)
繁體中文 (台灣)
diff --git a/ui/v2.5/src/locales/index.ts b/ui/v2.5/src/locales/index.ts
index 57e5a9cff..339f88c05 100644
--- a/ui/v2.5/src/locales/index.ts
+++ b/ui/v2.5/src/locales/index.ts
@@ -1,11 +1,13 @@
import deDE from "./de-DE.json";
import enGB from "./en-GB.json";
import enUS from "./en-US.json";
+import ptBR from "./pt-BR.json";
import zhTW from "./zh-TW.json";
export default {
deDE,
enGB,
enUS,
+ ptBR,
zhTW,
};
diff --git a/ui/v2.5/src/locales/pt-BR.json b/ui/v2.5/src/locales/pt-BR.json
new file mode 100644
index 000000000..d7d4e620d
--- /dev/null
+++ b/ui/v2.5/src/locales/pt-BR.json
@@ -0,0 +1,601 @@
+{
+ "actions": {
+ "add": "Adicionar",
+ "add_directory": "Adicionar diretório",
+ "add_entity": "Adicionar {entityType}",
+ "add_to_entity": "Adicionar em {entityType}",
+ "allow": "Permitir",
+ "allow_temporarily": "Permitir temporariamente",
+ "apply": "Aplicar",
+ "auto_tag": "Auto tag",
+ "backup": "Backup",
+ "cancel": "Cancelar",
+ "clean": "Limpar",
+ "clear_back_image": "Limpar imagem de fundo",
+ "clear_front_image": "Limpar imagem frontal",
+ "clear_image": "Limpar imagem",
+ "close": "Fechar",
+ "create": "Criar",
+ "create_entity": "Criar {entityType}",
+ "create_marker": "Criar marcador",
+ "created_entity": "Criar {entity_type}: {entity_name}",
+ "delete": "Apagar",
+ "delete_entity": "Apagar {entityType}",
+ "delete_file": "Apagar arquivo",
+ "delete_generated_supporting_files": "Apagar arquivos gerados de suporte",
+ "disallow": "Não permitir",
+ "download": "Download",
+ "download_backup": "Download backup",
+ "edit": "Editar",
+ "export": "Exportar…",
+ "export_all": "Exportar tudo…",
+ "find": "Encontrar",
+ "from_file": "Do arquivo…",
+ "from_url": "Da URL…",
+ "full_export": "Exportação completa",
+ "full_import": "Importação completa",
+ "generate": "Gerar",
+ "generate_thumb_default": "Gerar thumbnail padrão",
+ "generate_thumb_from_current": "Gerar thumbnail do atual",
+ "hash_migration": "migrar hash",
+ "hide": "Esconder",
+ "import": "Importar…",
+ "import_from_file": "Importar do arquivo",
+ "merge": "Unir",
+ "merge_from": "Unir do",
+ "merge_into": "Unir em",
+ "not_running": "não realizado",
+ "overwrite": "Sobrescrever",
+ "play_random": "Tocar aleatório",
+ "play_selected": "Tocar selecionado",
+ "preview": "Previsualizar",
+ "refresh": "Atualizar",
+ "reload_plugins": "Recarregar plugins",
+ "reload_scrapers": "Recarregar scrapers",
+ "remove": "Remover",
+ "rename_gen_files": "Renomear arquivos gerados",
+ "rescan": "Reescanear",
+ "reshuffle": "Reembaralhar",
+ "running": "rodando",
+ "save": "Salvar",
+ "save_filter": "Salvar filtro",
+ "scan": "Escanear",
+ "scrape_with": "Scrape com…",
+ "search": "Buscar",
+ "select_all": "Selecionar todos",
+ "select_none": "Selecionar nenhum",
+ "selective_auto_tag": "Auto Tag seletivo",
+ "selective_scan": "Escaneamento seletivo",
+ "set_as_default": "Aplicar como padrão",
+ "set_back_image": "Imagem de fundo…",
+ "set_front_image": "Imagem frontal…",
+ "set_image": "Aplicar imagem…",
+ "show": "Mostrar",
+ "skip": "Pular",
+ "tasks": {
+ "clean_confirm_message": "Tem certeza de que quer limpar? Isto irá apagar as informações do banco de dados e conteúdos gerados de todas as cenas e galerias que não são mais encontradas no sistema.",
+ "dry_mode_selected": "Modo não destrutivo. Nenhum arquivo será apagado, apenas será logado.",
+ "import_warning": "Tem certeza de que quer importar? Isto irá apagar o banco de dados e re-importar de seus metadados exportados."
+ },
+ "temp_disable": "Desabilitar temporariamente…",
+ "temp_enable": "Habilitar temporariamente…",
+ "view_random": "Mostrar aleatoriamente"
+ },
+ "actions_name": "Ações",
+ "age": "Idade",
+ "aliases": "Apelidos",
+ "also_known_as": "Também conhecido(a) como",
+ "ascending": "Ascendente",
+ "average_resolution": "Resolução média",
+ "birth_year": "Ano de nascimento",
+ "birthdate": "Data de nascimento",
+ "bitrate": "Taxa de bits",
+ "career_length": "Duração da carreira",
+ "child_studios": "Estúdios filhos",
+ "component_tagger": {
+ "config": {
+ "active_instance": "Ativar stash-box:",
+ "blacklist_desc": "Os itens da lista negra são excluídos das consultas. Observe que são expressões regulares e também não fazem distinção entre maiúsculas e minúsculas. Certos caracteres devem ser escritos com uma barra invertida: {chars_require_escape}",
+ "blacklist_label": "Lista negra",
+ "query_mode_auto": "Automático",
+ "query_mode_auto_desc": "Usa metadados se existentes, ou nome do arquivo",
+ "query_mode_dir": "Dir",
+ "query_mode_dir_desc": "Usa apenas o diretório pai do arquivo de vídeo",
+ "query_mode_filename": "Nome do arquivo",
+ "query_mode_filename_desc": "Usa apenas o nome do arquivo",
+ "query_mode_label": "Modo de consulta.",
+ "query_mode_metadata": "Metadados",
+ "query_mode_metadata_desc": "Usa apenas metadados",
+ "query_mode_path": "Caminho",
+ "query_mode_path_desc": "Usa o caminho inteiro do arquivo",
+ "set_cover_desc": "Substitua a capa da cena se alguma for encontrada.",
+ "set_cover_label": "Definir imagem da capa da cena",
+ "set_tag_desc": "Anexar tags à cena, sobrescrevendo ou mesclando com as tags existentes na cena.",
+ "set_tag_label": "Definir tags.",
+ "show_male_desc": "Artistas masculinos estarão disponíveis para tag.",
+ "show_male_label": "Mostrar artistas masculinos"
+ },
+ "noun_query": "Query",
+ "results": {
+ "fp_found": "{fpCount, plural, =0 {Nenhuma nova correspondência de impressão digital encontrada} other{# novas correspondências de impressão digital encontradas}}",
+ "fp_matches": "Duração é uma correspondência",
+ "fp_matches_multi": "Duração corresponde {matchCount}/{durationsLength} impressão digital(s)",
+ "hash_matches": "{hash_type} é uma correspondência",
+ "match_failed_already_tagged": "Cena já marcada",
+ "match_failed_no_result": "Nenhum resultado encontrado",
+ "match_success": "Cena marcada com sucesso",
+ "duration_off": "Duração por pelo menos {number}s",
+ "duration_unknown": "Duração desconhecida"
+ },
+ "verb_match_fp": "Combine as impressões digitais",
+ "verb_matched": "Combinado",
+ "verb_submit_fp": "Enviar {fpCount, plural, one{# impressão digital} other{# impressões digitais}}",
+ "verb_toggle_config": "{toggle} {configuration}",
+ "verb_toggle_unmatched": "{toggle} cenas incomparáveis"
+ },
+ "config": {
+ "about": {
+ "build_hash": "Build hash:",
+ "build_time": "Tempo de build:",
+ "check_for_new_version": "Verificar se há uma nova versão",
+ "latest_version_build_hash": "Build Hash da última versão:",
+ "new_version_notice": "[NOVA]",
+ "stash_discord": "Junte-se ao nosso {url} canal",
+ "stash_home": "Stash home no {url}",
+ "stash_open_collective": "Apoie-nos através de {url}",
+ "stash_wiki": "Stash {url} página",
+ "version": "Versão"
+ },
+ "categories": {
+ "about": "Sobre",
+ "interface": "Interface",
+ "logs": "Logs",
+ "plugins": "Plugins",
+ "scrapers": "Scrapers",
+ "tasks": "Tarefas",
+ "tools": "Ferramentas"
+ },
+ "dlna": {
+ "allow_temp_ip": "Permitir {tempIP}",
+ "allowed_ip_addresses": "Endereços de IP permitidos",
+ "default_ip_whitelist": "Whitelist de IP padrão",
+ "default_ip_whitelist_desc": "Endereços IP padrão permitidos a acessar DLNA. Use {wildcard} para permitir todos endereços de IP.",
+ "enabled_by_default": "Ativado por padrão",
+ "network_interfaces": "Interfaces",
+ "network_interfaces_desc": "Interfaces para expor servidor DLNA ativo. Uma lista vazia resulta em execução em todas as interfaces. Requer DLNA ser reiniciado depois de alterar.",
+ "recent_ip_addresses": "Endereços de IP recentes",
+ "server_display_name": "Nome de exibição do servidor",
+ "server_display_name_desc": "Nome de exibição do servidor DLNA. Padrão de {server_name} se vazio.",
+ "until_restart": "até reiniciar"
+ },
+ "general": {
+ "auth": {
+ "api_key": "Chave de API",
+ "api_key_desc": "Chave de API para sistemas externos. Exigido apenas quando username/senha está configurado. Username deve ser salvo antes de gerar uma chave de API.",
+ "authentication": "Autenticação",
+ "clear_api_key": "Limpar Chave de API",
+ "generate_api_key": "Gerar Chave de API",
+ "log_file": "Arquivo de log",
+ "log_file_desc": "Caminho para o arquivo para o log de saída. Em branco para desativar o registro de arquivos. Requer reinicialização.",
+ "log_http": "Log de acesso http",
+ "log_http_desc": "Logs de acesso http para o terminal. Requer reinicialização.",
+ "log_to_terminal": "Log para terminal.",
+ "log_to_terminal_desc": "Logs para o terminal, além de um arquivo. Sempre ativo se o log de arquivos estiver desativado. Requer reinicialização.",
+ "maximum_session_age": "Tempo máximo da sessão",
+ "maximum_session_age_desc": "Tempo ocioso máximo antes de uma sessão de login expirar, em segundos.",
+ "password": "Senha",
+ "password_desc": "Senha para acesso Stash. Deixe em branco para desativar a autenticação do usuário",
+ "stash-box_integration": "Integração com Stash-box",
+ "username": "Username",
+ "username_desc": "Username para acessar o Stash. Deixe em branco para desativar a autenticação do usuário"
+ },
+ "cache_location": "Localização do diretório do cache",
+ "cache_path_head": "Caminho do cache",
+ "calculate_md5_and_ohash_desc": "Calcular MD5 checksum além do oshash. A ativação fará com que as varreduras iniciais sejam mais lentas. Nomeação de arquivo Hash deve ser definido para oshash para desabilitar o cálculo MD5.",
+ "calculate_md5_and_ohash_label": "Calcular MD5 para vídeos",
+ "check_for_insecure_certificates": "Verifique se há certificados inseguros",
+ "check_for_insecure_certificates_desc": "Alguns sites usam ssl certificados inseguros. Quando desmarcado o scraper pula a verificação de certificados inseguros e permite o scraping desses sites. Se você receber um erro de certificado quando scraping desmarque isto.",
+ "chrome_cdp_path": "Chrome CDP path",
+ "chrome_cdp_path_desc": "Caminho do arquivo para o executavel do Chrome, ou um endereço remoto (começando com http:// ou https://, por exemplo http://localhost:9222/json/version) para uma instância do Chrome.",
+ "create_galleries_from_folders_desc": "Se marcado, cria galerias de pastas contendo imagens.",
+ "create_galleries_from_folders_label": "Crie galerias de pastas contendo imagens",
+ "db_path_head": "Caminho do banco de dados",
+ "directory_locations_to_your_content": "Locais de diretório para o seu conteúdo",
+ "exclude_image": "Excluir imagem",
+ "exclude_video": "Excluir vídeo",
+ "excluded_image_gallery_patterns_desc": "Regexps de imagem e galeria de arquivos/caminhos para excluir da Varredura e adicionar para Limpar",
+ "excluded_image_gallery_patterns_head": "Padrões de imagem/galeria excluidos",
+ "excluded_video_patterns_desc": "Regexps de video arquivos/caminhos para excluir da Varredura e adicionar para Limpar",
+ "excluded_video_patterns_head": "Padrões de vídeo excluidos",
+ "gallery_ext_desc": "Lista delimitada por vírgulas de extensões de arquivo que serão identificadas como arquivos ZIP da galeria.",
+ "gallery_ext_head": "Extensões zip da galeria",
+ "generated_file_naming_hash_desc": "Use MD5 ou oshash para nomeação de arquivos gerados. Mudando isso requer que todas as cenas tenham o valor MD5/oshash populado. Depois de alterar este valor, arquivos gerados existentes precisarão ser migrados ou regenerados. Veja a página de tarefas para migração.",
+ "generated_file_naming_hash_head": "Hash de nomeação de arquivo gerado",
+ "generated_files_location": "Local de diretório para os arquivos gerados (marcadores de cena, pré visualizações de cena, sprites, etc)",
+ "generated_path_head": "Caminho gerado",
+ "hashing": "Hashing",
+ "image_ext_desc": "Lista delimitada por vírgulas de extensões de arquivo que serão identificadas como imagens.",
+ "image_ext_head": "Extensões de imagem",
+ "logging": "Logging",
+ "maximum_streaming_transcode_size_desc": "Tamanho máximo para streams transcodados",
+ "maximum_streaming_transcode_size_head": "Tamanho máximo de transcodação de streaming",
+ "maximum_transcode_size_desc": "Tamanho máximo para transcodes gerados",
+ "maximum_transcode_size_head": "Tamanho máximo de transcode.",
+ "number_of_parallel_task_for_scan_generation_desc": "Defina como 0 para detecção automática. AVISO Execução de mais tarefas do que é necessário para obter 100% de utilização da CPU diminuirá o desempenho e potencialmente causar outros problemas.",
+ "number_of_parallel_task_for_scan_generation_head": "Número de tarefas paralelas para varredura/geração",
+ "parallel_scan_head": "Varredura/Geração paralela",
+ "preview_generation": "Pré visualizar a geração",
+ "scraper_user_agent": "Scraper User Agent",
+ "scraper_user_agent_desc": "User-Agent string usado durante solicitações http do scrape",
+ "scraping": "Scraping",
+ "sqlite_location": "Localização do arquivo para o banco de dados SQLite (requer reinicialização)",
+ "video_ext_desc": "Lista delimitada por vírgulas de extensões de arquivo que serão identificadas como vídeos.",
+ "video_ext_head": "Extensões de vídeo.",
+ "video_head": "Vídeo"
+ },
+ "logs": {
+ "log_level": "Log Level"
+ },
+ "plugins": {
+ "hooks": "Hooks",
+ "triggers_on": "Triggers on"
+ },
+ "scrapers": {
+ "entity_metadata": "{entityType} metadados",
+ "entity_scrapers": "{entityType} scrapers",
+ "search_by_name": "Buscar por nome",
+ "supported_types": "Tipos suportados",
+ "supported_urls": "URLs"
+ },
+ "stashbox": {
+ "add_instance": "Adicionar uma instância stash-box",
+ "api_key": "Chave de API",
+ "description": "Stash-box facilita o tagging automático de cenas e artistas baseados em 'impressões digitais' e nomes de arquivos..\nEndpoint e chave de API pode ser encontrado na sua página de conta na instancia stash-box. Os nomes são necessários quando mais de uma instância são adicionados.",
+ "endpoint": "Endpoint",
+ "graphql_endpoint": "GraphQL endpoint",
+ "name": "Nome",
+ "title": "Stash-box Endpoints"
+ },
+ "tasks": {
+ "added_job_to_queue": "{operation_name} adicionada para a fila de trabalho.",
+ "auto_tag_based_on_filenames": "Conteúdo automático de tag baseado em nomes de arquivos.",
+ "auto_tagging": "Auto tagging",
+ "backing_up_database": "Backup do banco de dados",
+ "backup_and_download": "Executa um backup do banco de dados e baixa do arquivo resultante.",
+ "backup_database": "Executa um backup do banco de dados para o mesmo diretório que o banco de dados, com o formato do nome do arquivo {filename_format}",
+ "cleanup_desc": "Verifique os arquivos ausentes removendo-os do banco de dados. Esta é uma ação destrutiva.",
+ "dont_include_file_extension_as_part_of_the_title": "Não incluir extensão de arquivo como parte do título",
+ "export_to_json": "Exporta o conteúdo do banco de dados para o formato JSON no diretório de metadados.",
+ "generate_desc": "Gerar imagem de suporte, sprite, video, vtt e outros arquivos.",
+ "generate_phashes_during_scan": "Gerar phashes durante a varredura (para uma deduplicação e identificação de cena)",
+ "generate_previews_during_scan": "Gerar pré-visualizações de imagem durante a digitalização (WebP animadas, somente necessário se o tipo de pré-visualização for definido para imagem animada)",
+ "generate_sprites_during_scan": "Gerar sprites durante a digitalização (para o cena scrubber)",
+ "generate_video_previews_during_scan": "Gerar pré-visualizações durante a digitalização (pré-visualizações de vídeo que tocam ao posicionar o mouse sobre uma cena)",
+ "generated_content": "Conteúdo gerado",
+ "import_from_exported_json": "Importação de JSON exportado no diretório de metadados. Limpa o banco de dados existente.",
+ "incremental_import": "Importação incremental de um arquivo zip de exportação fornecido.",
+ "job_queue": "Fila de trabalho.",
+ "maintenance": "Manutenção",
+ "migrate_hash_files": "Usado depois de alterar o hash gerado de nomeação de arquivos para renomear arquivos gerados existentes para o novo formato hash.",
+ "migrations": "Migrações",
+ "only_dry_run": "Executar apenas modo não destrutivo. Não remova nada",
+ "plugin_tasks": "Tarefas de plugin",
+ "scan_for_content_desc": "Varre para novos conteúdos e adicioná-los ao banco de dados.",
+ "set_name_date_details_from_metadata_if_present": "Definir nome, data, detalhes de metadados (se presente)"
+ },
+ "tools": {
+ "scene_duplicate_checker": "Verificador de cena duplicada",
+ "scene_filename_parser": {
+ "add_field": "Adicionar campo",
+ "capitalize_title": "Capitalizar o título.",
+ "display_fields": "Exibir os campos",
+ "escape_chars": "Use \\ para digitar caracteres literais",
+ "filename": "Nome do arquivo",
+ "filename_pattern": "Padrão de nome de arquivo",
+ "ignored_words": "Palavras ignoradas",
+ "matches_with": "Corresponde com {i}",
+ "select_parser_recipe": "Select Parser Recipe",
+ "title": "Parser de nome de arquivo de cena",
+ "whitespace_chars": "Caracteres de espaço em branco",
+ "whitespace_chars_desc": "Esses caracteres serão substituídos pelo espaço em branco no título."
+ },
+ "scene_tools": "Ferramentas de cena"
+ },
+ "ui": {
+ "custom_css": {
+ "description": "A página deve ser recarregada para alterações para terem efeito.",
+ "heading": "CSS customizado",
+ "option_label": "CSS customizado habilitado"
+ },
+ "handy_connection_key": "Chave de conexão",
+ "handy_connection_key_desc": "Chave de conexão para usar em cenas interativas.",
+ "language": {
+ "heading": "Idioma"
+ },
+ "max_loop_duration": {
+ "description": "Duração máxima da cena onde o player realizará o loop do vídeo - 0 para desabilitar",
+ "heading": "Duração máxima do loop."
+ },
+ "menu_items": {
+ "description": "Mostrar ou ocultar diferentes tipos de conteúdo na barra de navegação",
+ "heading": "Itens do menu"
+ },
+ "preview_type": {
+ "description": "Configuração para itens do paredão",
+ "heading": "Tipo de visualização",
+ "options": {
+ "animated": "Imagem animada",
+ "static": "Imagem estática",
+ "video": "Vídeo"
+ }
+ },
+ "scene_list": {
+ "heading": "Lista de cenas",
+ "options": {
+ "show_studio_as_text": "Mostrar estúdios como texto"
+ }
+ },
+ "scene_player": {
+ "heading": "Player de cenas",
+ "options": {
+ "auto_start_video": "Começar vídeos automaticamente"
+ }
+ },
+ "scene_wall": {
+ "heading": "Scene / Marker Wall",
+ "options": {
+ "display_title": "Exibir título e tags",
+ "toggle_sound": "Habilitar som"
+ }
+ },
+ "slideshow_delay": {
+ "description": "Slideshow está disponível em galerias quando no modo de exibição de paredão",
+ "heading": "Atraso do slideshow"
+ },
+ "title": "Interface de usuário"
+ }
+ },
+ "configuration": "Configuração",
+ "countables": {
+ "galleries": "{count, plural, one {Galeria} other {Galerias}}",
+ "images": "{count, plural, one {Imagem} other {Imagens}}",
+ "markers": "{count, plural, one {Marcador} other {Marcadores}}",
+ "movies": "{count, plural, one {Filme} other {Filmes}}",
+ "performers": "{count, plural, one {Artista} other {Artistas}}",
+ "scenes": "{count, plural, one {Cena} other {Cenas}}",
+ "studios": "{count, plural, one {Estúdio} other {Estúdios}}",
+ "tags": "{count, plural, one {Tag} other {Tags}}"
+ },
+ "country": "País",
+ "cover_image": "Imagem de capa",
+ "created_at": "Criado em",
+ "criterion_modifier": {
+ "equals": "é",
+ "excludes": "exclui",
+ "format_string": "{criterion} {modifierString} {valueString}",
+ "greater_than": "é maior que",
+ "includes": "inclui",
+ "includes_all": "inclui tudo",
+ "is_null": "é nulo",
+ "less_than": "é menor que",
+ "matches_regex": "regex combina com",
+ "not_equals": "não é",
+ "not_matches_regex": "regex não combina com",
+ "not_null": "não é nulo"
+ },
+ "date": "Data",
+ "death_date": "Data de óbito",
+ "death_year": "Ano da morte",
+ "descending": "Descendente",
+ "detail": "Detalhe",
+ "details": "Detalhes",
+ "developmentVersion": "Versão de desenvolvimento.",
+ "dialogs": {
+ "delete_confirm": "Tem certeza de que deseja excluir {entityName}?",
+ "delete_entity_desc": "{count, plural, one {Tem certeza de que deseja excluir este(a) {singularEntity}? A menos que o arquivo também seja excluído, este(a) {singularEntity} será re-adicionado quando a varredura for executada.} other {Tem certeza de que deseja excluir estes(as) {pluralEntity}? A menos que os arquivos também sejam excluídos, estes(as) {pluralEntity} serão re-adicionados quando a varredura for executada.}}",
+ "delete_entity_title": "{count, plural, one {Excluir {singularEntity}} other {Excluir {pluralEntity}}}",
+ "delete_object_desc": "Tem certeza de que deseja excluir {count, plural, one {este(a) {singularEntity}} other {estes(as) {pluralEntity}}}?",
+ "delete_object_overflow": "…e {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.",
+ "delete_object_title": "Excluir {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
+ "edit_entity_title": "Editar {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
+ "export_include_related_objects": "Inclua objetos relacionados na exportação",
+ "export_title": "Exportar",
+ "merge_tags": {
+ "destination": "Destino",
+ "source": "Fonte"
+ },
+ "overwrite_filter_confirm": "Tem certeza de que deseja sobrescrever a consulta salva existente {entityName}?",
+ "scene_gen": {
+ "image_previews": "Pré-visualizações de imagem (WebP animadas, somente necessário se o tipo de pré-visualização for definido para imagem animada)",
+ "markers": "Marcadores (vídeos de 20 segundos que iniciam no dado tempo)",
+ "overwrite": "Substituir arquivos gerados existentes",
+ "phash": "Hashes perceptivos (para desduplicação)",
+ "preview_exclude_end_time_desc": "Excluir os últimos x segundos de pré-visualizações de cena. Isso pode ser um valor em segundos, ou uma porcentagem (p. ex. 2%) da duração total da cena.",
+ "preview_exclude_end_time_head": "Excluir tempo de término",
+ "preview_exclude_start_time_desc": "Excluir os primeiros x segundos de pré-visualizações de cena. Isso pode ser um valor em segundos, ou uma porcentagem (p. ex. 2%) da duração total da cena.",
+ "preview_exclude_start_time_head": "Excluir tempo de início",
+ "preview_options": "Opções de pré-visualização",
+ "preview_preset_desc": "A predefinição regula o tamanho, a qualidade e o tempo de codificação da geração de pré-visualização. Predefinições além de “lenta” tem retornos diminuindo e não são recomendados.",
+ "preview_preset_head": "Codificação predefinida de pré-visualização",
+ "preview_seg_count_desc": "Número de segmentos em arquivos de pré-visualização.",
+ "preview_seg_count_head": "Número de segmentos em pré-visualização",
+ "preview_seg_duration_desc": "Duração de cada segmento de pré-visualização, em segundos.",
+ "preview_seg_duration_head": "Duração do segmento de pré-visualização",
+ "sprites": "Sprites (para scrubber de cena)",
+ "transcodes": "Transcodes (conversões MP4 de formatos de vídeo não suportados)",
+ "video_previews": "Pré-visualizações (pré-visualizações de vídeo que tocam ao posicionar o mouse sobre uma cena)"
+ },
+ "scrape_entity_title": "{entity_type} resultados de scrape",
+ "scrape_results_existing": "Existem",
+ "scrape_results_scraped": "Scraped",
+ "set_image_url_title": "URL da imagem",
+ "unsaved_changes": "Mudanças não salvas. Você tem certeza de que quer sair?"
+ },
+ "dimensions": "Dimensões",
+ "director": "Diretor(a)",
+ "display_mode": {
+ "grid": "Grid",
+ "list": "Lista",
+ "tagger": "Tagger",
+ "unknown": "Desconhecido(a)",
+ "wall": "Paredão"
+ },
+ "donate": "Doar",
+ "dupe_check": {
+ "description": "Níveis abaixo de 'Exato' podem demorar mais para calcular. Falsos positivos também podem ser encontrados em níveis de precisão mais baixos.",
+ "found_sets": "{setCount, plural, one{# conjunto de duplicatas encontrados.} other {# conjuntos de duplicatas encontrados.}}",
+ "options": {
+ "exact": "Exato",
+ "high": "Alto",
+ "low": "Baixo",
+ "medium": "Médio"
+ },
+ "search_accuracy_label": "Precisão de pesquisa",
+ "title": "Cenas duplicadas"
+ },
+ "duration": "Duração",
+ "effect_filters": {
+ "aspect": "Aspect",
+ "blue": "Azul",
+ "blur": "Blur",
+ "brightness": "Brilho",
+ "contrast": "Contraste",
+ "gamma": "Gama",
+ "green": "Verde",
+ "hue": "Matiz",
+ "name": "Filtros",
+ "name_transforms": "Transformadores",
+ "red": "Vermelho",
+ "reset_filters": "Redefinir filtros",
+ "reset_transforms": "Redefinir transformadores",
+ "rotate": "Girar",
+ "rotate_left_and_scale": "Girar para a esquerda e escalar",
+ "rotate_right_and_scale": "Girar a direita e escalar",
+ "saturation": "Saturação",
+ "scale": "Escala",
+ "warmth": "Calor"
+ },
+ "ethnicity": "Etnicidade",
+ "eye_color": "Cor dos olhos",
+ "fake_tits": "Peitos falsos",
+ "favourite": "Favorito(a)",
+ "file_info": "Informações do arquivo",
+ "file_mod_time": "Tempo de modificação do arquivo",
+ "filesize": "Tamanho do arquivo",
+ "filter": "Filtro",
+ "filter_name": "Nome do filtro",
+ "filters": "Filtros",
+ "framerate": "Taxa de quadros",
+ "galleries": "Galerias",
+ "gallery": "Galeria",
+ "gallery_count": "Contagem de galeria",
+ "gender": "Gênero",
+ "hair_color": "Cor do cabelo",
+ "hasMarkers": "Possui marcadores",
+ "height": "Altura",
+ "help": "Ajuda",
+ "image": "Imagem",
+ "image_count": "Contagem de imagem",
+ "images": "Imagens",
+ "images-size": "Tamanho das imagens",
+ "include_child_studios": "Incluem estúdios filho",
+ "instagram": "Instagram",
+ "interactive": "Interativo",
+ "isMissing": "Está faltando",
+ "library": "Biblioteca",
+ "loading": {
+ "generic": "Carregando…"
+ },
+ "marker_count": "Contagem de marcadores",
+ "markers": "Marcadores",
+ "measurements": "Medidas",
+ "media_info": {
+ "audio_codec": "Codec de áudio",
+ "checksum": "Checksum",
+ "downloaded_from": "Baixado de",
+ "hash": "Hash",
+ "performer_card": {
+ "age": "{age} {years_old}",
+ "age_context": "{age} {years_old} nesta cena"
+ },
+ "phash": "PHash",
+ "stream": "Stream",
+ "video_codec": "Codec de vídeo"
+ },
+ "metadata": "Metadados",
+ "movie": "Filme",
+ "movie_scene_number": "Número da cena do filme",
+ "movies": "Filmes",
+ "name": "Nome",
+ "new": "Novo",
+ "none": "Nenhum",
+ "o_counter": "O-contador",
+ "operations": "Operações",
+ "organized": "Organizado",
+ "pagination": {
+ "first": "Primeiro",
+ "last": "Último",
+ "next": "Próximo",
+ "previous": "Anterior"
+ },
+ "parent_studios": "Estúdios pai",
+ "path": "Caminho",
+ "performer": "Artista",
+ "performer_count": "Contagem de artistas",
+ "performer_image": "Imagem do(a) artita",
+ "performers": "Artistas",
+ "performerTags": "Tags de artitas",
+ "piercings": "Piercings",
+ "queue": "Fila",
+ "random": "Aleatória",
+ "rating": "Avaliação",
+ "resolution": "Resolução",
+ "scene": "Cena",
+ "scene_count": "Contagem de cena",
+ "scene_id": "Cena ID",
+ "scenes": "Cenas",
+ "scenes-size": "Tamanho de cenas",
+ "scenes_updated_at": "Cena atualizada em",
+ "sceneTagger": "Tagger de cena",
+ "sceneTags": "Tags de cena",
+ "search_filter": {
+ "add_filter": "Adicionar filtro",
+ "name": "Filtro",
+ "saved_filters": "Filtros salvos",
+ "update_filter": "Atualizar filtro"
+ },
+ "seconds": "Segundos",
+ "settings": "Definições",
+ "stash_id": "Stash ID",
+ "status": "Status: {statusText}",
+ "studio": "Estúdio",
+ "studio_depth": "Níveis (vazio para todos)",
+ "studios": "Estúdios",
+ "synopsis": "Sinopse",
+ "tag": "Tag",
+ "tag_count": "Número de tags",
+ "tags": "Tags",
+ "tattoos": "Tatuagens",
+ "title": "Título",
+ "toast": {
+ "added_entity": "{entity} adicionado(a)",
+ "added_generation_job_to_queue": "Trabalho de geração adicionado para fila",
+ "create_entity": "Criar {entity}",
+ "default_filter_set": "Filtragem padrão definada",
+ "delete_entity": "Excluir {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
+ "delete_past_tense": "Excluída {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
+ "generating_screenshot": "Gerando captura de tela…",
+ "merged_tags": "Tags mescladas",
+ "rescanning_entity": "Reescaneando {count, plural, one {{singularEntity}} other {{pluralEntity}}}…",
+ "started_auto_tagging": "Auto tagging iniciado",
+ "saved_entity": "{entity} salvo(a)",
+ "updated_entity": "{entity} atualizado(a)"
+ },
+ "total": "Total",
+ "twitter": "Twitter",
+ "up-dir": "Subir um diretório",
+ "updated_at": "Atualizado em",
+ "url": "URL",
+ "weight": "Peso",
+ "years_old": "anos"
+}
From c7d2ddc5db4870c72b659321c425bc10a9c22903 Mon Sep 17 00:00:00 2001
From: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
Date: Tue, 3 Aug 2021 13:13:48 +1000
Subject: [PATCH 09/51] Fix unsetting performer gender (#1606)
* Fix unset performer gender
* Fix button group appearing over select menu
---
ui/v2.5/src/components/Changelog/versions/v090.md | 1 +
.../Performers/PerformerDetails/PerformerEditPanel.tsx | 2 +-
ui/v2.5/src/index.scss | 1 +
3 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/ui/v2.5/src/components/Changelog/versions/v090.md b/ui/v2.5/src/components/Changelog/versions/v090.md
index cbb8b0f3f..b03c020e7 100644
--- a/ui/v2.5/src/components/Changelog/versions/v090.md
+++ b/ui/v2.5/src/components/Changelog/versions/v090.md
@@ -6,6 +6,7 @@
* Added de-DE language option. ([#1578](https://github.com/stashapp/stash/pull/1578))
### 🐛 Bug fixes
+* Fix unsetting performer gender not working correctly. ([#1606](https://github.com/stashapp/stash/pull/1606))
* Fix is missing date scene criterion causing invalid SQL. ([#1577](https://github.com/stashapp/stash/pull/1577))
* Fix rendering of carousel images on Apple devices. ([#1562](https://github.com/stashapp/stash/pull/1562))
* Show New and Delete buttons in mobile view. ([#1539](https://github.com/stashapp/stash/pull/1539))
\ No newline at end of file
diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx
index b89cbbd89..da024bb0e 100644
--- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx
+++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx
@@ -466,7 +466,7 @@ export const PerformerEditPanel: React.FC = ({
function getUpdateValues(values: InputValues): GQL.PerformerUpdateInput {
return {
...values,
- gender: stringToGender(values.gender),
+ gender: stringToGender(values.gender) ?? null,
rating: values.rating ?? null,
weight: Number(values.weight),
id: performer.id ?? "",
diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss
index 46acabbd7..58c81d61c 100755
--- a/ui/v2.5/src/index.scss
+++ b/ui/v2.5/src/index.scss
@@ -229,6 +229,7 @@ div.react-select__menu,
div.dropdown-menu {
background-color: $secondary;
color: $text-color;
+ z-index: 3;
.react-select__option,
.dropdown-item {
From 8a7577c9bfc743425a337261ccc48f9b7cfe5cb2 Mon Sep 17 00:00:00 2001
From: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
Date: Tue, 3 Aug 2021 14:29:57 +1000
Subject: [PATCH 10/51] Fix inf values causing marshal error (#1607)
---
pkg/api/resolver_model_scene.go | 4 ++--
pkg/api/types.go | 12 ++++++++++++
ui/v2.5/src/components/Changelog/versions/v090.md | 1 +
3 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/pkg/api/resolver_model_scene.go b/pkg/api/resolver_model_scene.go
index 89f1ed8ae..5d909f6b5 100644
--- a/pkg/api/resolver_model_scene.go
+++ b/pkg/api/resolver_model_scene.go
@@ -67,12 +67,12 @@ func (r *sceneResolver) File(ctx context.Context, obj *models.Scene) (*models.Sc
bitrate := int(obj.Bitrate.Int64)
return &models.SceneFileType{
Size: &obj.Size.String,
- Duration: &obj.Duration.Float64,
+ Duration: handleFloat64(obj.Duration.Float64),
VideoCodec: &obj.VideoCodec.String,
AudioCodec: &obj.AudioCodec.String,
Width: &width,
Height: &height,
- Framerate: &obj.Framerate.Float64,
+ Framerate: handleFloat64(obj.Framerate.Float64),
Bitrate: &bitrate,
}, nil
}
diff --git a/pkg/api/types.go b/pkg/api/types.go
index f786c3968..9af592806 100644
--- a/pkg/api/types.go
+++ b/pkg/api/types.go
@@ -1,7 +1,19 @@
package api
+import "math"
+
// An enum https://golang.org/ref/spec#Iota
const (
create = iota // 0
update = iota // 1
)
+
+// #1572 - Inf and NaN values cause the JSON marshaller to fail
+// Return nil for these values
+func handleFloat64(v float64) *float64 {
+ if math.IsInf(v, 0) || math.IsNaN(v) {
+ return nil
+ }
+
+ return &v
+}
diff --git a/ui/v2.5/src/components/Changelog/versions/v090.md b/ui/v2.5/src/components/Changelog/versions/v090.md
index b03c020e7..f7812419d 100644
--- a/ui/v2.5/src/components/Changelog/versions/v090.md
+++ b/ui/v2.5/src/components/Changelog/versions/v090.md
@@ -6,6 +6,7 @@
* Added de-DE language option. ([#1578](https://github.com/stashapp/stash/pull/1578))
### 🐛 Bug fixes
+* Fix infinity framerate values causing resolver error. ([#1607](https://github.com/stashapp/stash/pull/1607))
* Fix unsetting performer gender not working correctly. ([#1606](https://github.com/stashapp/stash/pull/1606))
* Fix is missing date scene criterion causing invalid SQL. ([#1577](https://github.com/stashapp/stash/pull/1577))
* Fix rendering of carousel images on Apple devices. ([#1562](https://github.com/stashapp/stash/pull/1562))
From 7287ad3a05d9b71142013a203579fda9531b3d52 Mon Sep 17 00:00:00 2001
From: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
Date: Wed, 4 Aug 2021 09:01:34 +1000
Subject: [PATCH 11/51] Remove stripes and add background colour to default
performer images (#1609)
---
static/performer/NoName01.png | Bin 52907 -> 13753 bytes
static/performer/NoName02.png | Bin 50577 -> 11353 bytes
static/performer/NoName03.png | Bin 49803 -> 11049 bytes
static/performer/NoName04.png | Bin 50726 -> 10578 bytes
static/performer/NoName05.png | Bin 49510 -> 11026 bytes
static/performer/NoName06.png | Bin 50850 -> 10757 bytes
static/performer/NoName07.png | Bin 47472 -> 8661 bytes
static/performer/NoName08.png | Bin 52331 -> 12597 bytes
static/performer/NoName09.png | Bin 48085 -> 9907 bytes
static/performer/NoName10.png | Bin 50703 -> 10761 bytes
static/performer/NoName11.png | Bin 50356 -> 11893 bytes
static/performer/NoName12.png | Bin 155535 -> 40410 bytes
static/performer/NoName13.png | Bin 44220 -> 8925 bytes
static/performer/NoName14.png | Bin 47163 -> 9840 bytes
static/performer/NoName15.png | Bin 53775 -> 13849 bytes
static/performer/NoName16.png | Bin 54490 -> 14569 bytes
static/performer/NoName17.png | Bin 50702 -> 10878 bytes
static/performer/NoName18.png | Bin 51283 -> 12196 bytes
static/performer/NoName19.png | Bin 51693 -> 12482 bytes
static/performer/NoName20.png | Bin 51377 -> 12116 bytes
static/performer/NoName21.png | Bin 48316 -> 10897 bytes
static/performer/NoName22.png | Bin 48764 -> 10689 bytes
static/performer/NoName23.png | Bin 50681 -> 12121 bytes
static/performer/NoName24.png | Bin 51390 -> 11273 bytes
static/performer/NoName25.png | Bin 50377 -> 11157 bytes
static/performer/NoName26.png | Bin 48774 -> 10647 bytes
static/performer/NoName27.png | Bin 50415 -> 11884 bytes
static/performer/NoName28.png | Bin 52673 -> 12687 bytes
static/performer/NoName29.png | Bin 50132 -> 11191 bytes
static/performer/NoName30.png | Bin 50024 -> 12278 bytes
static/performer/NoName31.png | Bin 49630 -> 11289 bytes
static/performer/NoName32.png | Bin 52015 -> 13180 bytes
static/performer/NoName33.png | Bin 51507 -> 11984 bytes
static/performer/NoName34.png | Bin 50740 -> 12270 bytes
static/performer/NoName35.png | Bin 47532 -> 8573 bytes
static/performer/NoName36.png | Bin 48086 -> 9733 bytes
static/performer/NoName37.png | Bin 48739 -> 9818 bytes
static/performer/NoName38.png | Bin 45142 -> 10489 bytes
static/performer/NoName39.png | Bin 49343 -> 10928 bytes
static/performer/NoName40.png | Bin 53940 -> 13857 bytes
.../src/components/Changelog/versions/v090.md | 1 +
41 files changed, 1 insertion(+)
diff --git a/static/performer/NoName01.png b/static/performer/NoName01.png
index 954267c9aebc9f7a1ad224e256c09e1cb939bdc9..cdcba1db9009b2d51379ab8578079ecd23fd1c2f 100644
GIT binary patch
literal 13753
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