Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ require (
github.com/tidwall/gjson v1.18.0
github.com/x-cray/logrus-prefixed-formatter v0.5.2
github.com/xo/dburl v0.24.2
github.com/yalue/onnxruntime_go v1.31.0
golang.org/x/crypto v0.50.0
golang.org/x/net v0.53.0
golang.org/x/oauth2 v0.36.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,8 @@ github.com/xo/dburl v0.23.8 h1:NwFghJfjaUW7tp+WE5mTLQQCfgseRsvgXjlSvk7x4t4=
github.com/xo/dburl v0.23.8/go.mod h1:uazlaAQxj4gkshhfuuYyvwCBouOmNnG2aDxTCFZpmL4=
github.com/xo/dburl v0.24.2 h1:aK6ASamrFjKl76h/UCBecc0BPBi97+IVmw4YWxx0rno=
github.com/xo/dburl v0.24.2/go.mod h1:uazlaAQxj4gkshhfuuYyvwCBouOmNnG2aDxTCFZpmL4=
github.com/yalue/onnxruntime_go v1.31.0 h1:1ln4YW1SFOFfGJZXe3jNOb2JUSt+l2pEneZfV8HdtFA=
github.com/yalue/onnxruntime_go v1.31.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
Expand Down
2 changes: 2 additions & 0 deletions pkg/api/heresphere.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"strconv"
"strings"
"sync"
"time"

"github.com/dustin/go-humanize"
restfulspec "github.com/emicklei/go-restful-openapi/v2"
Expand Down Expand Up @@ -795,6 +796,7 @@ func ProcessHeresphereUpdates(scene *models.Scene, requestData HereSphereAuthReq
}
if requestData.Rating != nil && *requestData.Rating != scene.StarRating && config.Config.Interfaces.Heresphere.AllowRatingUpdates {
scene.StarRating = *requestData.Rating
scene.StarRatingUpdatedAt = time.Now()
scene.Save()
}

Expand Down
16 changes: 16 additions & 0 deletions pkg/api/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,14 @@ type RequestSaveOptionsTaskSchedule struct {
LinkScenesHourStart int `json:"linkScenesHourStart"`
LinkScenesHourEnd int `json:"linkScenesHourEnd"`
LinkScenesStartDelay int `json:"linkScenesStartDelay"`

RecommendationEnabled bool `json:"recommendationEnabled"`
RecommendationHourInterval int `json:"recommendationHourInterval"`
RecommendationUseRange bool `json:"recommendationUseRange"`
RecommendationMinuteStart int `json:"recommendationMinuteStart"`
RecommendationHourStart int `json:"recommendationHourStart"`
RecommendationHourEnd int `json:"recommendationHourEnd"`
RecommendationStartDelay int `json:"recommendationStartDelay"`
}
type RequestSaveSiteMatchParams struct {
SiteId string `json:"site"`
Expand Down Expand Up @@ -1038,6 +1046,14 @@ 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.RecommendationSchedule.Enabled = r.RecommendationEnabled
config.Config.Cron.RecommendationSchedule.HourInterval = r.RecommendationHourInterval
config.Config.Cron.RecommendationSchedule.UseRange = r.RecommendationUseRange
config.Config.Cron.RecommendationSchedule.MinuteStart = r.RecommendationMinuteStart
config.Config.Cron.RecommendationSchedule.HourStart = r.RecommendationHourStart
config.Config.Cron.RecommendationSchedule.HourEnd = r.RecommendationHourEnd
config.Config.Cron.RecommendationSchedule.RunAtStartDelay = r.RecommendationStartDelay

config.SaveConfig()

resp.WriteHeaderAndEntity(http.StatusOK, r)
Expand Down
95 changes: 95 additions & 0 deletions pkg/api/recommendations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package api

import (
"net/http"

restfulspec "github.com/emicklei/go-restful-openapi/v2"
"github.com/emicklei/go-restful/v3"

"github.com/xbapps/xbvr/pkg/config"
"github.com/xbapps/xbvr/pkg/recommend"
)

type RecommendationResource struct{}

// RequestRecommendationConfig mirrors config.Config.Recommendation for the UI.
type RequestRecommendationConfig struct {
Enabled bool `json:"enabled"`
UseLearnedModel bool `json:"useLearnedModel"`
ModelType string `json:"modelType"`
UseVisualEmbeddings bool `json:"useVisualEmbeddings"`
WatchListSize int `json:"watchListSize"`
DeleteListSize int `json:"deleteListSize"`
ProtectRating float64 `json:"protectRating"`
GraceDays int `json:"graceDays"`
ExcludeRecentlyWatched bool `json:"excludeRecentlyWatched"`
DiversityDecay float64 `json:"diversityDecay"`
WActor float64 `json:"wActor"`
WTag float64 `json:"wTag"`
WSite float64 `json:"wSite"`
WQuality float64 `json:"wQuality"`
WFreshness float64 `json:"wFreshness"`
WSize float64 `json:"wSize"`
WVisualQuality float64 `json:"wVisualQuality"`
VQMaxSamples int `json:"vqMaxSamples"`
NoiseWeight float64 `json:"noiseWeight"`
}

func (i RecommendationResource) WebService() *restful.WebService {
tags := []string{"Recommendations"}

ws := new(restful.WebService)
ws.Path("/api/recommendations").
Consumes(restful.MIME_JSON).
Produces(restful.MIME_JSON)

ws.Route(ws.GET("/config").To(i.getConfig).
Metadata(restfulspec.KeyOpenAPITags, tags))
ws.Route(ws.POST("/config").To(i.saveConfig).
Metadata(restfulspec.KeyOpenAPITags, tags))
ws.Route(ws.POST("/recompute").Consumes("*/*").To(i.recompute).
Metadata(restfulspec.KeyOpenAPITags, tags))

return ws
}

func (i RecommendationResource) getConfig(req *restful.Request, resp *restful.Response) {
resp.WriteHeaderAndEntity(http.StatusOK, config.Config.Recommendation)
}

func (i RecommendationResource) saveConfig(req *restful.Request, resp *restful.Response) {
var r RequestRecommendationConfig
if err := req.ReadEntity(&r); err != nil {
log.Error(err)
resp.WriteHeaderAndEntity(http.StatusBadRequest, nil)
return
}

config.Config.Recommendation.Enabled = r.Enabled
config.Config.Recommendation.UseLearnedModel = r.UseLearnedModel
config.Config.Recommendation.ModelType = r.ModelType
config.Config.Recommendation.UseVisualEmbeddings = r.UseVisualEmbeddings
config.Config.Recommendation.WatchListSize = r.WatchListSize
config.Config.Recommendation.DeleteListSize = r.DeleteListSize
config.Config.Recommendation.ProtectRating = r.ProtectRating
config.Config.Recommendation.GraceDays = r.GraceDays
config.Config.Recommendation.ExcludeRecentlyWatched = r.ExcludeRecentlyWatched
config.Config.Recommendation.DiversityDecay = r.DiversityDecay
config.Config.Recommendation.WActor = r.WActor
config.Config.Recommendation.WTag = r.WTag
config.Config.Recommendation.WSite = r.WSite
config.Config.Recommendation.WQuality = r.WQuality
config.Config.Recommendation.WFreshness = r.WFreshness
config.Config.Recommendation.WSize = r.WSize
config.Config.Recommendation.WVisualQuality = r.WVisualQuality
config.Config.Recommendation.VQMaxSamples = r.VQMaxSamples
config.Config.Recommendation.NoiseWeight = r.NoiseWeight
config.SaveConfig()

resp.WriteHeaderAndEntity(http.StatusOK, config.Config.Recommendation)
}

func (i RecommendationResource) recompute(req *restful.Request, resp *restful.Response) {
go recommend.Generate()
resp.WriteHeaderAndEntity(http.StatusOK, map[string]string{"status": "started"})
}
3 changes: 3 additions & 0 deletions pkg/api/scenes.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,8 @@ func (i SceneResource) getFilters(req *restful.Request, resp *restful.Response)

// supported attributes
var outAttributes []string
outAttributes = append(outAttributes, "Recommended To Watch")
outAttributes = append(outAttributes, "Recommend To Delete")
outAttributes = append(outAttributes, "Multiple Video Files")
outAttributes = append(outAttributes, "Single Video File")
outAttributes = append(outAttributes, "Multiple Script Files")
Expand Down Expand Up @@ -789,6 +791,7 @@ func (i SceneResource) rateScene(req *restful.Request, resp *restful.Response) {
err = scene.GetIfExistByPK(uint(sceneId))
if err == nil {
scene.StarRating = r.Rating
scene.StarRatingUpdatedAt = time.Now()
scene.Save()
}
db.Close()
Expand Down
30 changes: 30 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,37 @@ type ObjectConfig struct {
HourEnd int `default:"23" json:"hourEnd"`
RunAtStartDelay int `default:"0" json:"runAtStartDelay"`
} `json:"linkScenesSchedule"`
RecommendationSchedule struct {
Enabled bool `default:"true" json:"enabled"`
HourInterval int `default:"24" 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:"recommendationSchedule"`
} `json:"cron"`
Recommendation struct {
Enabled bool `default:"true" json:"enabled"`
UseLearnedModel bool `default:"false" json:"useLearnedModel"`
ModelType string `default:"linear" json:"modelType"`
UseVisualEmbeddings bool `default:"false" json:"useVisualEmbeddings"`
WatchListSize int `default:"30" json:"watchListSize"`
DeleteListSize int `default:"30" json:"deleteListSize"`
ProtectRating float64 `default:"4" json:"protectRating"`
GraceDays int `default:"30" json:"graceDays"`
ExcludeRecentlyWatched bool `default:"true" json:"excludeRecentlyWatched"`
DiversityDecay float64 `default:"0.5" json:"diversityDecay"`
WActor float64 `default:"1.0" json:"wActor"`
WTag float64 `default:"0.7" json:"wTag"`
WSite float64 `default:"0.3" json:"wSite"`
WQuality float64 `default:"0.2" json:"wQuality"`
WFreshness float64 `default:"0.2" json:"wFreshness"`
WSize float64 `default:"0.5" json:"wSize"`
WVisualQuality float64 `default:"0.5" json:"wVisualQuality"`
VQMaxSamples int `default:"6" json:"vqMaxSamples"`
NoiseWeight float64 `default:"0.3" json:"noiseWeight"`
} `json:"recommendation"`
Storage struct {
MatchOhash bool `default:"false" json:"match_ohash"`
VideoExt []string `json:"video_ext"`
Expand Down
86 changes: 86 additions & 0 deletions pkg/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -2387,6 +2387,92 @@ func Migrate(migrateTo string) {
return tx.Table("scenes").AddIndex("idx_scenes_scraper_id", "scraper_id").Error
},
},
{
ID: "0088-add-recommendation-fields",
Migrate: func(tx *gorm.DB) error {
type Scene struct {
RecWatchScore float64 `json:"rec_watch_score" gorm:"default:0"`
RecDeleteScore float64 `json:"rec_delete_score" gorm:"default:0"`
RecScoredAt time.Time `json:"rec_scored_at"`
}
return tx.AutoMigrate(Scene{}).Error
},
},
{
ID: "0089-seed-recommendation-playlists",
Migrate: func(tx *gorm.DB) error {
// No Limit: the engine writes exactly Recommendation.WatchListSize scored
// scenes, so the list size is controlled by config, not the playlist.
forYou := RequestSceneList{
IsAvailable: optional.NewBool(true),
IsAccessible: optional.NewBool(true),
Sort: optional.NewString("rec_watch_desc"),
}
playlistForYou := models.Playlist{
Name: "For You",
IsSystem: true,
IsSmart: true,
IsDeoEnabled: true,
Ordering: -46,
PlaylistType: "scene",
SearchParams: forYou.ToJSON(),
}
playlistForYou.Save()

cleanup := RequestSceneList{
IsAvailable: optional.NewBool(true),
IsAccessible: optional.NewBool(true),
Sort: optional.NewString("rec_delete_desc"),
}
playlistCleanup := models.Playlist{
Name: "Cleanup",
IsSystem: true,
IsSmart: true,
IsDeoEnabled: true,
Ordering: -45,
PlaylistType: "scene",
SearchParams: cleanup.ToJSON(),
}
playlistCleanup.Save()

return nil
},
},
{
ID: "0090-add-file-visual-quality",
Migrate: func(tx *gorm.DB) error {
type File struct {
VisualQuality float64 `gorm:"default:0"`
VisualQualitySamples int `gorm:"default:0"`
VisualQualityComputedAt time.Time
}
return tx.AutoMigrate(File{}).Error
},
},
{
ID: "0091-add-file-visual-embedding",
Migrate: func(tx *gorm.DB) error {
type File struct {
VisualEmbedding []byte
VisualEmbeddingAt time.Time
}
return tx.AutoMigrate(File{}).Error
},
},
{
ID: "0092-scene-star-rating-updated-at",
Migrate: func(tx *gorm.DB) error {
type Scene struct {
StarRatingUpdatedAt time.Time
}
if err := tx.AutoMigrate(Scene{}).Error; err != nil {
return err
}
// Approximate existing ratings' timestamp from updated_at so the
// recent-rating cleanup protection has something to work with.
return tx.Exec("UPDATE scenes SET star_rating_updated_at = updated_at WHERE star_rating > 0").Error
},
},
}

// Wrap migrations to automatically track progress
Expand Down
11 changes: 11 additions & 0 deletions pkg/models/model_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ type File struct {
VideoProjection string `json:"projection" xbvrbackup:"projection"`
HasAlpha bool `json:"has_alpha" xbvrbackup:"has_alpha"`

// No-reference visual quality (median native-resolution Laplacian edge-energy
// over frames sampled every 5 min), computed by pkg/recommend for files that
// enter the recommendation lists. Higher = crisper/more detail.
VisualQuality float64 `json:"visual_quality" gorm:"default:0" xbvrbackup:"-"`
VisualQualitySamples int `json:"visual_quality_samples" gorm:"default:0" xbvrbackup:"-"`
VisualQualityComputedAt time.Time `json:"visual_quality_computed_at" xbvrbackup:"-"`

// Visual embedding (CNN descriptor of one frame), used as learned-ranker features.
VisualEmbedding []byte `json:"-" xbvrbackup:"-"`
VisualEmbeddingAt time.Time `json:"-" xbvrbackup:"-"`

HasHeatmap bool `json:"has_heatmap" xbvrbackup:"-"`
IsSelectedScript bool `json:"is_selected_script" xbvrbackup:"is_selected_script"`
IsExported bool `json:"is_exported" xbvrbackup:"-"`
Expand Down
46 changes: 32 additions & 14 deletions pkg/models/model_scene.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,20 +80,21 @@ type Scene struct {
MemberURL string `json:"members_url" xbvrbackup:"members_url"`
IsMultipart bool `json:"is_multipart" xbvrbackup:"is_multipart"`

StarRating float64 `json:"star_rating" xbvrbackup:"star_rating"`
Favourite bool `json:"favourite" gorm:"default:false" xbvrbackup:"favourite"`
Watchlist bool `json:"watchlist" gorm:"default:false" xbvrbackup:"watchlist"`
Wishlist bool `json:"wishlist" gorm:"default:false" xbvrbackup:"wishlist"`
IsAvailable bool `json:"is_available" gorm:"default:false" xbvrbackup:"-"`
IsAccessible bool `json:"is_accessible" gorm:"default:false" xbvrbackup:"-"`
IsWatched bool `json:"is_watched" gorm:"default:false" xbvrbackup:"is_watched"`
IsScripted bool `json:"is_scripted" gorm:"default:false" xbvrbackup:"-"`
Cuepoints []SceneCuepoint `json:"cuepoints" xbvrbackup:"-"`
History []History `json:"history" xbvrbackup:"-"`
AddedDate time.Time `json:"added_date" xbvrbackup:"added_date"`
LastOpened time.Time `json:"last_opened" xbvrbackup:"last_opened"`
TotalFileSize int64 `json:"total_file_size" xbvrbackup:"-"`
TotalWatchTime int `json:"total_watch_time" gorm:"default:0" xbvrbackup:"total_watch_time"`
StarRating float64 `json:"star_rating" xbvrbackup:"star_rating"`
StarRatingUpdatedAt time.Time `json:"star_rating_updated_at" xbvrbackup:"-"`
Favourite bool `json:"favourite" gorm:"default:false" xbvrbackup:"favourite"`
Watchlist bool `json:"watchlist" gorm:"default:false" xbvrbackup:"watchlist"`
Wishlist bool `json:"wishlist" gorm:"default:false" xbvrbackup:"wishlist"`
IsAvailable bool `json:"is_available" gorm:"default:false" xbvrbackup:"-"`
IsAccessible bool `json:"is_accessible" gorm:"default:false" xbvrbackup:"-"`
IsWatched bool `json:"is_watched" gorm:"default:false" xbvrbackup:"is_watched"`
IsScripted bool `json:"is_scripted" gorm:"default:false" xbvrbackup:"-"`
Cuepoints []SceneCuepoint `json:"cuepoints" xbvrbackup:"-"`
History []History `json:"history" xbvrbackup:"-"`
AddedDate time.Time `json:"added_date" xbvrbackup:"added_date"`
LastOpened time.Time `json:"last_opened" xbvrbackup:"last_opened"`
TotalFileSize int64 `json:"total_file_size" xbvrbackup:"-"`
TotalWatchTime int `json:"total_watch_time" gorm:"default:0" xbvrbackup:"total_watch_time"`

HasVideoPreview bool `json:"has_preview" gorm:"default:false" xbvrbackup:"-"`
// HasVideoThumbnail bool `json:"has_video_thumbnail" gorm:"default:false"`
Expand All @@ -112,6 +113,11 @@ type Scene struct {
AiScript bool `json:"ai_script" gorm:"default:false" xbvrbackup:"ai_script"`
HumanScript bool `json:"human_script" gorm:"default:false" xbvrbackup:"human_script"`

// Recommendation scores, computed by pkg/recommend and surfaced via deo playlists.
RecWatchScore float64 `json:"rec_watch_score" gorm:"default:0" xbvrbackup:"-"`
RecDeleteScore float64 `json:"rec_delete_score" gorm:"default:0" xbvrbackup:"-"`
RecScoredAt time.Time `json:"rec_scored_at" xbvrbackup:"-"`

Description string `gorm:"-" json:"description" xbvrbackup:"-"`
Score float64 `gorm:"-" json:"_score" xbvrbackup:"-"`

Expand Down Expand Up @@ -979,6 +985,10 @@ func queryScenes(db *gorm.DB, r RequestSceneList) (*gorm.DB, *gorm.DB) {
where = "exists (select 1 from external_reference_links where external_source like 'alternate scene %' and internal_db_id = scenes.id)"
case "Multiple Scenes Available at an Alternate Site":
where = "exists (select 1 from external_reference_links where external_source like 'alternate scene %' and internal_db_id = scenes.id group by external_source having count(*)>1)"
case "Recommended To Watch":
where = "scenes.rec_watch_score > 0"
case "Recommend To Delete":
where = "scenes.rec_delete_score > 0"
}

if negate {
Expand Down Expand Up @@ -1214,6 +1224,14 @@ func queryScenes(db *gorm.DB, r RequestSceneList) (*gorm.DB, *gorm.DB) {
case "alt_src_desc":
//tx = tx.Order(`(select max(er.external_date) from external_reference_links erl join external_references er on er.id=erl.external_reference_id where erl.internal_table='scenes' and erl.internal_db_id=scenes.id and er.external_source like 'alternate scene %') desc`)
tx = tx.Order(`(select max(erl.udf_datetime1) from external_reference_links erl where erl.internal_table='scenes' and erl.internal_db_id=scenes.id and erl.external_source like 'alternate scene %') desc`)
case "rec_watch_desc":
tx = tx.Where("scenes.rec_watch_score > 0").Order("scenes.rec_watch_score desc")
case "rec_watch_asc":
tx = tx.Where("scenes.rec_watch_score > 0").Order("scenes.rec_watch_score asc")
case "rec_delete_desc":
tx = tx.Where("scenes.rec_delete_score > 0").Order("scenes.rec_delete_score desc")
case "rec_delete_asc":
tx = tx.Where("scenes.rec_delete_score > 0").Order("scenes.rec_delete_score asc")
default:
tx = tx.Order("release_date desc")
}
Expand Down
Loading