diff --git a/.gitignore b/.gitignore index d2a95f0..72af3c8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,10 +2,11 @@ debug* tmpout tmpcache +screenshottemp main-packr.go a_main-packr.go packrd mediaweb mediaweb.exe mediaweb.log -mediaweb_windows_x64_setup.exe +mediaweb_windows_x64_setup.exe \ No newline at end of file diff --git a/README.md b/README.md index 854c0c4..21c0a37 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,24 @@ The main design goal of MediaWEB is that no additional dependencies shall be nee * The mediaweb executable * A configuration file, mediaweb.conf +Optional dependencies are: + +* [ffmpeg](https://www.ffmpeg.org/) for video thumbnail support + No additional stuff, such as dockers and similar is required. MediaWEB is well suited to run on small platforms such as Raspberry Pi, Banana Pi, ROCK64 and similar. It is still very fast and can be used with advantage on PC:s running Windows, Linux or Mac OS. +## Screenshots + +![browser](testmedia/screenshot_browser.jpg) + +![viewer](testmedia/screenshot_viewer.jpg) + ## Features * Simple WEB GUI for viewing your images and videos -* Thumbnail support, primary by reading of EXIF thumbnail if it exist, otherwise thumbnails will be created and stored in a thumbnail cache +* Thumbnail support for images and videos, primary by reading of EXIF thumbnail if it exist, otherwise thumbnails will be created and stored in a thumbnail cache. Video thumbnails requires [ffmpeg](https://www.ffmpeg.org/) to be installed. * Automatic rotation JPEG images when needed (based on EXIF information) * Optional authentication with username and password @@ -49,6 +59,10 @@ Then run following for all Linux platforms: Follow the instructions in the service.sh script. +For video thumbnail support, install ffmpeg: + + sudo apt-get install ffmpeg + To perform additional configuration, edit: sudo vi /etc/mediaweb.conf @@ -72,6 +86,8 @@ Run the installer and follow the instructions. To modify changes just edit mediaweb.conf in the installation directory and restart the mediaweb service in task manager. +You need to install [ffmpeg](https://www.ffmpeg.org/) separately and put ffmpeg into your PATH to get video thumbnail support. + ## Build from source (any platform) To build from source on any platform you need to: @@ -108,7 +124,6 @@ On Linux platforms execute following to install MediaWEB as a service: ## Future improvements -* Create thumbnails for videos (probably using ffmpeg) * Add support for TLS/SSL diff --git a/appveyor.yml b/appveyor.yml index 099125c..9dd16dc 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -17,21 +17,27 @@ stack: go 1.11 install: # Windows - cmd: set PATH=%GOPATH%\bin;%PATH% - - cmd: go get github.com\mattn\goveralls - cmd: '%GOPATH%\src\github.com\midstar\mediaweb\scripts\install_deps.bat' # Linux - sh: export GOPATH=/usr/go - sh: export PATH=$GOPATH/bin:$PATH - sh: sudo chmod -R a+rwx $GOPATH + - sh: go get github.com/mattn/goveralls - sh: sh $GOPATH/src/github.com/midstar/mediaweb/scripts/install_deps.sh - + + # Linux install ffmpeg + - sh: sudo apt -yq update > /dev/null + - sh: sudo apt install -yq --no-install-suggests --no-install-recommends ffmpeg > /dev/null + build_script: # Common - go test -v -cover github.com/midstar/mediaweb -coverprofile=coverage.out - # Windows and publish result on coveralls.io - - cmd: '%GOPATH%/bin/goveralls -coverprofile=coverage.out -service=appveyor-ci -repotoken=%COVERALLS_TOKEN%' + # Publish result on coveralls.io (pick the Linux result since we have ffmpeg there) + - sh: '$GOPATH/bin/goveralls -coverprofile=coverage.out -service=appveyor-ci -repotoken=$COVERALLS_TOKEN' + + # Windows build and zip - cmd: '%GOPATH%\src\github.com\midstar\mediaweb\scripts\build.bat %APPVEYOR_BUILD_VERSION%' - cmd: 'copy scripts\service.bat .' - cmd: 'copy configs\mediaweb.conf .' @@ -40,14 +46,13 @@ build_script: # Windows rename tempates directory to secure that packr is working - cmd: 'rename templates old_templates' - # Windows Test service installation/uninstallation script - cmd: 'scripts/service_test.bat' # Windows create windows setup (installer) - cmd: 'makensis -DVERSION=%APPVEYOR_BUILD_VERSION% %GOPATH%\src\github.com\midstar\mediaweb\scripts\windows_installer.nsi' - # Linux PC/x64 + # Linux PC/x64 build and zip - sh: 'sh $GOPATH/src/github.com/midstar/mediaweb/scripts/build.sh $APPVEYOR_BUILD_VERSION' - sh: 'cp scripts/service.sh .' - sh: 'cp configs/mediaweb.conf .' diff --git a/assert_test.go b/assert_test.go index 73ae6cf..06001a2 100644 --- a/assert_test.go +++ b/assert_test.go @@ -3,11 +3,13 @@ package main import ( "fmt" + "os" "runtime/debug" "testing" ) func assertTrue(t *testing.T, message string, check bool) { + t.Helper() if !check { debug.PrintStack() t.Fatal(message) @@ -15,6 +17,7 @@ func assertTrue(t *testing.T, message string, check bool) { } func assertFalse(t *testing.T, message string, check bool) { + t.Helper() if check { debug.PrintStack() t.Fatal(message) @@ -22,6 +25,7 @@ func assertFalse(t *testing.T, message string, check bool) { } func assertExpectNoErr(t *testing.T, message string, err error) { + t.Helper() if err != nil { debug.PrintStack() t.Fatalf("%s : %s", message, err) @@ -29,6 +33,7 @@ func assertExpectNoErr(t *testing.T, message string, err error) { } func assertExpectErr(t *testing.T, message string, err error) { + t.Helper() if err == nil { debug.PrintStack() t.Fatal(message) @@ -36,18 +41,22 @@ func assertExpectErr(t *testing.T, message string, err error) { } func assertEqualsInt(t *testing.T, message string, expected int, actual int) { + t.Helper() assertTrue(t, fmt.Sprintf("%s\nExpected: %d, Actual: %d", message, expected, actual), expected == actual) } func assertEqualsStr(t *testing.T, message string, expected string, actual string) { + t.Helper() assertTrue(t, fmt.Sprintf("%s\nExpected: %s, Actual: %s", message, expected, actual), expected == actual) } func assertEqualsBool(t *testing.T, message string, expected bool, actual bool) { + t.Helper() assertTrue(t, fmt.Sprintf("%s\nExpected: %t, Actual: %t", message, expected, actual), expected == actual) } func assertEqualsSlice(t *testing.T, message string, expected []uint32, actual []uint32) { + t.Helper() assertEqualsInt(t, fmt.Sprintf("%s\nSize missmatch", message), len(expected), len(actual)) for index, expvalue := range expected { actvalue := actual[index] @@ -55,3 +64,13 @@ func assertEqualsSlice(t *testing.T, message string, expected []uint32, actual [ actvalue), expvalue == actvalue) } } + +func assertFileExist(t *testing.T, message string, name string) { + t.Helper() + if _, err := os.Stat(name); err != nil { + if os.IsNotExist(err) { + debug.PrintStack() + t.Fatalf("%s : %s", message, err) + } + } +} diff --git a/main_common.go b/main_common.go index 0dc3bc9..ce894b0 100644 --- a/main_common.go +++ b/main_common.go @@ -14,8 +14,8 @@ func mainCommon() *WebAPI { llog.Info("Version: %s", applicationVersion) llog.Info("Build time: %s", applicationBuildTime) llog.Info("Git hash: %s", applicationGitHash) - media := createMedia(s.mediaPath, s.thumbPath, s.enableThumbCache, s.autoRotate) box := packr.New("templates", "./templates") + media := createMedia(box, s.mediaPath, s.thumbPath, s.enableThumbCache, s.autoRotate) webAPI := CreateWebAPI(s.port, "templates", media, box, s.userName, s.password) return webAPI } diff --git a/media.go b/media.go index 197314e..debcd07 100644 --- a/media.go +++ b/media.go @@ -7,10 +7,12 @@ import ( "io" "io/ioutil" "os" + "os/exec" "path/filepath" "strings" "github.com/disintegration/imaging" + packr "github.com/gobuffalo/packr/v2" "github.com/midstar/llog" "github.com/rwcarlsen/goexif/exif" ) @@ -20,10 +22,11 @@ var vidExtensions = [...]string{".avi", ".mov", ".vid", ".mkv", ".mp4"} // Media represents the media including its base path type Media struct { - mediaPath string // Top level path for media files - thumbPath string // Top level path for thumbnails - enableThumbCache bool // Generate thumbnails - autoRotate bool // Rotate JPEG files when needed + mediaPath string // Top level path for media files + thumbPath string // Top level path for thumbnails + enableThumbCache bool // Generate thumbnails + autoRotate bool // Rotate JPEG files when needed + box *packr.Box // For icons } // File represents a folder or any other file @@ -35,7 +38,7 @@ type File struct { // createMedia creates a new media. If thumb cache is enabled the path is // created when needed. -func createMedia(mediaPath string, thumbPath string, enableThumbCache bool, autoRotate bool) *Media { +func createMedia(box *packr.Box, mediaPath string, thumbPath string, enableThumbCache bool, autoRotate bool) *Media { llog.Info("Media path: %s", mediaPath) if enableThumbCache { directory := filepath.Dir(thumbPath) @@ -51,10 +54,13 @@ func createMedia(mediaPath string, thumbPath string, enableThumbCache bool, auto llog.Info("Thumbnail cache disabled") } llog.Info("JPEG auto rotate: %t", autoRotate) - return &Media{mediaPath: filepath.ToSlash(filepath.Clean(mediaPath)), + media := &Media{mediaPath: filepath.ToSlash(filepath.Clean(mediaPath)), thumbPath: filepath.ToSlash(filepath.Clean(thumbPath)), enableThumbCache: enableThumbCache, - autoRotate: autoRotate} + autoRotate: autoRotate, + box: box} + llog.Info("Video thumbnails supported (ffmpeg installed): %v", media.videoThumbnailSupport()) + return media } // getFullPath returns the full path from an absolute base @@ -127,23 +133,47 @@ func (m *Media) getFiles(relativePath string) ([]File, error) { // For all other files (including folders) "" is returned. // relativeFileName can also include an absolute or relative path. func (m *Media) getFileType(relativeFileName string) string { - extension := filepath.Ext(relativeFileName) // Check if this is an image + if m.isImage(relativeFileName) { + return "image" + } + + // Check if this is a video + if m.isVideo(relativeFileName) { + return "video" + } + + return "" // Not a video nor an image +} + +func (m *Media) isImage(pathAndFile string) bool { + extension := filepath.Ext(pathAndFile) for _, imgExtension := range imgExtensions { if strings.EqualFold(extension, imgExtension) { - return "image" + return true } } + return false +} - // Check if this is a video +func (m *Media) isVideo(pathAndFile string) bool { + extension := filepath.Ext(pathAndFile) for _, vidExtension := range vidExtensions { if strings.EqualFold(extension, vidExtension) { - return "video" + return true } } + return false +} - return "" // Not a video or an image +func (m *Media) isJPEG(pathAndFile string) bool { + extension := filepath.Ext(pathAndFile) + if strings.EqualFold(extension, ".jpg") == false && + strings.EqualFold(extension, ".jpeg") == false { + return false + } + return true } func (m *Media) extractEXIF(fullFilePath string) *exif.Exif { @@ -164,15 +194,6 @@ func (m *Media) extractEXIF(fullFilePath string) *exif.Exif { return ex } -func (m *Media) isJPEG(pathAndFile string) bool { - extension := filepath.Ext(pathAndFile) - if strings.EqualFold(extension, ".jpg") == false && - strings.EqualFold(extension, ".jpeg") == false { - return false - } - return true -} - // isRotationNeeded returns true if the file needs to be rotated. // It finds this out by reading the EXIF rotation information // in the file. @@ -299,7 +320,7 @@ func (m *Media) thumbnailPath(relativeMediaPath string) (string, error) { return m.getFullThumbPath(relativeThumbnailPath) } -// generateThumbnail generates a thumbnail from any of the supported +// generateImageThumbnail generates a thumbnail from any of the supported // images. Will create necessary subdirectories in the thumbpath. func (m *Media) generateImageThumbnail(fullMediaPath, fullThumbPath string) error { img, err := imaging.Open(fullMediaPath, imaging.AutoOrientation(true)) @@ -334,6 +355,9 @@ func (m *Media) generateImageThumbnail(fullMediaPath, fullThumbPath string) erro // 3. Generate a thumbnail to cache and write // 4. If all above fails return error func (m *Media) writeThumbnail(w io.Writer, relativeFilePath string) error { + if !m.isImage(relativeFilePath) && !m.isVideo(relativeFilePath) { + return fmt.Errorf("not a supported media type") + } err := m.writeEXIFThumbnail(w, relativeFilePath) if err != nil && m.enableThumbCache { err = nil @@ -346,12 +370,16 @@ func (m *Media) writeThumbnail(w io.Writer, relativeFilePath string) error { thumbFile, err := os.Open(thumbFileName) if err != nil { // No thumb exist. Create it - llog.Info("Creating new thumbnail for %s", relativeFilePath) + llog.Trace("Creating new thumbnail for %s", relativeFilePath) fullMediaPath, err := m.getFullMediaPath(relativeFilePath) if err != nil { return err } - err = m.generateImageThumbnail(fullMediaPath, thumbFileName) + if m.isVideo(fullMediaPath) { + err = m.generateVideoThumbnail(fullMediaPath, thumbFileName) + } else { + err = m.generateImageThumbnail(fullMediaPath, thumbFileName) + } if err != nil { return err } @@ -365,3 +393,111 @@ func (m *Media) writeThumbnail(w io.Writer, relativeFilePath string) error { } return err } + +// For testing purposes +var ffmpegCmd = "ffmpeg" + +// videoThumbnailSupport returns true if ffmpeg is installed, and thus +// video thumbnails is supported +func (m *Media) videoThumbnailSupport() bool { + _, err := exec.LookPath(ffmpegCmd) + return err == nil +} + +// generateVideoThumbnail generates a thumbnail from any of the supported +// videos. Will create necessary subdirectories in the thumbpath. +func (m *Media) generateVideoThumbnail(fullMediaPath, fullThumbPath string) error { + // The temporary file for the screenshot + screenShot := fullThumbPath + ".sh.jpg" + + // Extract the screenshot + err := m.extractVideoScreenshot(fullMediaPath, screenShot) + if err != nil { + return err + } + defer os.Remove(screenShot) // Remove temporary file + + // Generate thumbnail from the screenshot + img, err := imaging.Open(screenShot, imaging.AutoOrientation(true)) + if err != nil { + return fmt.Errorf("Unable to open screenshot image %s. Reason: %s", screenShot, err) + } + thumbImg := imaging.Thumbnail(img, 256, 256, imaging.Lanczos) + + // Add small video icon i upper right corner to indicate that this is + // a video + iconVideoImg, err := m.getVideoIcon() + if err != nil { + return err + } + thumbImg = imaging.Overlay(thumbImg, iconVideoImg, image.Pt(155, 11), 1.0) + + // Write thumbnail to file + outFile, err := os.Create(fullThumbPath) + if err != nil { + return fmt.Errorf("Unable to open %s for creating thumbnail. Reason %s", fullThumbPath, err) + } + defer outFile.Close() + err = imaging.Encode(outFile, thumbImg, imaging.JPEG) + + return err +} + +// Cache to avoid regenerate icon each time (do it once) +var videoIcon image.Image + +func (m *Media) getVideoIcon() (image.Image, error) { + if videoIcon != nil { + // To avoid re-generate + return videoIcon, nil + } + var err error + videoIconBytes, _ := m.box.Find("icon_video.png") + videoIcon, err = imaging.Decode(bytes.NewReader(videoIconBytes)) + if err != nil { + return nil, err + } + videoIcon = imaging.Resize(videoIcon, 90, 90, imaging.Lanczos) + return videoIcon, nil +} + +// extractVideoScreenshot extracts a screenshot from a video using external +// ffmpeg software. Will create necessary directories in the outFilePath +func (m *Media) extractVideoScreenshot(inFilePath, outFilePath string) error { + if !m.videoThumbnailSupport() { + return fmt.Errorf("video thumbnails not supported. ffmpeg not installed") + } + + // Create subdirectories if needed + directory := filepath.Dir(outFilePath) + err := os.MkdirAll(directory, os.ModePerm) + if err != nil { + return fmt.Errorf("Unable to create directories in %s for extracting screenshot. Reason %s", outFilePath, err) + } + + // Define argments for ffmpeg + ffmpegArgs := []string{ + "-i", + inFilePath, + "-ss", + "00:00:05", // 5 seconds into movie + "-vframes", + "1", + outFilePath} + + var stdout bytes.Buffer + var stderr bytes.Buffer + + //cmd := exec.Command(ffmpegCmd, ffmpegArg) + cmd := exec.Command(ffmpegCmd, ffmpegArgs...) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err = cmd.Run() + if err != nil { + errorStr := fmt.Sprintf("%s %s\nError: %s\nStdout: %s\nStderr: %s", + ffmpegCmd, strings.Join(ffmpegArgs, " "), err, stdout.String(), stderr.String()) + llog.Error(errorStr) + return fmt.Errorf(errorStr) + } + return err +} diff --git a/media_test.go b/media_test.go index 84f775b..81b1625 100644 --- a/media_test.go +++ b/media_test.go @@ -6,6 +6,8 @@ import ( "os" "testing" "time" + + packr "github.com/gobuffalo/packr/v2" ) type timerType struct { @@ -27,28 +29,32 @@ func LogTime(t *testing.T, whatWasMeasured string) { } func TestGetFiles(t *testing.T) { - media := createMedia("testmedia", ".", true, true) + box := packr.New("templates", "./templates") + media := createMedia(box, "testmedia", ".", true, true) files, err := media.getFiles("") assertExpectNoErr(t, "", err) assertTrue(t, "No files found", len(files) > 5) } func TestGetFilesInvalid(t *testing.T) { - media := createMedia("testmedia", ".", true, true) + box := packr.New("templates", "./templates") + media := createMedia(box, "testmedia", ".", true, true) files, err := media.getFiles("invalidfolder") assertExpectErr(t, "invalid path shall give errors", err) assertTrue(t, "Should not find any files", len(files) == 0) } func TestGetFilesHacker(t *testing.T) { - media := createMedia("testmedia", ".", true, true) + box := packr.New("templates", "./templates") + media := createMedia(box, "testmedia", ".", true, true) files, err := media.getFiles("../..") assertExpectErr(t, "hacker path shall give errors", err) assertTrue(t, "Should not find any files", len(files) == 0) } func TestIsRotationNeeded(t *testing.T) { - media := createMedia("testmedia", ".", true, true) + box := packr.New("templates", "./templates") + media := createMedia(box, "testmedia", ".", true, true) rotationNeeded := media.isRotationNeeded("exif_rotate/180deg.jpg") assertTrue(t, "Rotation should be needed", rotationNeeded) @@ -97,7 +103,8 @@ func TestRotateAndWrite(t *testing.T) { outFileName := "tmpout/TestRotateAndWrite/jpeg_rotated_fixed.jpg" os.MkdirAll("tmpout/TestRotateAndWrite", os.ModePerm) // If already exist no problem os.Remove(outFileName) - media := createMedia("testmedia", ".", true, true) + box := packr.New("templates", "./templates") + media := createMedia(box, "testmedia", ".", true, true) outFile, err := os.Create(outFileName) assertExpectNoErr(t, "unable to create out", err) defer outFile.Close() @@ -109,6 +116,7 @@ func TestRotateAndWrite(t *testing.T) { } func tEXIFThumbnail(t *testing.T, media *Media, filename string) { + t.Helper() inFileName := "exif_rotate/" + filename outFileName := "tmpout/TestWriteEXIFThumbnail/thumb_" + filename os.Remove(outFileName) @@ -119,12 +127,14 @@ func tEXIFThumbnail(t *testing.T, media *Media, filename string) { err = media.writeEXIFThumbnail(outFile, inFileName) LogTime(t, inFileName+" thumbnail time") assertExpectNoErr(t, "unable to extract thumbnail", err) + assertFileExist(t, "", outFileName) t.Logf("Manually check that %s thumbnail is ok", outFileName) } func TestWriteEXIFThumbnail(t *testing.T) { os.MkdirAll("tmpout/TestWriteEXIFThumbnail", os.ModePerm) // If already exist no problem - media := createMedia("testmedia", ".", true, true) + box := packr.New("templates", "./templates") + media := createMedia(box, "testmedia", ".", true, true) tEXIFThumbnail(t, media, "normal.jpg") tEXIFThumbnail(t, media, "180deg.jpg") @@ -148,7 +158,8 @@ func TestWriteEXIFThumbnail(t *testing.T) { func TestFullPath(t *testing.T) { // Root path - media := createMedia(".", ".", true, true) + box := packr.New("templates", "./templates") + media := createMedia(box, ".", ".", true, true) p, err := media.getFullMediaPath("afile.jpg") assertExpectNoErr(t, "unable to get valid full path", err) assertEqualsStr(t, "invalid path", "afile.jpg", p) @@ -157,7 +168,7 @@ func TestFullPath(t *testing.T) { assertExpectErr(t, "hackers shall not be allowed", err) // Relative path - media = createMedia("arelative/path", ".", true, true) + media = createMedia(box, "arelative/path", ".", true, true) p, err = media.getFullMediaPath("afile.jpg") assertExpectNoErr(t, "unable to get valid full path", err) assertEqualsStr(t, "invalid path", "arelative/path/afile.jpg", p) @@ -166,7 +177,7 @@ func TestFullPath(t *testing.T) { assertExpectErr(t, "hackers shall not be allowed", err) // Absolute path - media = createMedia("/root/absolute/path", ".", true, true) + media = createMedia(box, "/root/absolute/path", ".", true, true) p, err = media.getFullMediaPath("afile.jpg") assertExpectNoErr(t, "unable to get valid full path", err) assertEqualsStr(t, "invalid path", "/root/absolute/path/afile.jpg", p) @@ -176,7 +187,8 @@ func TestFullPath(t *testing.T) { } func TestThumbnailPath(t *testing.T) { - media := createMedia("/c/mediapath", "/d/thumbpath", true, true) + box := packr.New("templates", "./templates") + media := createMedia(box, "/c/mediapath", "/d/thumbpath", true, true) thumbPath, err := media.thumbnailPath("myimage.jpg") assertExpectNoErr(t, "", err) @@ -198,18 +210,21 @@ func TestThumbnailPath(t *testing.T) { } func tGenerateImageThumbnail(t *testing.T, media *Media, inFileName, outFileName string) { + t.Helper() os.Remove(outFileName) RestartTimer() err := media.generateImageThumbnail(inFileName, outFileName) LogTime(t, inFileName+"thumbnail generation: ") assertExpectNoErr(t, "", err) + assertFileExist(t, "", outFileName) t.Logf("Manually check that %s thumbnail is ok", outFileName) } func TestGenerateImageThumbnail(t *testing.T) { os.MkdirAll("tmpout/TestGenerateImageThumbnail", os.ModePerm) // If already exist no problem - media := createMedia("", "", true, true) + box := packr.New("templates", "./templates") + media := createMedia(box, "", "", true, true) tGenerateImageThumbnail(t, media, "testmedia/jpeg.jpg", "tmpout/TestGenerateImageThumbnail/jpeg_thumbnail.jpg") tGenerateImageThumbnail(t, media, "testmedia/jpeg_rotated.jpg", "tmpout/TestGenerateImageThumbnail/jpeg_rotated_thumbnail.jpg") @@ -227,6 +242,7 @@ func TestGenerateImageThumbnail(t *testing.T) { } func tWriteThumbnail(t *testing.T, media *Media, inFileName, outFileName string, failExpected bool) { + t.Helper() os.Remove(outFileName) outFile, err := os.Create(outFileName) assertExpectNoErr(t, "unable to create out", err) @@ -246,7 +262,8 @@ func TestWriteThumbnail(t *testing.T) { os.MkdirAll("tmpout/TestWriteThumbnail", os.ModePerm) // If already exist no problem os.RemoveAll("tmpout/TestWriteThumbnail/*") - media := createMedia("testmedia", "tmpcache/TestWriteThumbnail", true, true) + box := packr.New("templates", "./templates") + media := createMedia(box, "testmedia", "tmpcache/TestWriteThumbnail", true, true) // JPEG with embedded EXIF tWriteThumbnail(t, media, "jpeg.jpg", "tmpout/TestWriteThumbnail/jpeg.jpg", false) @@ -257,6 +274,11 @@ func TestWriteThumbnail(t *testing.T) { // Non JPEG - no exif tWriteThumbnail(t, media, "png.png", "tmpout/TestWriteThumbnail/png.jpg", false) + // Video - only if video is supported + if media.videoThumbnailSupport() { + tWriteThumbnail(t, media, "video.mp4", "tmpout/TestWriteThumbnail/video.jpg", false) + } + // Non existing file tWriteThumbnail(t, media, "dont_exist.jpg", "tmpout/TestWriteThumbnail/dont_exist.jpg", true) @@ -264,7 +286,7 @@ func TestWriteThumbnail(t *testing.T) { tWriteThumbnail(t, media, "invalid.jpg", "tmpout/TestWriteThumbnail/invalid.jpg", true) // Disable thumb cache - media = createMedia("testmedia", "tmpcache/TestWriteThumbnail", false, true) + media = createMedia(box, "testmedia", "tmpcache/TestWriteThumbnail", false, true) // JPEG with embedded EXIF tWriteThumbnail(t, media, "jpeg.jpg", "tmpout/TestWriteThumbnail/jpeg.jpg", false) @@ -272,3 +294,59 @@ func TestWriteThumbnail(t *testing.T) { // Non JPEG - no exif tWriteThumbnail(t, media, "png.png", "tmpout/TestWriteThumbnail/png.jpg", true) } + +func TestVideoThumbnailSupport(t *testing.T) { + // Since we cannot guarantee that ffmpeg is available on the test + // host we will replace the ffmpeg command temporary + origCmd := ffmpegCmd + defer func() { + ffmpegCmd = origCmd + }() + + box := packr.New("templates", "./templates") + media := createMedia(box, "", "", true, true) + + t.Logf("ffmpeg supported: %v", media.videoThumbnailSupport()) + + ffmpegCmd = "thiscommanddontexit" + assertFalse(t, ffmpegCmd, media.videoThumbnailSupport()) + + ffmpegCmd = "cmd" + shallBeTrueOnWindows := media.videoThumbnailSupport() + + ffmpegCmd = "echo" + shallBeTrueOnNonWindows := media.videoThumbnailSupport() + + assertTrue(t, "Shall be true on at least one platform", shallBeTrueOnWindows || shallBeTrueOnNonWindows) +} + +func tGenerateVideoThumbnail(t *testing.T, media *Media, inFileName, outFileName string) { + t.Helper() + os.Remove(outFileName) + RestartTimer() + err := media.generateVideoThumbnail(inFileName, outFileName) + LogTime(t, inFileName+"thumbnail generation: ") + assertExpectNoErr(t, "", err) + assertFileExist(t, "", outFileName) + t.Logf("Manually check that %s thumbnail is ok", outFileName) +} + +func TestGenerateVideoThumbnail(t *testing.T) { + box := packr.New("templates", "./templates") + media := createMedia(box, "", "", true, true) + if !media.videoThumbnailSupport() { + t.Skip("ffmpeg not installed skipping test") + return + } + tmp := "tmpout/TestGenerateVideoThumbnail" + os.MkdirAll(tmp, os.ModePerm) // If already exist no problem + tmpSpace := "tmpout/TestGenerateVideoThumbnail/with space in path" + os.MkdirAll(tmpSpace, os.ModePerm) // If already exist no problem + + tGenerateVideoThumbnail(t, media, "testmedia/video.mp4", tmp+"/video_thumbnail.jpg") + tGenerateVideoThumbnail(t, media, "testmedia/video.mp4", tmpSpace+"/video_thumbnail.jpg") + + // Test some invalid + err := media.generateImageThumbnail("nonexisting.mp4", "dont_matter.jpg") + assertExpectErr(t, "", err) +} diff --git a/templates/index.html b/templates/index.html index a201ee4..48c421c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -7,7 +7,12 @@ /***************************************/ /* Style for navigator */ #navigator { - margin-left: 20px; + /*margin-left: 20px;*/ + overflow: hidden; + background-color: white; + position: fixed; /* Set the navbar to fixed position */ + top: 0; /* Position the navbar at the top of the page */ + width: 100%; /* Full width */ } #navigator a { @@ -59,10 +64,13 @@ /* The Modal (background) */ .modal { + /*border-style: solid; + border-width: 5x 5px 5px 5px; + border-color: purple;*/ display: none; /* Hidden by default */ position: fixed; /* Stay in place */ z-index: 1; /* Sit on top */ - padding-top: 100px; /* Location of the box */ + padding-top: 30px; /* Location of the box */ left: 0; top: 0; width: 100%; /* Full width */ @@ -72,8 +80,11 @@ background-color: rgba(0,0,0,0.9); /* Black w/ opacity */ } - /* Modal Content (image) */ + /* Modal Content (image/video) */ .modal-content { + /*border-style: solid; + border-width: 1px 1px 1px 1px; + border-color: red;*/ margin: auto; display: block; max-width: 90%; @@ -83,6 +94,9 @@ /* Caption of Modal Image */ #caption { + /*border-style: solid; + border-width: 1px 1px 1px 1px; + border-color: blue;*/ margin: auto; display: block; width: 80%; @@ -90,9 +104,17 @@ text-align: center; color: #ccc; padding: 10px 0; - height: 150px; + max-height: 10%; } + + /* 100% Image Width on Smaller Screens */ + @media only screen and (max-width: 700px){ + .modal-content { + width: 100%; + } + } + /* Add Animation */ .modal-content, #caption { -webkit-animation-name: zoom; @@ -140,17 +162,10 @@ #mediaViewerNext { top: 30%; right: 35px; - } - - /* 100% Image Width on Smaller Screens */ - @media only screen and (max-width: 700px){ - .modal-content { - width: 100%; - } } /* Loader / spinner */ - #loader { + .loader { position: fixed; top: 15px; left: 35px; @@ -166,6 +181,10 @@ animation: spin 1s linear infinite; } + #mediaLoader { + z-index: 1; /* Sit on top */ + } + @-webkit-keyframes spin { 0% { -webkit-transform: rotate(0deg); } 100% { -webkit-transform: rotate(360deg); } @@ -188,9 +207,9 @@ var span = document.getElementById("mediaViewerClose"); span.onclick = closeFileDialog - /* Setup onload for images (close loader/spinner) */ + /* Setup onload for images in media viewer (close loader/spinner) */ var img = document.getElementById("mediaViewerImg"); - img.addEventListener("load", hideLoader) + img.addEventListener("load", hideMediaLoader) /* Check for URL search parameters */ var path = "" @@ -249,13 +268,23 @@ document.onkeydown = null; } - function showLoader() { - var loader = document.getElementById("loader"); + function showItemsLoader() { + var loader = document.getElementById("itemsLoader"); + loader.style.display = "block"; + } + + function hideItemsLoader() { + var loader = document.getElementById("itemsLoader"); + loader.style.display = "none"; + } + + function showMediaLoader() { + var loader = document.getElementById("mediaLoader"); loader.style.display = "block"; } - function hideLoader() { - var loader = document.getElementById("loader"); + function hideMediaLoader() { + var loader = document.getElementById("mediaLoader"); loader.style.display = "none"; } @@ -275,7 +304,7 @@ modalVideo.style.display = "none"; // Hide video modalImg.style.display = "block"; // Show image - showLoader() // Show loader / spinner + showMediaLoader() // Show loader / spinner } else if (file.Type == "video") { // This is a video @@ -283,10 +312,10 @@ modalImg.style.display = "none"; // Hide image modalVideo.style.display = "block"; // Show video - hideLoader() // Hide loader / spinner + hideMediaLoader() // Hide loader / spinner } else { alert("Unsupported file: " + file.Type); - hideLoader() // Hide loader / spinner + hideMediaLoader() // Hide loader / spinner } // Set the caption @@ -370,18 +399,31 @@ /* Update the global files */ globalFiles = files; - /* Add file items */ + /* Add folders first (on top) */ + for (var i=0; i < files.length; i++) { + var file = files[i]; + if (file.Type == "folder") { + addFileItem(file.Type, file.Name, file.Path, i); + } + } + + /* Then add all other items */ for (var i=0; i < files.length; i++) { var file = files[i]; - addFileItem(file.Type, file.Name, file.Path, i); + if (file.Type != "folder") { + addFileItem(file.Type, file.Name, file.Path, i); + } } + + /* Start cyclic check if items are loaded */ + checkIfAllThumbsLoaded(); } function addFileItem(type, name, path, index) { var itemTxt = "
\
\ - " + type +" \ + " + type +" \
\
\ " + name + " \ @@ -402,6 +444,28 @@ } } + // Close the itemsLoader if all items (thumbs) are loaded. + // If not it calls itself after a certain time (i.e. + // polls the items regulary) + function checkIfAllThumbsLoaded() { + var thumbs = document.getElementsByClassName("thumbImage"); + for (var i=0; i < thumbs.length; i++) { + var thumb = thumbs[i]; + if (!thumb.complete) { + // At least one thumb has not been loaded + + // Show spinner + showItemsLoader(); + + // Check again after 0.5 seconds + setTimeout(checkIfAllThumbsLoaded, 500) + return + } + } + // All thumbs loaded. Close the spinner + hideItemsLoader(); + } + function onError(error) { alert(error); } @@ -443,25 +507,30 @@ +
+ +
+ diff --git a/testmedia/screenshot_browser.jpg b/testmedia/screenshot_browser.jpg new file mode 100644 index 0000000..fe36125 Binary files /dev/null and b/testmedia/screenshot_browser.jpg differ diff --git a/testmedia/screenshot_viewer.jpg b/testmedia/screenshot_viewer.jpg new file mode 100644 index 0000000..812f967 Binary files /dev/null and b/testmedia/screenshot_viewer.jpg differ diff --git a/webapi.go b/webapi.go index 6fbc3c5..4c6872d 100644 --- a/webapi.go +++ b/webapi.go @@ -168,7 +168,7 @@ func (wa *WebAPI) serveHTTPThumbnail(w http.ResponseWriter, r *http.Request) { if err == nil { w.Header().Set("Content-Type", "image/jpeg") } else { - // No thumbnail. Use the default + // No thumbnail. Use the default w.Header().Set("Content-Type", "image/png") fileType := wa.media.getFileType(relativePath) if fileType == "image" { diff --git a/webapi_test.go b/webapi_test.go index ab898fa..a9d088e 100644 --- a/webapi_test.go +++ b/webapi_test.go @@ -24,6 +24,7 @@ func respToString(response io.ReadCloser) string { } func getHTML(t *testing.T, path string) string { + t.Helper() resp, err := http.Get(fmt.Sprintf("%s/%s", baseURL, path)) assertExpectNoErr(t, "", err) assertEqualsInt(t, "", int(http.StatusOK), int(resp.StatusCode)) @@ -33,6 +34,7 @@ func getHTML(t *testing.T, path string) string { } func getHTMLAuthenticate(t *testing.T, path, user, pass string, expectFail bool) string { + t.Helper() client := &http.Client{} req, err := http.NewRequest("GET", fmt.Sprintf("%s/%s", baseURL, path), nil) req.SetBasicAuth(user, pass) @@ -49,6 +51,7 @@ func getHTMLAuthenticate(t *testing.T, path, user, pass string, expectFail bool) } func getBinary(t *testing.T, path, contentType string) []byte { + t.Helper() resp, err := http.Get(fmt.Sprintf("%s/%s", baseURL, path)) assertExpectNoErr(t, "", err) assertEqualsInt(t, "", int(http.StatusOK), int(resp.StatusCode)) @@ -60,6 +63,7 @@ func getBinary(t *testing.T, path, contentType string) []byte { } func getObject(t *testing.T, path string, v interface{}) { + t.Helper() resp, err := http.Get(fmt.Sprintf("%s/%s", baseURL, path)) if err != nil { t.Fatalf("Unable to get path %s. Reason: %s", path, err) @@ -80,12 +84,14 @@ func getObject(t *testing.T, path string, v interface{}) { } func startserver(t *testing.T) { + t.Helper() go main() waitserver(t) } // waitserver waits for the server to be up and running func waitserver(t *testing.T) { + t.Helper() client := http.Client{Timeout: 100 * time.Millisecond} maxTries := 10 for i := 0; i < maxTries; i++ { @@ -193,8 +199,9 @@ func TestGetThumbnail(t *testing.T) { image = getBinary(t, "thumb/exif_rotate/no_exif.jpg", "image/jpeg") assertTrue(t, "", len(image) > 100) - image = getBinary(t, "thumb/video.mp4", "image/png") - assertTrue(t, "", len(image) > 100) + // Below will be png if ffmpeg is not installed and jpeg if ffmpeg is installed + //image = getBinary(t, "thumb/video.mp4", "image/jpeg") + //assertTrue(t, "", len(image) > 100) image = getBinary(t, "thumb/exif_rotate", "image/png") assertTrue(t, "", len(image) > 100) @@ -208,8 +215,8 @@ func TestGetThumbnail(t *testing.T) { } func TestGetThumbnailNoCache(t *testing.T) { - media := createMedia("testmedia", "", false, true) box := packr.New("templates", "./templates") + media := createMedia(box, "testmedia", "", false, true) webAPI := CreateWebAPI(9834, "templates", media, box, "", "") webAPI.Start() waitserver(t) @@ -249,8 +256,8 @@ func TestInvalidPath(t *testing.T) { } func TestAuthentication(t *testing.T) { - media := createMedia("testmedia", "", true, true) box := packr.New("templates", "./templates") + media := createMedia(box, "testmedia", "", true, true) webAPI := CreateWebAPI(9834, "templates", media, box, "myuser", "mypass") webAPI.Start() waitserver(t)