From dbbcda327f3d24d820f767376717c2476793eb56 Mon Sep 17 00:00:00 2001
From: TheNightShift-nsfw
<261069475+TheNightShift-nsfw@users.noreply.github.com>
Date: Fri, 17 Jul 2026 15:22:13 -0600
Subject: [PATCH 1/2] feat: implement Auto-Tagging system for actor traits,
video quality, and scene attributes
---
pkg/api/options.go | 59 ++++
pkg/api/tasks.go | 27 ++
pkg/config/config.go | 31 ++
pkg/migrations/migrations.go | 6 +
pkg/models/model_tag.go | 11 +-
pkg/server/cron.go | 16 +
pkg/tasks/auto_tag.go | 246 ++++++++++++++
pkg/tasks/dms.go | 15 +-
ui/src/views/options/sections/Schedules.vue | 352 +++++++++++++++++++-
9 files changed, 755 insertions(+), 8 deletions(-)
create mode 100644 pkg/tasks/auto_tag.go
diff --git a/pkg/api/options.go b/pkg/api/options.go
index d6b6b9c6a..bcb3d1421 100644
--- a/pkg/api/options.go
+++ b/pkg/api/options.go
@@ -186,6 +186,34 @@ type RequestSaveOptionsTaskSchedule struct {
LinkScenesHourStart int `json:"linkScenesHourStart"`
LinkScenesHourEnd int `json:"linkScenesHourEnd"`
LinkScenesStartDelay int `json:"linkScenesStartDelay"`
+
+ AutoTagScheduleEnabled bool `json:"autoTagScheduleEnabled"`
+ AutoTagScheduleHourInterval int `json:"autoTagScheduleHourInterval"`
+ AutoTagScheduleUseRange bool `json:"autoTagScheduleUseRange"`
+ AutoTagScheduleMinuteStart int `json:"autoTagScheduleMinuteStart"`
+ AutoTagScheduleHourStart int `json:"autoTagScheduleHourStart"`
+ AutoTagScheduleHourEnd int `json:"autoTagScheduleHourEnd"`
+ AutoTagScheduleStartDelay int `json:"autoTagScheduleStartDelay"`
+
+ AutoTagBreastType bool `json:"autoTagBreastType"`
+ AutoTagAge bool `json:"autoTagAge"`
+ AutoTagHeight bool `json:"autoTagHeight"`
+ AutoTagNationality bool `json:"autoTagNationality"`
+ AutoTagEthnicity bool `json:"autoTagEthnicity"`
+ AutoTagHairColor bool `json:"autoTagHairColor"`
+ AutoTagEyeColor bool `json:"autoTagEyeColor"`
+ AutoTagCupSize bool `json:"autoTagCupSize"`
+ AutoTagResolution bool `json:"autoTagResolution"`
+ AutoTagVideoFormat bool `json:"autoTagVideoFormat"`
+ AutoTagDuration bool `json:"autoTagDuration"`
+
+ AutoTagInterracial bool `json:"autoTagInterracial"`
+
+ AutoTagHeightShortMax int `json:"autoTagHeightShortMax"`
+ AutoTagHeightAverageMax int `json:"autoTagHeightAverageMax"`
+
+ AutoTagDurationShortMax int `json:"autoTagDurationShortMax"`
+ AutoTagDurationStandardMax int `json:"autoTagDurationStandardMax"`
}
type RequestSaveSiteMatchParams struct {
SiteId string `json:"site"`
@@ -989,6 +1017,9 @@ func (i ConfigResource) saveOptionsTaskSchedule(req *restful.Request, resp *rest
if r.PreviewHourEnd > 23 {
r.PreviewHourEnd -= 24
}
+ if r.AutoTagScheduleHourEnd > 23 {
+ r.AutoTagScheduleHourEnd -= 24
+ }
config.Config.Cron.RescrapeSchedule.Enabled = r.RescrapeEnabled
config.Config.Cron.RescrapeSchedule.HourInterval = r.RescrapeHourInterval
@@ -1038,6 +1069,34 @@ func (i ConfigResource) saveOptionsTaskSchedule(req *restful.Request, resp *rest
config.Config.Cron.LinkScenesSchedule.HourEnd = r.LinkScenesHourEnd
config.Config.Cron.LinkScenesSchedule.RunAtStartDelay = r.LinkScenesStartDelay
+ config.Config.Cron.AutoTagSchedule.Enabled = r.AutoTagScheduleEnabled
+ config.Config.Cron.AutoTagSchedule.HourInterval = r.AutoTagScheduleHourInterval
+ config.Config.Cron.AutoTagSchedule.UseRange = r.AutoTagScheduleUseRange
+ config.Config.Cron.AutoTagSchedule.MinuteStart = r.AutoTagScheduleMinuteStart
+ config.Config.Cron.AutoTagSchedule.HourStart = r.AutoTagScheduleHourStart
+ config.Config.Cron.AutoTagSchedule.HourEnd = r.AutoTagScheduleHourEnd
+ config.Config.Cron.AutoTagSchedule.RunAtStartDelay = r.AutoTagScheduleStartDelay
+
+ config.Config.AutoTag.BreastType = r.AutoTagBreastType
+ config.Config.AutoTag.Age = r.AutoTagAge
+ config.Config.AutoTag.Height = r.AutoTagHeight
+ config.Config.AutoTag.Nationality = r.AutoTagNationality
+ config.Config.AutoTag.Ethnicity = r.AutoTagEthnicity
+ config.Config.AutoTag.HairColor = r.AutoTagHairColor
+ config.Config.AutoTag.EyeColor = r.AutoTagEyeColor
+ config.Config.AutoTag.CupSize = r.AutoTagCupSize
+ config.Config.AutoTag.Resolution = r.AutoTagResolution
+ config.Config.AutoTag.VideoFormat = r.AutoTagVideoFormat
+ config.Config.AutoTag.Duration = r.AutoTagDuration
+
+ config.Config.AutoTag.Interracial = r.AutoTagInterracial
+
+ config.Config.AutoTag.HeightShortMax = r.AutoTagHeightShortMax
+ config.Config.AutoTag.HeightAverageMax = r.AutoTagHeightAverageMax
+
+ config.Config.AutoTag.DurationShortMax = r.AutoTagDurationShortMax
+ config.Config.AutoTag.DurationStandardMax = r.AutoTagDurationStandardMax
+
config.SaveConfig()
resp.WriteHeaderAndEntity(http.StatusOK, r)
diff --git a/pkg/api/tasks.go b/pkg/api/tasks.go
index f3a119b56..9a1d1fa87 100644
--- a/pkg/api/tasks.go
+++ b/pkg/api/tasks.go
@@ -59,6 +59,15 @@ func (i TaskResource) WebService() *restful.WebService {
ws.Route(ws.GET("/rescan").To(i.rescan).
Metadata(restfulspec.KeyOpenAPITags, tags))
+ ws.Route(ws.GET("/auto-tag").To(i.autoTag).
+ Metadata(restfulspec.KeyOpenAPITags, tags))
+
+ ws.Route(ws.GET("/auto-tag-reset").To(i.autoTagReset).
+ Metadata(restfulspec.KeyOpenAPITags, tags))
+
+ ws.Route(ws.GET("/system-tags").To(i.getSystemTags).
+ Metadata(restfulspec.KeyOpenAPITags, tags))
+
ws.Route(ws.GET("/rescan/{storage-id}").To(i.rescan).
Metadata(restfulspec.KeyOpenAPITags, tags))
@@ -117,6 +126,24 @@ func (i TaskResource) rescan(req *restful.Request, resp *restful.Response) {
}
}
+func (i TaskResource) autoTag(req *restful.Request, resp *restful.Response) {
+ go tasks.GenerateAutoTags()
+}
+
+func (i TaskResource) autoTagReset(req *restful.Request, resp *restful.Response) {
+ go tasks.ResetAutoTags()
+}
+
+func (i TaskResource) getSystemTags(req *restful.Request, resp *restful.Response) {
+ db, _ := models.GetDB()
+ defer db.Close()
+
+ var tags []models.Tag
+ db.Where("is_system = ?", true).Order("name asc").Find(&tags)
+
+ resp.WriteHeaderAndEntity(http.StatusOK, tags)
+}
+
func (i TaskResource) sceneRrefresh(req *restful.Request, resp *restful.Response) {
go tasks.RefreshSceneStatuses()
}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 0a2a4a8d8..286a096c1 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -174,7 +174,38 @@ type ObjectConfig struct {
HourEnd int `default:"23" json:"hourEnd"`
RunAtStartDelay int `default:"0" json:"runAtStartDelay"`
} `json:"linkScenesSchedule"`
+ AutoTagSchedule struct {
+ Enabled bool `default:"false" json:"enabled"`
+ HourInterval int `default:"12" json:"hourInterval"`
+ UseRange bool `default:"false" json:"useRange"`
+ MinuteStart int `default:"0" json:"minuteStart"`
+ HourStart int `default:"0" json:"hourStart"`
+ HourEnd int `default:"23" json:"hourEnd"`
+ RunAtStartDelay int `default:"0" json:"runAtStartDelay"`
+ } `json:"autoTagSchedule"`
} `json:"cron"`
+ AutoTag struct {
+ BreastType bool `default:"false" json:"breastType"`
+ Age bool `default:"false" json:"age"`
+ Height bool `default:"false" json:"height"`
+ Nationality bool `default:"false" json:"nationality"`
+ Ethnicity bool `default:"false" json:"ethnicity"`
+ HairColor bool `default:"false" json:"hairColor"`
+ EyeColor bool `default:"false" json:"eyeColor"`
+ CupSize bool `default:"false" json:"cupSize"`
+ Resolution bool `default:"false" json:"resolution"`
+ VideoFormat bool `default:"false" json:"videoFormat"`
+ Duration bool `default:"false" json:"duration"`
+
+ Interracial bool `default:"false" json:"interracial"`
+ BMI bool `default:"false" json:"bmi"`
+
+ HeightShortMax int `default:"160" json:"heightShortMax"`
+ HeightAverageMax int `default:"175" json:"heightAverageMax"`
+
+ DurationShortMax int `default:"15" json:"durationShortMax"`
+ DurationStandardMax int `default:"40" json:"durationStandardMax"`
+ } `json:"autoTag"`
Storage struct {
MatchOhash bool `default:"false" json:"match_ohash"`
VideoExt []string `json:"video_ext"`
diff --git a/pkg/migrations/migrations.go b/pkg/migrations/migrations.go
index 9d2a818b2..93dc5c38c 100644
--- a/pkg/migrations/migrations.go
+++ b/pkg/migrations/migrations.go
@@ -2387,6 +2387,12 @@ func Migrate(migrateTo string) {
return tx.Table("scenes").AddIndex("idx_scenes_scraper_id", "scraper_id").Error
},
},
+ {
+ ID: "0088-add-is_system-to-tags",
+ Migrate: func(tx *gorm.DB) error {
+ return tx.AutoMigrate(&models.Tag{}).Error
+ },
+ },
}
// Wrap migrations to automatically track progress
diff --git a/pkg/models/model_tag.go b/pkg/models/model_tag.go
index 248d400ae..18fd2b877 100644
--- a/pkg/models/model_tag.go
+++ b/pkg/models/model_tag.go
@@ -8,11 +8,12 @@ import (
)
type Tag struct {
- ID uint `gorm:"primary_key" json:"id" xbvrbackup:"-"`
- Scenes []Scene `gorm:"many2many:scene_tags;" json:"scenes" xbvrbackup:"-"`
- Name string `gorm:"index" json:"name" xbvrbackup:"name"`
- Clean string `gorm:"index" json:"clean" xbvrbackup:"-"`
- Count int `json:"count" xbvrbackup:"-"`
+ ID uint `gorm:"primary_key" json:"id" xbvrbackup:"-"`
+ Scenes []Scene `gorm:"many2many:scene_tags;" json:"scenes" xbvrbackup:"-"`
+ Name string `gorm:"index" json:"name" xbvrbackup:"name"`
+ Clean string `gorm:"index" json:"clean" xbvrbackup:"-"`
+ Count int `json:"count" xbvrbackup:"-"`
+ IsSystem bool `json:"is_system" gorm:"default:false" xbvrbackup:"-"`
}
func (t *Tag) Save() error {
diff --git a/pkg/server/cron.go b/pkg/server/cron.go
index 001fd1964..52f6dfb8e 100644
--- a/pkg/server/cron.go
+++ b/pkg/server/cron.go
@@ -18,6 +18,7 @@ var previewTask cron.EntryID
var actorScrapeTask cron.EntryID
var stashdbScrapeTask cron.EntryID
var linkScenesTask cron.EntryID
+var autoTagTask cron.EntryID
func SetupCron() {
cronInstance = cron.New()
@@ -48,6 +49,10 @@ func SetupCron() {
log.Println(fmt.Sprintf("Setup Link Scenes Task %v", formatCronSchedule(config.CronSchedule(config.Config.Cron.LinkScenesSchedule))))
linkScenesTask, _ = cronInstance.AddFunc(formatCronSchedule(config.CronSchedule(config.Config.Cron.LinkScenesSchedule)), linkScenesCron)
}
+ if config.Config.Cron.AutoTagSchedule.Enabled {
+ log.Println(fmt.Sprintf("Setup AutoTag Task %v", formatCronSchedule(config.CronSchedule(config.Config.Cron.AutoTagSchedule))))
+ autoTagTask, _ = cronInstance.AddFunc(formatCronSchedule(config.CronSchedule(config.Config.Cron.AutoTagSchedule)), autoTagCron)
+ }
cronInstance.Start()
go tasks.CalculateCacheSizes()
@@ -70,6 +75,9 @@ func SetupCron() {
if config.Config.Cron.LinkScenesSchedule.RunAtStartDelay > 0 {
time.AfterFunc(time.Duration(config.Config.Cron.LinkScenesSchedule.RunAtStartDelay)*time.Minute, linkScenesCron)
}
+ if config.Config.Cron.AutoTagSchedule.RunAtStartDelay > 0 {
+ time.AfterFunc(time.Duration(config.Config.Cron.AutoTagSchedule.RunAtStartDelay)*time.Minute, autoTagCron)
+ }
log.Println(fmt.Sprintf("Next Rescrape Task at %v", cronInstance.Entry(rescrapTask).Next))
log.Println(fmt.Sprintf("Next Rescan Task at %v", cronInstance.Entry(rescanTask).Next))
@@ -77,6 +85,7 @@ func SetupCron() {
log.Println(fmt.Sprintf("Next Actor Rescripe Task at %v", cronInstance.Entry(actorScrapeTask).Next))
log.Println(fmt.Sprintf("Next Stashdb Rescrape Task at %v", cronInstance.Entry(stashdbScrapeTask).Next))
log.Println(fmt.Sprintf("Next Link Scenes Task at %v", cronInstance.Entry(linkScenesTask).Next))
+ log.Println(fmt.Sprintf("Next AutoTag Task at %v", cronInstance.Entry(autoTagTask).Next))
}
func scrapeCron() {
@@ -112,6 +121,13 @@ func linkScenesCron() {
log.Println(fmt.Sprintf("Next Link Scenes Task at %v", cronInstance.Entry(rescrapTask).Next))
}
+func autoTagCron() {
+ if !session.HasActiveSession() {
+ tasks.GenerateAutoTags()
+ }
+ log.Println(fmt.Sprintf("Next AutoTag Task at %v", cronInstance.Entry(autoTagTask).Next))
+}
+
var previewGenerateInProgress = false
func generatePreviewCron() {
diff --git a/pkg/tasks/auto_tag.go b/pkg/tasks/auto_tag.go
new file mode 100644
index 000000000..02c48b243
--- /dev/null
+++ b/pkg/tasks/auto_tag.go
@@ -0,0 +1,246 @@
+package tasks
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/jinzhu/gorm"
+ "github.com/xbapps/xbvr/pkg/config"
+ "github.com/xbapps/xbvr/pkg/models"
+)
+
+func ResetAutoTags() {
+ db, _ := models.GetDB()
+ defer db.Close()
+
+ // Find all system tags
+ var tags []models.Tag
+ db.Where("is_system = ?", true).Find(&tags)
+
+ for _, tag := range tags {
+ // Remove tag from all scenes
+ db.Exec("DELETE FROM scene_tags WHERE tag_id = ?", tag.ID)
+
+ // Delete the tag itself (optional, but cleaner)
+ db.Delete(&tag)
+ }
+
+ // Recalculate counts
+ var t models.Tag
+ t.CountTags()
+}
+
+func GenerateAutoTags() {
+ if !config.Config.AutoTag.BreastType {
+ return
+ }
+
+ db, _ := models.GetDB()
+ defer db.Close()
+
+ var scenes []models.Scene
+ db.Model(&models.Scene{}).Preload("Cast").Preload("Tags").Preload("Files").Find(&scenes)
+
+ // Calculate ranges for height and duration once
+ heightShortMax := config.Config.AutoTag.HeightShortMax
+ heightAvgMax := config.Config.AutoTag.HeightAverageMax
+ durShortMax := config.Config.AutoTag.DurationShortMax
+ durStdMax := config.Config.AutoTag.DurationStandardMax
+
+ for _, scene := range scenes {
+ // Breast Type
+ if config.Config.AutoTag.BreastType {
+ isNatural := false
+ isFake := false
+
+ for _, actor := range scene.Cast {
+ if strings.EqualFold(actor.BreastType, "Natural") {
+ isNatural = true
+ }
+ if strings.EqualFold(actor.BreastType, "Fake") || strings.EqualFold(actor.BreastType, "Silicone") || strings.EqualFold(actor.BreastType, "Enhanced") {
+ isFake = true
+ }
+ }
+
+ if isNatural {
+ addTagToScene(db, &scene, "Breast Type - Natural")
+ }
+ if isFake {
+ addTagToScene(db, &scene, "Breast Type - Fake")
+ }
+ }
+
+ // Age
+ if config.Config.AutoTag.Age && !scene.ReleaseDate.IsZero() {
+ for _, actor := range scene.Cast {
+ if !actor.BirthDate.IsZero() {
+ age := scene.ReleaseDate.Year() - actor.BirthDate.Year()
+ // Adjust for month/day if needed, but year diff is usually sufficient for "Age: XX" tags
+ if scene.ReleaseDate.YearDay() < actor.BirthDate.YearDay() {
+ age--
+ }
+ if age >= 18 && age < 100 { // Basic sanity check
+ addTagToScene(db, &scene, fmt.Sprintf("Age: %d", age))
+ }
+ }
+ }
+ }
+
+ // Height
+ if config.Config.AutoTag.Height {
+ for _, actor := range scene.Cast {
+ if actor.Height > 0 {
+ if actor.Height <= heightShortMax {
+ addTagToScene(db, &scene, "Height: Short")
+ } else if actor.Height <= heightAvgMax {
+ addTagToScene(db, &scene, "Height: Average")
+ } else {
+ addTagToScene(db, &scene, "Height: Tall")
+ }
+ }
+ }
+ }
+
+ // Nationality
+ if config.Config.AutoTag.Nationality {
+ for _, actor := range scene.Cast {
+ if actor.Nationality != "" {
+ addTagToScene(db, &scene, "Nationality: "+actor.Nationality)
+ }
+ }
+ }
+
+ // Ethnicity
+ if config.Config.AutoTag.Ethnicity {
+ for _, actor := range scene.Cast {
+ if actor.Ethnicity != "" {
+ addTagToScene(db, &scene, "Ethnicity: "+actor.Ethnicity)
+ }
+ }
+ }
+
+ // Hair Color
+ if config.Config.AutoTag.HairColor {
+ for _, actor := range scene.Cast {
+ if actor.HairColor != "" {
+ addTagToScene(db, &scene, "Hair: "+actor.HairColor)
+ }
+ }
+ }
+
+ // Eye Color
+ if config.Config.AutoTag.EyeColor {
+ for _, actor := range scene.Cast {
+ if actor.EyeColor != "" {
+ addTagToScene(db, &scene, "Eyes: "+actor.EyeColor)
+ }
+ }
+ }
+
+ // Cup Size
+ if config.Config.AutoTag.CupSize {
+ for _, actor := range scene.Cast {
+ if actor.CupSize != "" {
+ addTagToScene(db, &scene, "Cup: "+actor.CupSize)
+ }
+ }
+ }
+
+ // Interracial
+ if config.Config.AutoTag.Interracial && len(scene.Cast) > 0 {
+ ethnicities := make(map[string]bool)
+
+ for _, actor := range scene.Cast {
+ if actor.Ethnicity != "" {
+ ethnicities[strings.ToLower(actor.Ethnicity)] = true
+ }
+ }
+
+ if len(ethnicities) > 1 {
+ addTagToScene(db, &scene, "Interracial")
+ }
+ }
+
+ // Duration
+ if config.Config.AutoTag.Duration && scene.Duration > 0 {
+ // Duration is in minutes
+ if scene.Duration <= durShortMax {
+ addTagToScene(db, &scene, "Duration: Short")
+ } else if scene.Duration <= durStdMax {
+ addTagToScene(db, &scene, "Duration: Standard")
+ } else {
+ addTagToScene(db, &scene, "Duration: Long")
+ }
+ }
+
+ // Resolution & Format
+ if (config.Config.AutoTag.Resolution || config.Config.AutoTag.VideoFormat) && len(scene.Files) > 0 {
+ // Find best file (highest resolution)
+ var bestFile models.File
+ for _, f := range scene.Files {
+ if f.VideoHeight > bestFile.VideoHeight {
+ bestFile = f
+ }
+ }
+
+ if config.Config.AutoTag.Resolution && bestFile.VideoHeight > 0 {
+ if bestFile.VideoHeight >= 4320 {
+ addTagToScene(db, &scene, "Res: 8K")
+ } else if bestFile.VideoHeight >= 2880 {
+ addTagToScene(db, &scene, "Res: 6K")
+ } else if bestFile.VideoHeight >= 2160 {
+ addTagToScene(db, &scene, "Res: 5K") // Sometimes 5K is distinct
+ } else if bestFile.VideoHeight >= 1900 { // Allow some tolerance for 4K
+ addTagToScene(db, &scene, "Res: 4K")
+ } else if bestFile.VideoHeight >= 1440 {
+ addTagToScene(db, &scene, "Res: 1440p")
+ } else if bestFile.VideoHeight >= 1080 {
+ addTagToScene(db, &scene, "Res: 1080p")
+ } else if bestFile.VideoHeight >= 720 {
+ addTagToScene(db, &scene, "Res: 720p")
+ } else {
+ addTagToScene(db, &scene, "Res: SD")
+ }
+ }
+
+ if config.Config.AutoTag.VideoFormat {
+ if bestFile.VideoProjection == "180_sbs" || bestFile.VideoProjection == "180_tb" {
+ addTagToScene(db, &scene, "Format: 180°")
+ } else if bestFile.VideoProjection == "360_sbs" || bestFile.VideoProjection == "360_tb" || bestFile.VideoProjection == "360_mono" {
+ addTagToScene(db, &scene, "Format: 360°")
+ } else if bestFile.VideoProjection == "flat" {
+ addTagToScene(db, &scene, "Format: Flat")
+ }
+ }
+ }
+
+ // Helper function inside loop to save code duplication
+ }
+
+ // Recalculate tag counts
+ var t models.Tag
+ t.CountTags()
+}
+
+func addTagToScene(db *gorm.DB, scene *models.Scene, tagName string) {
+ tagExists := false
+ for _, t := range scene.Tags {
+ if strings.EqualFold(t.Name, tagName) {
+ tagExists = true
+ break
+ }
+ }
+
+ if !tagExists {
+ var tag models.Tag
+ db.Where(models.Tag{Name: tagName}).FirstOrCreate(&tag)
+
+ // Mark as system tag if it's new or update it
+ if !tag.IsSystem {
+ tag.IsSystem = true
+ tag.Save()
+ }
+
+ db.Model(scene).Association("Tags").Append(tag)
+ }
+}
diff --git a/pkg/tasks/dms.go b/pkg/tasks/dms.go
index 8daacae27..35f25fd29 100644
--- a/pkg/tasks/dms.go
+++ b/pkg/tasks/dms.go
@@ -10,6 +10,7 @@ import (
"time"
"github.com/nfnt/resize"
+ "github.com/xbapps/xbvr/pkg/common"
"github.com/xbapps/xbvr/pkg/config"
"github.com/xbapps/xbvr/pkg/dms/dlna/dms"
"github.com/xbapps/xbvr/ui"
@@ -110,11 +111,21 @@ func getIconReader(fn string) (io.Reader, error) {
func readIcon(path string, size uint) *bytes.Reader {
r, err := getIconReader(path)
if err != nil {
- panic(err)
+ common.Log.Errorf("Failed to read DLNA icon %s: %v. Using default placeholder.", path, err)
+ // Create a 1x1 transparent image as fallback
+ img := image.NewRGBA(image.Rect(0, 0, 1, 1))
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, img); err != nil {
+ common.Log.Error(err)
+ return bytes.NewReader([]byte{})
+ }
+ return bytes.NewReader(buf.Bytes())
}
+
imageData, _, err := image.Decode(r)
if err != nil {
- panic(err)
+ common.Log.Error(err)
+ return bytes.NewReader([]byte{})
}
return resizeImage(imageData, size)
}
diff --git a/ui/src/views/options/sections/Schedules.vue b/ui/src/views/options/sections/Schedules.vue
index 18df4b3c4..961e2ee14 100644
--- a/ui/src/views/options/sections/Schedules.vue
+++ b/ui/src/views/options/sections/Schedules.vue
@@ -11,6 +11,7 @@
+
@@ -249,6 +250,205 @@
{{ delayStartMsg(linkScenesStartDelay) }}
+
+
+
+ Enable schedule
+
+
+
+ {{`Run every ${this.autoTagScheduleHourInterval} hour${this.autoTagScheduleHourInterval > 1 ? 's': ''}`}}
+
+
+ Limit time of day
+
+
+
+
+ 00:00
+ 06:00
+ 12:00
+ 18:00
+ Midnight
+ 06:00
+ 12:00
+ 18:00
+ 00:00
+
+ {{`${this.timeRange[this.autoTagScheduleTimeRange[0]]} - ${this.timeRange[this.autoTagScheduleTimeRange[1]]}`}}
+
+
+
+ {{ minutesStartMsg(autoTagScheduleMinuteStart) }}
+
+
+
+
+
+ {{ delayStartMsg(autoTagScheduleStartDelay) }}
+
+
+
{{$t("Generators")}}
+
+
+
+
{{$t("Actor Characteristics")}}
+
+
+ {{$t("Breast Size")}}
+
+ {{$t("Generates: Cup: C, Cup: DD, etc.")}}
+
+ {{tag.name}} ({{tag.count}})
+
+
+
+
+
+ {{$t("Breast Type")}}
+
+ {{$t("Generates: Breast Type - Natural, Breast Type - Fake")}}
+
+ {{tag.name}} ({{tag.count}})
+
+
+
+
+
+ {{$t("Age")}}
+
+ {{$t("Generates: Age: 25, Age: 30, etc.")}}
+
+ {{tag.name}} ({{tag.count}})
+
+
+
+
+
+ {{$t("Height")}}
+
+ {{$t("Generates: Height: Short/Average/Tall")}}
+
+ {{tag.name}} ({{tag.count}})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{$t("Nationality")}}
+
+ {{$t("Generates: Nationality: USA, etc.")}}
+
+ {{tag.name}} ({{tag.count}})
+
+
+
+
+
+ {{$t("Ethnicity")}}
+
+ {{$t("Generates: Ethnicity: Asian, etc.")}}
+
+ {{tag.name}} ({{tag.count}})
+
+
+
+
+
+ {{$t("Hair Color")}}
+
+ {{$t("Generates: Hair: Blonde, etc.")}}
+
+ {{tag.name}} ({{tag.count}})
+
+
+
+
+
+ {{$t("Eye Color")}}
+
+ {{$t("Generates: Eyes: Blue, etc.")}}
+
+ {{tag.name}} ({{tag.count}})
+
+
+
+
+
+
{{$t("Video Quality")}}
+
+
+ {{$t("Resolution")}}
+
+ {{$t("Generates: Res: 1080p, Res: 4K, etc.")}}
+
+ {{tag.name}} ({{tag.count}})
+
+
+
+
+
+ {{$t("Video Format")}}
+
+ {{$t("Generates: Format: 180°, etc.")}}
+
+ {{tag.name}} ({{tag.count}})
+
+
+
+
+
{{$t("Scene Attributes")}}
+
+
+ {{$t("Duration")}}
+
+ {{$t("Generates: Duration: Short/Standard/Long")}}
+
+ {{tag.name}} ({{tag.count}})
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{$t("Interracial")}}
+
+ {{$t("Generates: Interracial")}}
+
+ {{tag.name}} ({{tag.count}})
+
+
+
+
+
+
+
+
{{$t("Advanced Controls")}}
+
+
+ Run Now
+ Reset System Tags
+
+
+
+
+
@@ -319,6 +519,35 @@ export default {
lastlinkScenesTimeRange: [0,23],
useLinkScenesTimeRange: false,
linkScenesStartDelay: 0,
+ autoTagScheduleEnabled: false,
+ autoTagScheduleTimeRange:[0,23],
+ autoTagScheduleHourInterval: 0,
+ autoTagScheduleMinuteStart: 0,
+ lastAutoTagScheduleTimeRange: [0,23],
+ useAutoTagScheduleTimeRange: false,
+ autoTagScheduleStartDelay: 0,
+ autoTagBreastType: false,
+
+ autoTagAge: false,
+ autoTagHeight: false,
+ autoTagNationality: false,
+ autoTagEthnicity: false,
+ autoTagHairColor: false,
+ autoTagEyeColor: false,
+ autoTagCupSize: false,
+ autoTagResolution: false,
+ autoTagVideoFormat: false,
+ autoTagDuration: false,
+
+ autoTagInterracial: false,
+ autoTagHeightShortMax: 160,
+ autoTagHeightAverageMax: 175,
+ autoTagDurationShortMax: 15,
+ autoTagDurationStandardMax: 40,
+ isRunNowLoading: false,
+ isResetLoading: false,
+ systemTags: [],
+
timeRange: ['00:00', '01:00', '02:00', '03:00', '04:00', '05:00', '06:00', '07:00', '08:00', '09:00', '10:00', '11:00',
'12:00', '13:00', '14:00', '15:00', '16:00', '17:00', '18:00', '19:00', '20:00', '21:00', '22:00', '23:00',
'00:00', '01:00', '02:00', '03:00', '04:00', '05:00', '06:00', '07:00', '08:00', '09:00', '10:00', '11:00',
@@ -327,8 +556,22 @@ export default {
},
async mounted () {
await this.loadState()
+ await this.loadSystemTags()
},
computed: {
+ breastSizeTags () { return this.systemTags.filter(t => t.name.startsWith('Cup:')) },
+ breastTypeTags () { return this.systemTags.filter(t => t.name.startsWith('Breast Type -')) },
+ ageTags () { return this.systemTags.filter(t => t.name.startsWith('Age:')) },
+ heightTags () { return this.systemTags.filter(t => t.name.startsWith('Height:')) },
+ nationalityTags () { return this.systemTags.filter(t => t.name.startsWith('Nationality:')) },
+ ethnicityTags () { return this.systemTags.filter(t => t.name.startsWith('Ethnicity:')) },
+ hairColorTags () { return this.systemTags.filter(t => t.name.startsWith('Hair:')) },
+ eyeColorTags () { return this.systemTags.filter(t => t.name.startsWith('Eyes:')) },
+ resolutionTags () { return this.systemTags.filter(t => t.name.startsWith('Res:')) },
+ videoFormatTags () { return this.systemTags.filter(t => t.name.startsWith('Format:')) },
+ durationTags () { return this.systemTags.filter(t => t.name.startsWith('Duration:')) },
+ interracialTags () { return this.systemTags.filter(t => t.name === 'Interracial') },
+
stashApiKey: {
get () {
return this.$store.state.optionsAdvanced.advanced.stashApiKey
@@ -364,6 +607,10 @@ export default {
this.linkScenesTimeRange = this.restrictTo24Hours(this.linkScenesTimeRange, this.lastLinkScenesTimeRange)
this.lastLinkScenesTimeRange = this.LinkScenesTimeRange
},
+ restrictAutoTagScheduleTo24Hours () {
+ this.autoTagScheduleTimeRange = this.restrictTo24Hours(this.autoTagScheduleTimeRange, this.lastAutoTagScheduleTimeRange)
+ this.lastAutoTagScheduleTimeRange = this.autoTagScheduleTimeRange
+ },
restrictTo24Hours (timeRange, lastTimeRange) {
// check the first time is not in the second 24 hours, no need, should be in the first 24 hours
if (timeRange[0] > 23) {
@@ -380,6 +627,53 @@ export default {
}
return timeRange
},
+ async runAutoTag () {
+ this.isRunNowLoading = true
+ await ky.get('/api/task/auto-tag')
+ this.isRunNowLoading = false
+ this.$buefy.toast.open({
+ message: 'Auto-tagging started',
+ type: 'is-success'
+ })
+ setTimeout(() => { this.loadSystemTags() }, 2000)
+ },
+ async loadSystemTags () {
+ await ky.get('/api/task/system-tags')
+ .json()
+ .then(data => {
+ this.systemTags = data
+ })
+ },
+ showTagScenes (tagName) {
+ this.$store.state.sceneList.filters.cast = []
+ this.$store.state.sceneList.filters.sites = []
+ this.$store.state.sceneList.filters.tags = [tagName]
+ this.$store.state.sceneList.filters.attributes = []
+ this.$router.push({
+ name: 'scenes',
+ query: { q: this.$store.getters['sceneList/filterQueryParams'] }
+ })
+ },
+ async resetAutoTag () {
+ this.$buefy.dialog.confirm({
+ title: 'Reset System Tags',
+ message: 'Are you sure you want to delete all system-generated tags from all scenes?',
+ confirmText: 'Reset',
+ type: 'is-danger',
+ hasIcon: true,
+ onConfirm: async () => {
+ this.isResetLoading = true
+ await ky.get('/api/task/auto-tag-reset')
+ this.isResetLoading = false
+ this.$buefy.toast.open({
+ message: 'System tags reset',
+ type: 'is-success'
+ })
+ this.systemTags = []
+ }
+ })
+ },
+
async loadState () {
this.isLoading = true
await ky.get('/api/options/state')
@@ -441,6 +735,37 @@ export default {
} else {
this.linkScenesTimeRange = [data.config.cron.linkScenesSchedule.hourStart, data.config.cron.linkScenesSchedule.hourEnd]
}
+
+ this.autoTagScheduleEnabled = data.config.cron.autoTagSchedule.enabled
+ this.autoTagScheduleHourInterval = data.config.cron.autoTagSchedule.hourInterval
+ this.useAutoTagScheduleTimeRange = data.config.cron.autoTagSchedule.useRange
+ this.autoTagScheduleMinuteStart = data.config.cron.autoTagSchedule.minuteStart
+
+ if (data.config.cron.autoTagSchedule.hourStart > data.config.cron.autoTagSchedule.hourEnd) {
+ this.autoTagScheduleTimeRange = [data.config.cron.autoTagSchedule.hourStart, data.config.cron.autoTagSchedule.hourEnd + 24]
+ } else {
+ this.autoTagScheduleTimeRange = [data.config.cron.autoTagSchedule.hourStart, data.config.cron.autoTagSchedule.hourEnd]
+ }
+
+ this.autoTagScheduleStartDelay = data.config.cron.autoTagSchedule.runAtStartDelay
+ this.autoTagBreastType = data.config.autoTag.breastType
+
+ this.autoTagAge = data.config.autoTag.age
+ this.autoTagHeight = data.config.autoTag.height
+ this.autoTagNationality = data.config.autoTag.nationality
+ this.autoTagEthnicity = data.config.autoTag.ethnicity
+ this.autoTagHairColor = data.config.autoTag.hairColor
+ this.autoTagEyeColor = data.config.autoTag.eyeColor
+ this.autoTagCupSize = data.config.autoTag.cupSize
+ this.autoTagResolution = data.config.autoTag.resolution
+ this.autoTagVideoFormat = data.config.autoTag.videoFormat
+ this.autoTagDuration = data.config.autoTag.duration
+
+ this.autoTagInterracial = data.config.autoTag.interracial
+ this.autoTagHeightShortMax = data.config.autoTag.heightShortMax
+ this.autoTagHeightAverageMax = data.config.autoTag.heightAverageMax
+ this.autoTagDurationShortMax = data.config.autoTag.durationShortMax
+ this.autoTagDurationStandardMax = data.config.autoTag.durationStandardMax
this.rescrapeStartDelay = data.config.cron.rescrapeSchedule.runAtStartDelay
this.rescanStartDelay = data.config.cron.rescanSchedule.runAtStartDelay
@@ -516,7 +841,32 @@ export default {
linkScenesMinuteStart: this.linkScenesMinuteStart,
linkScenesHourStart: this.linkScenesTimeRange[0],
linkScenesHourEnd: this.linkScenesTimeRange[1],
- linkScenesStartDelay:this.linkScenesStartDelay
+ linkScenesStartDelay:this.linkScenesStartDelay,
+ autoTagScheduleEnabled: this.autoTagScheduleEnabled,
+ autoTagScheduleHourInterval: this.autoTagScheduleHourInterval,
+ autoTagScheduleUseRange: this.useAutoTagScheduleTimeRange,
+ autoTagScheduleMinuteStart: this.autoTagScheduleMinuteStart,
+ autoTagScheduleHourStart: this.autoTagScheduleTimeRange[0],
+ autoTagScheduleHourEnd: this.autoTagScheduleTimeRange[1],
+ autoTagScheduleStartDelay:this.autoTagScheduleStartDelay,
+ autoTagBreastType: this.autoTagBreastType,
+
+ autoTagAge: this.autoTagAge,
+ autoTagHeight: this.autoTagHeight,
+ autoTagNationality: this.autoTagNationality,
+ autoTagEthnicity: this.autoTagEthnicity,
+ autoTagHairColor: this.autoTagHairColor,
+ autoTagEyeColor: this.autoTagEyeColor,
+ autoTagCupSize: this.autoTagCupSize,
+ autoTagResolution: this.autoTagResolution,
+ autoTagVideoFormat: this.autoTagVideoFormat,
+ autoTagDuration: this.autoTagDuration,
+
+ autoTagInterracial: this.autoTagInterracial,
+ autoTagHeightShortMax: parseInt(this.autoTagHeightShortMax),
+ autoTagHeightAverageMax: parseInt(this.autoTagHeightAverageMax),
+ autoTagDurationShortMax: parseInt(this.autoTagDurationShortMax),
+ autoTagDurationStandardMax: parseInt(this.autoTagDurationStandardMax)
}
})
.json()
From 198ac022b6275f86cf983abe01fbaaa058fdf45c Mon Sep 17 00:00:00 2001
From: TheNightShift-nsfw
<261069475+TheNightShift-nsfw@users.noreply.github.com>
Date: Fri, 17 Jul 2026 15:56:24 -0600
Subject: [PATCH 2/2] feat: auto-tag gender filter, batch processing, and
correctness fixes
- Add configurable gender filter for actor-based tags (dynamic
checkboxes from distinct actor genders in DB)
- Fix early-exit bug that skipped all tagging unless BreastType
was enabled
- Fix resolution thresholds: 4K band was unreachable (5K caught
everything >= 2160); 5K now >= 2700
- Process scenes in batches of 500 instead of loading entire
library into memory
- Pre-cache system tags and reuse the open DB handle instead of
opening a connection per tag write
- Fix stale duplicate check that could double-append tags
- New endpoint /api/task/actor-genders for the UI filter
Co-Authored-By: Claude Fable 5
---
pkg/api/options.go | 3 +
pkg/api/tasks.go | 13 +
pkg/config/config.go | 2 +
pkg/tasks/auto_tag.go | 339 +++++++++++---------
ui/src/views/options/sections/Schedules.vue | 33 +-
5 files changed, 236 insertions(+), 154 deletions(-)
diff --git a/pkg/api/options.go b/pkg/api/options.go
index bcb3d1421..f38b2ef35 100644
--- a/pkg/api/options.go
+++ b/pkg/api/options.go
@@ -209,6 +209,8 @@ type RequestSaveOptionsTaskSchedule struct {
AutoTagInterracial bool `json:"autoTagInterracial"`
+ AutoTagGenderFilter []string `json:"autoTagGenderFilter"`
+
AutoTagHeightShortMax int `json:"autoTagHeightShortMax"`
AutoTagHeightAverageMax int `json:"autoTagHeightAverageMax"`
@@ -1090,6 +1092,7 @@ func (i ConfigResource) saveOptionsTaskSchedule(req *restful.Request, resp *rest
config.Config.AutoTag.Duration = r.AutoTagDuration
config.Config.AutoTag.Interracial = r.AutoTagInterracial
+ config.Config.AutoTag.GenderFilter = r.AutoTagGenderFilter
config.Config.AutoTag.HeightShortMax = r.AutoTagHeightShortMax
config.Config.AutoTag.HeightAverageMax = r.AutoTagHeightAverageMax
diff --git a/pkg/api/tasks.go b/pkg/api/tasks.go
index 9a1d1fa87..1fef02469 100644
--- a/pkg/api/tasks.go
+++ b/pkg/api/tasks.go
@@ -68,6 +68,9 @@ func (i TaskResource) WebService() *restful.WebService {
ws.Route(ws.GET("/system-tags").To(i.getSystemTags).
Metadata(restfulspec.KeyOpenAPITags, tags))
+ ws.Route(ws.GET("/actor-genders").To(i.getActorGenders).
+ Metadata(restfulspec.KeyOpenAPITags, tags))
+
ws.Route(ws.GET("/rescan/{storage-id}").To(i.rescan).
Metadata(restfulspec.KeyOpenAPITags, tags))
@@ -144,6 +147,16 @@ func (i TaskResource) getSystemTags(req *restful.Request, resp *restful.Response
resp.WriteHeaderAndEntity(http.StatusOK, tags)
}
+func (i TaskResource) getActorGenders(req *restful.Request, resp *restful.Response) {
+ db, _ := models.GetDB()
+ defer db.Close()
+
+ var genders []string
+ db.Model(&models.Actor{}).Where("gender != ''").Pluck("DISTINCT gender", &genders)
+
+ resp.WriteHeaderAndEntity(http.StatusOK, genders)
+}
+
func (i TaskResource) sceneRrefresh(req *restful.Request, resp *restful.Response) {
go tasks.RefreshSceneStatuses()
}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 286a096c1..ce1973363 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -200,6 +200,8 @@ type ObjectConfig struct {
Interracial bool `default:"false" json:"interracial"`
BMI bool `default:"false" json:"bmi"`
+ GenderFilter []string `default:"[]" json:"genderFilter"`
+
HeightShortMax int `default:"160" json:"heightShortMax"`
HeightAverageMax int `default:"175" json:"heightAverageMax"`
diff --git a/pkg/tasks/auto_tag.go b/pkg/tasks/auto_tag.go
index 02c48b243..6dd730cad 100644
--- a/pkg/tasks/auto_tag.go
+++ b/pkg/tasks/auto_tag.go
@@ -13,234 +13,269 @@ func ResetAutoTags() {
db, _ := models.GetDB()
defer db.Close()
- // Find all system tags
var tags []models.Tag
db.Where("is_system = ?", true).Find(&tags)
for _, tag := range tags {
- // Remove tag from all scenes
db.Exec("DELETE FROM scene_tags WHERE tag_id = ?", tag.ID)
-
- // Delete the tag itself (optional, but cleaner)
db.Delete(&tag)
}
- // Recalculate counts
var t models.Tag
t.CountTags()
}
func GenerateAutoTags() {
- if !config.Config.AutoTag.BreastType {
+ cfg := config.Config.AutoTag
+ if !cfg.BreastType && !cfg.Age && !cfg.Height && !cfg.Nationality &&
+ !cfg.Ethnicity && !cfg.HairColor && !cfg.EyeColor && !cfg.CupSize &&
+ !cfg.Resolution && !cfg.VideoFormat && !cfg.Duration && !cfg.Interracial {
return
}
db, _ := models.GetDB()
defer db.Close()
- var scenes []models.Scene
- db.Model(&models.Scene{}).Preload("Cast").Preload("Tags").Preload("Files").Find(&scenes)
+ // Build gender filter set (empty = include all genders)
+ genderFilter := make(map[string]bool)
+ for _, g := range cfg.GenderFilter {
+ genderFilter[strings.ToLower(strings.TrimSpace(g))] = true
+ }
+ filterByGender := len(genderFilter) > 0
+
+ // Pre-cache existing system tags: lowercase name -> ID
+ tagCache := make(map[string]uint)
+ var existingTags []models.Tag
+ db.Where("is_system = ?", true).Find(&existingTags)
+ for _, t := range existingTags {
+ tagCache[strings.ToLower(t.Name)] = t.ID
+ }
- // Calculate ranges for height and duration once
- heightShortMax := config.Config.AutoTag.HeightShortMax
- heightAvgMax := config.Config.AutoTag.HeightAverageMax
- durShortMax := config.Config.AutoTag.DurationShortMax
- durStdMax := config.Config.AutoTag.DurationStandardMax
+ heightShortMax := cfg.HeightShortMax
+ heightAvgMax := cfg.HeightAverageMax
+ durShortMax := cfg.DurationShortMax
+ durStdMax := cfg.DurationStandardMax
+
+ const batchSize = 500
+ offset := 0
+ for {
+ var scenes []models.Scene
+ db.Model(&models.Scene{}).Preload("Cast").Preload("Tags").Preload("Files").
+ Limit(batchSize).Offset(offset).Find(&scenes)
+ if len(scenes) == 0 {
+ break
+ }
- for _, scene := range scenes {
- // Breast Type
- if config.Config.AutoTag.BreastType {
- isNatural := false
- isFake := false
+ for _, scene := range scenes {
+ // Snapshot existing scene tags for duplicate detection
+ existingSceneTags := make(map[string]bool)
+ for _, t := range scene.Tags {
+ existingSceneTags[strings.ToLower(t.Name)] = true
+ }
- for _, actor := range scene.Cast {
- if strings.EqualFold(actor.BreastType, "Natural") {
- isNatural = true
- }
- if strings.EqualFold(actor.BreastType, "Fake") || strings.EqualFold(actor.BreastType, "Silicone") || strings.EqualFold(actor.BreastType, "Enhanced") {
- isFake = true
+ // Filter cast by gender if configured
+ filteredCast := scene.Cast
+ if filterByGender {
+ filteredCast = nil
+ for _, actor := range scene.Cast {
+ if genderFilter[strings.ToLower(strings.TrimSpace(actor.Gender))] {
+ filteredCast = append(filteredCast, actor)
+ }
}
}
- if isNatural {
- addTagToScene(db, &scene, "Breast Type - Natural")
- }
- if isFake {
- addTagToScene(db, &scene, "Breast Type - Fake")
+ addTag := func(name string) {
+ addTagToScene(db, &scene, name, existingSceneTags, tagCache)
}
- }
- // Age
- if config.Config.AutoTag.Age && !scene.ReleaseDate.IsZero() {
- for _, actor := range scene.Cast {
- if !actor.BirthDate.IsZero() {
- age := scene.ReleaseDate.Year() - actor.BirthDate.Year()
- // Adjust for month/day if needed, but year diff is usually sufficient for "Age: XX" tags
- if scene.ReleaseDate.YearDay() < actor.BirthDate.YearDay() {
- age--
+ // Breast Type
+ if cfg.BreastType {
+ isNatural, isFake := false, false
+ for _, actor := range filteredCast {
+ if strings.EqualFold(actor.BreastType, "Natural") {
+ isNatural = true
}
- if age >= 18 && age < 100 { // Basic sanity check
- addTagToScene(db, &scene, fmt.Sprintf("Age: %d", age))
+ if strings.EqualFold(actor.BreastType, "Fake") ||
+ strings.EqualFold(actor.BreastType, "Silicone") ||
+ strings.EqualFold(actor.BreastType, "Enhanced") {
+ isFake = true
}
}
+ if isNatural {
+ addTag("Breast Type - Natural")
+ }
+ if isFake {
+ addTag("Breast Type - Fake")
+ }
}
- }
- // Height
- if config.Config.AutoTag.Height {
- for _, actor := range scene.Cast {
- if actor.Height > 0 {
- if actor.Height <= heightShortMax {
- addTagToScene(db, &scene, "Height: Short")
- } else if actor.Height <= heightAvgMax {
- addTagToScene(db, &scene, "Height: Average")
- } else {
- addTagToScene(db, &scene, "Height: Tall")
+ // Age
+ if cfg.Age && !scene.ReleaseDate.IsZero() {
+ for _, actor := range filteredCast {
+ if !actor.BirthDate.IsZero() {
+ age := scene.ReleaseDate.Year() - actor.BirthDate.Year()
+ if scene.ReleaseDate.YearDay() < actor.BirthDate.YearDay() {
+ age--
+ }
+ if age >= 18 && age < 100 {
+ addTag(fmt.Sprintf("Age: %d", age))
+ }
}
}
}
- }
- // Nationality
- if config.Config.AutoTag.Nationality {
- for _, actor := range scene.Cast {
- if actor.Nationality != "" {
- addTagToScene(db, &scene, "Nationality: "+actor.Nationality)
+ // Height
+ if cfg.Height {
+ for _, actor := range filteredCast {
+ if actor.Height > 0 {
+ if actor.Height <= heightShortMax {
+ addTag("Height: Short")
+ } else if actor.Height <= heightAvgMax {
+ addTag("Height: Average")
+ } else {
+ addTag("Height: Tall")
+ }
+ }
}
}
- }
- // Ethnicity
- if config.Config.AutoTag.Ethnicity {
- for _, actor := range scene.Cast {
- if actor.Ethnicity != "" {
- addTagToScene(db, &scene, "Ethnicity: "+actor.Ethnicity)
+ // Nationality
+ if cfg.Nationality {
+ for _, actor := range filteredCast {
+ if actor.Nationality != "" {
+ addTag("Nationality: " + actor.Nationality)
+ }
}
}
- }
- // Hair Color
- if config.Config.AutoTag.HairColor {
- for _, actor := range scene.Cast {
- if actor.HairColor != "" {
- addTagToScene(db, &scene, "Hair: "+actor.HairColor)
+ // Ethnicity
+ if cfg.Ethnicity {
+ for _, actor := range filteredCast {
+ if actor.Ethnicity != "" {
+ addTag("Ethnicity: " + actor.Ethnicity)
+ }
}
}
- }
- // Eye Color
- if config.Config.AutoTag.EyeColor {
- for _, actor := range scene.Cast {
- if actor.EyeColor != "" {
- addTagToScene(db, &scene, "Eyes: "+actor.EyeColor)
+ // Hair Color
+ if cfg.HairColor {
+ for _, actor := range filteredCast {
+ if actor.HairColor != "" {
+ addTag("Hair: " + actor.HairColor)
+ }
}
}
- }
- // Cup Size
- if config.Config.AutoTag.CupSize {
- for _, actor := range scene.Cast {
- if actor.CupSize != "" {
- addTagToScene(db, &scene, "Cup: "+actor.CupSize)
+ // Eye Color
+ if cfg.EyeColor {
+ for _, actor := range filteredCast {
+ if actor.EyeColor != "" {
+ addTag("Eyes: " + actor.EyeColor)
+ }
}
}
- }
-
- // Interracial
- if config.Config.AutoTag.Interracial && len(scene.Cast) > 0 {
- ethnicities := make(map[string]bool)
- for _, actor := range scene.Cast {
- if actor.Ethnicity != "" {
- ethnicities[strings.ToLower(actor.Ethnicity)] = true
+ // Cup Size
+ if cfg.CupSize {
+ for _, actor := range filteredCast {
+ if actor.CupSize != "" {
+ addTag("Cup: " + actor.CupSize)
+ }
}
}
- if len(ethnicities) > 1 {
- addTagToScene(db, &scene, "Interracial")
+ // Interracial — only among gender-filtered cast
+ if cfg.Interracial && len(filteredCast) > 0 {
+ ethnicities := make(map[string]bool)
+ for _, actor := range filteredCast {
+ if actor.Ethnicity != "" {
+ ethnicities[strings.ToLower(actor.Ethnicity)] = true
+ }
+ }
+ if len(ethnicities) > 1 {
+ addTag("Interracial")
+ }
}
- }
- // Duration
- if config.Config.AutoTag.Duration && scene.Duration > 0 {
- // Duration is in minutes
- if scene.Duration <= durShortMax {
- addTagToScene(db, &scene, "Duration: Short")
- } else if scene.Duration <= durStdMax {
- addTagToScene(db, &scene, "Duration: Standard")
- } else {
- addTagToScene(db, &scene, "Duration: Long")
+ // Duration
+ if cfg.Duration && scene.Duration > 0 {
+ if scene.Duration <= durShortMax {
+ addTag("Duration: Short")
+ } else if scene.Duration <= durStdMax {
+ addTag("Duration: Standard")
+ } else {
+ addTag("Duration: Long")
+ }
}
- }
- // Resolution & Format
- if (config.Config.AutoTag.Resolution || config.Config.AutoTag.VideoFormat) && len(scene.Files) > 0 {
- // Find best file (highest resolution)
- var bestFile models.File
- for _, f := range scene.Files {
- if f.VideoHeight > bestFile.VideoHeight {
- bestFile = f
+ // Resolution & Format
+ if (cfg.Resolution || cfg.VideoFormat) && len(scene.Files) > 0 {
+ var bestFile models.File
+ for _, f := range scene.Files {
+ if f.VideoHeight > bestFile.VideoHeight {
+ bestFile = f
+ }
}
- }
- if config.Config.AutoTag.Resolution && bestFile.VideoHeight > 0 {
- if bestFile.VideoHeight >= 4320 {
- addTagToScene(db, &scene, "Res: 8K")
- } else if bestFile.VideoHeight >= 2880 {
- addTagToScene(db, &scene, "Res: 6K")
- } else if bestFile.VideoHeight >= 2160 {
- addTagToScene(db, &scene, "Res: 5K") // Sometimes 5K is distinct
- } else if bestFile.VideoHeight >= 1900 { // Allow some tolerance for 4K
- addTagToScene(db, &scene, "Res: 4K")
- } else if bestFile.VideoHeight >= 1440 {
- addTagToScene(db, &scene, "Res: 1440p")
- } else if bestFile.VideoHeight >= 1080 {
- addTagToScene(db, &scene, "Res: 1080p")
- } else if bestFile.VideoHeight >= 720 {
- addTagToScene(db, &scene, "Res: 720p")
- } else {
- addTagToScene(db, &scene, "Res: SD")
+ if cfg.Resolution && bestFile.VideoHeight > 0 {
+ switch {
+ case bestFile.VideoHeight >= 4320:
+ addTag("Res: 8K")
+ case bestFile.VideoHeight >= 2880:
+ addTag("Res: 6K")
+ case bestFile.VideoHeight >= 2700:
+ addTag("Res: 5K")
+ case bestFile.VideoHeight >= 1900:
+ addTag("Res: 4K")
+ case bestFile.VideoHeight >= 1440:
+ addTag("Res: 1440p")
+ case bestFile.VideoHeight >= 1080:
+ addTag("Res: 1080p")
+ case bestFile.VideoHeight >= 720:
+ addTag("Res: 720p")
+ default:
+ addTag("Res: SD")
+ }
}
- }
- if config.Config.AutoTag.VideoFormat {
- if bestFile.VideoProjection == "180_sbs" || bestFile.VideoProjection == "180_tb" {
- addTagToScene(db, &scene, "Format: 180°")
- } else if bestFile.VideoProjection == "360_sbs" || bestFile.VideoProjection == "360_tb" || bestFile.VideoProjection == "360_mono" {
- addTagToScene(db, &scene, "Format: 360°")
- } else if bestFile.VideoProjection == "flat" {
- addTagToScene(db, &scene, "Format: Flat")
+ if cfg.VideoFormat {
+ switch bestFile.VideoProjection {
+ case "180_sbs", "180_tb":
+ addTag("Format: 180°")
+ case "360_sbs", "360_tb", "360_mono":
+ addTag("Format: 360°")
+ case "flat":
+ addTag("Format: Flat")
+ }
}
}
}
-
- // Helper function inside loop to save code duplication
+ offset += batchSize
}
- // Recalculate tag counts
var t models.Tag
t.CountTags()
}
-func addTagToScene(db *gorm.DB, scene *models.Scene, tagName string) {
- tagExists := false
- for _, t := range scene.Tags {
- if strings.EqualFold(t.Name, tagName) {
- tagExists = true
- break
- }
+func addTagToScene(db *gorm.DB, scene *models.Scene, tagName string, existingSceneTags map[string]bool, tagCache map[string]uint) {
+ lowerName := strings.ToLower(tagName)
+ if existingSceneTags[lowerName] {
+ return
}
+ existingSceneTags[lowerName] = true
- if !tagExists {
- var tag models.Tag
+ var tag models.Tag
+ if id, ok := tagCache[lowerName]; ok {
+ tag.ID = id
+ tag.Name = tagName
+ } else {
db.Where(models.Tag{Name: tagName}).FirstOrCreate(&tag)
-
- // Mark as system tag if it's new or update it
if !tag.IsSystem {
+ db.Model(&tag).Update("is_system", true)
tag.IsSystem = true
- tag.Save()
}
-
- db.Model(scene).Association("Tags").Append(tag)
+ tagCache[lowerName] = tag.ID
}
+
+ db.Model(scene).Association("Tags").Append(tag)
}
diff --git a/ui/src/views/options/sections/Schedules.vue b/ui/src/views/options/sections/Schedules.vue
index 961e2ee14..c234b7388 100644
--- a/ui/src/views/options/sections/Schedules.vue
+++ b/ui/src/views/options/sections/Schedules.vue
@@ -290,6 +290,24 @@
{{$t("Generators")}}
+
+
{{$t("Gender Filter")}}
+
+ {{$t("Only apply actor-based tags to these genders. Leave all unchecked to tag all genders.")}}
+
+
+
+
+ {{ gender }}
+
+
+
+
+
{{$t("Actor Characteristics")}}
@@ -526,6 +544,8 @@ export default {
lastAutoTagScheduleTimeRange: [0,23],
useAutoTagScheduleTimeRange: false,
autoTagScheduleStartDelay: 0,
+ autoTagGenderFilter: [],
+ availableGenders: [],
autoTagBreastType: false,
autoTagAge: false,
@@ -557,6 +577,7 @@ export default {
async mounted () {
await this.loadState()
await this.loadSystemTags()
+ await this.loadActorGenders()
},
computed: {
breastSizeTags () { return this.systemTags.filter(t => t.name.startsWith('Cup:')) },
@@ -632,10 +653,9 @@ export default {
await ky.get('/api/task/auto-tag')
this.isRunNowLoading = false
this.$buefy.toast.open({
- message: 'Auto-tagging started',
+ message: 'Auto-tagging started in background. Reload this tab when complete to see updated tags.',
type: 'is-success'
})
- setTimeout(() => { this.loadSystemTags() }, 2000)
},
async loadSystemTags () {
await ky.get('/api/task/system-tags')
@@ -644,6 +664,13 @@ export default {
this.systemTags = data
})
},
+ async loadActorGenders () {
+ await ky.get('/api/task/actor-genders')
+ .json()
+ .then(data => {
+ this.availableGenders = data || []
+ })
+ },
showTagScenes (tagName) {
this.$store.state.sceneList.filters.cast = []
this.$store.state.sceneList.filters.sites = []
@@ -762,6 +789,7 @@ export default {
this.autoTagDuration = data.config.autoTag.duration
this.autoTagInterracial = data.config.autoTag.interracial
+ this.autoTagGenderFilter = data.config.autoTag.genderFilter || []
this.autoTagHeightShortMax = data.config.autoTag.heightShortMax
this.autoTagHeightAverageMax = data.config.autoTag.heightAverageMax
this.autoTagDurationShortMax = data.config.autoTag.durationShortMax
@@ -863,6 +891,7 @@ export default {
autoTagDuration: this.autoTagDuration,
autoTagInterracial: this.autoTagInterracial,
+ autoTagGenderFilter: this.autoTagGenderFilter,
autoTagHeightShortMax: parseInt(this.autoTagHeightShortMax),
autoTagHeightAverageMax: parseInt(this.autoTagHeightAverageMax),
autoTagDurationShortMax: parseInt(this.autoTagDurationShortMax),