精简后的项目:仅保留 api/ui 源码、文档、构建依赖
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user