This commit is contained in:
bob12224 2026-03-24 01:28:39 -04:00 committed by GitHub
commit 2b0ed0e57b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 175 additions and 4 deletions

View file

@ -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

View file

@ -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!

View file

@ -6,6 +6,7 @@ type SceneFileType {
width: Int
height: Int
framerate: Float
frames: Int
bitrate: Int
}

View file

@ -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)

View file

@ -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,

View file

@ -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

View file

@ -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) {

View file

@ -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
}

View file

@ -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"`

View file

@ -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"`
}

View file

@ -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"`

View file

@ -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"`
}

View file

@ -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,

View file

@ -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,
}

View file

@ -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

View file

@ -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"),

View file

@ -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,

View file

@ -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)
}

View file

@ -0,0 +1 @@
ALTER TABLE video_files ADD COLUMN frames INTEGER DEFAULT NULL;

View file

@ -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 {

View file

@ -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

View file

@ -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
}

View file

@ -110,6 +110,7 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
values={{ value: intl.formatNumber(props.file.frame_rate ?? 0) }}
/>
</TextField>
<TextField id="frames" value={`${props.file.frames ?? 0}`} truncate />
<TextField id="bitrate">
<FormattedMessage
id="megabits_per_second"

View file

@ -195,6 +195,16 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (
</ul>
);
const FramesCell = (scene: GQL.SlimSceneDataFragment) => (
<ul className="comma-list">
{scene.files.map((file) => (
<li key={file.id}>
<span>{file?.frames ?? 0}</span>
</li>
))}
</ul>
);
const BitRateCell = (scene: GQL.SlimSceneDataFragment) => (
<ul className="comma-list">
{scene.files.map((file) => (
@ -357,6 +367,11 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (
label: intl.formatMessage({ id: "framerate" }),
render: FrameRateCell,
},
{
value: "frames",
label: intl.formatMessage({ id: "frames" }),
render: FramesCell,
},
{
value: "bitrate",
label: intl.formatMessage({ id: "bitrate" }),

View file

@ -1203,6 +1203,7 @@
"filters": "Filters",
"folder": "Folder",
"framerate": "Frame Rate",
"frames": "Frame Count",
"frames_per_second": "{value} fps",
"front_page": {
"types": {