From d47b98fe5006bb384d28b835a6a0f716db402ace Mon Sep 17 00:00:00 2001 From: Fredd C Date: Thu, 16 Jul 2026 13:17:52 +0800 Subject: [PATCH] feat(recommend): learned recommendation engine (For You / Cleanup) Single-user, content-based recommender that scores the library into two system playlists - "For You" (watch) and "Cleanup" (delete candidates) - surfaced through the existing scene lists / HereSphere. - Learns a taste profile from ratings, favourites and watch history; optional logistic-regression and factorization-machine rankers. - Signals: actors, tags, sites, resolution, freshness, file size and a no-reference visual-quality score; optional MobileNetV2 visual embeddings (CPU, ONNX Runtime, opt-in, default off). - Cleanup protects favourites, watchlisted, recently-rated and recently -added scenes; For You can exclude recently-watched scenes. - Daily deterministic noise so both lists refresh day to day. - Options -> Recommendations, plus a recompute schedule. --- go.mod | 1 + go.sum | 2 + pkg/api/heresphere.go | 2 + pkg/api/options.go | 16 + pkg/api/recommendations.go | 95 ++++ pkg/api/scenes.go | 3 + pkg/config/config.go | 30 + pkg/migrations/migrations.go | 86 +++ pkg/models/model_file.go | 11 + pkg/models/model_scene.go | 46 +- pkg/recommend/DESIGN.md | 145 +++++ pkg/recommend/embed.go | 246 ++++++++ pkg/recommend/engine.go | 526 ++++++++++++++++++ pkg/recommend/fm.go | 114 ++++ pkg/recommend/model.go | 271 +++++++++ pkg/recommend/quality.go | 260 +++++++++ pkg/server/cron.go | 17 +- pkg/server/server.go | 1 + ui/src/store/index.js | 2 + ui/src/store/optionsRecommendations.js | 74 +++ ui/src/views/options/Options.vue | 5 +- .../options/sections/Recommendations.vue | 138 +++++ ui/src/views/options/sections/Schedules.vue | 39 +- 23 files changed, 2111 insertions(+), 19 deletions(-) create mode 100644 pkg/api/recommendations.go create mode 100644 pkg/recommend/DESIGN.md create mode 100644 pkg/recommend/embed.go create mode 100644 pkg/recommend/engine.go create mode 100644 pkg/recommend/fm.go create mode 100644 pkg/recommend/model.go create mode 100644 pkg/recommend/quality.go create mode 100644 ui/src/store/optionsRecommendations.js create mode 100644 ui/src/views/options/sections/Recommendations.vue diff --git a/go.mod b/go.mod index 43b4e2818..836a75213 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 708e0ab35..bca6931a6 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/api/heresphere.go b/pkg/api/heresphere.go index 00eee80ae..86c32d4f7 100644 --- a/pkg/api/heresphere.go +++ b/pkg/api/heresphere.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" "sync" + "time" "github.com/dustin/go-humanize" restfulspec "github.com/emicklei/go-restful-openapi/v2" @@ -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() } diff --git a/pkg/api/options.go b/pkg/api/options.go index d6b6b9c6a..0ba138518 100644 --- a/pkg/api/options.go +++ b/pkg/api/options.go @@ -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"` @@ -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) diff --git a/pkg/api/recommendations.go b/pkg/api/recommendations.go new file mode 100644 index 000000000..bb616c021 --- /dev/null +++ b/pkg/api/recommendations.go @@ -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"}) +} diff --git a/pkg/api/scenes.go b/pkg/api/scenes.go index ef736bd30..4868ea380 100644 --- a/pkg/api/scenes.go +++ b/pkg/api/scenes.go @@ -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") @@ -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() diff --git a/pkg/config/config.go b/pkg/config/config.go index 0a2a4a8d8..08aac1163 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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"` diff --git a/pkg/migrations/migrations.go b/pkg/migrations/migrations.go index 9d2a818b2..0cce5d371 100644 --- a/pkg/migrations/migrations.go +++ b/pkg/migrations/migrations.go @@ -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 diff --git a/pkg/models/model_file.go b/pkg/models/model_file.go index eddfac60c..c4c0b5111 100644 --- a/pkg/models/model_file.go +++ b/pkg/models/model_file.go @@ -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:"-"` diff --git a/pkg/models/model_scene.go b/pkg/models/model_scene.go index b12249ef9..3b7e86b10 100644 --- a/pkg/models/model_scene.go +++ b/pkg/models/model_scene.go @@ -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"` @@ -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:"-"` @@ -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 { @@ -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") } diff --git a/pkg/recommend/DESIGN.md b/pkg/recommend/DESIGN.md new file mode 100644 index 000000000..01ab3613e --- /dev/null +++ b/pkg/recommend/DESIGN.md @@ -0,0 +1,145 @@ +# xbvr Recommendation Engine — Design + +Native (in-fork) recommender with two outputs: +- **For You** — owned, available scenes you're most likely to want to watch, ranked. +- **Cleanup** — owned, available scenes you're least likely to miss, ranked (list-only; no auto-delete). + +Both surface as **deo-enabled smart playlists** so HereSphere/DeoVR list them automatically, sorted by a true score (not a coarse tier). + +## How it surfaces (the xbvr constraint) + +HereSphere/DeoVR lists = playlists with `is_deo_enabled = true`; each runs its `search_params` +(`RequestSceneList`) live and returns scenes in the sort order baked into those params +(`heresphere.go:992`, `deovr.go:627`). Playlists are filter-only (no explicit membership table), +and sort is limited to built-in fields. + +So we add **two persisted score columns** on `scenes` and make them sortable/filterable: +- `rec_watch_score REAL` and `rec_delete_score REAL` (+ `rec_scored_at`). +- New sort keys `rec_watch_desc` / `rec_delete_desc` (`model_scene.go` sort switch). +- New filter attributes `Recommended To Watch` / `Recommend To Delete` + → `rec_watch_score > 0` / `rec_delete_score > 0` (`model_scene.go` attribute switch). +- Two seeded system playlists ("For You", "Cleanup") that filter the attribute and sort by score, + `is_deo_enabled = true`, `limit` from config. + +A background task (cron + on-demand API) recomputes the scores and writes them back. The lists then +reflect the latest scores with zero per-request cost. + +## Signals available (from this DB) + +- Explicit (sparse, high value): `favourite` (391), `star_rating` (49), `watchlist` (132), `wishlist`. +- Implicit (dense): `histories` (190k rows / 4.9k scenes), `total_watch_time`, `is_watched`, + `last_opened`. This is the primary fuel. +- Content: `scene_cast` (actors), `scene_tags` (tags), `site`/`studio`, `release_date`/`added_date`, + file resolution/FOV/duration, `is_scripted`. + +## Algorithm + +### 1. Per-scene affinity (how much you liked a scene you've engaged with) + +``` +affinity(s) = clamp( + favourite_bonus(s) // +1.0 if favourite + + rating_term(s) // (star-3)/2 in [-1,+1] when rated, else 0 + + engagement_term(s) // implicit watch signal, see below + , -1, +1) + +engagement_term(s): + completion = min(1, total_watch_time / max(duration, 1)) + sessions = history session count for s + if sessions == 0: return 0 + if completion < 0.10 and sessions == 1: return -0.3 // sampled & abandoned + base = 0.4*completion + 0.3*min(1, (sessions-1)) // rewatch is a strong like + return min(0.8, base) +``` + +Only scenes with affinity != 0 contribute to the taste profile (positives pull toward, mild +negatives push away). + +### 2. Taste profile (weighted, IDF-damped frequency over content features) + +For each feature dimension, weight = Σ affinity(s) over scenes that have that feature, then damp by +global popularity so ubiquitous tags ("blowjob", "pov") don't dominate: + +``` +actorWeight[a] = Σ_s∈scenes(a) affinity(s) +tagWeight[t] = ( Σ_s∈scenes(t) affinity(s) ) / log(1 + globalTagCount[t]) // TF-IDF-ish +siteWeight[site]= Σ_s∈scenes(site) affinity(s) +``` + +Each map is then normalized to [-1, 1] by its max abs value. + +### 3. Watch score (candidates: available, not recently watched) + +``` +rec_watch_score(s) = Wactor * mean(actorWeight over cast(s)) + + Wtag * sum(tagWeight over tags(s)) / sqrt(#tags(s)) + + Wsite * siteWeight[site(s)] + + Wqual * qualityBoost(s) // higher resolution, scripted-if-you-like + + Wfresh * freshnessBoost(s) // newer release/added, decays + - diversityPenalty(s) // cap repeats of same top actor in the list +candidates excluded: not available, recently watched (configurable), favourite (already known). +Keep top N (config, default 150); everything else → 0. +``` + +### 4. Delete score (candidates: available scenes) + +Hard protections force score = 0 (never suggest deleting): `favourite`, `watchlist`, `wishlist`, +`star_rating >= protectRating` (default 4), added within `graceDays` (default 30), or present in the +current watch top-set. + +``` +unloved(s) = watchedButCold(s) // watched, low completion, old last_opened, low/no rating + + neverWatchedOld(s) // never watched & added long ago + + lowAffinity(s) // cast/tags/site you don't favour (negative taste match) + + lowRating(s) // star 1-2 → strong +rec_delete_score(s) = unloved(s) * (1 + Wsize * sizeNorm(s)) // bigger files rank a bit higher +Keep top M (config, default 200); everything else → 0. +``` + +`sizeNorm` = file size scaled to [0,1] across the collection. Size is a *booster/tiebreaker*, not the +primary driver (you asked to surface "stuff you won't miss", with reclaim value as a secondary nudge). + +### Mutual exclusion +A scene in the watch top-set is protected from the delete list. Otherwise the two lists rarely +overlap (high-affinity-unwatched vs low-affinity). + +## Visual quality (no-reference, file-level) + +PSNR/SSIM/VMAF need a pristine reference we don't have, so quality is a *no-reference* +sharpness measure: sample one frame every 5 min (from 5 min, capped at `VQMaxSamples`), +run a Laplacian (edge) convolution at **native resolution** and read the mean edge +energy (`signalstats.YAVG`); the per-file score is the median across samples. Works with +ffmpeg 4.x (no `blurdetect`). Stored on `files`: `visual_quality`, +`visual_quality_samples`, `visual_quality_computed_at`. Measured lazily and cached — +only for the video files of scenes that land in the two lists (expensive: ~10-13s/frame +at 8K). `vqConcurrency` files measured in parallel via xbvr's own ffmpeg. + +Two-pass re-rank: after the watch/delete sets are chosen, measure their files, take each +scene's best file quality, cohort-normalize to a [0,1] percentile, then nudge the scores +(`* (1 ± WVisualQuality*(relQ-0.5))`) so crisp scenes rise in For You and soft scenes rise +in Cleanup. Membership of the lists is unchanged; only the order within each is adjusted. + +## Config (persisted in KV `config`, editable in UI) + +`Config.Recommendation`: enabled, weights (Wactor/Wtag/Wsite/Wqual/Wfresh/Wsize), watchListSize, +deleteListSize, protectRating, graceDays, excludeRecentlyWatched. Plus a `Cron.RecommendationSchedule`. + +## Files (fork changes) + +Backend: +- `pkg/models/model_scene.go` — add 3 fields; add sort cases; add attribute cases. +- `pkg/api/scenes.go` — add the two attributes to the filter list. +- `pkg/config/config.go` — add `Recommendation` struct + `Cron.RecommendationSchedule`. +- `pkg/migrations/migrations.go` — migration: add columns + seed "For You"/"Cleanup" playlists. +- `pkg/recommend/engine.go` — the scoring engine (this package). +- `pkg/tasks/recommend.go` — `GenerateRecommendations()` task wrapper. +- `pkg/server/cron.go` — schedule it. +- `pkg/api/recommendations.go` — POST recompute, GET/POST config; register in `server.go`. + +UI: +- `ui/src/store/optionsRecommendations.js`, `ui/src/views/options/sections/Recommendations.vue`, + wire into `Options.vue` + `store/index.js`. + +Build/deploy: +- `Dockerfile.build` — multi-stage (node → golang+CGO → ubuntu) producing a runnable image. +``` diff --git a/pkg/recommend/embed.go b/pkg/recommend/embed.go new file mode 100644 index 000000000..41bd3d0ac --- /dev/null +++ b/pkg/recommend/embed.go @@ -0,0 +1,246 @@ +package recommend + +import ( + "bytes" + "encoding/binary" + "math" + "os" + "os/exec" + "strconv" + "sync" + "time" + + "github.com/jinzhu/gorm" + ort "github.com/yalue/onnxruntime_go" + + "github.com/xbapps/xbvr/pkg/common" + "github.com/xbapps/xbvr/pkg/models" +) + +// Visual embeddings: a small pretrained CNN (MobileNetV2, ONNX) turns one frame of +// each scene into a 1000-d descriptor of its visual content/style. Embeddings are +// computed once per file (cached on the files table) and fed to the learned ranker as +// dense features, so it can learn your visual taste, not just metadata. Inference is +// ~30ms on CPU; the cost is the one-time frame decode per file. + +const ( + embedDim = 1000 + embedSize = 224 + embedConcurrency = 3 +) + +var ( + imgMean = [3]float32{0.485, 0.456, 0.406} + imgStd = [3]float32{0.229, 0.224, 0.225} + + embedOnce sync.Once + embedReady bool + embedSession *ort.DynamicAdvancedSession + embedInName string + embedOutName string + embedMu sync.Mutex // ORT session Run is not concurrency-safe +) + +func envOr(k, d string) string { + if v := os.Getenv(k); v != "" { + return v + } + return d +} + +func embedLib() string { return envOr("XBVR_ONNX_LIB", "/usr/lib/libonnxruntime.so") } +func embedModel() string { return envOr("XBVR_ONNX_MODEL", "/usr/share/xbvr/mobilenet.onnx") } + +// embedInit loads the ONNX runtime and model once. Visual embeddings silently +// disable themselves if the shared library or model file is not present. +func embedInit() bool { + embedOnce.Do(func() { + if _, err := os.Stat(embedLib()); err != nil { + common.Log.Warnf("recommend: onnx runtime %s missing, visual embeddings disabled", embedLib()) + return + } + if _, err := os.Stat(embedModel()); err != nil { + common.Log.Warnf("recommend: onnx model %s missing, visual embeddings disabled", embedModel()) + return + } + ort.SetSharedLibraryPath(embedLib()) + if err := ort.InitializeEnvironment(); err != nil { + common.Log.Errorf("recommend: onnx init failed: %v", err) + return + } + ins, outs, err := ort.GetInputOutputInfo(embedModel()) + if err != nil || len(ins) == 0 || len(outs) == 0 { + common.Log.Errorf("recommend: onnx model inspect failed: %v", err) + return + } + embedInName, embedOutName = ins[0].Name, outs[0].Name + sess, err := ort.NewDynamicAdvancedSession(embedModel(), + []string{embedInName}, []string{embedOutName}, nil) + if err != nil { + common.Log.Errorf("recommend: onnx session failed: %v", err) + return + } + embedSession = sess + embedReady = true + common.Log.Infof("recommend: visual embeddings enabled (%s)", embedModel()) + }) + return embedReady +} + +// embedFile extracts one representative frame and returns its L2-normalised embedding. +func embedFile(path string, duration float64) ([]float32, bool) { + if _, err := os.Stat(path); err != nil { + return nil, false + } + ts := 60 + if duration > 30 { + ts = int(duration / 3) // a representative frame, a third of the way in + } + cmd := exec.Command(ffmpegBin(), "-hide_banner", "-loglevel", "error", + "-ss", strconv.Itoa(ts), "-i", path, "-frames:v", "1", + "-vf", "scale=224:224", "-pix_fmt", "rgb24", "-f", "rawvideo", "-") + var buf bytes.Buffer + cmd.Stdout = &buf + if err := cmd.Run(); err != nil { + return nil, false + } + raw := buf.Bytes() + if len(raw) < embedSize*embedSize*3 { + return nil, false + } + // HWC uint8 -> normalised NCHW float32 + data := make([]float32, 3*embedSize*embedSize) + for y := 0; y < embedSize; y++ { + for x := 0; x < embedSize; x++ { + for c := 0; c < 3; c++ { + v := float32(raw[(y*embedSize+x)*3+c]) / 255.0 + data[c*embedSize*embedSize+y*embedSize+x] = (v - imgMean[c]) / imgStd[c] + } + } + } + inT, err := ort.NewTensor(ort.NewShape(1, 3, embedSize, embedSize), data) + if err != nil { + return nil, false + } + defer inT.Destroy() + outT, err := ort.NewEmptyTensor[float32](ort.NewShape(1, embedDim)) + if err != nil { + return nil, false + } + defer outT.Destroy() + + embedMu.Lock() + err = embedSession.Run([]ort.Value{inT}, []ort.Value{outT}) + embedMu.Unlock() + if err != nil { + return nil, false + } + + emb := make([]float32, embedDim) + copy(emb, outT.GetData()) + l2normalize(emb) + return emb, true +} + +// embedScenes embeds every available scene's video files that lack an embedding, +// persisting incrementally. The first run over a fresh library is slow (~1 frame +// decode per file); afterwards it is a no-op except for newly added files. +func embedScenes(db *gorm.DB, scenes []models.Scene) { + if !embedInit() { + return + } + var jobs []*models.File + for i := range scenes { + if !scenes[i].IsAvailable { + continue + } + for fi := range scenes[i].Files { + f := &scenes[i].Files[fi] + if f.Type == "video" && len(f.VisualEmbedding) == 0 { + jobs = append(jobs, f) + } + } + } + if len(jobs) == 0 { + return + } + common.Log.Infof("recommend: embedding %d files (one-time per file)…", len(jobs)) + + in := make(chan *models.File) + type res struct { + id uint + vec []float32 + } + out := make(chan res) + var wg sync.WaitGroup + for w := 0; w < embedConcurrency; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for f := range in { + if vec, ok := embedFile(f.GetPath(), f.VideoDuration); ok { + out <- res{f.ID, vec} + } else { + out <- res{f.ID, nil} + } + } + }() + } + go func() { + for _, f := range jobs { + in <- f + } + close(in) + wg.Wait() + close(out) + }() + + done := 0 + for r := range out { + now := time.Now() + blob := encodeFloats(r.vec) // empty blob for failures so we don't retry forever + db.Model(&models.File{}).Where("id = ?", r.id).Updates(map[string]interface{}{ + "visual_embedding": blob, + "visual_embedding_at": now, + }) + done++ + } + common.Log.Infof("recommend: embedded %d files", done) +} + +func l2normalize(v []float32) { + var s float64 + for _, x := range v { + s += float64(x) * float64(x) + } + if s == 0 { + return + } + n := float32(1 / math.Sqrt(s)) + for i := range v { + v[i] *= n + } +} + +func encodeFloats(v []float32) []byte { + if len(v) == 0 { + return []byte{} + } + buf := make([]byte, 4*len(v)) + for i, x := range v { + binary.LittleEndian.PutUint32(buf[i*4:], math.Float32bits(x)) + } + return buf +} + +func decodeFloats(b []byte) []float32 { + n := len(b) / 4 + if n == 0 { + return nil + } + v := make([]float32, n) + for i := 0; i < n; i++ { + v[i] = math.Float32frombits(binary.LittleEndian.Uint32(b[i*4:])) + } + return v +} diff --git a/pkg/recommend/engine.go b/pkg/recommend/engine.go new file mode 100644 index 000000000..b73f1589f --- /dev/null +++ b/pkg/recommend/engine.go @@ -0,0 +1,526 @@ +// Package recommend computes "For You" (watch) and "Cleanup" (delete) scores for +// scenes and persists them to scenes.rec_watch_score / scenes.rec_delete_score so +// the seeded deo-enabled playlists can surface them, ranked, in HereSphere/DeoVR. +// +// See DESIGN.md for the algorithm rationale. +package recommend + +import ( + "hash/fnv" + "math" + "sort" + "strconv" + "time" + + "github.com/xbapps/xbvr/pkg/common" + "github.com/xbapps/xbvr/pkg/config" + "github.com/xbapps/xbvr/pkg/models" +) + +// Generate recomputes recommendation scores for the whole library. +func Generate() { + cfg := loadConfig() + if !cfg.Enabled { + common.Log.Info("recommend: disabled in config, skipping") + return + } + + db, err := models.GetDB() + if err != nil { + common.Log.Errorf("recommend: cannot open db: %v", err) + return + } + defer db.Close() + + now := time.Now() + grace := time.Duration(cfg.GraceDays) * 24 * time.Hour + + // Watch-session counts per scene (implicit engagement signal). + type histAgg struct { + SceneID uint + Sessions int + } + var hist []histAgg + db.Table("histories").Select("scene_id, count(*) as sessions").Group("scene_id").Scan(&hist) + sessions := make(map[uint]int, len(hist)) + for _, h := range hist { + sessions[h.SceneID] = h.Sessions + } + + // Load every scene that is either a candidate (available) or carries taste + // signal (watched / favourite / rated), with cast, tags and files. + var scenes []models.Scene + db.Preload("Cast").Preload("Tags").Preload("Files"). + Where("is_available = ? OR is_watched = ? OR favourite = ? OR star_rating > 0", true, true, true). + Find(&scenes) + + // Global tag frequency (for IDF damping of ubiquitous tags). + tagGlobal := make(map[string]int) + for i := range scenes { + for _, t := range scenes[i].Tags { + tagGlobal[t.Name]++ + } + } + + // Build the taste profile from per-scene affinity. + actorW := make(map[uint]float64) + tagW := make(map[string]float64) + siteW := make(map[string]float64) + for i := range scenes { + s := &scenes[i] + a := affinity(s, sessions[s.ID]) + if a == 0 { + continue + } + for _, c := range s.Cast { + actorW[c.ID] += a + } + for _, t := range s.Tags { + tagW[t.Name] += a + } + if s.Site != "" { + siteW[s.Site] += a + } + } + // IDF damping: a generic tag present on thousands of scenes is uninformative. + for name, w := range tagW { + tagW[name] = w / math.Log(1+float64(tagGlobal[name])) + } + normalizeUint(actorW) + normalizeStr(tagW) + normalizeStr(siteW) + + // Largest file group, for size normalisation in the delete score. + var maxSize int64 = 1 + for i := range scenes { + if scenes[i].TotalFileSize > maxSize { + maxSize = scenes[i].TotalFileSize + } + } + + // Optionally learn the taste function from feedback instead of the hand-tuned + // profile weights. Falls back to the heuristic profile if there isn't enough + // labelled signal yet (cold start). + var model tasteScorer + if cfg.UseLearnedModel { + model = trainModel(scenes, sessions, now, cfg.ModelType, cfg.UseVisualEmbeddings) + } + tasteOf := func(s *models.Scene) float64 { + if model != nil { + return model.taste(s, now) + } + return tasteMatch(s, actorW, tagW, siteW, cfg) + } + + // Score candidates. + watchScores := make(map[uint]float64) + deleteScores := make(map[uint]float64) + sceneCast := make(map[uint][]uint) // for diversity-aware watch selection + for i := range scenes { + s := &scenes[i] + if !s.IsAvailable { + continue + } + taste := tasteOf(s) + if watchEligible(s, cfg, now) { + if sc := watchScore(s, taste, cfg, now); sc > 0 { + watchScores[s.ID] = sc * dailyJitter(s.ID, "w", now, cfg.NoiseWeight) + ids := make([]uint, 0, len(s.Cast)) + for _, c := range s.Cast { + ids = append(ids, c.ID) + } + sceneCast[s.ID] = ids + } + } + if deleteEligible(s, cfg, now, grace) { + if sc := deleteScore(s, taste, sessions[s.ID], cfg, now, maxSize); sc > 0 { + deleteScores[s.ID] = sc * dailyJitter(s.ID, "d", now, cfg.NoiseWeight) + } + } + } + + // Pick the watch list with a per-actor diversity penalty so it isn't dominated + // by a handful of performers; a scene recommended to watch is never also a + // delete candidate (watch wins). + topWatch := selectDiverse(watchScores, sceneCast, cfg.WatchListSize, cfg.DiversityDecay) + for id := range topWatch { + delete(deleteScores, id) + } + topDelete := topN(deleteScores, cfg.DeleteListSize) + + // Persist the base scores immediately so the lists populate right away. + db.Model(&models.Scene{}). + Where("rec_watch_score > 0 OR rec_delete_score > 0"). + Updates(map[string]interface{}{"rec_watch_score": 0, "rec_delete_score": 0}) + for id, sc := range topWatch { + db.Model(&models.Scene{}).Where("id = ?", id). + Updates(map[string]interface{}{"rec_watch_score": sc, "rec_scored_at": now}) + } + for id, sc := range topDelete { + db.Model(&models.Scene{}).Where("id = ?", id). + Updates(map[string]interface{}{"rec_delete_score": sc, "rec_scored_at": now}) + } + common.Log.Infof("recommend: scored %d scenes -> %d to watch, %d to clean up", + len(scenes), len(topWatch), len(topDelete)) + + // Second pass: measure no-reference visual quality of the selected scenes' files + // and re-rank as results land (crisp scenes rise in "For You", soft ones rise in + // "Cleanup"). Lists are already live; this refines their order. Quality is measured + // once per file and cached. + if cfg.WVisualQuality > 0 && cfg.VQMaxSamples > 0 { + applyVisualQuality(db, scenes, topWatch, topDelete, cfg) + } + + // Compute visual embeddings for any available files that lack them (one-time per + // file, cached). These feed the learned model as features on the NEXT recompute. + if cfg.UseVisualEmbeddings { + embedScenes(db, scenes) + } +} + +// affinity expresses how much the user liked a scene they have engaged with, +// in [-1, 1]. Scenes with zero affinity do not shape the taste profile. +func affinity(s *models.Scene, sessions int) float64 { + a := 0.0 + if s.Favourite { + a += 1.0 + } + if s.StarRating > 0 { + a += (s.StarRating - 3) / 2 // 1..5 -> -1..+1 + } + a += engagement(s, sessions) + return clamp(a, -1, 1) +} + +func engagement(s *models.Scene, sessions int) float64 { + eff := sessions + if eff == 0 && s.IsWatched { + eff = 1 + } + if eff == 0 { + return 0 + } + completion := 0.0 + if s.Duration > 0 { + completion = math.Min(1, float64(s.TotalWatchTime)/float64(s.Duration)) + } + if completion < 0.10 && eff <= 1 { + return -0.3 // sampled once and abandoned + } + base := 0.4*completion + 0.3*math.Min(1, float64(eff-1)) // rewatching is a strong like + return math.Min(0.8, base) +} + +// tasteMatch combines the actor/tag/site profile weights for a scene. +func tasteMatch(s *models.Scene, actorW map[uint]float64, tagW, siteW map[string]float64, cfg recConfig) float64 { + actorScore := 0.0 + if len(s.Cast) > 0 { + sum := 0.0 + for _, c := range s.Cast { + sum += actorW[c.ID] + } + actorScore = sum / float64(len(s.Cast)) + } + tagScore := 0.0 + if len(s.Tags) > 0 { + sum := 0.0 + for _, t := range s.Tags { + sum += tagW[t.Name] + } + tagScore = sum / math.Sqrt(float64(len(s.Tags))) + } + siteScore := siteW[s.Site] + return cfg.WActor*actorScore + cfg.WTag*tagScore + cfg.WSite*siteScore +} + +// recentWatchWindow is how long a scene stays "recently watched" and is kept out of +// the For You list when ExcludeRecentlyWatched is enabled. +const recentWatchWindow = 30 * 24 * time.Hour + +func watchEligible(s *models.Scene, cfg recConfig, now time.Time) bool { + if s.Favourite { + return false // already known-liked, no need to recommend + } + if cfg.ExcludeRecentlyWatched && s.IsWatched && !s.LastOpened.IsZero() && + now.Sub(s.LastOpened) < recentWatchWindow { + return false // just watched it; don't resurface yet + } + return true +} + +func watchScore(s *models.Scene, taste float64, cfg recConfig, now time.Time) float64 { + return taste + cfg.WQuality*qualityBoost(s) + cfg.WFreshness*freshness(s, now) +} + +func deleteEligible(s *models.Scene, cfg recConfig, now time.Time, grace time.Duration) bool { + if s.Favourite || s.Watchlist || s.Wishlist { + return false + } + if cfg.ProtectRating > 0 && s.StarRating >= cfg.ProtectRating { + return false + } + // Recently rated (any value) in the last 3 months -> you're actively engaging + // with it, so don't suggest deleting it yet. + if s.StarRating > 0 && !s.StarRatingUpdatedAt.IsZero() && now.Sub(s.StarRatingUpdatedAt) < 90*24*time.Hour { + return false + } + if !s.AddedDate.IsZero() && now.Sub(s.AddedDate) < grace { + return false // too recently added to judge + } + return true +} + +func deleteScore(s *models.Scene, taste float64, sessions int, cfg recConfig, now time.Time, maxSize int64) float64 { + unloved := 0.0 + if s.IsWatched || sessions > 0 { + completion := 0.0 + if s.Duration > 0 { + completion = math.Min(1, float64(s.TotalWatchTime)/float64(s.Duration)) + } + if completion < 0.5 { + unloved += 0.5 * (1 - completion) // watched but never finished + } + if !s.LastOpened.IsZero() { + unloved += 0.3 * clamp(now.Sub(s.LastOpened).Hours()/24/365, 0, 1) // not opened in ages + } + } else { + if !s.AddedDate.IsZero() { + unloved += 0.6 * clamp(now.Sub(s.AddedDate).Hours()/24/365, 0, 1) // had it for ages, never watched + } else { + unloved += 0.3 + } + } + if taste < 0 { + unloved += math.Min(0.5, -taste) // cast/tags/site you don't favour + } + if s.StarRating > 0 && s.StarRating <= 2 { + unloved += 0.8 // you rated it poorly + } + if unloved <= 0 { + return 0 + } + sizeNorm := float64(s.TotalFileSize) / float64(maxSize) + return unloved * (1 + cfg.WSize*sizeNorm) +} + +// qualityBoost rewards higher resolution (max across the scene's files). +func qualityBoost(s *models.Scene) float64 { + maxH := 0 + for _, f := range s.Files { + if f.VideoHeight > maxH { + maxH = f.VideoHeight + } + } + if maxH <= 0 { + return 0 + } + return clamp((float64(maxH)-1920)/(4096-1920), 0, 1) +} + +// freshness decays with age (newer acquisitions/releases score higher). +func freshness(s *models.Scene, now time.Time) float64 { + ref := s.AddedDate + if ref.IsZero() { + ref = s.ReleaseDate + } + if ref.IsZero() { + return 0 + } + ageDays := now.Sub(ref).Hours() / 24 + if ageDays < 0 { + ageDays = 0 + } + return math.Exp(-ageDays / 365) // ~0.37 at one year +} + +// --- helpers --- + +type recConfig struct { + Enabled bool + UseLearnedModel bool + ModelType string + UseVisualEmbeddings bool + WatchListSize int + DeleteListSize int + ProtectRating float64 + GraceDays int + ExcludeRecentlyWatched bool + DiversityDecay float64 + WActor float64 + WTag float64 + WSite float64 + WQuality float64 + WFreshness float64 + WSize float64 + WVisualQuality float64 + VQMaxSamples int + NoiseWeight float64 +} + +func loadConfig() recConfig { + c := config.Config.Recommendation + return recConfig{ + Enabled: c.Enabled, + UseLearnedModel: c.UseLearnedModel, + ModelType: c.ModelType, + UseVisualEmbeddings: c.UseVisualEmbeddings, + WatchListSize: c.WatchListSize, + DeleteListSize: c.DeleteListSize, + ProtectRating: c.ProtectRating, + GraceDays: c.GraceDays, + ExcludeRecentlyWatched: c.ExcludeRecentlyWatched, + DiversityDecay: c.DiversityDecay, + WActor: c.WActor, + WTag: c.WTag, + WSite: c.WSite, + WQuality: c.WQuality, + WFreshness: c.WFreshness, + WSize: c.WSize, + WVisualQuality: c.WVisualQuality, + VQMaxSamples: c.VQMaxSamples, + NoiseWeight: c.NoiseWeight, + } +} + +// dailyJitter returns a multiplier in [1-weight, 1+weight] that is stable for a given +// scene within a calendar day but changes each day, so the lists rotate daily. A +// separate salt keeps the watch and cleanup jitter independent. +func dailyJitter(id uint, salt string, now time.Time, weight float64) float64 { + if weight <= 0 { + return 1 + } + h := fnv.New64a() + h.Write([]byte(now.Format("2006-01-02") + salt + strconv.FormatUint(uint64(id), 10))) + frac := float64(h.Sum64()%100000) / 100000 // [0,1) + f := 1 + weight*(frac*2-1) + if f < 0.05 { + f = 0.05 + } + return f +} + +func clamp(v, lo, hi float64) float64 { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func normalizeUint(m map[uint]float64) { + max := 0.0 + for _, v := range m { + if a := math.Abs(v); a > max { + max = a + } + } + if max == 0 { + return + } + for k := range m { + m[k] /= max + } +} + +func normalizeStr(m map[string]float64) { + max := 0.0 + for _, v := range m { + if a := math.Abs(v); a > max { + max = a + } + } + if max == 0 { + return + } + for k := range m { + m[k] /= max + } +} + +// topN returns the n highest-scoring entries. +func topN(scores map[uint]float64, n int) map[uint]float64 { + if n <= 0 || len(scores) <= n { + return scores + } + type kv struct { + id uint + sc float64 + } + arr := make([]kv, 0, len(scores)) + for id, sc := range scores { + arr = append(arr, kv{id, sc}) + } + sort.Slice(arr, func(i, j int) bool { + if arr[i].sc != arr[j].sc { + return arr[i].sc > arr[j].sc + } + return arr[i].id < arr[j].id + }) + out := make(map[uint]float64, n) + for i := 0; i < n; i++ { + out[arr[i].id] = arr[i].sc + } + return out +} + +// selectDiverse greedily picks up to n scenes, penalising those whose cast is +// already over-represented (penalty = decay^maxActorCount). It returns the chosen +// scenes mapped to their *adjusted* score, so the surfaced playlist order reflects +// the diversified ranking. A decay of 1 (or <= 0) disables the penalty. +func selectDiverse(scores map[uint]float64, cast map[uint][]uint, n int, decay float64) map[uint]float64 { + if decay <= 0 || decay >= 1 { + return topN(scores, n) + } + type cand struct { + id uint + raw float64 + } + cands := make([]cand, 0, len(scores)) + for id, sc := range scores { + cands = append(cands, cand{id, sc}) + } + sort.Slice(cands, func(i, j int) bool { + if cands[i].raw != cands[j].raw { + return cands[i].raw > cands[j].raw + } + return cands[i].id < cands[j].id + }) + if n <= 0 || n > len(cands) { + n = len(cands) + } + + actorCount := make(map[uint]int) + used := make([]bool, len(cands)) + chosen := make(map[uint]float64, n) + for len(chosen) < n { + bestIdx := -1 + bestAdj := -1.0 + for i := range cands { + if used[i] { + continue + } + maxCount := 0 + for _, a := range cast[cands[i].id] { + if actorCount[a] > maxCount { + maxCount = actorCount[a] + } + } + adj := cands[i].raw * math.Pow(decay, float64(maxCount)) + if adj > bestAdj { + bestAdj = adj + bestIdx = i + } + } + if bestIdx < 0 { + break + } + used[bestIdx] = true + chosen[cands[bestIdx].id] = bestAdj + for _, a := range cast[cands[bestIdx].id] { + actorCount[a]++ + } + } + return chosen +} diff --git a/pkg/recommend/fm.go b/pkg/recommend/fm.go new file mode 100644 index 000000000..458641f12 --- /dev/null +++ b/pkg/recommend/fm.go @@ -0,0 +1,114 @@ +package recommend + +import ( + "math/rand" + "sort" + "strconv" + "time" + + "github.com/xbapps/xbvr/pkg/common" + "github.com/xbapps/xbvr/pkg/models" +) + +// Factorization Machine: extends the linear model with learned pairwise feature +// interactions via low-rank latent vectors, so it can capture "I like this actor +// especially in this studio" or particular tag combinations that a linear model +// can't. Still pure Go, sparse, CPU-cheap. Prediction: +// +// y = w0 + Σ wi·xi + 0.5·Σ_f [ (Σ_i vi,f·xi)² − Σ_i (vi,f·xi)² ] +const ( + fmFactors = 8 + fmEpochs = 15 + fmLearnRate = 0.02 + fmL2 = 0.002 +) + +type fmModel struct { + fi *featureIndex + w0 float64 + w []float64 + v [][]float64 // len(features) x fmFactors + useEmbed bool +} + +func trainFM(examples []trainExample, fi *featureIndex, pos, neg int, useEmbed bool) *fmModel { + n := len(fi.keys) + rng := rand.New(rand.NewSource(1)) // deterministic + v := make([][]float64, n) + for i := range v { + v[i] = make([]float64, fmFactors) + for f := range v[i] { + v[i][f] = rng.NormFloat64() * 0.01 + } + } + m := &fmModel{fi: fi, w: make([]float64, n), v: v, useEmbed: useEmbed} + + sums := make([]float64, fmFactors) + for epoch := 0; epoch < fmEpochs; epoch++ { + for k := range examples { + ex := examples[(k*7+epoch)%len(examples)] + p := sigmoid(m.raw(ex.feat, sums)) + g := (p - ex.label) * ex.weight + + m.w0 -= fmLearnRate * (g + fmL2*m.w0) + for i, x := range ex.feat { + m.w[i] -= fmLearnRate * (g*x + fmL2*m.w[i]) + vi := m.v[i] + for f := 0; f < fmFactors; f++ { + // dY/dv_if = x_i * (sum_f - v_if * x_i) + grad := x * (sums[f] - vi[f]*x) + vi[f] -= fmLearnRate * (g*grad + fmL2*vi[f]) + } + } + } + } + m.logTop(pos, neg) + return m +} + +// raw computes the FM score and leaves the per-factor sums in `sums` (reused buffer), +// which the training step needs for the interaction gradient. +func (m *fmModel) raw(feat map[int]float64, sums []float64) float64 { + z := m.w0 + for i, x := range feat { + z += m.w[i] * x + } + for f := 0; f < fmFactors; f++ { + var s, sq float64 + for i, x := range feat { + vv := m.v[i][f] * x + s += vv + sq += vv * vv + } + sums[f] = s + z += 0.5 * (s*s - sq) + } + return z +} + +func (m *fmModel) taste(s *models.Scene, now time.Time) float64 { + feat := sceneFeatures(s, m.fi, false, now, m.useEmbed) + sums := make([]float64, fmFactors) + return (sigmoid(m.raw(feat, sums)) - 0.5) * 2 +} + +func (m *fmModel) logTop(pos, neg int) { + type fw struct { + key string + w float64 + } + arr := make([]fw, 0, len(m.fi.keys)) + for i, k := range m.fi.keys { + arr = append(arr, fw{k, m.w[i]}) + } + sort.Slice(arr, func(i, j int) bool { return arr[i].w > arr[j].w }) + top := []string{} + for i := 0; i < len(arr) && len(top) < 8; i++ { + if arr[i].key == "bias" { + continue + } + top = append(top, arr[i].key+" "+strconv.FormatFloat(arr[i].w, 'f', 2, 64)) + } + common.Log.Infof("recommend: trained fm model (k=%d) on %d liked / %d disliked scenes; top linear signals: %v", + fmFactors, pos, neg, top) +} diff --git a/pkg/recommend/model.go b/pkg/recommend/model.go new file mode 100644 index 000000000..72a583c49 --- /dev/null +++ b/pkg/recommend/model.go @@ -0,0 +1,271 @@ +package recommend + +import ( + "math" + "sort" + "strconv" + "time" + + "github.com/xbapps/xbvr/pkg/common" + "github.com/xbapps/xbvr/pkg/models" +) + +// A self-contained logistic-regression ranker trained on the user's own feedback. +// It replaces the hand-tuned actor/tag/site weights with weights LEARNED from which +// scenes were favourited / rated / actually watched. Pure Go, sparse features, trained +// by SGD in well under a second — no external ML dependency. +// +// The model predicts P(like) for a scene from its content features; the engine turns +// that into a taste value in [-1,1] that feeds the existing watch/delete formulas, so +// the heuristic and learned scorers are interchangeable (config toggle). + +const ( + modelEpochs = 20 + modelLearnRate = 0.05 + modelL2 = 0.002 + modelMinPositive = 10 // need at least this many liked scenes to train +) + +type featureIndex struct { + idx map[string]int + keys []string +} + +func newFeatureIndex() *featureIndex { + return &featureIndex{idx: make(map[string]int)} +} + +// get returns the index for a feature key, allocating one when training. +func (fi *featureIndex) get(key string, train bool) (int, bool) { + if i, ok := fi.idx[key]; ok { + return i, true + } + if !train { + return 0, false + } + i := len(fi.keys) + fi.idx[key] = i + fi.keys = append(fi.keys, key) + return i, true +} + +type learnedModel struct { + fi *featureIndex + w []float64 + useEmbed bool +} + +func sigmoid(z float64) float64 { + if z >= 0 { + return 1 / (1 + math.Exp(-z)) + } + e := math.Exp(z) + return e / (1 + e) +} + +// resBand buckets the scene's best vertical resolution. +func resBand(s *models.Scene) string { + maxH := 0 + for i := range s.Files { + if s.Files[i].Type == "video" && s.Files[i].VideoHeight > maxH { + maxH = s.Files[i].VideoHeight + } + } + switch { + case maxH >= 3800: + return "8k" + case maxH >= 2900: + return "6k" + case maxH >= 2100: + return "5k" + case maxH > 0: + return "sd" + default: + return "unknown" + } +} + +// sceneFeatures builds the sparse feature vector for a scene. When useEmbed is set +// and the scene has a cached visual embedding, its dimensions are added as dense +// features so the learned ranker can pick up visual taste. +func sceneFeatures(s *models.Scene, fi *featureIndex, train bool, now time.Time, useEmbed bool) map[int]float64 { + f := make(map[int]float64, len(s.Cast)+len(s.Tags)+6) + add := func(key string, val float64) { + if i, ok := fi.get(key, train); ok { + f[i] = val + } + } + add("bias", 1) + for _, c := range s.Cast { + add("actor:"+strconv.Itoa(int(c.ID)), 1) + } + for _, t := range s.Tags { + add("tag:"+t.Name, 1) + } + if s.Site != "" { + add("site:"+s.Site, 1) + } + add("res:"+resBand(s), 1) + if s.IsScripted { + add("scripted", 1) + } + add("freshness", freshness(s, now)) + if useEmbed { + if emb := sceneEmbedding(s); emb != nil { + for i, v := range emb { + add("emb:"+strconv.Itoa(i), float64(v)) + } + } + } + return f +} + +// sceneEmbedding returns the visual embedding of the scene's first embedded video file. +func sceneEmbedding(s *models.Scene) []float32 { + for i := range s.Files { + if s.Files[i].Type == "video" && len(s.Files[i].VisualEmbedding) > 0 { + return decodeFloats(s.Files[i].VisualEmbedding) + } + } + return nil +} + +// labelOf derives a training label from feedback: 1 = liked, 0 = disliked, ok=false +// when the scene carries no clear signal (excluded from training). +func labelOf(s *models.Scene, sessions int, now time.Time) (float64, bool) { + completion := 0.0 + if s.Duration > 0 { + completion = float64(s.TotalWatchTime) / float64(s.Duration) + } + switch { + case s.Favourite, s.StarRating >= 4, sessions >= 2, s.IsWatched && completion >= 0.6: + return 1, true + case s.StarRating > 0 && s.StarRating <= 2: + return 0, true + case s.IsWatched && completion < 0.10 && !s.Favourite: + return 0, true // sampled then abandoned + case s.IsAvailable && !s.IsWatched && !s.AddedDate.IsZero() && now.Sub(s.AddedDate) > 60*24*time.Hour: + return 0, true // owned for ages, never watched + default: + return 0, false + } +} + +type trainExample struct { + feat map[int]float64 + label float64 + weight float64 +} + +// tasteScorer maps a scene to a taste value in [-1, 1] for the scoring formulas. +// Both the linear and factorization-machine models implement it. +type tasteScorer interface { + taste(s *models.Scene, now time.Time) float64 +} + +// buildExamples labels the loaded scenes and builds class-balanced training examples. +func buildExamples(scenes []models.Scene, sessions map[uint]int, now time.Time, useEmbed bool) ([]trainExample, *featureIndex, int, int) { + fi := newFeatureIndex() + var examples []trainExample + pos, neg := 0, 0 + for i := range scenes { + s := &scenes[i] + label, ok := labelOf(s, sessions[s.ID], now) + if !ok { + continue + } + examples = append(examples, trainExample{feat: sceneFeatures(s, fi, true, now, useEmbed), label: label}) + if label == 1 { + pos++ + } else { + neg++ + } + } + if pos == 0 || neg == 0 { + return examples, fi, pos, neg + } + // Balance the classes so the rarer label isn't drowned out. + total := float64(pos + neg) + wPos := total / (2 * float64(pos)) + wNeg := total / (2 * float64(neg)) + for i := range examples { + if examples[i].label == 1 { + examples[i].weight = wPos + } else { + examples[i].weight = wNeg + } + } + return examples, fi, pos, neg +} + +// trainModel fits the chosen learner on the user's feedback. Returns nil (falling +// back to the heuristic) when there is not enough labelled signal yet. +func trainModel(scenes []models.Scene, sessions map[uint]int, now time.Time, modelType string, useEmbed bool) tasteScorer { + examples, fi, pos, neg := buildExamples(scenes, sessions, now, useEmbed) + if pos < modelMinPositive || neg == 0 { + common.Log.Infof("recommend: not enough labelled signal to train (pos=%d neg=%d), using heuristic", pos, neg) + return nil + } + if modelType == "fm" { + return trainFM(examples, fi, pos, neg, useEmbed) + } + return trainLinear(examples, fi, pos, neg, useEmbed) +} + +// trainLinear fits a logistic regression by SGD. +func trainLinear(examples []trainExample, fi *featureIndex, pos, neg int, useEmbed bool) *learnedModel { + w := make([]float64, len(fi.keys)) + for epoch := 0; epoch < modelEpochs; epoch++ { + // Deterministic shuffle (index rotation) keeps it dependency-free and stable. + for k := range examples { + ex := examples[(k*7+epoch)%len(examples)] + z := 0.0 + for i, v := range ex.feat { + z += w[i] * v + } + g := (sigmoid(z) - ex.label) * ex.weight + for i, v := range ex.feat { + w[i] -= modelLearnRate * (g*v + modelL2*w[i]) + } + } + } + m := &learnedModel{fi: fi, w: w, useEmbed: useEmbed} + m.logTopFeatures(pos, neg, "linear") + return m +} + +// predictLike returns P(like) for a scene. +func (m *learnedModel) predictLike(s *models.Scene, now time.Time) float64 { + feat := sceneFeatures(s, m.fi, false, now, m.useEmbed) + z := 0.0 + for i, v := range feat { + z += m.w[i] * v + } + return sigmoid(z) +} + +// taste maps the like probability to [-1, 1] for the existing scoring formulas. +func (m *learnedModel) taste(s *models.Scene, now time.Time) float64 { + return (m.predictLike(s, now) - 0.5) * 2 +} + +// logTopFeatures surfaces what the model learned (most liked actors/tags/sites). +func (m *learnedModel) logTopFeatures(pos, neg int, kind string) { + type fw struct { + key string + w float64 + } + arr := make([]fw, 0, len(m.fi.keys)) + for i, k := range m.fi.keys { + arr = append(arr, fw{k, m.w[i]}) + } + sort.Slice(arr, func(i, j int) bool { return arr[i].w > arr[j].w }) + top := []string{} + for i := 0; i < len(arr) && len(top) < 8; i++ { + if arr[i].key == "bias" { + continue + } + top = append(top, arr[i].key+" "+strconv.FormatFloat(arr[i].w, 'f', 2, 64)) + } + common.Log.Infof("recommend: trained %s model on %d liked / %d disliked scenes; top signals: %v", kind, pos, neg, top) +} diff --git a/pkg/recommend/quality.go b/pkg/recommend/quality.go new file mode 100644 index 000000000..0934be921 --- /dev/null +++ b/pkg/recommend/quality.go @@ -0,0 +1,260 @@ +package recommend + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "sync" + "time" + + "github.com/jinzhu/gorm" + + "github.com/xbapps/xbvr/pkg/common" + "github.com/xbapps/xbvr/pkg/models" +) + +// No-reference visual quality: sample one frame every 5 minutes (from 5 min), +// run a Laplacian (edge) convolution at native resolution and read the mean edge +// energy (signalstats.YAVG). Higher = crisper/more detail. The median across the +// sampled frames is stored per file. Works with ffmpeg 4.x (no blurdetect needed). + +const ( + vqSampleIntervalSec = 300 // one frame every 5 minutes + vqFirstSampleSec = 300 // first sample at 5 minutes + vqConcurrency = 2 // files measured in parallel (each ffmpeg is itself threaded) +) + +var vqYavgRe = regexp.MustCompile(`lavfi\.signalstats\.YAVG=([0-9.]+)`) + +func ffmpegBin() string { + p := filepath.Join(common.BinDir, "ffmpeg") + if _, err := os.Stat(p); err == nil { + return p + } + return "ffmpeg" +} + +func sampleTimestamps(durationSec float64, maxSamples int) []int { + if durationSec <= 0 || maxSamples <= 0 { + return nil + } + if durationSec < vqFirstSampleSec+1 { + return []int{int(durationSec / 2)} // short file: one mid-point sample + } + var ts []int + for t := vqFirstSampleSec; float64(t) < durationSec-2 && len(ts) < maxSamples; t += vqSampleIntervalSec { + ts = append(ts, t) + } + return ts +} + +// measureFrameEdgeEnergy returns the mean Laplacian edge energy of one frame at +// time t (native resolution). +func measureFrameEdgeEnergy(path string, t int) (float64, bool) { + tmp, err := os.CreateTemp("", "xbvr-vq-*.txt") + if err != nil { + return 0, false + } + meta := tmp.Name() + tmp.Close() + defer os.Remove(meta) + + cmd := exec.Command(ffmpegBin(), + "-hide_banner", "-loglevel", "error", + "-ss", strconv.Itoa(t), "-i", path, "-frames:v", "1", + "-vf", "format=gray,convolution=0 -1 0 -1 4 -1 0 -1 0,signalstats,metadata=print:file="+meta, + "-f", "null", "-") + if err := cmd.Run(); err != nil { + return 0, false + } + data, err := os.ReadFile(meta) + if err != nil { + return 0, false + } + m := vqYavgRe.FindSubmatch(data) + if m == nil { + return 0, false + } + v, err := strconv.ParseFloat(string(m[1]), 64) + if err != nil { + return 0, false + } + return v, true +} + +// computeFileQuality samples a file and returns (median edge energy, sample count). +func computeFileQuality(f *models.File, maxSamples int) (float64, int) { + path := f.GetPath() + if _, err := os.Stat(path); err != nil { + return 0, 0 + } + var vals []float64 + for _, t := range sampleTimestamps(f.VideoDuration, maxSamples) { + if v, ok := measureFrameEdgeEnergy(path, t); ok { + vals = append(vals, v) + } + } + if len(vals) == 0 { + return 0, 0 + } + sort.Float64s(vals) + return median(vals), len(vals) +} + +// vqJob / vqResult carry a file (and its scene) through the measurement workers. +type vqJob struct { + sceneID uint + file *models.File +} +type vqResult struct { + sceneID uint + fileID uint + quality float64 + samples int +} + +// applyVisualQuality measures the selected scenes' video files and re-ranks the two +// lists as each result lands: it persists every file's quality, keeps a running +// per-scene best, and after each new measurement re-normalizes and rewrites the +// affected scene scores in the DB. The base scores are already persisted, so the +// lists are live throughout and simply refine in place. +func applyVisualQuality(db *gorm.DB, scenes []models.Scene, topWatch, topDelete map[uint]float64, cfg recConfig) { + sceneIndex := make(map[uint]*models.Scene, len(scenes)) + for i := range scenes { + sceneIndex[scenes[i].ID] = &scenes[i] + } + + // Running per-scene best quality. Seed with any already-cached file qualities. + sceneBestQ := make(map[uint]float64) + var jobs []vqJob + collect := func(id uint) { + s := sceneIndex[id] + if s == nil { + return + } + for fi := range s.Files { + f := &s.Files[fi] + if f.Type != "video" { + continue + } + if f.VisualQualitySamples > 0 { // already measured (cached) + if f.VisualQuality > sceneBestQ[id] { + sceneBestQ[id] = f.VisualQuality + } + continue + } + jobs = append(jobs, vqJob{sceneID: id, file: f}) + } + } + for id := range topWatch { + collect(id) + } + for id := range topDelete { + collect(id) + } + + // If some scenes already have cached quality, re-rank once up front. + if len(sceneBestQ) > 0 { + rerankByQuality(db, sceneBestQ, topWatch, topDelete, cfg) + } + if len(jobs) == 0 { + return + } + + in := make(chan vqJob) + out := make(chan vqResult) + var wg sync.WaitGroup + for w := 0; w < vqConcurrency; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := range in { + q, n := computeFileQuality(j.file, cfg.VQMaxSamples) + out <- vqResult{sceneID: j.sceneID, fileID: j.file.ID, quality: q, samples: n} + } + }() + } + go func() { + for _, j := range jobs { + in <- j + } + close(in) + wg.Wait() + close(out) + }() + + measured := 0 + for r := range out { + now := time.Now() + db.Model(&models.File{}).Where("id = ?", r.fileID).Updates(map[string]interface{}{ + "visual_quality": r.quality, + "visual_quality_samples": r.samples, + "visual_quality_computed_at": now, + }) + measured++ + if r.quality > 0 && r.quality > sceneBestQ[r.sceneID] { + sceneBestQ[r.sceneID] = r.quality + rerankByQuality(db, sceneBestQ, topWatch, topDelete, cfg) + } + } + common.Log.Infof("recommend: measured visual quality for %d files, re-ranked %d scenes", + measured, len(sceneBestQ)) +} + +// rerankByQuality re-normalizes the measured scenes' quality to a [0,1] percentile and +// rewrites their scores in the DB (crisp -> up in watch, soft -> up in delete). Scores +// are always derived from the persisted base values, so repeated calls don't compound. +func rerankByQuality(db *gorm.DB, sceneBestQ map[uint]float64, topWatch, topDelete map[uint]float64, cfg recConfig) { + relQ := percentileRanks(sceneBestQ) + for id, r := range relQ { + if base, ok := topWatch[id]; ok { + db.Model(&models.Scene{}).Where("id = ?", id). + Update("rec_watch_score", base*(1+cfg.WVisualQuality*(r-0.5))) + } else if base, ok := topDelete[id]; ok { + db.Model(&models.Scene{}).Where("id = ?", id). + Update("rec_delete_score", base*(1+cfg.WVisualQuality*(0.5-r))) + } + } +} + +func median(sorted []float64) float64 { + n := len(sorted) + if n == 0 { + return 0 + } + if n%2 == 1 { + return sorted[n/2] + } + return (sorted[n/2-1] + sorted[n/2]) / 2 +} + +// percentileRanks maps each id to its rank in [0,1] (lowest value -> 0, highest -> 1). +func percentileRanks(m map[uint]float64) map[uint]float64 { + out := make(map[uint]float64, len(m)) + n := len(m) + if n == 0 { + return out + } + if n == 1 { + for id := range m { + out[id] = 0.5 + } + return out + } + type kv struct { + id uint + v float64 + } + arr := make([]kv, 0, n) + for id, v := range m { + arr = append(arr, kv{id, v}) + } + sort.Slice(arr, func(i, j int) bool { return arr[i].v < arr[j].v }) + for i, e := range arr { + out[e.id] = float64(i) / float64(n-1) + } + return out +} diff --git a/pkg/server/cron.go b/pkg/server/cron.go index 001fd1964..f639c24c1 100644 --- a/pkg/server/cron.go +++ b/pkg/server/cron.go @@ -7,6 +7,7 @@ import ( "github.com/robfig/cron/v3" "github.com/xbapps/xbvr/pkg/api" "github.com/xbapps/xbvr/pkg/config" + "github.com/xbapps/xbvr/pkg/recommend" "github.com/xbapps/xbvr/pkg/session" "github.com/xbapps/xbvr/pkg/tasks" ) @@ -18,6 +19,7 @@ var previewTask cron.EntryID var actorScrapeTask cron.EntryID var stashdbScrapeTask cron.EntryID var linkScenesTask cron.EntryID +var recommendTask cron.EntryID func SetupCron() { cronInstance = cron.New() @@ -48,6 +50,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.RecommendationSchedule.Enabled { + log.Println(fmt.Sprintf("Setup Recommendation Task %v", formatCronSchedule(config.CronSchedule(config.Config.Cron.RecommendationSchedule)))) + recommendTask, _ = cronInstance.AddFunc(formatCronSchedule(config.CronSchedule(config.Config.Cron.RecommendationSchedule)), recommendCron) + } cronInstance.Start() go tasks.CalculateCacheSizes() @@ -70,7 +76,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.RecommendationSchedule.RunAtStartDelay > 0 { + time.AfterFunc(time.Duration(config.Config.Cron.RecommendationSchedule.RunAtStartDelay)*time.Minute, recommendCron) + } 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)) log.Println(fmt.Sprintf("Next Preview Generation Task at %v", cronInstance.Entry(previewTask).Next)) @@ -112,6 +120,13 @@ func linkScenesCron() { log.Println(fmt.Sprintf("Next Link Scenes Task at %v", cronInstance.Entry(rescrapTask).Next)) } +func recommendCron() { + if !session.HasActiveSession() { + recommend.Generate() + } + log.Println(fmt.Sprintf("Next Recommendation Task at %v", cronInstance.Entry(recommendTask).Next)) +} + var previewGenerateInProgress = false func generatePreviewCron() { diff --git a/pkg/server/server.go b/pkg/server/server.go index 392524274..baeab690b 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -95,6 +95,7 @@ func StartServer(version, commit, branch, date string) { restful.Add(api.AkaResource{}.WebService()) restful.Add(api.TagGroupResource{}.WebService()) restful.Add(api.ExternalReference{}.WebService()) + restful.Add(api.RecommendationResource{}.WebService()) restConfig := restfulspec.Config{ WebServices: restful.RegisteredWebServices(), diff --git a/ui/src/store/index.js b/ui/src/store/index.js index 1a7840cd5..5b6af55c3 100644 --- a/ui/src/store/index.js +++ b/ui/src/store/index.js @@ -17,6 +17,7 @@ import optionsFunscripts from './optionsFunscripts' import optionsVendor from './optionsVendor' import optionsAdvanced from './optionsAdvanced' import optionsSceneCreate from './optionsSceneCreate' +import optionsRecommendations from './optionsRecommendations' Vue.use(Vuex) @@ -38,5 +39,6 @@ export default new Vuex.Store({ optionsVendor, optionsAdvanced, optionsSceneCreate, + optionsRecommendations, } }) diff --git a/ui/src/store/optionsRecommendations.js b/ui/src/store/optionsRecommendations.js new file mode 100644 index 000000000..b9f757878 --- /dev/null +++ b/ui/src/store/optionsRecommendations.js @@ -0,0 +1,74 @@ +import ky from 'ky' + +const state = { + loading: false, + config: { + enabled: true, + useLearnedModel: false, + modelType: 'linear', + useVisualEmbeddings: false, + watchListSize: 30, + deleteListSize: 30, + protectRating: 4, + graceDays: 30, + excludeRecentlyWatched: true, + diversityDecay: 0.5, + wActor: 1.0, + wTag: 0.7, + wSite: 0.3, + wQuality: 0.2, + wFreshness: 0.2, + wSize: 0.5, + wVisualQuality: 0.5, + vqMaxSamples: 6, + noiseWeight: 0.3 + } +} + +const mutations = { + setConfig (state, cfg) { + state.config = { ...state.config, ...cfg } + }, + setField (state, { key, value }) { + state.config[key] = value + }, + setLoading (state, v) { + state.loading = v + } +} + +const actions = { + async load ({ commit }) { + commit('setLoading', true) + try { + const data = await ky.get('/api/recommendations/config').json() + commit('setConfig', data) + } finally { + commit('setLoading', false) + } + }, + async save ({ state, commit }) { + commit('setLoading', true) + try { + const data = await ky.post('/api/recommendations/config', { json: { ...state.config } }).json() + commit('setConfig', data) + } finally { + commit('setLoading', false) + } + }, + async recompute ({ commit }) { + commit('setLoading', true) + try { + await ky.post('/api/recommendations/recompute', { json: {} }).json() + } finally { + commit('setLoading', false) + } + } +} + +export default { + namespaced: true, + state, + mutations, + actions +} diff --git a/ui/src/views/options/Options.vue b/ui/src/views/options/Options.vue index ae591e296..102dd2c6f 100644 --- a/ui/src/views/options/Options.vue +++ b/ui/src/views/options/Options.vue @@ -10,6 +10,7 @@ + + @@ -63,12 +65,13 @@ import InterfaceDLNA from './sections/InterfaceDLNA.vue' import Cache from './sections/Cache.vue' import Previews from './sections/Previews.vue' import Schedules from './sections/Schedules.vue' +import Recommendations from './sections/Recommendations.vue' import InterfaceDeoVR from './sections/InterfaceDeoVR.vue' import InterfaceAdvanced from './sections/InterfaceAdvanced.vue' import SceneMatchParams from './overlays/SceneMatchParams.vue' export default { - components: { Storage, SceneDataScrapers, SceneCreate, Funscripts, SceneDataImportExport, InterfaceWeb, InterfaceDLNA, InterfaceDeoVR, Cache, Previews, Schedules, InterfaceAdvanced,SceneMatchParams }, + components: { Storage, SceneDataScrapers, SceneCreate, Funscripts, SceneDataImportExport, InterfaceWeb, InterfaceDLNA, InterfaceDeoVR, Cache, Previews, Schedules, Recommendations, InterfaceAdvanced,SceneMatchParams }, data: function () { return { active: 'storage' diff --git a/ui/src/views/options/sections/Recommendations.vue b/ui/src/views/options/sections/Recommendations.vue new file mode 100644 index 000000000..4612288ae --- /dev/null +++ b/ui/src/views/options/sections/Recommendations.vue @@ -0,0 +1,138 @@ + + + diff --git a/ui/src/views/options/sections/Schedules.vue b/ui/src/views/options/sections/Schedules.vue index 18df4b3c4..8d0fef46e 100644 --- a/ui/src/views/options/sections/Schedules.vue +++ b/ui/src/views/options/sections/Schedules.vue @@ -11,6 +11,7 @@ +
@@ -250,6 +251,23 @@
{{ delayStartMsg(linkScenesStartDelay) }}
+
+

{{$t("Recompute Recommendations")}}

+

Retrains the model, refreshes the For You / Cleanup lists, and processes any + new files (embeddings, quality). Skips while you're watching. Changes apply after a restart.

+ + Enable schedule + + + +
{{`Run every ${this.recommendationHourInterval} hour${this.recommendationHourInterval > 1 ? 's': ''}`}}
+
+
+ + +
{{ delayStartMsg(recommendationStartDelay) }}
+
+

Save settings @@ -317,8 +335,11 @@ export default { linkScenesHourInterval: 0, linkScenesMinuteStart: 0, lastlinkScenesTimeRange: [0,23], - useLinkScenesTimeRange: false, + useLinkScenesTimeRange: false, linkScenesStartDelay: 0, + recommendationEnabled: true, + recommendationHourInterval: 24, + recommendationStartDelay: 0, 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', @@ -447,7 +468,12 @@ export default { this.previewStartDelay = data.config.cron.previewSchedule.runAtStartDelay this.actorRescrapeStartDelay = data.config.cron.actorRescrapeSchedule.runAtStartDelay this.stashdbRescrapeStartDelay = data.config.cron.stashdbRescrapeSchedule.runAtStartDelay - this.linkScenesStartDelay = data.config.cron.linkScenesSchedule.runAtStartDelay + this.linkScenesStartDelay = data.config.cron.linkScenesSchedule.runAtStartDelay + if (data.config.cron.recommendationSchedule) { + this.recommendationEnabled = data.config.cron.recommendationSchedule.enabled + this.recommendationHourInterval = data.config.cron.recommendationSchedule.hourInterval + this.recommendationStartDelay = data.config.cron.recommendationSchedule.runAtStartDelay + } this.isLoading = false }) }, @@ -516,7 +542,14 @@ export default { linkScenesMinuteStart: this.linkScenesMinuteStart, linkScenesHourStart: this.linkScenesTimeRange[0], linkScenesHourEnd: this.linkScenesTimeRange[1], - linkScenesStartDelay:this.linkScenesStartDelay + linkScenesStartDelay:this.linkScenesStartDelay, + recommendationEnabled: this.recommendationEnabled, + recommendationHourInterval: this.recommendationHourInterval, + recommendationUseRange: false, + recommendationMinuteStart: 0, + recommendationHourStart: 0, + recommendationHourEnd: 23, + recommendationStartDelay: this.recommendationStartDelay } }) .json()