精简后的项目:仅保留 api/ui 源码、文档、构建依赖

This commit is contained in:
gallery
2026-09-11 16:11:09 +08:00
commit 490e85634e
457 changed files with 86862 additions and 0 deletions
@@ -0,0 +1,87 @@
package scanner_tasks
import (
"fmt"
"image"
_ "image/jpeg"
"os"
"github.com/buckket/go-blurhash"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/log"
"github.com/photoview/photoview/api/scanner/media_encoding"
"github.com/photoview/photoview/api/scanner/scanner_task"
)
type BlurhashTask struct {
scanner_task.ScannerTaskBase
}
func (t BlurhashTask) AfterProcessMedia(ctx scanner_task.TaskContext, mediaData *media_encoding.EncodeMediaData, updatedURLs []*models.MediaURL, mediaIndex int, mediaTotal int) error {
hasThumbnailUpdated := false
for _, url := range updatedURLs {
if url.Purpose == models.PhotoThumbnail || url.Purpose == models.VideoThumbnail {
hasThumbnailUpdated = true
break
}
}
var media *models.Media
if err := ctx.GetDB().Preload("MediaURL").Where("id = ?", mediaData.Media.ID).First(&media).Error; err != nil {
return fmt.Errorf("failed to get media(id:%d): %w", mediaData.Media.ID, err)
}
if media.Blurhash != nil && !hasThumbnailUpdated {
log.Info(ctx, "No thumbnail updated, ignore generating blurhash", "media", media.Path)
return nil
}
thumbnail, err := media.GetThumbnail()
if err != nil {
return fmt.Errorf("failed to get thumbnail of image %q: %w", mediaData.Media.Path, err)
}
hashStr, err := generateBlurhashFromThumbnail(thumbnail)
if err != nil {
return fmt.Errorf("failed to generate blurhash of image %q: %w", mediaData.Media.Path, err)
}
media.Blurhash = &hashStr
if err := ctx.GetDB().Select("blurhash").Save(media).Error; err != nil {
return fmt.Errorf("failed to store blurhash of image %q: %w", mediaData.Media.Path, err)
}
log.Info(ctx, "Generated blurhash of image", "media", mediaData.Media.Path)
return nil
}
// generateBlurhashFromThumbnail generates a blurhash for a single media and stores it in the database
func generateBlurhashFromThumbnail(thumbnail *models.MediaURL) (string, error) {
path, err := thumbnail.CachedPath()
if err != nil {
return "", fmt.Errorf("get path of media(id:%d) error: %w", thumbnail.MediaID, err)
}
f, err := os.Open(path)
if err != nil {
return "", fmt.Errorf("open %q error: %w", path, err)
}
defer f.Close()
imageData, _, err := image.Decode(f)
if err != nil {
return "", fmt.Errorf("decode %q error: %w", path, err)
}
const (
componentX = 4
componentY = 3
)
hashStr, err := blurhash.Encode(componentX, componentY, imageData)
if err != nil {
return "", fmt.Errorf("encode blurhash of %q error: %w", path, err)
}
return hashStr, nil
}
@@ -0,0 +1,136 @@
package cleanup_tasks
import (
"os"
"path"
"strconv"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/face_detection"
"github.com/photoview/photoview/api/scanner/scanner_utils"
"github.com/photoview/photoview/api/utils"
"github.com/pkg/errors"
"gorm.io/gorm"
)
// CleanupMedia removes media entries from the database that are no longer present on the filesystem
func CleanupMedia(db *gorm.DB, albumId int, albumMedia []*models.Media) []error {
albumMediaIds := make([]int, len(albumMedia))
for i, media := range albumMedia {
albumMediaIds[i] = media.ID
}
// Will get from database
var mediaList []models.Media
query := db.Where("album_id = ?", albumId)
// Select media from database that was not found on hard disk
if len(albumMedia) > 0 {
query = query.Where("NOT id IN (?)", albumMediaIds)
}
if err := query.Find(&mediaList).Error; err != nil {
return []error{errors.Wrap(err, "get media files to be deleted from database")}
}
deleteErrors := make([]error, 0)
mediaIDs := make([]int, 0)
for _, media := range mediaList {
mediaIDs = append(mediaIDs, media.ID)
cachePath := path.Join(utils.MediaCachePath(), strconv.Itoa(int(albumId)), strconv.Itoa(int(media.ID)))
err := os.RemoveAll(cachePath)
if err != nil {
deleteErrors = append(deleteErrors, errors.Wrapf(err, "delete unused cache folder (%s)", cachePath))
}
}
if len(mediaIDs) > 0 {
if err := db.Where("id IN (?)", mediaIDs).Delete(models.Media{}).Error; err != nil {
deleteErrors = append(deleteErrors, errors.Wrap(err, "delete old media from database"))
}
// Reload faces after deleting media
if face_detection.GlobalFaceDetector != nil {
if err := face_detection.GlobalFaceDetector.ReloadFacesFromDatabase(db); err != nil {
deleteErrors = append(deleteErrors, errors.Wrap(err, "reload faces from database"))
}
}
}
return deleteErrors
}
// DeleteOldUserAlbums finds and deletes old albums in the database and cache that does not exist on the filesystem anymore.
func DeleteOldUserAlbums(db *gorm.DB, scannedAlbums []*models.Album, user *models.User) []error {
if len(scannedAlbums) == 0 {
return nil
}
scannedAlbumIDs := make([]interface{}, len(scannedAlbums))
for i, album := range scannedAlbums {
scannedAlbumIDs[i] = album.ID
}
// Old albums to be deleted
var deleteAlbums []models.Album
// Find old albums in database
query := db.
Select("albums.*").
Table("user_albums").
Joins("JOIN albums ON user_albums.album_id = albums.id").
Where("user_id = ?", user.ID).
Where("album_id NOT IN (?)", scannedAlbumIDs)
if err := query.Find(&deleteAlbums).Error; err != nil {
return []error{errors.Wrap(err, "get albums to be deleted from database")}
}
if len(deleteAlbums) == 0 {
return []error{}
}
deleteErrors := make([]error, 0)
// Delete old albums from cache
deleteAlbumIDs := make([]int, len(deleteAlbums))
for i, album := range deleteAlbums {
deleteAlbumIDs[i] = album.ID
cachePath := path.Join(utils.MediaCachePath(), strconv.Itoa(int(album.ID)))
err := os.RemoveAll(cachePath)
if err != nil {
deleteErrors = append(deleteErrors, errors.Wrapf(err, "delete unused cache folder (%s)", cachePath))
}
}
// Delete old albums from database
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("album_id IN (?)", deleteAlbumIDs).Delete(&models.UserAlbums{}).Error; err != nil {
return err
}
if err := tx.Where("id IN (?)", deleteAlbumIDs).Delete(models.Album{}).Error; err != nil {
return err
}
return nil
})
if err != nil {
scanner_utils.ScannerError(nil, "Could not delete old albums from database:\n%s\n", err)
deleteErrors = append(deleteErrors, err)
}
// Reload faces after deleting albums
if face_detection.GlobalFaceDetector != nil {
if err := face_detection.GlobalFaceDetector.ReloadFacesFromDatabase(db); err != nil {
deleteErrors = append(deleteErrors, err)
}
}
return deleteErrors
}
@@ -0,0 +1,105 @@
package cleanup_tasks_test
import (
"os"
"path"
"testing"
"github.com/otiai10/copy"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/face_detection"
"github.com/photoview/photoview/api/test_utils"
scanner_utils "github.com/photoview/photoview/api/test_utils/scanner"
"github.com/stretchr/testify/assert"
)
func TestMain(m *testing.M) {
test_utils.IntegrationTestRun(m)
}
func TestCleanupMedia(t *testing.T) {
test_utils.FilesystemTest(t)
db := test_utils.DatabaseTest(t)
if !assert.NoError(t, face_detection.InitializeFaceDetector(db)) {
return
}
testDir := t.TempDir()
assert.NoError(t, copy.Copy("../../test_media/library", testDir))
countAllMedia := func() int {
var allMedia []*models.Media
if !assert.NoError(t, db.Find(&allMedia).Error) {
return -1
}
return len(allMedia)
}
countAllMediaURLs := func() int {
var allMediaURLs []*models.MediaURL
if !assert.NoError(t, db.Find(&allMediaURLs).Error) {
return -1
}
return len(allMediaURLs)
}
pass := "1234"
user1, err := models.RegisterUser(db, "user1", &pass, true)
if !assert.NoError(t, err) {
return
}
user2, err := models.RegisterUser(db, "user2", &pass, true)
if !assert.NoError(t, err) {
return
}
rootAlbum := models.Album{
Title: "root album",
Path: testDir,
}
if !assert.NoError(t, db.Save(&rootAlbum).Error) {
return
}
err = db.Model(user1).Association("Albums").Append(&rootAlbum)
if !assert.NoError(t, err) {
return
}
err = db.Model(user2).Association("Albums").Append(&rootAlbum)
if !assert.NoError(t, err) {
return
}
t.Run("Modify albums", func(t *testing.T) {
scanner_utils.RunScannerOnUser(t, db, user1)
assert.Equal(t, 9, countAllMedia())
assert.Equal(t, 18, countAllMediaURLs())
// move faces directory
assert.NoError(t, os.Rename(path.Join(testDir, "faces"), path.Join(testDir, "faces_moved")))
scanner_utils.RunScannerAll(t, db)
assert.Equal(t, 9, countAllMedia())
assert.Equal(t, 18, countAllMediaURLs())
// remove faces_moved directory
assert.NoError(t, os.RemoveAll(path.Join(testDir, "faces_moved")))
scanner_utils.RunScannerAll(t, db)
assert.Equal(t, 3, countAllMedia())
assert.Equal(t, 6, countAllMediaURLs())
})
t.Run("Modify images", func(t *testing.T) {
assert.NoError(t, os.Rename(path.Join(testDir, "buttercup_close_summer_yellow.jpg"), path.Join(testDir, "yellow-flower.jpg")))
scanner_utils.RunScannerAll(t, db)
assert.Equal(t, 3, countAllMedia())
assert.Equal(t, 6, countAllMediaURLs())
assert.NoError(t, os.Remove(path.Join(testDir, "lilac_lilac_bush_lilac.jpg")))
scanner_utils.RunScannerAll(t, db)
assert.Equal(t, 2, countAllMedia())
assert.Equal(t, 4, countAllMediaURLs())
})
}
@@ -0,0 +1,22 @@
package cleanup_tasks
import (
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/scanner_task"
"github.com/photoview/photoview/api/scanner/scanner_utils"
)
type MediaCleanupTask struct {
scanner_task.ScannerTaskBase
}
func (t MediaCleanupTask) AfterScanAlbum(ctx scanner_task.TaskContext, changedMedia []*models.Media,
albumMedia []*models.Media) error {
cleanupErrors := CleanupMedia(ctx.GetDB(), ctx.GetAlbum().ID, albumMedia)
for _, err := range cleanupErrors {
scanner_utils.ScannerError(ctx, "delete old media: %s", err)
}
return nil
}
+74
View File
@@ -0,0 +1,74 @@
package scanner_tasks
import (
"errors"
"fmt"
"gorm.io/gorm"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/log"
"github.com/photoview/photoview/api/scanner/externaltools/exif"
"github.com/photoview/photoview/api/scanner/scanner_task"
)
type ExifTask struct {
scanner_task.ScannerTaskBase
}
func (t ExifTask) AfterMediaFound(ctx scanner_task.TaskContext, media *models.Media, newMedia bool) error {
if !newMedia {
return nil
}
if err := SaveEXIF(ctx.GetDB(), media); err != nil {
log.Warn(ctx, "SaveEXIF failed", "title", media.Title, "error", err, "path", media.Path)
}
return nil
}
// SaveEXIF scans the media file for exif metadata and saves it in the database if found
func SaveEXIF(tx *gorm.DB, media *models.Media) error {
// Check if EXIF data already exists
if media.ExifID != nil {
var e models.MediaEXIF
var err error
if err = tx.First(&e, media.ExifID).Error; err == nil {
return nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("failed to get EXIF for %q from database: %w", media.Path, err)
}
log.Warn(
tx.Statement.Context,
"EXIF metadata not found in database, will re-parse it",
"path", media.Path,
"error", err,
)
media.ExifID = nil
}
exifData, err := exif.Parse(media.Path)
if err != nil {
return fmt.Errorf("failed to parse exif data: %w", err)
}
if exifData == nil {
return nil
}
// Add EXIF to database and link to media
if err := tx.Model(media).Association("Exif").Replace(exifData); err != nil {
return fmt.Errorf("failed to save media exif to database: %w", err)
}
if exifData.DateShot != nil && !exifData.DateShot.Equal(media.DateShot) {
if err := tx.Save(media).Error; err != nil {
return fmt.Errorf("failed to update EXIF metadata for the media %s: %w", media.Path, err)
}
media.DateShot = *exifData.DateShot
}
return nil
}
@@ -0,0 +1,31 @@
package scanner_tasks
import (
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/face_detection"
"github.com/photoview/photoview/api/scanner/media_encoding"
"github.com/photoview/photoview/api/scanner/scanner_task"
"github.com/photoview/photoview/api/scanner/scanner_utils"
)
type FaceDetectionTask struct {
scanner_task.ScannerTaskBase
}
func (t FaceDetectionTask) AfterProcessMedia(ctx scanner_task.TaskContext, mediaData *media_encoding.EncodeMediaData,
updatedURLs []*models.MediaURL, mediaIndex int, mediaTotal int) error {
didProcess := len(updatedURLs) > 0
if didProcess && mediaData.Media.Type == models.MediaTypePhoto {
media := mediaData.Media
if face_detection.GlobalFaceDetector == nil {
return nil
}
if err := face_detection.GlobalFaceDetector.DetectFaces(ctx.GetDB(), media); err != nil {
scanner_utils.ScannerError(ctx, "Error detecting faces in image (%s): %s", media.Path, err)
}
}
return nil
}
@@ -0,0 +1,37 @@
package scanner_tasks
import (
"io/fs"
"log"
"github.com/photoview/photoview/api/scanner/scanner_task"
ignore "github.com/sabhiram/go-gitignore"
)
type IgnorefileTask struct {
scanner_task.ScannerTaskBase
}
type ignorefileTaskKey string
const albumIgnoreKey ignorefileTaskKey = "album_ignore_key"
func getAlbumIgnore(ctx scanner_task.TaskContext) *ignore.GitIgnore {
return ctx.Value(albumIgnoreKey).(*ignore.GitIgnore)
}
func (t IgnorefileTask) BeforeScanAlbum(ctx scanner_task.TaskContext) (scanner_task.TaskContext, error) {
albumIgnore := ignore.CompileIgnoreLines(*ctx.GetCache().GetAlbumIgnore(ctx.GetAlbum().Path)...)
return ctx.WithValue(albumIgnoreKey, albumIgnore), nil
}
func (t IgnorefileTask) MediaFound(ctx scanner_task.TaskContext, fileInfo fs.FileInfo, mediaPath string) (bool, error) {
// Match file against ignore data
if getAlbumIgnore(ctx).MatchesPath(fileInfo.Name()) {
log.Printf("File %s ignored\n", fileInfo.Name())
return true, nil
}
return false, nil
}
@@ -0,0 +1,78 @@
package scanner_tasks
import (
"fmt"
"time"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/graphql/notification"
"github.com/photoview/photoview/api/scanner/media_encoding"
"github.com/photoview/photoview/api/scanner/scanner_task"
"github.com/photoview/photoview/api/utils"
)
type NotificationTask struct {
scanner_task.ScannerTaskBase
throttle utils.Throttle
albumKey string
}
func NewNotificationTask() NotificationTask {
notifyThrottle := utils.NewThrottle(500 * time.Millisecond)
notifyThrottle.Trigger(nil)
return NotificationTask{
albumKey: utils.GenerateToken(),
throttle: notifyThrottle,
}
}
func (t NotificationTask) AfterMediaFound(ctx scanner_task.TaskContext, media *models.Media, newMedia bool) error {
if newMedia {
t.throttle.Trigger(func() {
notification.BroadcastNotification(&models.Notification{
Key: t.albumKey,
Type: models.NotificationTypeMessage,
Header: fmt.Sprintf("Found new media in album '%s'", ctx.GetAlbum().Title),
Content: fmt.Sprintf("Found %s", media.Path),
})
})
}
return nil
}
func (t NotificationTask) AfterProcessMedia(ctx scanner_task.TaskContext, mediaData *media_encoding.EncodeMediaData,
updatedURLs []*models.MediaURL, mediaIndex int, mediaTotal int) error {
if len(updatedURLs) > 0 {
progress := float64(mediaIndex) / float64(mediaTotal) * 100.0
notification.BroadcastNotification(&models.Notification{
Key: t.albumKey,
Type: models.NotificationTypeProgress,
Header: fmt.Sprintf("Processing media for album '%s'", ctx.GetAlbum().Title),
Content: fmt.Sprintf("Processed media at %s", mediaData.Media.Path),
Progress: &progress,
})
}
return nil
}
func (t NotificationTask) AfterScanAlbum(ctx scanner_task.TaskContext, changedMedia []*models.Media,
albumMedia []*models.Media) error {
if len(changedMedia) > 0 {
timeoutDelay := 2000
notification.BroadcastNotification(&models.Notification{
Key: t.albumKey,
Type: models.NotificationTypeMessage,
Positive: true,
Header: fmt.Sprintf("Done processing media for album '%s'", ctx.GetAlbum().Title),
Content: "All media have been processed",
Timeout: &timeoutDelay,
})
}
return nil
}
@@ -0,0 +1,62 @@
package processing_tasks
import (
"fmt"
"io/fs"
"github.com/photoview/photoview/api/scanner/media_encoding"
"github.com/photoview/photoview/api/scanner/media_type"
"github.com/photoview/photoview/api/scanner/scanner_task"
"github.com/photoview/photoview/api/utils"
)
type CounterpartFilesTask struct {
scanner_task.ScannerTaskBase
}
func (t CounterpartFilesTask) MediaFound(ctx scanner_task.TaskContext, fileInfo fs.FileInfo, mediaPath string) (skip bool, err error) {
fileType := media_type.GetMediaType(mediaPath)
if !fileType.IsSupported() {
return true, nil
}
if utils.EnvDisableRawProcessing.GetBool() {
if !fileType.IsWebCompatible() {
return true, nil
}
// Don't skip the JPEGs if raw processing is disabled. Treat them as standalone files.
return false, nil
}
if fileType.IsWebCompatible() {
_, existed := media_type.FindRawCounterpart(mediaPath)
if existed {
return true, nil
}
}
return false, nil
}
func (t CounterpartFilesTask) BeforeProcessMedia(ctx scanner_task.TaskContext, mediaData *media_encoding.EncodeMediaData) (scanner_task.TaskContext, error) {
mediaType := ctx.GetCache().GetMediaType(mediaData.Media.Path)
if mediaType == media_type.TypeUnknown {
return ctx, fmt.Errorf("scan for counterpart file %s failed: media type is %s", mediaData.Media.Path, mediaType)
}
if mediaType.IsWebCompatible() {
return ctx, nil
}
counterpartFile, ok := media_type.FindWebCounterpart(mediaData.Media.Path)
if !ok {
return ctx, nil
}
mediaData.CounterpartPath = &counterpartFile
return ctx, nil
}
@@ -0,0 +1,101 @@
package processing_tasks
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/photoview/photoview/api/scanner/scanner_task"
"github.com/photoview/photoview/api/test_utils"
"github.com/photoview/photoview/api/utils"
)
func TestMain(m *testing.M) {
test_utils.IntegrationTestRun(m)
}
func TestCounterpartFilesTaskMediaFound(t *testing.T) {
mediaPath := test_utils.PathFromAPIRoot("scanner", "test_media", "real_media")
tests := []struct {
name string
file string
disableRawProcessing bool
wantSkip bool
}{
{
name: "StandaloneProcessRaw",
file: "standalone_jpg.jpg",
disableRawProcessing: false,
wantSkip: false,
},
{
name: "StandaloneNotProcessRaw",
file: "standalone_jpg.jpg",
disableRawProcessing: true,
wantSkip: false,
},
{
name: "RawJpegProcessRaw",
file: "raw_with_jpg.jpg",
disableRawProcessing: false,
wantSkip: true,
},
{
name: "RawJpegNotProcessRaw",
file: "raw_with_jpg.jpg",
disableRawProcessing: true,
wantSkip: false,
},
{
name: "RawProcessRaw",
file: "raw_with_jpg.tiff",
disableRawProcessing: false,
wantSkip: false,
},
{
name: "RawNotProcessRaw",
file: "raw_with_jpg.tiff",
disableRawProcessing: true,
wantSkip: true,
},
{
name: "UnknownProcessRaw",
file: "file.pdf",
disableRawProcessing: false,
wantSkip: true,
},
{
name: "UnknownNotProcessRaw",
file: "file.pdf",
disableRawProcessing: true,
wantSkip: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv(string(utils.EnvDisableRawProcessing), fmt.Sprintf("%v", tc.disableRawProcessing))
ctx := scanner_task.NewTaskContext(context.Background(), nil, nil, nil)
fname := filepath.Join(mediaPath, tc.file)
fi, err := os.Stat(fname)
if err != nil {
t.Fatalf("Stat(%q) error: %v", fname, err)
}
var task CounterpartFilesTask
got, err := task.MediaFound(ctx, fi, fname)
if err != nil {
t.Fatalf("task.MediaFound(ctx, %q) error: %v", fname, err)
}
if got, want := got, tc.wantSkip; got != want {
t.Errorf("task.MediaFound(ctx, %q) = (skip)%v, want skip: %v", fname, got, want)
}
})
}
}
@@ -0,0 +1,126 @@
package processing_tasks
import (
"os"
"path"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/log"
"github.com/photoview/photoview/api/scanner/media_encoding"
"github.com/photoview/photoview/api/scanner/scanner_task"
"github.com/pkg/errors"
)
type ProcessPhotoTask struct {
scanner_task.ScannerTaskBase
}
func (t ProcessPhotoTask) ProcessMedia(ctx scanner_task.TaskContext, mediaData *media_encoding.EncodeMediaData, mediaCachePath string) ([]*models.MediaURL, error) {
if mediaData.Media.Type != models.MediaTypePhoto {
return []*models.MediaURL{}, nil
}
updatedURLs := make([]*models.MediaURL, 0)
photo := mediaData.Media
log.Info(ctx, "Processing photo", "photo", photo.Path)
photoURLFromDB := makePhotoURLChecker(ctx.GetDB(), photo.ID)
// original photo url
origURL, err := photoURLFromDB(models.MediaOriginal)
if err != nil {
return []*models.MediaURL{}, err
}
// Thumbnail
thumbURL, err := photoURLFromDB(models.PhotoThumbnail)
if err != nil {
return []*models.MediaURL{}, errors.Wrap(err, "error processing photo thumbnail")
}
// Highres
highResURL, err := photoURLFromDB(models.PhotoHighRes)
if err != nil {
return []*models.MediaURL{}, errors.Wrap(err, "error processing photo highres")
}
var baseImagePath string = photo.Path
// Generate high res jpeg
if highResURL == nil {
contentType, err := mediaData.ContentType()
if err != nil {
return []*models.MediaURL{}, err
}
if !contentType.IsWebCompatible() {
highresName := generateUniqueMediaNamePrefixed("highres", photo.Path, ".jpg")
baseImagePath = path.Join(mediaCachePath, highresName)
highRes, err := generateSaveHighResJPEG(ctx.GetDB(), photo, mediaData, highresName, baseImagePath, nil)
if err != nil {
return []*models.MediaURL{}, err
}
updatedURLs = append(updatedURLs, highRes)
}
} else {
// Verify that highres photo still exists in cache
baseImagePath = path.Join(mediaCachePath, highResURL.MediaName)
if _, err := os.Stat(baseImagePath); os.IsNotExist(err) {
log.Info(ctx, "High-res photo found in database but not in cache, re-encoding photo to cache", "media_name", highResURL.MediaName)
updatedURLs = append(updatedURLs, highResURL)
err = mediaData.EncodeHighRes(baseImagePath)
if err != nil {
return []*models.MediaURL{}, errors.Wrap(err, "creating high-res cached image")
}
}
}
// Save original photo to database
if origURL == nil {
// Make sure photo dimensions is set
photoDimensions, err := media_encoding.GetPhotoDimensions(photo.Path)
if err != nil {
return []*models.MediaURL{}, err
}
original, err := saveOriginalPhotoToDB(ctx.GetDB(), photo, mediaData, photoDimensions)
if err != nil {
return []*models.MediaURL{}, errors.Wrap(err, "saving original photo to database")
}
updatedURLs = append(updatedURLs, original)
}
// Save thumbnail to cache
if thumbURL == nil {
thumbnailName := generateUniqueMediaNamePrefixed("thumbnail", photo.Path, ".jpg")
thumbnail, err := generateSaveThumbnailJPEG(ctx.GetDB(), photo, thumbnailName, mediaCachePath, baseImagePath, nil)
if err != nil {
return []*models.MediaURL{}, err
}
updatedURLs = append(updatedURLs, thumbnail)
} else {
// Verify that thumbnail photo still exists in cache
thumbPath := path.Join(mediaCachePath, thumbURL.MediaName)
if _, err := os.Stat(thumbPath); os.IsNotExist(err) {
updatedURLs = append(updatedURLs, thumbURL)
log.Info(ctx, "Thumbnail photo found in database but not in cache, re-encoding photo to cache", "media_name", thumbURL.MediaName)
_, err := media_encoding.EncodeThumbnail(ctx.GetDB(), baseImagePath, thumbPath)
if err != nil {
return []*models.MediaURL{}, errors.Wrap(err, "could not create thumbnail cached image")
}
}
}
return updatedURLs, nil
}
@@ -0,0 +1,230 @@
package processing_tasks
import (
"context"
"fmt"
"os"
"path"
"strings"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/log"
"github.com/photoview/photoview/api/scanner/media_encoding"
"github.com/photoview/photoview/api/scanner/media_encoding/executable_worker"
"github.com/photoview/photoview/api/scanner/scanner_task"
"github.com/photoview/photoview/api/utils"
"github.com/pkg/errors"
"gopkg.in/vansante/go-ffprobe.v2"
)
type ProcessVideoTask struct {
scanner_task.ScannerTaskBase
}
func (t ProcessVideoTask) ProcessMedia(ctx scanner_task.TaskContext, mediaData *media_encoding.EncodeMediaData, mediaCachePath string) ([]*models.MediaURL, error) {
if mediaData.Media.Type != models.MediaTypeVideo {
return []*models.MediaURL{}, nil
}
updatedURLs := make([]*models.MediaURL, 0)
video := mediaData.Media
log.Info(ctx, "Processing video", "video", video.Path)
mediaURLFromDB := makePhotoURLChecker(ctx.GetDB(), video.ID)
videoOriginalURL, err := mediaURLFromDB(models.MediaOriginal)
if err != nil {
return []*models.MediaURL{}, errors.Wrap(err, "error processing video original format")
}
videoWebURL, err := mediaURLFromDB(models.VideoWeb)
if err != nil {
return []*models.MediaURL{}, errors.Wrap(err, "error processing video web-format")
}
videoThumbnailURL, err := mediaURLFromDB(models.VideoThumbnail)
if err != nil {
return []*models.MediaURL{}, errors.Wrap(err, "error processing video thumbnail")
}
videoType, err := mediaData.ContentType()
if err != nil {
return []*models.MediaURL{}, fmt.Errorf("getting video content type error: %w", err)
}
if videoOriginalURL == nil && videoType.IsWebCompatible() {
origVideoPath := video.Path
videoMediaName := generateUniqueMediaName(video.Path)
webMetadata, err := ReadVideoStreamMetadata(origVideoPath)
if err != nil {
return []*models.MediaURL{}, errors.Wrapf(err, "failed to read metadata for original video (%s)", video.Title)
}
fileStats, err := os.Stat(origVideoPath)
if err != nil {
return []*models.MediaURL{}, errors.Wrap(err, "reading file stats of original video")
}
mediaURL := models.MediaURL{
MediaID: video.ID,
MediaName: videoMediaName,
Width: webMetadata.Width,
Height: webMetadata.Height,
Purpose: models.MediaOriginal,
ContentType: videoType.String(),
FileSize: fileStats.Size(),
}
if err := ctx.GetDB().Create(&mediaURL).Error; err != nil {
return []*models.MediaURL{}, errors.Wrapf(err, "insert original video into database (%s)", video.Title)
}
updatedURLs = append(updatedURLs, &mediaURL)
}
if videoWebURL == nil && !videoType.IsWebCompatible() {
webVideoName := fmt.Sprintf("web_video_%s_%s", path.Base(video.Path), utils.GenerateToken())
webVideoName = strings.ReplaceAll(webVideoName, ".", "_")
webVideoName = strings.ReplaceAll(webVideoName, " ", "_")
webVideoName = webVideoName + ".mp4"
webVideoPath := path.Join(mediaCachePath, webVideoName)
err = executable_worker.Ffmpeg.EncodeMp4(video.Path, webVideoPath)
if err != nil {
return []*models.MediaURL{}, errors.Wrapf(err, "could not encode mp4 video (%s)", video.Path)
}
webMetadata, err := ReadVideoStreamMetadata(webVideoPath)
if err != nil {
return []*models.MediaURL{}, errors.Wrapf(err, "failed to read metadata for encoded web-video (%s)", video.Title)
}
fileStats, err := os.Stat(webVideoPath)
if err != nil {
return []*models.MediaURL{}, errors.Wrap(err, "reading file stats of web-optimized video")
}
mediaURL := models.MediaURL{
MediaID: video.ID,
MediaName: webVideoName,
Width: webMetadata.Width,
Height: webMetadata.Height,
Purpose: models.VideoWeb,
ContentType: "video/mp4",
FileSize: fileStats.Size(),
}
if err := ctx.GetDB().Create(&mediaURL).Error; err != nil {
return []*models.MediaURL{}, errors.Wrapf(err, "failed to insert encoded web-video into database (%s)", video.Title)
}
updatedURLs = append(updatedURLs, &mediaURL)
}
probeData, err := mediaData.VideoMetadata()
if err != nil {
return []*models.MediaURL{}, err
}
if videoThumbnailURL == nil {
videoThumbName := fmt.Sprintf("video_thumb_%s_%s", path.Base(video.Path), utils.GenerateToken())
videoThumbName = strings.ReplaceAll(videoThumbName, ".", "_")
videoThumbName = strings.ReplaceAll(videoThumbName, " ", "_")
videoThumbName = videoThumbName + ".jpg"
thumbImagePath := path.Join(mediaCachePath, videoThumbName)
err = executable_worker.Ffmpeg.EncodeVideoThumbnail(video.Path, thumbImagePath, probeData)
if err != nil {
return []*models.MediaURL{}, errors.Wrapf(err, "failed to generate thumbnail for video (%s)", video.Title)
}
thumbDimensions, err := media_encoding.GetPhotoDimensions(thumbImagePath)
if err != nil {
return []*models.MediaURL{}, errors.Wrap(err, "get dimensions of video thumbnail image")
}
fileStats, err := os.Stat(thumbImagePath)
if err != nil {
return []*models.MediaURL{}, errors.Wrap(err, "reading file stats of video thumbnail")
}
thumbMediaURL := models.MediaURL{
MediaID: video.ID,
MediaName: videoThumbName,
Width: thumbDimensions.Width,
Height: thumbDimensions.Height,
Purpose: models.VideoThumbnail,
ContentType: "image/jpeg",
FileSize: fileStats.Size(),
}
if err := ctx.GetDB().Create(&thumbMediaURL).Error; err != nil {
return []*models.MediaURL{}, errors.Wrapf(err, "failed to insert video thumbnail image into database (%s)", video.Title)
}
updatedURLs = append(updatedURLs, &thumbMediaURL)
} else {
// Verify that video thumbnail still exists in cache
thumbImagePath := path.Join(mediaCachePath, videoThumbnailURL.MediaName)
if _, err := os.Stat(thumbImagePath); os.IsNotExist(err) {
log.Info(ctx, "Video thumbnail found in database but not in cache, re-encoding video thumbnail to cache", "video", videoThumbnailURL.MediaName)
updatedURLs = append(updatedURLs, videoThumbnailURL)
err = executable_worker.Ffmpeg.EncodeVideoThumbnail(video.Path, thumbImagePath, probeData)
if err != nil {
return []*models.MediaURL{}, errors.Wrapf(err, "failed to generate thumbnail for video (%s)", video.Title)
}
thumbDimensions, err := media_encoding.GetPhotoDimensions(thumbImagePath)
if err != nil {
return []*models.MediaURL{}, errors.Wrap(err, "get dimensions of video thumbnail image")
}
fileStats, err := os.Stat(thumbImagePath)
if err != nil {
return []*models.MediaURL{}, errors.Wrap(err, "reading file stats of video thumbnail")
}
videoThumbnailURL.Width = thumbDimensions.Width
videoThumbnailURL.Height = thumbDimensions.Height
videoThumbnailURL.FileSize = fileStats.Size()
if err := ctx.GetDB().Save(videoThumbnailURL).Error; err != nil {
return []*models.MediaURL{}, errors.Wrap(err, "updating video thumbnail url in database after re-encoding")
}
}
}
return updatedURLs, nil
}
func ReadVideoMetadata(videoPath string) (*ffprobe.ProbeData, error) {
ctx, cancelFn := context.WithTimeout(context.Background(), utils.MediaProbeTimeout())
defer cancelFn()
data, err := ffprobe.ProbeURL(ctx, videoPath)
if err != nil {
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, fmt.Errorf("could not get stream from file metadata (%s)", path.Base(videoPath))
}
return stream, nil
}
@@ -0,0 +1,97 @@
package processing_tasks
import (
"os"
"path"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/media_encoding"
"github.com/pkg/errors"
"gorm.io/gorm"
)
func generateSaveHighResJPEG(tx *gorm.DB, media *models.Media, imageData *media_encoding.EncodeMediaData, highResName string, imagePath string, mediaURL *models.MediaURL) (*models.MediaURL, error) {
err := imageData.EncodeHighRes(imagePath)
if err != nil {
return nil, errors.Wrap(err, "creating high-res cached image")
}
photoDimensions, err := media_encoding.GetPhotoDimensions(imagePath)
if err != nil {
return nil, err
}
fileStats, err := os.Stat(imagePath)
if err != nil {
return nil, errors.Wrap(err, "reading file stats of highres photo")
}
if mediaURL == nil {
mediaURL = &models.MediaURL{
MediaID: media.ID,
MediaName: highResName,
Width: photoDimensions.Width,
Height: photoDimensions.Height,
Purpose: models.PhotoHighRes,
ContentType: "image/jpeg",
FileSize: fileStats.Size(),
}
if err := tx.Create(&mediaURL).Error; err != nil {
return nil, errors.Wrapf(err, "could not insert highres media url (%d, %s)", media.ID, highResName)
}
} else {
mediaURL.Width = photoDimensions.Width
mediaURL.Height = photoDimensions.Height
mediaURL.FileSize = fileStats.Size()
if err := tx.Save(&mediaURL).Error; err != nil {
return nil, errors.Wrapf(err, "could not update media url after side car changes (%d, %s)", media.ID, highResName)
}
}
return mediaURL, nil
}
func generateSaveThumbnailJPEG(tx *gorm.DB, media *models.Media, thumbnailName string, photoCachePath string, baseImagePath string, mediaURL *models.MediaURL) (*models.MediaURL, error) {
thumbOutputPath := path.Join(photoCachePath, thumbnailName)
thumbSize, err := media_encoding.EncodeThumbnail(tx, baseImagePath, thumbOutputPath)
if err != nil {
return nil, errors.Wrap(err, "could not create thumbnail cached image")
}
fileStats, err := os.Stat(thumbOutputPath)
if err != nil {
return nil, errors.Wrap(err, "reading file stats of thumbnail photo")
}
if mediaURL == nil {
mediaURL = &models.MediaURL{
MediaID: media.ID,
MediaName: thumbnailName,
Width: thumbSize.Width,
Height: thumbSize.Height,
Purpose: models.PhotoThumbnail,
ContentType: "image/jpeg",
FileSize: fileStats.Size(),
}
if err := tx.Create(&mediaURL).Error; err != nil {
return nil, errors.Wrapf(err, "could not insert thumbnail media url (%d, %s)", media.ID, thumbnailName)
}
} else {
mediaURL.Width = thumbSize.Width
mediaURL.Height = thumbSize.Height
mediaURL.FileSize = fileStats.Size()
if err := tx.Save(&mediaURL).Error; err != nil {
return nil, errors.Wrapf(err, "could not update media url after side car changes (%d, %s)", media.ID, thumbnailName)
}
}
return mediaURL, nil
}
@@ -0,0 +1,81 @@
package processing_tasks
import (
"fmt"
"os"
"path"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/media_encoding"
"github.com/photoview/photoview/api/utils"
"github.com/pkg/errors"
"gorm.io/gorm"
)
// Higher order function used to check if MediaURL for a given MediaPurpose exists
func makePhotoURLChecker(tx *gorm.DB, mediaID int) func(purpose models.MediaPurpose) (*models.MediaURL, error) {
return func(purpose models.MediaPurpose) (*models.MediaURL, error) {
var mediaURL []*models.MediaURL
result := tx.Where("purpose = ?", purpose).Where("media_id = ?", mediaID).Find(&mediaURL)
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected > 0 {
return mediaURL[0], nil
}
return nil, nil
}
}
func generateUniqueMediaNamePrefixed(prefix string, mediaPath string, extension string) string {
mediaName := fmt.Sprintf("%s_%s_%s", prefix, path.Base(mediaPath), utils.GenerateToken())
mediaName = models.SanitizeMediaName(mediaName)
mediaName = mediaName + extension
return mediaName
}
func generateUniqueMediaName(mediaPath string) string {
filename := path.Base(mediaPath)
baseName := filename[0 : len(filename)-len(path.Ext(filename))]
baseExt := path.Ext(filename)
mediaName := fmt.Sprintf("%s_%s", baseName, utils.GenerateToken())
mediaName = models.SanitizeMediaName(mediaName) + baseExt
return mediaName
}
func saveOriginalPhotoToDB(tx *gorm.DB, photo *models.Media, imageData *media_encoding.EncodeMediaData, photoDimensions media_encoding.Dimension) (*models.MediaURL, error) {
originalImageName := generateUniqueMediaName(photo.Path)
contentType, err := imageData.ContentType()
if err != nil {
return nil, err
}
fileStats, err := os.Stat(photo.Path)
if err != nil {
return nil, errors.Wrap(err, "reading file stats of original photo")
}
mediaURL := models.MediaURL{
Media: photo,
MediaName: originalImageName,
Width: photoDimensions.Width,
Height: photoDimensions.Height,
Purpose: models.MediaOriginal,
ContentType: contentType.String(),
FileSize: fileStats.Size(),
}
if err := tx.Create(&mediaURL).Error; err != nil {
return nil, errors.Wrapf(err, "inserting original photo url: %d, %s", photo.ID, photo.Title)
}
return &mediaURL, nil
}
@@ -0,0 +1,160 @@
package processing_tasks
import (
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"log"
"os"
"path"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/media_encoding"
"github.com/photoview/photoview/api/scanner/media_type"
"github.com/photoview/photoview/api/scanner/scanner_task"
"github.com/photoview/photoview/api/scanner/scanner_utils"
"github.com/pkg/errors"
)
type SidecarTask struct {
scanner_task.ScannerTaskBase
}
func (t SidecarTask) AfterMediaFound(ctx scanner_task.TaskContext, media *models.Media, newMedia bool) error {
if media.Type != models.MediaTypePhoto || !newMedia {
return nil
}
mediaType := ctx.GetCache().GetMediaType(media.Path)
if mediaType == media_type.TypeUnknown {
return fmt.Errorf("scan for sidecar file %s failed: media type is %s", media.Path, mediaType)
}
if mediaType.IsWebCompatible() {
return nil
}
var sideCarPath *string = nil
var sideCarHash *string = nil
sideCarPath = scanForSideCarFile(media.Path)
if sideCarPath != nil {
sideCarHash = hashSideCarFile(sideCarPath)
}
// Add sidecar data to media
media.SideCarPath = sideCarPath
media.SideCarHash = sideCarHash
if err := ctx.GetDB().Save(media).Error; err != nil {
return errors.Wrapf(err, "update media sidecar info (%s)", *sideCarPath)
}
return nil
}
func (t SidecarTask) ProcessMedia(ctx scanner_task.TaskContext, mediaData *media_encoding.EncodeMediaData, mediaCachePath string) (updatedURLs []*models.MediaURL, err error) {
mediaType, err := mediaData.ContentType()
if err != nil {
return []*models.MediaURL{}, errors.Wrap(err, "sidecar task, process media")
}
if mediaType.IsWebCompatible() {
return []*models.MediaURL{}, nil
}
photo := mediaData.Media
sideCarFileHasChanged := false
var currentFileHash *string
currentSideCarPath := scanForSideCarFile(photo.Path)
if currentSideCarPath != nil {
currentFileHash = hashSideCarFile(currentSideCarPath)
if photo.SideCarHash == nil || *photo.SideCarHash != *currentFileHash {
sideCarFileHasChanged = true
}
} else if photo.SideCarPath != nil { // sidecar has been deleted since last scan
sideCarFileHasChanged = true
}
if !sideCarFileHasChanged {
return []*models.MediaURL{}, nil
}
fmt.Printf("Detected changed sidecar file for %s recreating JPG's to reflect changes\n", photo.Path)
highResURL, err := photo.GetHighRes()
if err != nil {
return []*models.MediaURL{}, errors.Wrap(err, "sidecar task, get high-res media_url")
}
thumbURL, err := photo.GetThumbnail()
if err != nil {
return []*models.MediaURL{}, errors.Wrap(err, "sidecar task, get high-res media_url")
}
// update high res image may be cropped so dimentions and file size can change
baseImagePath := path.Join(mediaCachePath, highResURL.MediaName) // update base image path for thumbnail
tempHighResPath := baseImagePath + ".hold"
os.Rename(baseImagePath, tempHighResPath)
updatedHighRes, err := generateSaveHighResJPEG(ctx.GetDB(), photo, mediaData, highResURL.MediaName, baseImagePath, highResURL)
if err != nil {
os.Rename(tempHighResPath, baseImagePath)
return []*models.MediaURL{}, errors.Wrap(err, "sidecar task, recreating high-res cached image")
}
os.Remove(tempHighResPath)
// update thumbnail image may be cropped so dimentions and file size can change
thumbPath := path.Join(mediaCachePath, thumbURL.MediaName)
tempThumbPath := thumbPath + ".hold" // hold onto the original image incase for some reason we fail to recreate one with the new settings
os.Rename(thumbPath, tempThumbPath)
updatedThumbnail, err := generateSaveThumbnailJPEG(ctx.GetDB(), photo, thumbURL.MediaName, mediaCachePath, baseImagePath, thumbURL)
if err != nil {
os.Rename(tempThumbPath, thumbPath)
return []*models.MediaURL{}, errors.Wrap(err, "recreating thumbnail cached image")
}
os.Remove(tempThumbPath)
photo.SideCarHash = currentFileHash
photo.SideCarPath = currentSideCarPath
// save new side car hash
if err := ctx.GetDB().Save(&photo).Error; err != nil {
return []*models.MediaURL{}, errors.Wrapf(err, "could not update side car hash for media: %s", photo.Path)
}
return []*models.MediaURL{
updatedThumbnail,
updatedHighRes,
}, nil
}
func scanForSideCarFile(path string) *string {
testPath := path + ".xmp"
if scanner_utils.FileExists(testPath) {
return &testPath
}
return nil
}
func hashSideCarFile(path *string) *string {
if path == nil {
return nil
}
f, err := os.Open(*path)
if err != nil {
log.Printf("ERROR: %s", err)
}
defer f.Close()
h := md5.New()
if _, err := io.Copy(h, f); err != nil {
log.Printf("ERROR: %s", err)
}
hash := hex.EncodeToString(h.Sum(nil))
return &hash
}
+157
View File
@@ -0,0 +1,157 @@
package scanner_tasks
import (
"fmt"
"io/fs"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/log"
"github.com/photoview/photoview/api/scanner/media_encoding"
"github.com/photoview/photoview/api/scanner/scanner_task"
"github.com/photoview/photoview/api/scanner/scanner_tasks/cleanup_tasks"
"github.com/photoview/photoview/api/scanner/scanner_tasks/processing_tasks"
)
var allTasks []scanner_task.ScannerTask = []scanner_task.ScannerTask{
NotificationTask{},
IgnorefileTask{},
processing_tasks.CounterpartFilesTask{},
processing_tasks.SidecarTask{},
processing_tasks.ProcessPhotoTask{},
processing_tasks.ProcessVideoTask{},
FaceDetectionTask{},
BlurhashTask{},
ExifTask{},
VideoMetadataTask{},
cleanup_tasks.MediaCleanupTask{},
}
type scannerTasks struct {
scanner_task.ScannerTaskBase
}
var Tasks scannerTasks = scannerTasks{}
func simpleCombinedTasks(ctx scanner_task.TaskContext, doTask func(ctx scanner_task.TaskContext,
task scanner_task.ScannerTask) error) error {
for _, task := range allTasks {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
err := doTask(ctx, task)
if err != nil {
return err
}
}
return nil
}
func (t scannerTasks) BeforeScanAlbum(ctx scanner_task.TaskContext) (scanner_task.TaskContext, error) {
for _, task := range allTasks {
var err error
ctx, err = task.BeforeScanAlbum(ctx)
if err != nil {
return ctx, err
}
select {
case <-ctx.Done():
return ctx, ctx.Err()
default:
}
}
return ctx, nil
}
func (t scannerTasks) MediaFound(ctx scanner_task.TaskContext, fileInfo fs.FileInfo, mediaPath string) (bool, error) {
for _, task := range allTasks {
select {
case <-ctx.Done():
return false, ctx.Err()
default:
}
skip, err := task.MediaFound(ctx, fileInfo, mediaPath)
if err != nil {
return false, err
}
if skip {
log.Info(ctx, "skip the media", "media_path", mediaPath, "by_task", fmt.Sprintf("%T", task))
return true, nil
}
}
return false, nil
}
func (t scannerTasks) AfterScanAlbum(ctx scanner_task.TaskContext, changedMedia []*models.Media,
albumMedia []*models.Media) error {
return simpleCombinedTasks(ctx, func(ctx scanner_task.TaskContext, task scanner_task.ScannerTask) error {
return task.AfterScanAlbum(ctx, changedMedia, albumMedia)
})
}
func (t scannerTasks) AfterMediaFound(ctx scanner_task.TaskContext, media *models.Media, newMedia bool) error {
return simpleCombinedTasks(ctx, func(ctx scanner_task.TaskContext, task scanner_task.ScannerTask) error {
return task.AfterMediaFound(ctx, media, newMedia)
})
}
func (t scannerTasks) BeforeProcessMedia(ctx scanner_task.TaskContext,
mediaData *media_encoding.EncodeMediaData) (scanner_task.TaskContext, error) {
for _, task := range allTasks {
select {
case <-ctx.Done():
return ctx, ctx.Err()
default:
}
var err error
ctx, err = task.BeforeProcessMedia(ctx, mediaData)
if err != nil {
return ctx, err
}
}
return ctx, nil
}
func (t scannerTasks) ProcessMedia(ctx scanner_task.TaskContext, mediaData *media_encoding.EncodeMediaData,
mediaCachePath string) ([]*models.MediaURL, error) {
allNewMedia := make([]*models.MediaURL, 0)
for _, task := range allTasks {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
newMedia, err := task.ProcessMedia(ctx, mediaData, mediaCachePath)
if err != nil {
return []*models.MediaURL{}, err
}
allNewMedia = append(allNewMedia, newMedia...)
}
return allNewMedia, nil
}
func (t scannerTasks) AfterProcessMedia(ctx scanner_task.TaskContext, mediaData *media_encoding.EncodeMediaData,
updatedURLs []*models.MediaURL, mediaIndex int, mediaTotal int) error {
return simpleCombinedTasks(ctx, func(ctx scanner_task.TaskContext, task scanner_task.ScannerTask) error {
return task.AfterProcessMedia(ctx, mediaData, updatedURLs, mediaIndex, mediaTotal)
})
}
@@ -0,0 +1,100 @@
package scanner_tasks
import (
"fmt"
"log"
"strconv"
"strings"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/scanner_task"
"github.com/photoview/photoview/api/scanner/scanner_tasks/processing_tasks"
"github.com/pkg/errors"
"gopkg.in/vansante/go-ffprobe.v2"
"gorm.io/gorm"
)
type VideoMetadataTask struct {
scanner_task.ScannerTaskBase
}
func (t VideoMetadataTask) AfterMediaFound(ctx scanner_task.TaskContext, media *models.Media, newMedia bool) error {
if !newMedia || media.Type != models.MediaTypeVideo {
return nil
}
err := ScanVideoMetadata(ctx.GetDB(), media)
if err != nil {
log.Printf("WARN: ScanVideoMetadata for %s failed: %s\n", media.Title, err)
}
return nil
}
func ScanVideoMetadata(tx *gorm.DB, video *models.Media) error {
data, err := processing_tasks.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)
}
}
framerate := getFrameRate(stream)
videoMetadata := models.VideoMetadata{
Width: stream.Width,
Height: stream.Height,
Duration: data.Format.DurationSeconds,
Codec: &stream.CodecLongName,
Framerate: framerate,
Bitrate: &stream.BitRate,
ColorProfile: &stream.Profile,
Audio: &audioText,
}
video.VideoMetadata = &videoMetadata
if err := tx.Save(video).Error; err != nil {
return errors.Wrapf(err, "failed to add video metadata to database (%s)", video.Title)
}
return nil
}
func getFrameRate(stream *ffprobe.Stream) *float64 {
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
}
}
}
}
return framerate
}