From 0e9d37ca77e4b3afa0816613579d065701f70105 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Sun, 12 Jul 2020 14:17:49 +0200 Subject: [PATCH] Add video metadata --- .../migrations/008_video_support.down.sql | 6 + .../migrations/008_video_support.up.sql | 25 +- api/go.mod | 2 +- api/go.sum | 1 + api/gqlgen.yml | 2 + api/graphql/generated.go | 579 +++++++++++++++++- api/graphql/models/media.go | 21 +- api/graphql/models/video_metadata.go | 33 + api/graphql/resolvers/media.go | 15 + api/graphql/schema.graphql | 14 + api/scanner/process_video.go | 11 +- api/scanner/scanner_media.go | 12 +- api/scanner/video_metadata.go | 70 +++ ui/src/components/sidebar/MediaSidebar.js | 40 +- 14 files changed, 798 insertions(+), 33 deletions(-) create mode 100644 api/graphql/models/video_metadata.go create mode 100644 api/scanner/video_metadata.go diff --git a/api/database/migrations/008_video_support.down.sql b/api/database/migrations/008_video_support.down.sql index e2bd1328..ea6cedc3 100644 --- a/api/database/migrations/008_video_support.down.sql +++ b/api/database/migrations/008_video_support.down.sql @@ -9,3 +9,9 @@ ALTER TABLE photo_url CHANGE COLUMN media_name photo_name varchar(512) NOT NULL; ALTER TABLE share_token CHANGE COLUMN media_id photo_id int; ALTER TABLE photo DROP COLUMN media_type; + +ALTER TABLE photo + DROP FOREIGN KEY photo_ibfk_3, + DROP COLUMN video_metadata_id; + +DROP TABLE video_metadata; \ No newline at end of file diff --git a/api/database/migrations/008_video_support.up.sql b/api/database/migrations/008_video_support.up.sql index 1827d1b2..c5d87d45 100644 --- a/api/database/migrations/008_video_support.up.sql +++ b/api/database/migrations/008_video_support.up.sql @@ -4,8 +4,27 @@ ALTER TABLE photo_url RENAME TO media_url; ALTER TABLE photo_exif RENAME TO media_exif; ALTER TABLE media CHANGE COLUMN photo_id media_id int NOT NULL AUTO_INCREMENT; -ALTER TABLE media_url CHANGE COLUMN photo_id media_id int NOT NULL; -ALTER TABLE media_url CHANGE COLUMN photo_name media_name varchar(512) NOT NULL; +ALTER TABLE media_url + CHANGE COLUMN photo_id media_id int NOT NULL, + CHANGE COLUMN photo_name media_name varchar(512) NOT NULL; ALTER TABLE share_token CHANGE COLUMN photo_id media_id int; -ALTER TABLE media ADD COLUMN media_type varchar(64) NOT NULL DEFAULT "photo"; +CREATE TABLE video_metadata ( + metadata_id int NOT NULL AUTO_INCREMENT, + + width int(6) NOT NULL, + height int(6) NOT NULL, + duration double NOT NULL, + codec varchar(128), + framerate double, + bitrate int(24), + color_profile varchar(128), + audio varchar(128), + + PRIMARY KEY (metadata_id) +); + +ALTER TABLE media + ADD COLUMN media_type varchar(64) NOT NULL DEFAULT "photo", + ADD COLUMN video_metadata_id int, + ADD FOREIGN KEY (video_metadata_id) REFERENCES video_metadata(metadata_id); diff --git a/api/go.mod b/api/go.mod index 436aaddf..7c2a6891 100644 --- a/api/go.mod +++ b/api/go.mod @@ -25,7 +25,7 @@ require ( github.com/urfave/cli v1.22.4 // indirect github.com/urfave/cli/v2 v2.2.0 // indirect github.com/vektah/dataloaden v0.3.0 // indirect - github.com/vektah/gqlparser v1.3.1 + github.com/vektah/gqlparser v1.3.1 // indirect github.com/vektah/gqlparser/v2 v2.0.1 github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0 github.com/xor-gate/goexif2 v1.1.0 diff --git a/api/go.sum b/api/go.sum index 59b9de40..85449931 100644 --- a/api/go.sum +++ b/api/go.sum @@ -165,6 +165,7 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b h1:0mm1VjtFUOIlE1SbDlwjYaDxZVDP2S5ou6y0gSgXHu8= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/api/gqlgen.yml b/api/gqlgen.yml index eb985768..e12dcc01 100644 --- a/api/gqlgen.yml +++ b/api/gqlgen.yml @@ -30,6 +30,8 @@ models: model: github.com/viktorstrate/photoview/api/graphql/models.MediaURL MediaEXIF: model: github.com/viktorstrate/photoview/api/graphql/models.MediaEXIF + VideoMetadata: + model: github.com/viktorstrate/photoview/api/graphql/models.VideoMetadata Album: model: github.com/viktorstrate/photoview/api/graphql/models.Album ShareToken: diff --git a/api/graphql/generated.go b/api/graphql/generated.go index bc8d82a9..73b98a48 100644 --- a/api/graphql/generated.go +++ b/api/graphql/generated.go @@ -71,18 +71,19 @@ type ComplexityRoot struct { } Media struct { - Album func(childComplexity int) int - Downloads func(childComplexity int) int - Exif func(childComplexity int) int - Favorite func(childComplexity int) int - HighRes func(childComplexity int) int - ID func(childComplexity int) int - Path func(childComplexity int) int - Shares func(childComplexity int) int - Thumbnail func(childComplexity int) int - Title func(childComplexity int) int - Type func(childComplexity int) int - VideoWeb func(childComplexity int) int + Album func(childComplexity int) int + Downloads func(childComplexity int) int + Exif func(childComplexity int) int + Favorite func(childComplexity int) int + HighRes func(childComplexity int) int + ID func(childComplexity int) int + Path func(childComplexity int) int + Shares func(childComplexity int) int + Thumbnail func(childComplexity int) int + Title func(childComplexity int) int + Type func(childComplexity int) int + VideoMetadata func(childComplexity int) int + VideoWeb func(childComplexity int) int } MediaDownload struct { @@ -190,6 +191,19 @@ type ComplexityRoot struct { RootPath func(childComplexity int) int Username func(childComplexity int) int } + + VideoMetadata struct { + Audio func(childComplexity int) int + Bitrate func(childComplexity int) int + Codec func(childComplexity int) int + ColorProfile func(childComplexity int) int + Duration func(childComplexity int) int + Framerate func(childComplexity int) int + Height func(childComplexity int) int + ID func(childComplexity int) int + Media func(childComplexity int) int + Width func(childComplexity int) int + } } type AlbumResolver interface { @@ -208,6 +222,7 @@ type MediaResolver interface { VideoWeb(ctx context.Context, obj *models.Media) (*models.MediaURL, error) Album(ctx context.Context, obj *models.Media) (*models.Album, error) Exif(ctx context.Context, obj *models.Media) (*models.MediaEXIF, error) + VideoMetadata(ctx context.Context, obj *models.Media) (*models.VideoMetadata, error) Shares(ctx context.Context, obj *models.Media) ([]*models.ShareToken, error) Downloads(ctx context.Context, obj *models.Media) ([]*models.MediaDownload, error) @@ -443,6 +458,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Media.Type(childComplexity), true + case "Media.videoMetadata": + if e.complexity.Media.VideoMetadata == nil { + break + } + + return e.complexity.Media.VideoMetadata(childComplexity), true + case "Media.videoWeb": if e.complexity.Media.VideoWeb == nil { break @@ -1040,6 +1062,76 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.User.Username(childComplexity), true + case "VideoMetadata.audio": + if e.complexity.VideoMetadata.Audio == nil { + break + } + + return e.complexity.VideoMetadata.Audio(childComplexity), true + + case "VideoMetadata.bitrate": + if e.complexity.VideoMetadata.Bitrate == nil { + break + } + + return e.complexity.VideoMetadata.Bitrate(childComplexity), true + + case "VideoMetadata.codec": + if e.complexity.VideoMetadata.Codec == nil { + break + } + + return e.complexity.VideoMetadata.Codec(childComplexity), true + + case "VideoMetadata.colorProfile": + if e.complexity.VideoMetadata.ColorProfile == nil { + break + } + + return e.complexity.VideoMetadata.ColorProfile(childComplexity), true + + case "VideoMetadata.duration": + if e.complexity.VideoMetadata.Duration == nil { + break + } + + return e.complexity.VideoMetadata.Duration(childComplexity), true + + case "VideoMetadata.framerate": + if e.complexity.VideoMetadata.Framerate == nil { + break + } + + return e.complexity.VideoMetadata.Framerate(childComplexity), true + + case "VideoMetadata.height": + if e.complexity.VideoMetadata.Height == nil { + break + } + + return e.complexity.VideoMetadata.Height(childComplexity), true + + case "VideoMetadata.id": + if e.complexity.VideoMetadata.ID == nil { + break + } + + return e.complexity.VideoMetadata.ID(childComplexity), true + + case "VideoMetadata.media": + if e.complexity.VideoMetadata.Media == nil { + break + } + + return e.complexity.VideoMetadata.Media(childComplexity), true + + case "VideoMetadata.width": + if e.complexity.VideoMetadata.Width == nil { + break + } + + return e.complexity.VideoMetadata.Width(childComplexity), true + } return 0, false } @@ -1340,6 +1432,7 @@ type Media { "The album that holds the media" album: Album! exif: MediaEXIF + videoMetadata: VideoMetadata favorite: Boolean! type: MediaType! @@ -1372,6 +1465,19 @@ type MediaEXIF { exposureProgram: Int } +type VideoMetadata { + id: Int! + media: Media! + width: Int! + height: Int! + duration: Float! + codec: String + framerate: Float + bitrate: Int + colorProfile: String + audio: String +} + type SearchResult { query: String! albums: [Album!]! @@ -2642,6 +2748,37 @@ func (ec *executionContext) _Media_exif(ctx context.Context, field graphql.Colle return ec.marshalOMediaEXIF2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMediaEXIF(ctx, field.Selections, res) } +func (ec *executionContext) _Media_videoMetadata(ctx context.Context, field graphql.CollectedField, obj *models.Media) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Media", + Field: field, + Args: nil, + IsMethod: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Media().VideoMetadata(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*models.VideoMetadata) + fc.Result = res + return ec.marshalOVideoMetadata2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐVideoMetadata(ctx, field.Selections, res) +} + func (ec *executionContext) _Media_favorite(ctx context.Context, field graphql.CollectedField, obj *models.Media) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -5419,6 +5556,331 @@ func (ec *executionContext) _User_admin(ctx context.Context, field graphql.Colle return ec.marshalNBoolean2bool(ctx, field.Selections, res) } +func (ec *executionContext) _VideoMetadata_id(ctx context.Context, field graphql.CollectedField, obj *models.VideoMetadata) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "VideoMetadata", + Field: field, + Args: nil, + IsMethod: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.ID(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(int) + fc.Result = res + return ec.marshalNInt2int(ctx, field.Selections, res) +} + +func (ec *executionContext) _VideoMetadata_media(ctx context.Context, field graphql.CollectedField, obj *models.VideoMetadata) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "VideoMetadata", + Field: field, + Args: nil, + IsMethod: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Media(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*models.Media) + fc.Result = res + return ec.marshalNMedia2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMedia(ctx, field.Selections, res) +} + +func (ec *executionContext) _VideoMetadata_width(ctx context.Context, field graphql.CollectedField, obj *models.VideoMetadata) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "VideoMetadata", + Field: field, + Args: nil, + IsMethod: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Width, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(int) + fc.Result = res + return ec.marshalNInt2int(ctx, field.Selections, res) +} + +func (ec *executionContext) _VideoMetadata_height(ctx context.Context, field graphql.CollectedField, obj *models.VideoMetadata) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "VideoMetadata", + Field: field, + Args: nil, + IsMethod: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Height, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(int) + fc.Result = res + return ec.marshalNInt2int(ctx, field.Selections, res) +} + +func (ec *executionContext) _VideoMetadata_duration(ctx context.Context, field graphql.CollectedField, obj *models.VideoMetadata) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "VideoMetadata", + Field: field, + Args: nil, + IsMethod: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Duration, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(float64) + fc.Result = res + return ec.marshalNFloat2float64(ctx, field.Selections, res) +} + +func (ec *executionContext) _VideoMetadata_codec(ctx context.Context, field graphql.CollectedField, obj *models.VideoMetadata) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "VideoMetadata", + Field: field, + Args: nil, + IsMethod: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Codec, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) _VideoMetadata_framerate(ctx context.Context, field graphql.CollectedField, obj *models.VideoMetadata) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "VideoMetadata", + Field: field, + Args: nil, + IsMethod: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Framerate, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*float64) + fc.Result = res + return ec.marshalOFloat2ᚖfloat64(ctx, field.Selections, res) +} + +func (ec *executionContext) _VideoMetadata_bitrate(ctx context.Context, field graphql.CollectedField, obj *models.VideoMetadata) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "VideoMetadata", + Field: field, + Args: nil, + IsMethod: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Bitrate, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int) + fc.Result = res + return ec.marshalOInt2ᚖint(ctx, field.Selections, res) +} + +func (ec *executionContext) _VideoMetadata_colorProfile(ctx context.Context, field graphql.CollectedField, obj *models.VideoMetadata) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "VideoMetadata", + Field: field, + Args: nil, + IsMethod: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.ColorProfile, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) _VideoMetadata_audio(ctx context.Context, field graphql.CollectedField, obj *models.VideoMetadata) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "VideoMetadata", + Field: field, + Args: nil, + IsMethod: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Audio, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + func (ec *executionContext) ___Directive_name(ctx context.Context, field graphql.CollectedField, obj *introspection.Directive) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -6765,6 +7227,17 @@ func (ec *executionContext) _Media(ctx context.Context, sel ast.SelectionSet, ob res = ec._Media_exif(ctx, field, obj) return res }) + case "videoMetadata": + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Media_videoMetadata(ctx, field, obj) + return res + }) case "favorite": out.Values[i] = ec._Media_favorite(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -7481,6 +7954,63 @@ func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj return out } +var videoMetadataImplementors = []string{"VideoMetadata"} + +func (ec *executionContext) _VideoMetadata(ctx context.Context, sel ast.SelectionSet, obj *models.VideoMetadata) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, videoMetadataImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("VideoMetadata") + case "id": + out.Values[i] = ec._VideoMetadata_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "media": + out.Values[i] = ec._VideoMetadata_media(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "width": + out.Values[i] = ec._VideoMetadata_width(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "height": + out.Values[i] = ec._VideoMetadata_height(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "duration": + out.Values[i] = ec._VideoMetadata_duration(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "codec": + out.Values[i] = ec._VideoMetadata_codec(ctx, field, obj) + case "framerate": + out.Values[i] = ec._VideoMetadata_framerate(ctx, field, obj) + case "bitrate": + out.Values[i] = ec._VideoMetadata_bitrate(ctx, field, obj) + case "colorProfile": + out.Values[i] = ec._VideoMetadata_colorProfile(ctx, field, obj) + case "audio": + out.Values[i] = ec._VideoMetadata_audio(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch() + if invalids > 0 { + return graphql.Null + } + return out +} + var __DirectiveImplementors = []string{"__Directive"} func (ec *executionContext) ___Directive(ctx context.Context, sel ast.SelectionSet, obj *introspection.Directive) graphql.Marshaler { @@ -7805,6 +8335,20 @@ func (ec *executionContext) marshalNBoolean2bool(ctx context.Context, sel ast.Se return res } +func (ec *executionContext) unmarshalNFloat2float64(ctx context.Context, v interface{}) (float64, error) { + return graphql.UnmarshalFloat(v) +} + +func (ec *executionContext) marshalNFloat2float64(ctx context.Context, sel ast.SelectionSet, v float64) graphql.Marshaler { + res := graphql.MarshalFloat(v) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "must not be null") + } + } + return res +} + func (ec *executionContext) unmarshalNInt2int(ctx context.Context, v interface{}) (int, error) { return graphql.UnmarshalInt(v) } @@ -8619,6 +9163,17 @@ func (ec *executionContext) marshalOUser2ᚖgithubᚗcomᚋviktorstrateᚋphotov return ec._User(ctx, sel, v) } +func (ec *executionContext) marshalOVideoMetadata2githubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐVideoMetadata(ctx context.Context, sel ast.SelectionSet, v models.VideoMetadata) graphql.Marshaler { + return ec._VideoMetadata(ctx, sel, &v) +} + +func (ec *executionContext) marshalOVideoMetadata2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐVideoMetadata(ctx context.Context, sel ast.SelectionSet, v *models.VideoMetadata) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._VideoMetadata(ctx, sel, v) +} + func (ec *executionContext) marshalO__EnumValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐEnumValueᚄ(ctx context.Context, sel ast.SelectionSet, v []introspection.EnumValue) graphql.Marshaler { if v == nil { return graphql.Null diff --git a/api/graphql/models/media.go b/api/graphql/models/media.go index bf38d12f..4f3df094 100644 --- a/api/graphql/models/media.go +++ b/api/graphql/models/media.go @@ -8,14 +8,15 @@ import ( ) type Media struct { - MediaID int - Title string - Path string - PathHash string - AlbumId int - ExifId *int - Favorite bool - Type MediaType + MediaID int + Title string + Path string + PathHash string + AlbumId int + ExifId *int + Favorite bool + Type MediaType + VideoMetadataId *int } func (p *Media) ID() int { @@ -45,7 +46,7 @@ type MediaURL struct { func NewMediaFromRow(row *sql.Row) (*Media, error) { media := Media{} - if err := row.Scan(&media.MediaID, &media.Title, &media.Path, &media.PathHash, &media.AlbumId, &media.ExifId, &media.Favorite, &media.Type); err != nil { + if err := row.Scan(&media.MediaID, &media.Title, &media.Path, &media.PathHash, &media.AlbumId, &media.ExifId, &media.Favorite, &media.Type, &media.VideoMetadataId); err != nil { return nil, err } @@ -57,7 +58,7 @@ func NewMediaFromRows(rows *sql.Rows) ([]*Media, error) { for rows.Next() { var media Media - if err := rows.Scan(&media.MediaID, &media.Title, &media.Path, &media.PathHash, &media.AlbumId, &media.ExifId, &media.Favorite, &media.Type); err != nil { + if err := rows.Scan(&media.MediaID, &media.Title, &media.Path, &media.PathHash, &media.AlbumId, &media.ExifId, &media.Favorite, &media.Type, &media.VideoMetadataId); err != nil { return nil, err } medias = append(medias, &media) diff --git a/api/graphql/models/video_metadata.go b/api/graphql/models/video_metadata.go new file mode 100644 index 00000000..51c14323 --- /dev/null +++ b/api/graphql/models/video_metadata.go @@ -0,0 +1,33 @@ +package models + +import "database/sql" + +type VideoMetadata struct { + MetadataID int + Width int + Height int + Duration float64 + Codec *string + Framerate *float64 + Bitrate *int + ColorProfile *string + Audio *string +} + +func (metadata *VideoMetadata) ID() int { + return metadata.MetadataID +} + +func (metadata *VideoMetadata) Media() *Media { + panic("not implemented") +} + +func NewVideoMetadataFromRow(row *sql.Row) (*VideoMetadata, error) { + meta := VideoMetadata{} + + if err := row.Scan(&meta.MetadataID, &meta.Width, &meta.Height, &meta.Duration, &meta.Codec, &meta.Framerate, &meta.Bitrate, &meta.ColorProfile, &meta.Audio); err != nil { + return nil, err + } + + return &meta, nil +} diff --git a/api/graphql/resolvers/media.go b/api/graphql/resolvers/media.go index f3d83085..de4dffcd 100644 --- a/api/graphql/resolvers/media.go +++ b/api/graphql/resolvers/media.go @@ -192,6 +192,21 @@ func (r *mediaResolver) Exif(ctx context.Context, obj *models.Media) (*models.Me return exif, nil } +func (r *mediaResolver) VideoMetadata(ctx context.Context, obj *models.Media) (*models.VideoMetadata, error) { + row := r.Database.QueryRow("SELECT video_metadata.* FROM media JOIN video_metadata ON media.video_metadata_id = video_metadata.metadata_id WHERE media.media_id = ?", obj.MediaID) + + metadata, err := models.NewVideoMetadataFromRow(row) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } else { + return nil, errors.Wrapf(err, "could not get video metadata of media from database") + } + } + + return metadata, nil +} + func (r *mutationResolver) FavoriteMedia(ctx context.Context, mediaID int, favorite bool) (*models.Media, error) { user := auth.UserFromContext(ctx) diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index 0053c932..6c44e542 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -217,6 +217,7 @@ type Media { "The album that holds the media" album: Album! exif: MediaEXIF + videoMetadata: VideoMetadata favorite: Boolean! type: MediaType! @@ -249,6 +250,19 @@ type MediaEXIF { exposureProgram: Int } +type VideoMetadata { + id: Int! + media: Media! + width: Int! + height: Int! + duration: Float! + codec: String + framerate: Float + bitrate: Int + colorProfile: String + audio: String +} + type SearchResult { query: String! albums: [Album!]! diff --git a/api/scanner/process_video.go b/api/scanner/process_video.go index fe2962ee..1758cadb 100644 --- a/api/scanner/process_video.go +++ b/api/scanner/process_video.go @@ -106,7 +106,7 @@ func (enc *EncodeMediaData) VideoMetadata() (*ffprobe.ProbeData, error) { return enc._videoMetadata, nil } -func readVideoStreamMetadata(videoPath string) (*ffprobe.Stream, error) { +func readVideoMetadata(videoPath string) (*ffprobe.ProbeData, error) { ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() @@ -115,6 +115,15 @@ func readVideoStreamMetadata(videoPath string) (*ffprobe.Stream, error) { return nil, errors.Wrapf(err, "could not read video metadata (%s)", path.Base(videoPath)) } + return data, nil +} + +func readVideoStreamMetadata(videoPath string) (*ffprobe.Stream, error) { + data, err := readVideoMetadata(videoPath) + if err != nil { + return nil, errors.Wrap(err, "read video stream metadata") + } + stream := data.FirstVideoStream() if stream == nil { return nil, errors.Wrapf(err, "could not get stream from file metadata (%s)", path.Base(videoPath)) diff --git a/api/scanner/scanner_media.go b/api/scanner/scanner_media.go index b68d5184..c67a2689 100644 --- a/api/scanner/scanner_media.go +++ b/api/scanner/scanner_media.go @@ -50,15 +50,21 @@ func ScanMedia(tx *sql.Tx, mediaPath string, albumId int, cache *AlbumScannerCac } row := tx.QueryRow("SELECT * FROM media WHERE media_id = ?", media_id) - photo, err := models.NewMediaFromRow(row) + media, err := models.NewMediaFromRow(row) if err != nil { return nil, false, errors.Wrap(err, "failed to get media by id from database") } - _, err = ScanEXIF(tx, photo) + _, err = ScanEXIF(tx, media) if err != nil { log.Printf("WARN: ScanEXIF for %s failed: %s\n", mediaName, err) } - return photo, true, nil + if media.Type == models.MediaTypeVideo { + if err = ScanVideoMetadata(tx, media); err != nil { + log.Printf("WARN: ScanVideoMetadata for %s failed: %s\n", mediaName, err) + } + } + + return media, true, nil } diff --git a/api/scanner/video_metadata.go b/api/scanner/video_metadata.go new file mode 100644 index 00000000..f05bdc1a --- /dev/null +++ b/api/scanner/video_metadata.go @@ -0,0 +1,70 @@ +package scanner + +import ( + "database/sql" + "fmt" + "strconv" + "strings" + + "github.com/pkg/errors" + "github.com/viktorstrate/photoview/api/graphql/models" +) + +func ScanVideoMetadata(tx *sql.Tx, video *models.Media) error { + + data, err := readVideoMetadata(video.Path) + if err != nil { + return errors.Wrapf(err, "scan video metadata failed (%s)", video.Title) + } + + stream := data.FirstVideoStream() + if stream == nil { + return errors.New(fmt.Sprintf("could not get video stream from metadata (%s)", video.Path)) + } + + audio := data.FirstAudioStream() + var audioText string + if audio == nil { + audioText = "No audio" + } else { + switch audio.Channels { + case 0: + audioText = "No audio" + case 1: + audioText = "Mono audio" + case 2: + audioText = "Stereo audio" + default: + audioText = fmt.Sprintf("Audio (%d channels)", audio.Channels) + } + } + + var framerate *float64 = nil + if stream.AvgFrameRate != "" { + parts := strings.Split(stream.AvgFrameRate, "/") + if len(parts) == 2 { + if numerator, err := strconv.ParseInt(parts[0], 10, 64); err == nil { + if denominator, err := strconv.ParseInt(parts[1], 10, 64); err == nil { + result := float64(numerator) / float64(denominator) + framerate = &result + } + } + } + } + + result, err := tx.Exec("INSERT INTO video_metadata (width, height, duration, codec, framerate, bitrate, color_profile, audio) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", stream.Width, stream.Height, data.Format.DurationSeconds, stream.CodecLongName, framerate, stream.BitRate, stream.Profile, audioText) + if err != nil { + return errors.Wrapf(err, "failed to insert video metadata into database (%s)", video.Title) + } + + metadata_id, err := result.LastInsertId() + if err != nil { + return err + } + + if _, err = tx.Exec("UPDATE media SET video_metadata_id = ? WHERE media_id = ?", metadata_id, video.MediaID); err != nil { + return err + } + + return nil +} diff --git a/ui/src/components/sidebar/MediaSidebar.js b/ui/src/components/sidebar/MediaSidebar.js index e7b06a95..de9e1193 100644 --- a/ui/src/components/sidebar/MediaSidebar.js +++ b/ui/src/components/sidebar/MediaSidebar.js @@ -29,6 +29,17 @@ const mediaQuery = gql` width height } + videoMetadata { + id + width + height + duration + codec + framerate + bitrate + colorProfile + audio + } exif { camera maker @@ -96,7 +107,7 @@ const Name = styled.div` margin: 0.75rem 0 1rem; ` -const ExifInfo = styled.div` +const MetadataInfo = styled.div` margin-bottom: 1.5rem; ` @@ -154,11 +165,33 @@ const SidebarContent = ({ media, hidePreview }) => { exif.focalLength = `${exif.focalLength}mm` } - exif.exposureProgram = exifItems = exifKeys.map(key => ( + exifItems = exifKeys.map(key => ( )) } + let videoMetadataItems = [] + if (media && media.videoMetadata) { + let metadata = Object.keys(media.videoMetadata) + .filter(x => !['id', '__typename', 'width', 'height'].includes(x)) + .reduce( + (prev, curr) => ({ + ...prev, + [curr]: media.videoMetadata[curr], + }), + {} + ) + + metadata = { + dimensions: `${media.videoMetadata.width}x${media.videoMetadata.height}`, + ...metadata, + } + + videoMetadataItems = Object.keys(metadata).map(key => ( + + )) + } + let previewImage = null if (media) { if (media.highRes) previewImage = media.highRes @@ -175,7 +208,8 @@ const SidebarContent = ({ media, hidePreview }) => { )} {media && media.title} - {exifItems} + {videoMetadataItems} + {exifItems}