Merge branch 'master' into stable24

old_stable24
Varun Patil 2022-11-10 22:03:18 -08:00
commit 2771531590
23 changed files with 477 additions and 281 deletions

View File

@ -8,7 +8,12 @@ This file is manually updated. Please file an issue if something is missing.
- **Massively improved video performance**
- Memories now comes with a dedicated transcoding server with HLS support.
- Read the documentation [here](https://github.com/pulsejet/memories/wiki/Configuration#video-transcoding) carefully for more details.
- **Feature**: show EXIF metadata in sidebar ([#68](https://github.com/pulsejet/memories/issues/68))
- **Feature**: Show EXIF metadata in sidebar ([#68](https://github.com/pulsejet/memories/issues/68))
- **Feature**: Show duration on video tiles
- **Feature**: Allow editing all image formats (HEIC etc.)
- Fix stretched images in viewer ([#176](https://github.com/pulsejet/memories/issues/176))
- Restore metadata after image edit ([#174](https://github.com/pulsejet/memories/issues/174))
- Fix loss of resolution after image edit
## v4.6.1, v3.6.1 (2022-11-07)

View File

@ -52,12 +52,13 @@ return [
['name' => 'Faces#faces', 'url' => '/api/faces', 'verb' => 'GET'],
['name' => 'Faces#preview', 'url' => '/api/faces/preview/{id}', 'verb' => 'GET'],
['name' => 'Image#info', 'url' => '/api/info/{id}', 'verb' => 'GET'],
['name' => 'Image#edit', 'url' => '/api/edit/{id}', 'verb' => 'PATCH'],
['name' => 'Image#info', 'url' => '/api/image/info/{id}', 'verb' => 'GET'],
['name' => 'Image#setExif', 'url' => '/api/image/set-exif/{id}', 'verb' => 'PATCH'],
['name' => 'Image#jpeg', 'url' => '/api/image/jpeg/{id}', 'verb' => 'GET'],
['name' => 'Archive#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'],
['name' => 'Video#transcode', 'url' => '/api/video/transcode/{fileid}/{profile}', 'verb' => 'GET'],
['name' => 'Video#transcode', 'url' => '/api/video/transcode/{client}/{fileid}/{profile}', 'verb' => 'GET'],
// Config API
['name' => 'Other#setUserConfig', 'url' => '/api/config/{key}', 'verb' => 'PUT'],

View File

@ -264,6 +264,14 @@ class Index extends Command
return;
}
// skip 'IMDB' in path
if (false !== strpos($folderPath, 'IMDB')) {
$this->output->writeln('Skipping folder '.$folderPath.' because of IMDB');
$this->previousLineLength = 0;
return;
}
$nodes = $folder->getDirectoryListing();
foreach ($nodes as &$node) {

View File

@ -82,34 +82,33 @@ class VideoSetup extends Command
return $this->suggestDisable($output);
}
// Check go-transcode binary
$output->writeln('Checking for go-transcode binary');
// Check go-vod binary
$output->writeln('Checking for go-vod binary');
// Detect architecture
$arch = \OCA\Memories\Util::getArch();
$libc = \OCA\Memories\Util::getLibc();
if (!$arch || !$libc) {
$output->writeln('<error>Compatible go-transcode binary not found</error>');
$this->suggestGoTranscode($output);
if (!$arch) {
$output->writeln('<error>Compatible go-vod binary not found</error>');
$this->suggestGoVod($output);
return $this->suggestDisable($output);
}
$goTranscodePath = realpath(__DIR__."/../../exiftool-bin/go-transcode-{$arch}-{$libc}");
$output->writeln("Trying go-transcode from {$goTranscodePath}");
chmod($goTranscodePath, 0755);
$goVodPath = realpath(__DIR__."/../../exiftool-bin/go-vod-{$arch}");
$output->writeln("Trying go-vod from {$goVodPath}");
chmod($goVodPath, 0755);
$goTranscode = shell_exec($goTranscodePath.' --help');
if (!$goTranscode || false === strpos($goTranscode, 'Available Commands')) {
$output->writeln('<error>go-transcode could not be run</error>');
$this->suggestGoTranscode($output);
$goVod = shell_exec($goVodPath.' test');
if (!$goVod || false === strpos($goVod, 'test successful')) {
$output->writeln('<error>go-vod could not be run</error>');
$this->suggestGoVod($output);
return $this->suggestDisable($output);
}
// Go transcode is working. Yay!
$output->writeln('go-transcode is installed!');
$output->writeln('go-vod is installed!');
$output->writeln('');
$output->writeln('You can use transcoding and HLS streaming');
$output->writeln('This is recommended for better performance, but has implications if');
@ -127,10 +126,7 @@ class VideoSetup extends Command
return 0;
}
$tConfig = realpath(__DIR__.'/../../transcoder.yaml');
$this->config->setSystemValue('memories.transcoder', $goTranscodePath);
$this->config->setSystemValue('memories.transcoder_config', $tConfig);
$this->config->setSystemValue('memories.transcoder', $goVodPath);
$this->config->setSystemValue('memories.no_transcode', false);
$output->writeln('Transcoding and HLS are now enabled!');
@ -153,10 +149,10 @@ class VideoSetup extends Command
return 0;
}
protected function suggestGoTranscode(OutputInterface $output): void
protected function suggestGoVod(OutputInterface $output): void
{
$output->writeln('You may build go-transcode from source');
$output->writeln('It can be downloaded from https://github.com/pulsejet/go-transcode');
$output->writeln('You may build go-vod from source');
$output->writeln('It can be downloaded from https://github.com/pulsejet/go-vod');
$output->writeln('Once built, point the path to the binary in the config for `memories.transcoder`');
}

View File

@ -31,6 +31,7 @@ use OCP\App\IAppManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\IConfig;
@ -133,6 +134,35 @@ class ApiBase extends Controller
return $folder;
}
/**
* Get a file with ID from user's folder.
*
* @param int $fileId
*
* @return null|File
*/
protected function getUserFile(int $id)
{
$user = $this->userSession->getUser();
if (null === $user) {
return null;
}
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
// Check for permissions and get numeric Id
$file = $userFolder->getById($id);
if (0 === \count($file)) {
return null;
}
// Check if node is a file
if (!$file[0] instanceof File) {
return null;
}
return $file[0];
}
protected function isRecursive()
{
return null === $this->request->getParam('folder');

View File

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace OCA\Memories\Controller;
use OCA\Memories\AppInfo\Application;
use OCA\Memories\Exif;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
@ -38,79 +39,94 @@ class ImageController extends ApiBase
*/
public function info(string $id): JSONResponse
{
$user = $this->userSession->getUser();
if (null === $user) {
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
}
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
// Check for permissions and get numeric Id
$file = $userFolder->getById((int) $id);
if (0 === \count($file)) {
$file = $this->getUserFile((int) $id);
if (!$file) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
$file = $file[0];
// Get the image info
$basic = false !== $this->request->getParam('basic', false);
$info = $this->timelineQuery->getInfoById($file->getId(), $basic);
// Get latest exif data if requested
if ($this->request->getParam('current', false)) {
$info['current'] = Exif::getExifFromFile($file);
}
return new JSONResponse($info, Http::STATUS_OK);
}
/**
* @NoAdminRequired
*
* Change exif data for one file
* Set the exif data for a file.
*
* @param string fileid
*/
public function edit(string $id): JSONResponse
public function setExif(string $id): JSONResponse
{
$user = $this->userSession->getUser();
if (null === $user) {
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
}
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
// Check for permissions and get numeric Id
$file = $userFolder->getById((int) $id);
if (0 === \count($file)) {
$file = $this->getUserFile((int) $id);
if (!$file) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
$file = $file[0];
// Check if user has permissions
if (!$file->isUpdateable()) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
// Get new date from body
$body = $this->request->getParams();
if (!isset($body['date'])) {
return new JSONResponse(['message' => 'Missing date'], Http::STATUS_BAD_REQUEST);
}
// Get original file from body
$exif = $this->request->getParam('raw');
$path = $file->getStorage()->getLocalFile($file->getInternalPath());
// Make sure the date is valid
try {
Exif::parseExifDate($body['date']);
} catch (\Exception $e) {
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
// Update date
try {
$res = Exif::updateExifDate($file, $body['date']);
if (false === $res) {
return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
}
Exif::setExif($path, $exif);
} catch (\Exception $e) {
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
}
// Update remote file if not local
if (!$file->getStorage()->isLocal()) {
$file->putContent(fopen($path, 'r')); // closes the handler
}
// Reprocess the file
$this->timelineWrite->processFile($file, true);
return $this->info($id);
return new JSONResponse([], Http::STATUS_OK);
}
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*
* Get a full resolution JPEG for editing from a file.
*/
public function jpeg(string $id)
{
$file = $this->getUserFile((int) $id);
if (!$file) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
// check if valid image
$mimetype = $file->getMimeType();
if (!\in_array($mimetype, Application::IMAGE_MIMES, true)) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
// Get the image
$path = $file->getStorage()->getLocalFile($file->getInternalPath());
$image = new \Imagick($path);
$image->setImageFormat('jpeg');
$image->setImageCompressionQuality(95);
$blob = $image->getImageBlob();
// Return the image
$response = new Http\DataDisplayResponse($blob, Http::STATUS_OK, ['Content-Type' => $image->getImageMimeType()]);
$response->cacheFor(3600 * 24, false, false);
return $response;
}
}

View File

@ -103,6 +103,9 @@ class PageController extends Controller
$policy->addAllowedScriptDomain('blob:');
$policy->addAllowedMediaDomain('blob:');
// Image editor
$policy->addAllowedConnectDomain('data:');
// Allow nominatim for metadata
$policy->addAllowedConnectDomain('nominatim.openstreetmap.org');
$policy->addAllowedFrameDomain('www.openstreetmap.org');

View File

@ -36,12 +36,9 @@ class VideoController extends ApiBase
*
* Transcode a video to HLS by proxy
*
* @param string fileid
* @param string video profile
*
* @return JSONResponse an empty JSONResponse with respective http status code
*/
public function transcode(string $fileid, string $profile): Http\Response
public function transcode(string $client, string $fileid, string $profile): Http\Response
{
$user = $this->userSession->getUser();
if (null === $user) {
@ -53,6 +50,11 @@ class VideoController extends ApiBase
return new JSONResponse(['message' => 'Transcoding disabled'], Http::STATUS_FORBIDDEN);
}
// Check client identifier is 8 characters or more
if (\strlen($client) < 8) {
return new JSONResponse(['message' => 'Invalid client identifier'], Http::STATUS_BAD_REQUEST);
}
// Get file
$files = $this->rootFolder->getUserFolder($user->getUID())->getById($fileid);
if (0 === \count($files)) {
@ -78,14 +80,13 @@ class VideoController extends ApiBase
}
// Make upstream request
[$data, $contentType, $returnCode] = $this->getUpstream($path, $profile);
[$data, $contentType, $returnCode] = $this->getUpstream($client, $path, $profile);
// If status code was 0, it's likely the server is down
// Make one attempt to start if we can't find the process
if (0 === $returnCode) {
$transcoder = $this->config->getSystemValue('memories.transcoder', false);
$tConfig = $this->config->getSystemValue('memories.transcoder_config', false);
if (!$transcoder || !$tConfig) {
if (!$transcoder) {
return new JSONResponse(['message' => 'Transcoder not configured'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
@ -97,15 +98,12 @@ class VideoController extends ApiBase
}
// Check if already running
exec('ps a | grep go-transcode | grep -v grep', $procs);
if (0 === \count($procs)) {
shell_exec("mkdir -p {$tmpDir}/transcoder"); // php func has some weird problems
shell_exec("{$env} nohup {$transcoder} serve --config {$tConfig} > {$tmpDir}/transcoder/run.log 2>&1 & > /dev/null");
}
shell_exec("pkill {$transcoder}");
shell_exec("{$env} nohup {$transcoder} > {$tmpDir}/go-vod.log 2>&1 & > /dev/null");
// wait for 2s and try again
sleep(2);
[$data, $contentType, $returnCode] = $this->getUpstream($path, $profile);
// wait for 1s and try again
sleep(1);
[$data, $contentType, $returnCode] = $this->getUpstream($client, $path, $profile);
}
// Check data was received
@ -117,14 +115,15 @@ class VideoController extends ApiBase
$response = new DataDisplayResponse($data, Http::STATUS_OK, [
'Content-Type' => $contentType,
]);
$response->cacheFor(3600 * 24, false, false);
$response->cacheFor(0, false, false);
return $response;
}
private function getUpstream($path, $profile)
private function getUpstream($client, $path, $profile)
{
$ch = curl_init("http://localhost:47788/vod/{$path}/{$profile}");
$path = rawurlencode($path);
$ch = curl_init("http://127.0.0.1:47788/{$client}{$path}/{$profile}");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
curl_setopt($ch, CURLOPT_HEADER, 0);

View File

@ -102,7 +102,7 @@ trait TimelineQueryDays
// We don't actually use m.datetaken here, but postgres
// needs that all fields in ORDER BY are also in SELECT
// when using DISTINCT on selected fields
$query->select($fileid, 'm.isvideo', 'm.datetaken', 'm.dayid', 'm.w', 'm.h')
$query->select($fileid, 'm.isvideo', 'm.video_duration', 'm.datetaken', 'm.dayid', 'm.w', 'm.h')
->from('memories', 'm')
;
@ -198,11 +198,12 @@ trait TimelineQueryDays
// Convert field types
$row['fileid'] = (int) $row['fileid'];
$row['isvideo'] = (int) $row['isvideo'];
$row['video_duration'] = (int) $row['video_duration'];
$row['dayid'] = (int) $row['dayid'];
$row['w'] = (int) $row['w'];
$row['h'] = (int) $row['h'];
if (!$row['isvideo']) {
unset($row['isvideo']);
unset($row['isvideo'], $row['video_duration']);
}
if ($row['categoryid']) {
$row['isfavorite'] = 1;

View File

@ -97,9 +97,11 @@ class TimelineWrite
$dateTaken = gmdate('Y-m-d H:i:s', $dateTaken);
[$w, $h] = Exif::getDimensions($exif);
// Store raw metadata in the database
// We need to remove blacklisted fields to prevent leaking info
unset($exif['SourceFile'], $exif['FileName'], $exif['ExifToolVersion'], $exif['Directory'], $exif['FileSize'], $exif['FileModifyDate'], $exif['FileAccessDate'], $exif['FileInodeChangeDate'], $exif['FilePermissions']);
// Video parameters
$videoDuration = 0;
if ($isvideo) {
$videoDuration = round($exif['Duration'] ?? $exif['TrackDuration'] ?? 0);
}
// Truncate any fields >2048 chars
foreach ($exif as $key => &$value) {
@ -124,6 +126,7 @@ class TimelineWrite
->set('datetaken', $query->createNamedParameter($dateTaken, IQueryBuilder::PARAM_STR))
->set('mtime', $query->createNamedParameter($mtime, IQueryBuilder::PARAM_INT))
->set('isvideo', $query->createNamedParameter($isvideo, IQueryBuilder::PARAM_INT))
->set('video_duration', $query->createNamedParameter($videoDuration, IQueryBuilder::PARAM_INT))
->set('w', $query->createNamedParameter($w, IQueryBuilder::PARAM_INT))
->set('h', $query->createNamedParameter($h, IQueryBuilder::PARAM_INT))
->set('exif', $query->createNamedParameter($exifJson, IQueryBuilder::PARAM_STR))
@ -141,6 +144,7 @@ class TimelineWrite
'datetaken' => $query->createNamedParameter($dateTaken, IQueryBuilder::PARAM_STR),
'mtime' => $query->createNamedParameter($mtime, IQueryBuilder::PARAM_INT),
'isvideo' => $query->createNamedParameter($isvideo, IQueryBuilder::PARAM_INT),
'video_duration' => $query->createNamedParameter($videoDuration, IQueryBuilder::PARAM_INT),
'w' => $query->createNamedParameter($w, IQueryBuilder::PARAM_INT),
'h' => $query->createNamedParameter($h, IQueryBuilder::PARAM_INT),
'exif' => $query->createNamedParameter($exifJson, IQueryBuilder::PARAM_STR),

View File

@ -109,7 +109,12 @@ class Exif
throw new \Exception('Failed to get local file path');
}
return self::getExifFromLocalPath($path);
$exif = self::getExifFromLocalPath($path);
// We need to remove blacklisted fields to prevent leaking info
unset($exif['SourceFile'], $exif['FileName'], $exif['ExifToolVersion'], $exif['Directory'], $exif['FileSize'], $exif['FileModifyDate'], $exif['FileAccessDate'], $exif['FileInodeChangeDate'], $exif['FilePermissions'], $exif['ThumbnailImage']);
return $exif;
}
/** Get exif data as a JSON object from a local file path */
@ -225,29 +230,35 @@ class Exif
}
/**
* Update exif date using exiftool.
* Set exif data using raw json.
*
* @param string $newDate formatted in standard Exif format (YYYY:MM:DD HH:MM:SS)
* @param string $path to local file
* @param array $data exif data
*
* @throws \Exception on failure
*/
public static function updateExifDate(File &$file, string $newDate)
public static function setExif(string &$path, array &$data)
{
// Don't want to mess these up, definitely
if ($file->isEncrypted()) {
throw new \Exception('Cannot update exif date on encrypted files');
}
$data['SourceFile'] = $path;
$raw = json_encode([$data]);
$cmd = array_merge(self::getExiftool(), ['-json=-', $path]);
$proc = proc_open($cmd, [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);
// Get path to local (copy) of the file
$path = $file->getStorage()->getLocalFile($file->getInternalPath());
if (!\is_string($path)) {
throw new \Exception('Failed to get local file path');
}
fwrite($pipes[0], $raw);
fclose($pipes[0]);
// Update exif data
self::updateExifDateForLocalFile($path, $newDate);
$stdout = self::readOrTimeout($pipes[1], 30000);
fclose($pipes[1]);
fclose($pipes[2]);
proc_terminate($proc);
if (false !== strpos($stdout, 'error')) {
error_log("Exiftool error: {$stdout}");
// Update remote file if not local
if (!$file->getStorage()->isLocal()) {
$file->putContent(fopen($path, 'r')); // closes the handler
throw new \Exception('Could not set exif data: '.$stdout);
}
}
@ -357,7 +368,7 @@ class Exif
private static function getExifFromLocalPathWithStaticProc(string &$path)
{
fwrite(self::$staticPipes[0], "{$path}\n-json\n-api\nQuickTimeUTC=1\n-n\n-execute\n");
fwrite(self::$staticPipes[0], "{$path}\n-json\n-b\n-api\nQuickTimeUTC=1\n-n\n-execute\n");
fflush(self::$staticPipes[0]);
$readyToken = "\n{ready}\n";
@ -379,7 +390,7 @@ class Exif
private static function getExifFromLocalPathWithSeparateProc(string &$path)
{
$pipes = [];
$proc = proc_open(array_merge(self::getExiftool(), ['-api', 'QuickTimeUTC=1', '-n', '-json', $path]), [
$proc = proc_open(array_merge(self::getExiftool(), ['-api', 'QuickTimeUTC=1', '-n', '-json', '-b', $path]), [
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);
@ -410,29 +421,4 @@ class Exif
return $json[0];
}
/**
* Update exif date using exiftool for a local file.
*
* @param string $newDate formatted in standard Exif format (YYYY:MM:DD HH:MM:SS)
*
* @throws \Exception on failure
*/
private static function updateExifDateForLocalFile(string $path, string $newDate)
{
$cmd = array_merge(self::getExiftool(), ['-api', 'QuickTimeUTC=1', '-overwrite_original', '-DateTimeOriginal='.$newDate, $path]);
$proc = proc_open($cmd, [
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);
$stdout = self::readOrTimeout($pipes[1], 300000);
fclose($pipes[1]);
fclose($pipes[2]);
proc_terminate($proc);
if (false !== strpos($stdout, 'error')) {
error_log("Exiftool error: {$stdout}");
throw new \Exception('Could not update exif date: '.$stdout);
}
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Your name <your@email.com>
* @author Your name <your@email.com>
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace OCA\Memories\Migration;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Auto-generated migration step: Please modify to your needs!
*/
class Version400700Date20221110030909 extends SimpleMigrationStep
{
/**
* @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
*/
public function preSchemaChange(IOutput $output, \Closure $schemaClosure, array $options): void
{
}
/**
* @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
*/
public function changeSchema(IOutput $output, \Closure $schemaClosure, array $options): ?ISchemaWrapper
{
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$table = $schema->getTable('memories');
$table->addColumn('video_duration', Types::INTEGER, [
'notnull' => true,
'default' => 0,
]);
return $schema;
}
/**
* @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
*/
public function postSchemaChange(IOutput $output, \Closure $schemaClosure, array $options): void
{
}
}

View File

@ -17,11 +17,9 @@ mv "exiftool-$exifver" exiftool
rm -rf *.zip exiftool/t exiftool/html
chmod 755 exiftool/exiftool
gotranscode="v0.0.2"
wget -q "https://github.com/pulsejet/go-transcode/releases/download/$gotranscode/go-transcode-amd64-musl"
wget -q "https://github.com/pulsejet/go-transcode/releases/download/$gotranscode/go-transcode-amd64-glibc"
wget -q "https://github.com/pulsejet/go-transcode/releases/download/$gotranscode/go-transcode-aarch64-musl"
wget -q "https://github.com/pulsejet/go-transcode/releases/download/$gotranscode/go-transcode-aarch64-glibc"
chmod 755 go-transcode-*
govod="0.0.3"
wget -q "https://github.com/pulsejet/go-vod/releases/download/$govod/go-vod-amd64"
wget -q "https://github.com/pulsejet/go-vod/releases/download/$govod/go-vod-aarch64"
chmod 755 go-vod-*
cd ..

View File

@ -9,7 +9,9 @@ import GlobalMixin from "../mixins/GlobalMixin";
import { basename, dirname, extname, join } from "path";
import { emit } from "@nextcloud/event-bus";
import { showError, showSuccess } from "@nextcloud/dialogs";
import { generateUrl } from "@nextcloud/router";
import axios from "@nextcloud/axios";
import FilerobotImageEditor from "filerobot-image-editor";
import { FilerobotImageEditorConfig } from "react-filerobot-image-editor";
@ -25,11 +27,22 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
@Prop() mime: string;
@Prop() src: string;
private exif: any = null;
private imageEditor: FilerobotImageEditor = null;
get config(): FilerobotImageEditorConfig & { theme: any } {
let src: string;
if (["image/png", "image/jpeg", "image/webp"].includes(this.mime)) {
src = this.src;
} else {
src = generateUrl("/apps/memories/api/image/jpeg/{fileid}", {
fileid: this.fileid,
});
}
return {
source: this.src,
source: src,
defaultSavedImageName: this.defaultSavedImageName,
defaultSavedImageType: this.defaultSavedImageType,
@ -84,8 +97,8 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
},
},
savingPixelRatio: 1,
previewPixelRatio: 1,
savingPixelRatio: 8,
previewPixelRatio: window.devicePixelRatio,
};
}
@ -117,14 +130,32 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
};
}
mounted() {
async mounted() {
this.imageEditor = new FilerobotImageEditor(
<any>this.$refs.editor,
<any>this.config
);
this.imageEditor.render();
window.addEventListener("keydown", this.handleKeydown, true);
window.addEventListener("DOMNodeInserted", this.handleSfxModal);
// Get latest exif data
try {
const res = await axios.get(
generateUrl("/apps/memories/api/image/info/{id}?basic=1&current=1", {
id: this.fileid,
})
);
this.exif = res.data?.current;
if (!this.exif) {
throw new Error("No exif data");
}
} catch (err) {
console.error(err);
alert(
this.t("memories", "Failed to get Exif data. Metadata may be lost!")
);
}
}
beforeDestroy() {
@ -147,46 +178,64 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
* User saved the image
*
* @see https://github.com/scaleflex/filerobot-image-editor#onsave
* @param {object} props destructuring object
* @param {string} props.fullName the file name
* @param {HTMLCanvasElement} props.imageCanvas the image canvas
* @param {string} props.mimeType the image mime type
* @param {number} props.quality the image saving quality
*/
async onSave({
fullName,
imageCanvas,
mimeType,
quality,
imageBase64,
}: {
fullName?: string;
imageCanvas?: HTMLCanvasElement;
mimeType?: string;
quality?: number;
imageBase64?: string;
}): Promise<void> {
if (!imageBase64) {
throw new Error("No image data");
}
const { origin, pathname } = new URL(this.src);
const putUrl = origin + join(dirname(pathname), fullName);
// toBlob is not very smart...
mimeType = mimeType.replace("jpg", "jpeg");
// Sanity check, 0 < quality < 1
quality = Math.max(Math.min(quality, 1), 0) || 1;
if (
!this.exif &&
!confirm(this.t("memories", "No Exif data found! Continue?"))
) {
return;
}
try {
const blob = await new Promise((resolve: BlobCallback) =>
imageCanvas.toBlob(resolve, mimeType, quality)
);
const blob = await fetch(imageBase64).then((res) => res.blob());
const response = await axios.put(putUrl, new File([blob], fullName));
const fileid =
parseInt(response?.headers?.["oc-fileid"]?.split("oc")[0]) || null;
if (response.status >= 400) {
throw new Error("Failed to save image");
}
// Strip old and incorrect exif data
const exif = this.exif;
delete exif.Orientation;
delete exif.Rotation;
delete exif.ImageHeight;
delete exif.ImageWidth;
delete exif.ImageSize;
delete exif.ModifyDate;
delete exif.ExifImageHeight;
delete exif.ExifImageWidth;
delete exif.ExifImageSize;
// Update exif data
await axios.patch(
generateUrl("/apps/memories/api/image/set-exif/{id}", {
id: fileid,
}),
{
raw: exif,
}
);
showSuccess(this.t("memories", "Image saved successfully"));
if (putUrl !== this.src) {
emit("files:file:created", {
fileid:
parseInt(response?.headers?.["oc-fileid"]?.split("oc")[0]) || null,
});
if (fileid !== this.fileid) {
emit("files:file:created", { fileid });
} else {
emit("files:file:updated", { fileid: this.fileid });
emit("files:file:updated", { fileid });
}
this.onClose(undefined, false);
} catch (error) {
@ -247,21 +296,6 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
).click();
}
}
/**
* Watch out for Modal inject in document root
* That way we can adjust the focusTrap
*
* @param {Event} event Dom insertion event
*/
handleSfxModal(event) {
if (
event.target?.classList &&
event.target.classList.contains("SfxModal-Wrapper")
) {
emit("viewer:trapElements:changed", event.target);
}
}
}
</script>
@ -590,4 +624,9 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
filter: var(--background-invert-if-dark);
}
.FIE_carousel-prev-button,
.FIE_carousel-next-button {
background: none !important;
}
</style>

View File

@ -87,7 +87,7 @@ export default class Metadata extends Mixins(GlobalMixin) {
let state = this.state;
const res = await axios.get<any>(
generateUrl("/apps/memories/api/info/{id}", { id: fileInfo.id })
generateUrl("/apps/memories/api/image/info/{id}", { id: fileInfo.id })
);
if (state !== this.state) return;
@ -163,7 +163,7 @@ export default class Metadata extends Mixins(GlobalMixin) {
const dt = this.exif["DateTimeOriginal"] || this.exif["CreateDate"];
if (!dt) return null;
const m = moment(dt, "YYYY:MM:DD HH:mm:ss");
const m = moment.utc(dt, "YYYY:MM:DD HH:mm:ss");
if (!m.isValid()) return null;
m.locale(getCanonicalLocale());
return m;
@ -193,6 +193,7 @@ export default class Metadata extends Mixins(GlobalMixin) {
const make = this.exif["Make"];
const model = this.exif["Model"];
if (!make || !model) return null;
if (model.startsWith(make)) return model;
return `${make} ${model}`;
}

View File

@ -1,6 +1,7 @@
import PhotoSwipe from "photoswipe";
import { generateUrl } from "@nextcloud/router";
import { loadState } from "@nextcloud/initial-state";
import axios from "@nextcloud/axios";
import videojs from "video.js";
import "video.js/dist/video-js.min.css";
@ -13,6 +14,10 @@ const config_noTranscode = loadState(
<string>"UNSET"
) as boolean | string;
// Generate client id for this instance
// Does not need to be cryptographically secure
const clientId = Math.random().toString(36).substring(2, 15).padEnd(12, "0");
/**
* Check if slide has video content
*
@ -62,6 +67,16 @@ class VideoContentSetup {
if (isVideoContent(slide) && this.options.preventDragOffset) {
const origEvent = e.originalEvent;
if (origEvent.type === "pointerdown") {
// Check if directly over the videojs control bar
const elems = document.elementsFromPoint(
origEvent.clientX,
origEvent.clientY
);
if (elems.some((el) => el.classList.contains("vjs-control-bar"))) {
e.preventDefault();
return;
}
const videoHeight = Math.ceil(slide.height * slide.currZoomLevel);
const verticalEnding = videoHeight + slide.bounds.center.y;
const pointerYPos = origEvent.pageY - pswp.offset.y;
@ -86,29 +101,28 @@ class VideoContentSetup {
const fileid = content.data.photo.fileid;
// Create hls sources if enabled
let hlsSources = [];
let sources: any[] = [];
const baseUrl = generateUrl(
`/apps/memories/api/video/transcode/${fileid}`
`/apps/memories/api/video/transcode/${clientId}/${fileid}`
);
if (!config_noTranscode) {
hlsSources.push({
sources.push({
src: `${baseUrl}/index.m3u8`,
type: "application/x-mpegURL",
});
}
sources.push({
src: e.slide.data.src,
});
const overrideNative = !videojs.browser.IS_SAFARI;
content.videojs = videojs(content.videoElement, {
fluid: true,
fill: true,
autoplay: true,
controls: true,
sources: [
...hlsSources,
{
src: e.slide.data.src,
},
],
sources: sources,
preload: "metadata",
playbackRates: [0.5, 1, 1.5, 2],
responsive: true,
@ -122,16 +136,15 @@ class VideoContentSetup {
},
});
content.videojs.on("error", function () {
if (this.error().code === 4) {
if (this.src().includes("m3u8")) {
content.videojs.on("error", () => {
if (content.videojs.error().code === 4) {
if (content.videojs.src().includes("m3u8")) {
// HLS could not be streamed
console.error("Video.js: HLS stream could not be opened.");
this.src({
content.videojs.src({
src: e.slide.data.src,
});
this.options().html5.nativeAudioTracks = true;
this.options().html5.nativeVideoTracks = true;
this.updateRotation(content, 0);
}
}
});
@ -150,7 +163,17 @@ class VideoContentSetup {
});
}, 500);
globalThis.videojs = content.videojs;
// Get correct orientation
axios
.get<any>(
generateUrl("/apps/memories/api/image/info/{id}", {
id: content.data.photo.fileid,
})
)
.then((response) => {
content.data.exif = response.data?.exif;
this.updateRotation(content);
});
}
}
});
@ -172,6 +195,28 @@ class VideoContentSetup {
});
}
updateRotation(content, val?: number) {
const rotation = val ?? Number(content.data.exif?.Rotation);
const shouldRotate = content.videojs?.src().includes("m3u8");
if (rotation && shouldRotate) {
let transform = `rotate(${rotation}deg)`;
if (rotation === 90 || rotation === 270) {
content.videoElement.style.width = content.element.style.height;
content.videoElement.style.height = content.element.style.width;
transform = `translateY(-${content.element.style.width}) ${transform}`;
content.videoElement.style.transformOrigin = "bottom left";
}
content.videoElement.style.transform = transform;
} else {
content.videoElement.style.transform = "none";
content.videoElement.style.width = "100%";
content.videoElement.style.height = "100%";
}
}
onContentDestroy({ content }) {
if (isVideoContent(content)) {
if (content._videoPosterImg) {
@ -206,6 +251,8 @@ class VideoContentSetup {
placeholderElStyle.width = width + "px";
placeholderElStyle.height = height + "px";
}
this.updateRotation(content);
}
}

View File

@ -286,11 +286,13 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
beforeDestroy() {
unsubscribe(this.config_eventName, this.softRefresh);
unsubscribe("files:file:created", this.softRefresh);
this.resetState();
}
created() {
subscribe(this.config_eventName, this.softRefresh);
subscribe("files:file:created", this.softRefresh);
window.addEventListener("resize", this.handleResizeWithDelay);
}

View File

@ -229,7 +229,6 @@ export default class Viewer extends Mixins(GlobalMixin) {
/** Event on file changed */
handleFileUpdated({ fileid }: { fileid: number }) {
console.log("file updated", fileid);
if (this.currentPhoto && this.currentPhoto.fileid === fileid) {
this.currentPhoto.etag += "_";
this.photoswipe.refreshSlideContent(this.currIndex);
@ -470,7 +469,8 @@ export default class Viewer extends Mixins(GlobalMixin) {
// Get the thumbnail image
this.photoswipe.addFilter("thumbEl", (thumbEl, data, index) => {
const photo = this.list[index - this.globalAnchor];
if (!photo || photo.flag & this.c.FLAG_IS_VIDEO) return thumbEl;
if (!photo || !photo.w || !photo.h || photo.flag & this.c.FLAG_IS_VIDEO)
return thumbEl;
return this.thumbElem(photo) || thumbEl;
});
@ -586,7 +586,7 @@ export default class Viewer extends Mixins(GlobalMixin) {
}
get canEdit() {
return ["image/jpeg", "image/png"].includes(this.currentPhoto?.mimetype);
return this.currentPhoto?.mimetype?.startsWith("image/");
}
private openEditor() {
@ -840,6 +840,10 @@ export default class Viewer extends Mixins(GlobalMixin) {
width: 100%;
}
img.pswp__img {
object-fit: contain;
}
.pswp__button {
color: white;

View File

@ -16,7 +16,13 @@
@click="toggleSelect"
/>
<Video :size="22" v-if="data.flag & c.FLAG_IS_VIDEO" />
<div class="video" v-if="data.flag & c.FLAG_IS_VIDEO">
<span v-if="data.video_duration" class="time">
{{ videoDuration }}
</span>
<Video :size="22" />
</div>
<Star :size="22" v-if="data.flag & c.FLAG_IS_FAVORITE" />
<div
@ -43,14 +49,17 @@
</template>
<script lang="ts">
import { Component, Emit, Mixins, Prop, Watch } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import { getPreviewUrl } from "../../services/FileUtils";
import { IDay, IPhoto } from "../../types";
import * as utils from "../../services/Utils";
import errorsvg from "../../assets/error.svg";
import CheckCircle from "vue-material-design-icons/CheckCircle.vue";
import Star from "vue-material-design-icons/Star.vue";
import Video from "vue-material-design-icons/PlayCircleOutline.vue";
import { Component, Emit, Mixins, Prop, Watch } from "vue-property-decorator";
import errorsvg from "../../assets/error.svg";
import GlobalMixin from "../../mixins/GlobalMixin";
import { getPreviewUrl } from "../../services/FileUtils";
import { IDay, IPhoto } from "../../types";
@Component({
components: {
@ -91,6 +100,13 @@ export default class Photo extends Mixins(GlobalMixin) {
this.refresh();
}
get videoDuration() {
if (this.data.video_duration) {
return utils.getDurationStr(this.data.video_duration);
}
return null;
}
async refresh() {
this.src = await this.getSrc();
}
@ -274,7 +290,7 @@ $icon-size: $icon-half-size * 2;
color: var(--color-primary);
}
}
.play-circle-outline-icon,
.video,
.star-icon {
position: absolute;
z-index: 100;
@ -282,12 +298,23 @@ $icon-size: $icon-half-size * 2;
transition: transform 0.15s ease;
filter: invert(1) brightness(100);
}
.play-circle-outline-icon {
.video {
position: absolute;
top: var(--icon-dist);
right: var(--icon-dist);
.p-outer.selected > & {
transform: translate(-$icon-size, $icon-size);
}
display: flex;
align-items: center;
justify-content: center;
.time {
font-size: 0.75em;
font-weight: bold;
margin-right: 3px;
}
}
.star-icon {
bottom: var(--icon-dist);

View File

@ -143,8 +143,8 @@ import axios from "@nextcloud/axios";
import * as utils from "../../services/Utils";
import * as dav from "../../services/DavRequests";
const INFO_API_URL = "/apps/memories/api/info/{id}";
const EDIT_API_URL = "/apps/memories/api/edit/{id}";
const INFO_API_URL = "/apps/memories/api/image/info/{id}";
const EDIT_API_URL = "/apps/memories/api/image/set-exif/{id}";
@Component({
components: {
@ -269,12 +269,11 @@ export default class EditDate extends Mixins(GlobalMixin) {
try {
this.processing = true;
const fileid = this.photos[0].fileid;
const res = await axios.patch<any>(
generateUrl(EDIT_API_URL, { id: fileid }),
{
date: this.getExifFormat(this.getDate()),
}
);
await axios.patch<any>(generateUrl(EDIT_API_URL, { id: fileid }), {
raw: {
DateTimeOriginal: this.getExifFormat(this.getDate()),
},
});
emit("files:file:updated", { fileid });
this.emitRefresh(true);
this.close();

View File

@ -69,6 +69,26 @@ export function getFromNowStr(date: Date) {
return text.charAt(0).toUpperCase() + text.slice(1);
}
/** Convert number of seconds to time string */
export function getDurationStr(sec: number) {
let hours = Math.floor(sec / 3600);
let minutes: number | string = Math.floor((sec - hours * 3600) / 60);
let seconds: number | string = sec - hours * 3600 - minutes * 60;
if (seconds < 10) {
seconds = "0" + seconds;
}
if (hours > 0) {
if (minutes < 10) {
minutes = "0" + minutes;
}
return `${hours}:${minutes}:${seconds}`;
}
return `${minutes}:${seconds}`;
}
/**
* Returns a hash code from a string
* @param {String} str The string to hash.

View File

@ -77,6 +77,8 @@ export type IPhoto = {
/** Video flag from server */
isvideo?: boolean;
/** Video duration from server */
video_duration?: number;
/** Favorite flag from server */
isfavorite?: boolean;
/** Is this a folder */

View File

@ -1,59 +0,0 @@
# allow debug outputs
debug: false
# mount debug pprof endpoint at /debug/pprof/
pprof: false
# bind server to IP:PORT (use :47788 for all connections)
# DO NOT expose this port to the world
bind: localhost:47788
# X-Forwarded-For headers will be used to determine the client IP
proxy: true
# For static files
vod:
# Root directory for media
media-dir: /
# Temporary transcode output directory, if empty, default tmp folder will be used
transcode-dir: /tmp/transcoder/data
# Available video profiles
# Do not change these
video-profiles:
360p:
width: 640 # px
height: 360 # px
bitrate: 800 # kbps
480p:
width: 640
height: 480
bitrate: 1200
720p:
width: 1280
height: 720
bitrate: 2800
1080p:
width: 1920
height: 1080
bitrate: 5000
# Use video keyframes as existing reference for chunks split
# Using this might cause long probing times in order to get
# all keyframes - therefore they should be cached
video-keyframes: false
# Single audio profile used
audio-profile:
bitrate: 192 # kbps
# If cache is enabled
cache: true
# If dir is empty, cache will be stored in the same directory as media source
# If not empty, cache files will be saved to specified directory
cache-dir: /tmp/transcoder/cache
# OPTIONAL: Use custom ffmpeg & ffprobe binary paths
ffmpeg-binary: ffmpeg
ffprobe-binary: ffprobe