diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index a26ce6817..89776d732 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -34,6 +34,8 @@ NOTE: The `make` command in Windows will be `mingw32-make` with MinGW. For examp #### Ubuntu 1. Install dependencies: `sudo apt-get install golang git gcc nodejs ffmpeg -y` + * To install `pnpm`: `npm install -g pnpm` + * Check Go Version, if old (`make generate` fails) use `sudo snap install go --classic` or install from tarball ### OpenBSD diff --git a/graphql/schema/types/file.graphql b/graphql/schema/types/file.graphql index fcc2a58c8..e6827513b 100644 --- a/graphql/schema/types/file.graphql +++ b/graphql/schema/types/file.graphql @@ -91,6 +91,7 @@ type VideoFile implements BaseFile { video_codec: String! audio_codec: String! frame_rate: Float! + frames: Int! bit_rate: Int! created_at: Time! diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index 4d99e0a21..1bbc63e9e 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -6,6 +6,7 @@ type SceneFileType { width: Int height: Int framerate: Float + frames: Int bitrate: Int } diff --git a/internal/manager/generator_sprite.go b/internal/manager/generator_sprite.go index dc56fde88..c2081d9fd 100644 --- a/internal/manager/generator_sprite.go +++ b/internal/manager/generator_sprite.go @@ -105,7 +105,7 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO slowSeek := false - // For files with small duration / low frame count try to seek using frame number intead of seconds + // For files with small duration / low frame count try to seek using frame number instead of seconds if videoFile.VideoStreamDuration < 5 || (0 < videoFile.FrameCount && videoFile.FrameCount <= int64(chunkCount)) { // some files can have FrameCount == 0, only use SlowSeek if duration < 5 if videoFile.VideoStreamDuration <= 0 { s := fmt.Sprintf("video %s: duration(%.3f)/frame count(%d) invalid, skipping sprite creation", videoFile.Path, videoFile.VideoStreamDuration, videoFile.FrameCount) diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index 01bab9430..0d272b7ff 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -441,6 +441,7 @@ func fileToJSON(f models.File) jsonschema.DirEntry { VideoCodec: ff.VideoCodec, AudioCodec: ff.AudioCodec, FrameRate: ff.FrameRate, + Frames: ff.Frames, BitRate: ff.BitRate, Interactive: ff.Interactive, InteractiveSpeed: ff.InteractiveSpeed, diff --git a/pkg/ffmpeg/ffprobe.go b/pkg/ffmpeg/ffprobe.go index 59f8ed218..e255af363 100644 --- a/pkg/ffmpeg/ffprobe.go +++ b/pkg/ffmpeg/ffprobe.go @@ -221,6 +221,7 @@ func (f *FFProbe) NewVideoFile(videoPath string) (*VideoFile, error) { "-show_format", "-show_streams", "-show_error", + "-count_frames", } // show_entries stream_side_data=rotation requires 5.x or later ffprobe diff --git a/pkg/file/scan.go b/pkg/file/scan.go index 4cfcaf7ae..d20fffdb1 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -700,7 +700,7 @@ func (s *Scanner) isHandlerRequired(ctx context.Context, f models.File) bool { // Missing metadata includes the following: // - file size // - image format, width or height -// - video codec, audio codec, format, width, height, framerate or bitrate +// - video codec, audio codec, format, width, height, framerate, frames or bitrate func (s *Scanner) isMissingMetadata(ctx context.Context, f ScannedFile, existing models.File) bool { for _, h := range s.FileDecorators { if h.IsMissingMetadata(ctx, f.FS, existing) { diff --git a/pkg/file/video/scan.go b/pkg/file/video/scan.go index 21be0cd11..3a42b4627 100644 --- a/pkg/file/video/scan.go +++ b/pkg/file/video/scan.go @@ -52,6 +52,7 @@ func (d *Decorator) Decorate(ctx context.Context, fs models.FS, f models.File) ( Height: videoFile.Height, Duration: videoFile.FileDuration, FrameRate: videoFile.FrameRate, + Frames: videoFile.FrameCount, BitRate: videoFile.Bitrate, Interactive: interactive, }, nil @@ -76,6 +77,6 @@ func (d *Decorator) IsMissingMetadata(ctx context.Context, fs models.FS, f model return vf.VideoCodec == unsetString || vf.AudioCodec == unsetString || vf.Format == unsetString || vf.Width == unsetNumber || vf.Height == unsetNumber || vf.FrameRate == unsetNumber || - vf.Duration == unsetNumber || + vf.Frames == unsetNumber || vf.Duration == unsetNumber || vf.BitRate == unsetNumber || interactive != vf.Interactive } diff --git a/pkg/models/jsonschema/file_folder.go b/pkg/models/jsonschema/file_folder.go index dfe581f78..c8f819bc5 100644 --- a/pkg/models/jsonschema/file_folder.go +++ b/pkg/models/jsonschema/file_folder.go @@ -84,6 +84,7 @@ type VideoFile struct { VideoCodec string `json:"video_codec,omitempty"` AudioCodec string `json:"audio_codec,omitempty"` FrameRate float64 `json:"frame_rate,omitempty"` + Frames int64 `json:"frames,omitempty"` BitRate int64 `json:"bitrate,omitempty"` Interactive bool `json:"interactive,omitempty"` diff --git a/pkg/models/jsonschema/scene.go b/pkg/models/jsonschema/scene.go index 8f15b9c5d..e4eab0666 100644 --- a/pkg/models/jsonschema/scene.go +++ b/pkg/models/jsonschema/scene.go @@ -31,6 +31,7 @@ type SceneFile struct { Width int `json:"width"` Height int `json:"height"` Framerate string `json:"framerate"` + Frames int `json:"frames"` Bitrate int `json:"bitrate"` } diff --git a/pkg/models/model_file.go b/pkg/models/model_file.go index f6b8bdc51..eae6b926c 100644 --- a/pkg/models/model_file.go +++ b/pkg/models/model_file.go @@ -285,6 +285,7 @@ type VideoFile struct { VideoCodec string `json:"video_codec"` AudioCodec string `json:"audio_codec"` FrameRate float64 `json:"frame_rate"` + Frames int64 `json:"frames"` BitRate int64 `json:"bitrate"` Interactive bool `json:"interactive"` diff --git a/pkg/models/model_scene.go b/pkg/models/model_scene.go index 64ad34b9c..8e0c52dbc 100644 --- a/pkg/models/model_scene.go +++ b/pkg/models/model_scene.go @@ -274,6 +274,7 @@ type SceneFileType struct { Width *int `graphql:"width" json:"width"` Height *int `graphql:"height" json:"height"` Framerate *float64 `graphql:"framerate" json:"framerate"` + Frames *int `graphql:"frames" json:"frames"` Bitrate *int `graphql:"bitrate" json:"bitrate"` } diff --git a/pkg/scraper/script.go b/pkg/scraper/script.go index f8e47b5d8..36b1e6aa5 100644 --- a/pkg/scraper/script.go +++ b/pkg/scraper/script.go @@ -45,6 +45,7 @@ type videoFileInput struct { VideoCodec string `json:"video_codec,omitempty"` AudioCodec string `json:"audio_codec,omitempty"` FrameRate float64 `json:"frame_rate,omitempty"` + Frames int64 `json:"frames,omitempty"` BitRate int64 `json:"bitrate,omitempty"` Interactive bool `json:"interactive,omitempty"` @@ -106,6 +107,7 @@ func videoFileInputFromVideoFile(vf *models.VideoFile) videoFileInput { VideoCodec: vf.VideoCodec, AudioCodec: vf.AudioCodec, FrameRate: vf.FrameRate, + Frames: vf.Frames, BitRate: vf.BitRate, Interactive: vf.Interactive, InteractiveSpeed: vf.InteractiveSpeed, diff --git a/pkg/scraper/stash.go b/pkg/scraper/stash.go index 23c4b9063..9e2a739e8 100644 --- a/pkg/scraper/stash.go +++ b/pkg/scraper/stash.go @@ -307,6 +307,7 @@ type stashVideoFile struct { Width int `graphql:"width" json:"width"` Height int `graphql:"height" json:"height"` Framerate float64 `graphql:"frame_rate" json:"frame_rate"` + Frames int `graphql:"frames" json:"frames"` Bitrate int `graphql:"bit_rate" json:"bit_rate"` } @@ -318,6 +319,7 @@ func (f stashVideoFile) SceneFileType() models.SceneFileType { Width: &f.Width, Height: &f.Height, Framerate: &f.Framerate, + Frames: &f.Frames, Bitrate: &f.Bitrate, } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 7c383dc4c..026a18c08 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 85 +var appSchemaVersion uint = 86 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/file.go b/pkg/sqlite/file.go index b8e807e37..d09096c6d 100644 --- a/pkg/sqlite/file.go +++ b/pkg/sqlite/file.go @@ -60,6 +60,7 @@ type videoFileRow struct { VideoCodec string `db:"video_codec"` AudioCodec string `db:"audio_codec"` FrameRate float64 `db:"frame_rate"` + Frames int64 `db:"frames"` BitRate int64 `db:"bit_rate"` Interactive bool `db:"interactive"` InteractiveSpeed null.Int `db:"interactive_speed"` @@ -74,6 +75,7 @@ func (f *videoFileRow) fromVideoFile(ff models.VideoFile) { f.VideoCodec = ff.VideoCodec f.AudioCodec = ff.AudioCodec f.FrameRate = ff.FrameRate + f.Frames = ff.Frames f.BitRate = ff.BitRate f.Interactive = ff.Interactive f.InteractiveSpeed = intFromPtr(ff.InteractiveSpeed) @@ -104,6 +106,7 @@ type videoFileQueryRow struct { VideoCodec null.String `db:"video_codec"` AudioCodec null.String `db:"audio_codec"` FrameRate null.Float `db:"frame_rate"` + Frames null.Int `db:"frames"` BitRate null.Int `db:"bit_rate"` Interactive null.Bool `db:"interactive"` InteractiveSpeed null.Int `db:"interactive_speed"` @@ -118,6 +121,7 @@ func (f *videoFileQueryRow) resolve() *models.VideoFile { VideoCodec: f.VideoCodec.String, AudioCodec: f.AudioCodec.String, FrameRate: f.FrameRate.Float64, + Frames: f.Frames.Int64, BitRate: f.BitRate.Int64, Interactive: f.Interactive.Bool, InteractiveSpeed: nullIntPtr(f.InteractiveSpeed), @@ -135,6 +139,7 @@ func videoFileQueryColumns() []interface{} { table.Col("video_codec"), table.Col("audio_codec"), table.Col("frame_rate"), + table.Col("frames"), table.Col("bit_rate"), table.Col("interactive"), table.Col("interactive_speed"), diff --git a/pkg/sqlite/file_test.go b/pkg/sqlite/file_test.go index 55c41f4f7..b84ab7345 100644 --- a/pkg/sqlite/file_test.go +++ b/pkg/sqlite/file_test.go @@ -41,6 +41,7 @@ func Test_fileFileStore_Create(t *testing.T) { width = 640 height = 480 framerate = 2.345 + frames int64 = 3 bitrate int64 = 234 videoCodec = "videoCodec" audioCodec = "audioCodec" @@ -104,6 +105,7 @@ func Test_fileFileStore_Create(t *testing.T) { Width: width, Height: height, FrameRate: framerate, + Frames: frames, BitRate: bitrate, }, false, @@ -258,6 +260,7 @@ func Test_fileStore_Update(t *testing.T) { width = 640 height = 480 framerate = 2.345 + frames int64 = 3 bitrate int64 = 234 videoCodec = "videoCodec" audioCodec = "audioCodec" @@ -323,6 +326,7 @@ func Test_fileStore_Update(t *testing.T) { Width: width, Height: height, FrameRate: framerate, + Frames: frames, BitRate: bitrate, }, false, diff --git a/pkg/sqlite/migrations/86_postmigrate.go b/pkg/sqlite/migrations/86_postmigrate.go new file mode 100644 index 000000000..b682dd94d --- /dev/null +++ b/pkg/sqlite/migrations/86_postmigrate.go @@ -0,0 +1,124 @@ +package migrations + +import ( + "context" + "fmt" + "os/exec" + "path/filepath" + + "github.com/jmoiron/sqlx" + "github.com/stashapp/stash/pkg/ffmpeg" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/sqlite" +) + +func post86(ctx context.Context, db *sqlx.DB) error { + logger.Info("Running post-migration for schema version 86") + + ffprobePath, _ := exec.LookPath("ffprobe") + + mm := schema85PostMigrator{ + migrator: migrator{ + db: db, + }, + ffprobe: ffmpeg.NewFFProbe(ffprobePath), + } + + return mm.migrate(ctx) +} + +type schema85PostMigrator struct { + migrator + ffprobe *ffmpeg.FFProbe +} + +func (m *schema85PostMigrator) migrate(ctx context.Context) error { + const ( + limit = 1000 + logEvery = 10000 + ) + + result := struct { + Count int `db:"count"` + }{0} + + if err := m.db.Get(&result, "SELECT COUNT(*) AS count FROM `video_files` WHERE `frames` IS NULL"); err != nil { + return err + } + + if result.Count == 0 { + return nil + } + + logger.Infof("Backfilling frames for %d video files...", result.Count) + + lastID := 0 + count := 0 + + for { + gotSome := false + + if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { + query := ` + SELECT f.id, folders.path, f.basename + FROM video_files vf + JOIN files f ON f.id = vf.file_id + JOIN folders ON folders.id = f.parent_folder_id + WHERE vf.frames IS NULL + ` + if lastID != 0 { + query += fmt.Sprintf(" AND f.id > %d", lastID) + } + query += fmt.Sprintf(" ORDER BY f.id LIMIT %d", limit) + + rows, err := tx.Query(query) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var fileID int + var dir string + var basename string + + if err := rows.Scan(&fileID, &dir, &basename); err != nil { + return err + } + + gotSome = true + lastID = fileID + count++ + + path := filepath.Join(dir, basename) + + frames, err := m.ffprobe.GetReadFrameCount(path) + if err != nil || frames <= 0 { + continue + } + + if _, err := tx.Exec("UPDATE `video_files` SET `frames` = ? WHERE `file_id` = ?", frames, fileID); err != nil { + return err + } + } + + return rows.Err() + }); err != nil { + return err + } + + if !gotSome { + break + } + + if count%logEvery == 0 { + logger.Infof("Checked %d video files", count) + } + } + + return nil +} + +func init() { + sqlite.RegisterPostMigration(86, post86) +} diff --git a/pkg/sqlite/migrations/86_video_file_frames.up.sql b/pkg/sqlite/migrations/86_video_file_frames.up.sql new file mode 100644 index 000000000..33d495f71 --- /dev/null +++ b/pkg/sqlite/migrations/86_video_file_frames.up.sql @@ -0,0 +1 @@ +ALTER TABLE video_files ADD COLUMN frames INTEGER DEFAULT NULL; \ No newline at end of file diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 4ab310ee7..a922f10f9 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -934,6 +934,7 @@ func makeFile(i int) models.File { VideoCodec: getFileStringValue(i, "videoCodec"), AudioCodec: getFileStringValue(i, "audioCodec"), FrameRate: getFileDuration(i) * 2, + Frames: int64(getFileDuration(i) * getFileDuration(i) * 2), BitRate: int64(getFileDuration(i)) * 3, } } else if i >= fileIdxStartImageFiles && i < fileIdxStartGalleryFiles { diff --git a/ui/v2.5/graphql/data/file.graphql b/ui/v2.5/graphql/data/file.graphql index 7386adb81..6a12c8089 100644 --- a/ui/v2.5/graphql/data/file.graphql +++ b/ui/v2.5/graphql/data/file.graphql @@ -15,6 +15,7 @@ fragment VideoFileData on VideoFile { width height frame_rate + frames bit_rate fingerprints { type @@ -80,6 +81,7 @@ fragment VisualFileData on VisualFile { width height frame_rate + frames bit_rate fingerprints { type diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index 0dae3c2d5..5780fec1d 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -188,6 +188,7 @@ fragment ScrapedSceneData on ScrapedScene { width height framerate + frames bitrate } @@ -275,6 +276,7 @@ fragment ScrapedStashBoxSceneData on ScrapedScene { width height framerate + frames bitrate } diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx index cd11a2c8a..7069ac66c 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx @@ -110,6 +110,7 @@ const FileInfoPanel: React.FC = ( values={{ value: intl.formatNumber(props.file.frame_rate ?? 0) }} /> + = ( ); + const FramesCell = (scene: GQL.SlimSceneDataFragment) => ( +
    + {scene.files.map((file) => ( +
  • + {file?.frames ?? 0} +
  • + ))} +
+ ); + const BitRateCell = (scene: GQL.SlimSceneDataFragment) => (
    {scene.files.map((file) => ( @@ -357,6 +367,11 @@ export const SceneListTable: React.FC = ( label: intl.formatMessage({ id: "framerate" }), render: FrameRateCell, }, + { + value: "frames", + label: intl.formatMessage({ id: "frames" }), + render: FramesCell, + }, { value: "bitrate", label: intl.formatMessage({ id: "bitrate" }), diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 37b6b6d44..88a7291c6 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1203,6 +1203,7 @@ "filters": "Filters", "folder": "Folder", "framerate": "Frame Rate", + "frames": "Frame Count", "frames_per_second": "{value} fps", "front_page": { "types": {