Merge branch 'master' into stable24

old_stable24
Varun Patil 2023-02-26 12:36:58 -08:00
commit 04505e48c6
35 changed files with 485 additions and 197 deletions

View File

@ -2,7 +2,7 @@
This file is manually updated. Please file an issue if something is missing.
## v4.11.0, v3.11.0 (unreleased)
## v4.11.0, v3.11.0 (2023-02-10)
- **Feature**: Show map of photos ([#396](https://github.com/pulsejet/memories/pull/396))
To index existing images, you must run `occ memories:index -f`

View File

@ -23,24 +23,18 @@ Memories is a _batteries-included_ photo management solution for Nextcloud with
- **🗺️ Map**: View your photos on a map, tagged with accurate reverse geocoding.
- **⚡️ Performance**: Memories is very fast.
## 🌐 Online Demo
- To get an idea of what memories looks and feels like, check out the [public demo](https://memories-demo.radialapps.com/apps/memories/).
- The demo is read-only and may be slow (free tier VM from [Oracle Cloud](https://www.oracle.com/cloud/free/)).
- Photo credits go to [Unsplash](https://unsplash.com/) (for individual credits, refer to each folder).
## 🚀 Installation
1. Install the app from the Nextcloud app store.
1. Perform the recommended [configuration steps](https://github.com/pulsejet/memories/wiki/Configuration).
1. Run `php ./occ memories:index` to generate metadata indices for existing photos.
1. Run `php occ memories:index` to generate metadata indices for existing photos.
1. Open the 📷 Memories app in Nextcloud and set the directory containing your photos.
## 🏗 Development Setup
1. ☁ Clone this into your `apps` folder of your Nextcloud.
1. 👩‍💻 In a terminal, run the command `make dev-setup` to install the dependencies.
1. 🏗 To build the Typescript, run `make build-js`. Watch changes with: `make watch-js`. Lint-fix PHP with `make php-lint`.
1. 🏗 To build/watch the UI, run `make watch-js`. Lint-fix PHP with `make php-lint`.
1. ✅ Enable the app through the app management of your Nextcloud.
1. ⚒️ (Strongly recommended) use VS Code and install Vetur and Prettier.

View File

@ -5,7 +5,7 @@
<name>Memories</name>
<summary>Fast, modern and advanced photo management suite</summary>
<description><![CDATA[
# Memories
# Memories: Photo Management for Nextcloud
Memories is a *batteries-included* photo management solution for Nextcloud with advanced features including:
@ -21,17 +21,11 @@ Memories is a *batteries-included* photo management solution for Nextcloud with
- **🗺️ Map**: View your photos on a map, tagged with accurate reverse geocoding.
- **⚡️ Performance**: Memories is very fast.
## 🌐 Online Demo
- To get an idea of what memories looks and feels like, check out the [public demo](https://memories-demo.radialapps.com/apps/memories/).
- The demo is read-only and may be slow (free tier VM from [Oracle Cloud](https://www.oracle.com/cloud/free/)).
- Photo credits go to [Unsplash](https://unsplash.com/) (for individual credits, refer to each folder).
## 🚀 Installation
1. Install the app from the Nextcloud app store.
1. Install the app from the Nextcloud app store (try a demo [here](https://memories-demo.radialapps.com/apps/memories/)).
1. Perform the recommended [configuration steps](https://github.com/pulsejet/memories/wiki/Extra-Configuration).
1. Run `php ./occ memories:index` to generate metadata indices for existing photos.
1. Run `php occ memories:index` to generate metadata indices for existing photos.
1. Open the 📷 Memories app in Nextcloud and set the directory containing your photos.
]]></description>
<version>3.11.0</version>

View File

@ -5,13 +5,13 @@ test.beforeEach(login("/folders"));
test.describe("Open", () => {
test("Look for Folders", async ({ page }) => {
expect(await page.locator(".big-icon").count(), "Number of folders").toBe(
2
);
const ct = await page.locator(".big-icon").count();
expect(ct, "Number of folders").toBe(2);
});
test("Open folder", async ({ page }) => {
await page.locator("text=Local").click();
await page.waitForSelector('img[src*="api/image/preview"]');
await page.waitForTimeout(2000);
await page.waitForSelector("img.ximg");
});
});

View File

@ -13,6 +13,6 @@ export function login(route: string) {
await expect(page).toHaveURL(
"http://localhost:8080/index.php/apps/memories" + route
);
await page.waitForSelector('img[src*="api/image/preview"]');
await page.waitForSelector("img.ximg");
};
}

View File

@ -6,7 +6,7 @@ test.beforeEach(login("/"));
test.describe("Open", () => {
test("Look for Images", async ({ page }) => {
expect(
await page.locator('img[src*="api/image/preview"]').count(),
await page.locator("img.ximg").count(),
"Number of previews"
).toBeGreaterThan(4);
await page.waitForTimeout(1000);
@ -22,6 +22,8 @@ test.describe("Open", () => {
});
test("Select two images and delete", async ({ page }) => {
await page.waitForTimeout(4000);
const i1 = "div:nth-child(2) > div:nth-child(1) > .p-outer";
const i2 = "div:nth-child(2) > div:nth-child(2) > .p-outer";
@ -43,16 +45,7 @@ test.describe("Open", () => {
await page.waitForTimeout(1000);
await page.locator('[aria-label="Delete"]').click();
await page.waitForTimeout(4000);
expect(await page.locator(`img[src="${src1}"]`).count()).toBe(0);
expect(await page.locator(`img[src="${src2}"]`).count()).toBe(0);
// refresh page
await page.reload();
await page.waitForTimeout(4000); // cache
await page.reload(); // prevent stale cache issues
await page.waitForTimeout(4000); // cache
await page.waitForSelector('img[src*="api/image/preview"]');
await page.waitForTimeout(2000);
expect(await page.locator(`img[src="${src1}"]`).count()).toBe(0);
expect(await page.locator(`img[src="${src2}"]`).count()).toBe(0);
});

View File

@ -168,6 +168,9 @@ class PlacesSetup extends Command
$sql = str_replace('*PREFIX*memories_planet_geometry', 'memories_planet_geometry', $query->getSQL());
$insertGeometry = $this->connection->prepare($sql);
// The number of places in the current transaction
$txnCount = 0;
// Iterate over the data file
$handle = fopen($datafile, 'r');
if ($handle) {
@ -177,6 +180,11 @@ class PlacesSetup extends Command
if ('' === trim($line)) {
continue;
}
// Begin transaction
if (0 === $txnCount++) {
$this->connection->beginTransaction();
}
++$count;
// Decode JSON
@ -228,7 +236,7 @@ class PlacesSetup extends Command
}
if (GIS_TYPE_MYSQL === $this->gisType) {
$points = implode(',', array_map(function (&$point) {
$points = implode(',', array_map(function ($point) {
$x = $point[0];
$y = $point[1];
@ -237,7 +245,7 @@ class PlacesSetup extends Command
$geometry = "POLYGON(({$points}))";
} elseif (GIS_TYPE_POSTGRES === $this->gisType) {
$geometry = implode(',', array_map(function (&$point) {
$geometry = implode(',', array_map(function ($point) {
$x = $point[0];
$y = $point[1];
@ -260,10 +268,14 @@ class PlacesSetup extends Command
}
}
// Print progress
// Commit transaction every once in a while
if (0 === $count % 500) {
$this->connection->commit();
$txnCount = 0;
// Print progress
$end = time();
$elapsed = $end - $start;
$elapsed = ($end - $start) ?: 1;
$rate = $count / $elapsed;
$remaining = APPROX_PLACES - $count;
$eta = round($remaining / $rate);
@ -275,6 +287,11 @@ class PlacesSetup extends Command
fclose($handle);
}
// Commit final transaction
if ($txnCount > 0) {
$this->connection->commit();
}
// Delete file
unlink($datafile);
@ -292,30 +309,46 @@ class PlacesSetup extends Command
protected function detectGisType()
{
// Make sure database prefix is set
$prefix = $this->config->getSystemValue('dbtableprefix', '') ?: '';
if ('' === $prefix) {
$this->output->writeln('<error>Database table prefix is not set</error>');
$this->output->writeln('Custom database extensions cannot be used without a prefix');
$this->output->writeln('Reverse geocoding will not work and is disabled');
$this->gisType = GIS_TYPE_NONE;
return;
}
// Warn the admin about the database prefix not being used
$this->output->writeln('');
$this->output->writeln("Database table prefix is set to '{$prefix}'");
$this->output->writeln('If the planet can be imported, it will not use this prefix');
$this->output->writeln('The table will be named "memories_planet_geometry"');
$this->output->writeln('This is necessary for using custom database extensions');
$this->output->writeln('');
// Detect database type
$platform = strtolower(\get_class($this->connection->getDatabasePlatform()));
// Test MySQL-like support in databse
try {
$res = $this->connection->executeQuery("SELECT ST_GeomFromText('POINT(1 1)')")->fetch();
if (0 === \count($res)) {
throw new \Exception('Invalid result');
}
$this->output->writeln('MySQL-like support detected!');
if (str_contains($platform, 'mysql') || str_contains($platform, 'mariadb')) {
try {
$res = $this->connection->executeQuery("SELECT ST_GeomFromText('POINT(1 1)')")->fetch();
if (0 === \count($res)) {
throw new \Exception('Invalid result');
}
$this->output->writeln('MySQL-like support detected!');
$this->gisType = GIS_TYPE_MYSQL;
// Make sure this is actually MySQL
$res = $this->connection->executeQuery('SELECT VERSION()')->fetch();
if (0 === \count($res)) {
throw new \Exception('Invalid result');
return;
} catch (\Exception $e) {
$this->output->writeln('No MySQL-like support detected');
}
if (false === strpos($res['VERSION()'], 'MariaDB') && false === strpos($res['VERSION()'], 'MySQL')) {
throw new \Exception('MySQL not detected');
}
$this->gisType = GIS_TYPE_MYSQL;
} catch (\Exception $e) {
$this->output->writeln('No MySQL-like support detected');
}
// Test Postgres native geometry like support in database
if (GIS_TYPE_NONE === $this->gisType) {
if (str_contains($platform, 'postgres')) {
try {
$res = $this->connection->executeQuery("SELECT POINT('1,1')")->fetch();
if (0 === \count($res)) {
@ -323,6 +356,8 @@ class PlacesSetup extends Command
}
$this->output->writeln('Postgres native geometry support detected!');
$this->gisType = GIS_TYPE_POSTGRES;
return;
} catch (\Exception $e) {
$this->output->writeln('No Postgres native geometry support detected');
}
@ -360,6 +395,10 @@ class PlacesSetup extends Command
protected function ensureDeleted(string $filename)
{
if (!file_exists($filename)) {
return;
}
unlink($filename);
if (file_exists($filename)) {
$this->output->writeln('<error>Failed to delete data file</error>');

View File

@ -130,6 +130,8 @@ class VideoSetup extends Command
$this->config->setSystemValue('memories.no_transcode', true);
$output->writeln('<error>Transcoding and HLS are now disabled</error>');
$this->killGoVod($output, $goVodPath);
return 0;
}
@ -157,6 +159,8 @@ class VideoSetup extends Command
$this->config->setSystemValue('memories.qsv', false);
}
$this->killGoVod($output, $goVodPath);
return 0;
}
@ -183,4 +187,10 @@ class VideoSetup extends Command
return 0;
}
protected function killGoVod(OutputInterface $output, string $path): void
{
$output->writeln("\nKilling any existing go-vod processes");
\OCA\Memories\Util::pkill($path);
}
}

View File

@ -39,6 +39,7 @@ use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IRequest;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
class ApiBase extends Controller
{
@ -48,6 +49,7 @@ class ApiBase extends Controller
protected IAppManager $appManager;
protected TimelineQuery $timelineQuery;
protected IDBConnection $connection;
protected LoggerInterface $logger;
public function __construct(
IRequest $request,
@ -55,7 +57,8 @@ class ApiBase extends Controller
IUserSession $userSession,
IDBConnection $connection,
IRootFolder $rootFolder,
IAppManager $appManager
IAppManager $appManager,
LoggerInterface $logger
) {
parent::__construct(Application::APPNAME, $request);
@ -64,6 +67,7 @@ class ApiBase extends Controller
$this->connection = $connection;
$this->rootFolder = $rootFolder;
$this->appManager = $appManager;
$this->logger = $logger;
$this->timelineQuery = new TimelineQuery($connection);
}
@ -297,7 +301,7 @@ class ApiBase extends Controller
/**
* Given a list of file ids, return the first preview image possible.
*/
protected function getPreviewFromImageList(array &$list, int $quality = 512)
protected function getPreviewFromImageList(array $list, int $quality = 512)
{
// Get preview manager
$previewManager = \OC::$server->get(\OCP\IPreview::class);

View File

@ -88,6 +88,12 @@ class PageController extends Controller
// Common state
self::provideCommonInitialState($this->initialState);
// Extra translations
if (\OCA\Memories\Util::recognizeIsEnabled($this->appManager)) {
// Auto translation for tags
Util::addTranslations('recognize');
}
$response = new TemplateResponse($this->appName, 'main');
$response->setContentSecurityPolicy(self::getCSP());
$response->cacheFor(0);

View File

@ -90,7 +90,7 @@ class PlacesController extends ApiBase
shuffle($list);
// Get preview from image list
return $this->getPreviewFromImageList(array_map(static function (&$item) {
return $this->getPreviewFromImageList(array_map(function ($item) {
return (int) $item['fileid'];
}, $list));
}

View File

@ -92,7 +92,7 @@ class TagsController extends ApiBase
shuffle($list);
// Get preview from image list
return $this->getPreviewFromImageList(array_map(static function (&$item) {
return $this->getPreviewFromImageList(array_map(function ($item) {
return (int) $item['fileid'];
}, $list));
}

View File

@ -80,8 +80,20 @@ class VideoController extends ApiBase
}
// Request and check data was received
if (200 !== $this->getUpstream($client, $path, $profile)) {
return new JSONResponse(['message' => 'Transcode failed'], Http::STATUS_INTERNAL_SERVER_ERROR);
try {
$status = $this->getUpstream($client, $path, $profile);
if (409 === $status || -1 === $status) {
// Just a conflict (transcoding process changed)
return new JSONResponse(['message' => 'Conflict'], Http::STATUS_CONFLICT);
}
if (200 !== $status) {
throw new \Exception("Transcoder returned {$status}");
}
} catch (\Exception $e) {
$msg = 'Transcode failed: '.$e->getMessage();
$this->logger->error($msg, ['app' => 'memories']);
return new JSONResponse(['message' => $msg], Http::STATUS_INTERNAL_SERVER_ERROR);
}
// The response was already streamed, so we have nothing to do here
@ -115,8 +127,9 @@ class VideoController extends ApiBase
// Response data
$name = '';
$blob = null;
$mime = '';
$blob = null;
$liveVideoPath = null;
// Video is inside the file
$path = null;
@ -172,15 +185,7 @@ class VideoController extends ApiBase
$name = $liveFile->getName();
$blob = $liveFile->getContent();
$mime = $liveFile->getMimeType();
if ($transcode && !$this->config->getSystemValue('memories.no_transcode', true)) {
// Only Apple uses HEVC for now, so pass this to the transcoder
// If this is H.264 it won't get transcoded anyway
$liveVideoPath = $liveFile->getStorage()->getLocalFile($liveFile->getInternalPath());
if ($this->getUpstream($transcode, $liveVideoPath, 'max.mov')) {
exit;
}
}
$liveVideoPath = $liveFile->getStorage()->getLocalFile($liveFile->getInternalPath());
}
}
@ -189,6 +194,24 @@ class VideoController extends ApiBase
return new JSONResponse(['message' => 'Live file not found'], Http::STATUS_NOT_FOUND);
}
// Transcode video if allowed
if ($transcode && !$this->config->getSystemValue('memories.no_transcode', true)) {
// If video path not given, write to temp file
if (!$liveVideoPath) {
$liveVideoPath = tempnam(sys_get_temp_dir(), 'livevideo');
file_put_contents($liveVideoPath, $blob);
register_shutdown_function(function () use ($liveVideoPath) {
unlink($liveVideoPath);
});
}
// If this is H.264 it won't get transcoded anyway
if ($this->getUpstream($transcode, $liveVideoPath, 'max.mov')) {
exit;
}
}
// Make and send response
$response = new DataDisplayResponse($blob, Http::STATUS_OK, []);
$response->setHeaders([
@ -213,14 +236,20 @@ class VideoController extends ApiBase
// Get transcoder path
$transcoder = $this->config->getSystemValue('memories.transcoder', false);
if (!$transcoder) {
return 0;
throw new \Exception('Transcoder not configured');
}
// Make transcoder executable
if (!is_executable($transcoder)) {
@chmod($transcoder, 0755);
if (!is_executable($transcoder)) {
throw new \Exception("Transcoder not executable (chmod 755 {$transcoder})");
}
}
// Kill the transcoder in case it's running
\OCA\Memories\Util::pkill($transcoder);
// Check for environment variables
$env = [];
@ -244,28 +273,48 @@ class VideoController extends ApiBase
$env[] = "FFMPEG='{$ffmpegPath}'";
$env[] = "FFPROBE='{$ffprobePath}'";
// (Re-)create Temp dir
$instanceId = $this->config->getSystemValue('instanceid', 'default');
$defaultTmp = sys_get_temp_dir().'/go-vod/'.$instanceId;
// Get temp directory
$defaultTmp = sys_get_temp_dir().'/go-vod/';
$tmpPath = $this->config->getSystemValue('memories.tmp_path', $defaultTmp);
shell_exec("rm -rf '{$tmpPath}'");
mkdir($tmpPath, 0755, true);
// Remove trailing slash from temp path if present
if ('/' === substr($tmpPath, -1)) {
$tmpPath = substr($tmpPath, 0, -1);
// Make sure path ends with slash
if ('/' !== substr($tmpPath, -1)) {
$tmpPath .= '/';
}
// Add instance ID to path
$tmpPath .= $this->config->getSystemValue('instanceid', 'default');
// (Re-)create temp dir
shell_exec("rm -rf '{$tmpPath}' && mkdir -p '{$tmpPath}' && chmod 755 '{$tmpPath}'");
// Check temp directory exists
if (!is_dir($tmpPath)) {
throw new \Exception("Temp directory could not be created ({$tmpPath})");
}
// Check temp directory is writable
if (!is_writable($tmpPath)) {
throw new \Exception("Temp directory is not writable ({$tmpPath})");
}
// Set temp dir
$env[] = "GOVOD_TEMPDIR='{$tmpPath}'";
// Kill already running and start new
\OCA\Memories\Util::pkill($transcoder);
// Start transcoder
$env = implode(' ', $env);
shell_exec("{$env} nohup {$transcoder} > '{$tmpPath}.log' 2>&1 & > /dev/null");
$logFile = $tmpPath.'.log';
shell_exec("{$env} nohup {$transcoder} > '{$logFile}' 2>&1 & > /dev/null");
// wait for 1s and try again
sleep(1);
return $this->getUpstreamInternal($client, $path, $profile);
$returnCode = $this->getUpstreamInternal($client, $path, $profile);
if (0 === $returnCode) {
throw new \Exception("Transcoder could not be started, check {$logFile}");
}
return $returnCode;
}
private function getUpstreamInternal($client, $path, $profile)

View File

@ -82,6 +82,8 @@ trait TimelineWriteMap
return;
}
$this->connection->beginTransaction();
$query = $this->connection->getQueryBuilder();
$query->update('memories_mapclusters')
->set('point_count', $query->createFunction('point_count + 1'))
@ -92,6 +94,8 @@ trait TimelineWriteMap
$query->executeStatement();
$this->mapUpdateAggregates($clusterId);
$this->connection->commit();
}
/**
@ -104,6 +108,8 @@ trait TimelineWriteMap
*/
private function mapCreateCluster(float $lat, float $lon): int
{
$this->connection->beginTransaction();
$query = $this->connection->getQueryBuilder();
$query->insert('memories_mapclusters')
->values([
@ -117,6 +123,8 @@ trait TimelineWriteMap
$clusterId = (int) $query->getLastInsertId();
$this->mapUpdateAggregates($clusterId);
$this->connection->commit();
return $clusterId;
}
@ -133,6 +141,8 @@ trait TimelineWriteMap
return;
}
$this->connection->beginTransaction();
$query = $this->connection->getQueryBuilder();
$query->update('memories_mapclusters')
->set('point_count', $query->createFunction('point_count - 1'))
@ -143,6 +153,8 @@ trait TimelineWriteMap
$query->executeStatement();
$this->mapUpdateAggregates($clusterId);
$this->connection->commit();
}
/**
@ -162,6 +174,7 @@ trait TimelineWriteMap
->set('lon', $query->createFunction('lon_sum / point_count'))
->set('last_update', $query->createNamedParameter(time(), IQueryBuilder::PARAM_INT))
->where($query->expr()->eq('id', $query->createNamedParameter($clusterId, IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->gt('point_count', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT)))
;
$query->executeStatement();
}

View File

@ -12,6 +12,7 @@ class Exif
{
private const EXIFTOOL_VER = '12.50';
private const EXIFTOOL_TIMEOUT = 30000;
private const EXIFTOOL_ARGS = ['-api', 'QuickTimeUTC=1', '-n', '-U', '-json', '--b'];
/** Opened instance of exiftool when running in command mode */
private static $staticProc;
@ -115,6 +116,17 @@ class Exif
// 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']);
// Ignore zero date
if (\array_key_exists('DateTimeOriginal', $exif) && '0000:00:00 00:00:00' === $exif['DateTimeOriginal']) {
unset($exif['DateTimeOriginal']);
}
// Ignore zero lat lng
if (\array_key_exists('GPSLatitude', $exif) && abs((float) $exif['GPSLatitude']) < 0.0001
&& \array_key_exists('GPSLongitude', $exif) && abs((float) $exif['GPSLongitude']) < 0.0001) {
unset($exif['GPSLatitude'], $exif['GPSLongitude']);
}
return $exif;
}
@ -142,6 +154,8 @@ class Exif
$dt = $date;
if (isset($dt) && \is_string($dt) && !empty($dt)) {
$dt = explode('-', explode('+', $dt, 2)[0], 2)[0]; // get rid of timezone if present
$dt = explode('.', $dt, 2)[0]; // timezone may be after a dot (https://github.com/pulsejet/memories/pull/397)
$dt = \DateTime::createFromFormat('Y:m:d H:i:s', $dt);
if (!$dt) {
throw new \Exception("Invalid date: {$date}");
@ -182,7 +196,11 @@ class Exif
{
// Try to parse the date from exif metadata
$dt = $exif['DateTimeOriginal'] ?? null;
if (!isset($dt) || empty($dt)) {
$dt = $exif['CreateDate'] ?? null;
}
// Check if found something
try {
return self::parseExifDate($dt);
} catch (\Exception $ex) {
@ -385,7 +403,8 @@ class Exif
private static function getExifFromLocalPathWithStaticProc(string &$path)
{
fwrite(self::$staticPipes[0], "{$path}\n-U\n-json\n--b\n-api\nQuickTimeUTC=1\n-n\n-execute\n");
$args = implode("\n", self::EXIFTOOL_ARGS);
fwrite(self::$staticPipes[0], "{$path}\n{$args}\n-execute\n");
fflush(self::$staticPipes[0]);
$readyToken = "\n{ready}\n";
@ -407,7 +426,7 @@ class Exif
private static function getExifFromLocalPathWithSeparateProc(string &$path, array $extraArgs = [])
{
$pipes = [];
$proc = proc_open(array_merge(self::getExiftool(), ['-api', 'QuickTimeUTC=1', '-n', '-U', '-json', '--b'], $extraArgs, [$path]), [
$proc = proc_open(array_merge(self::getExiftool(), self::EXIFTOOL_ARGS, $extraArgs, [$path]), [
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);

View File

@ -157,6 +157,11 @@ class Util
*/
public static function pkill(string $name): void
{
// don't kill everything
if (empty($name)) {
return;
}
// get pids using ps as array
$pids = shell_exec("ps -ef | grep {$name} | grep -v grep | awk '{print $2}'");
if (null === $pids || empty($pids)) {

View File

@ -20,7 +20,7 @@ mv "exiftool-$exifver" exiftool
rm -rf *.zip exiftool/t exiftool/html
chmod 755 exiftool/exiftool
govod="0.0.25"
govod="0.0.26"
echo "Getting go-vod $govod"
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"

View File

@ -44,7 +44,7 @@
</div>
<OnThisDay
v-if="routeIsBase"
v-if="routeIsBase && config_enableTopMemories"
:key="config_timelinePath"
:viewer="$refs.viewer"
@load="scrollerManager.adjust()"

View File

@ -17,8 +17,8 @@
<div class="previews fill-block">
<div class="preview-container fill-block">
<div class="img-outer" v-for="info of previews" :key="info.fileid">
<img
class="fill-block"
<XImg
class="ximg fill-block"
:src="getPreviewUrl(info, true, 256)"
@error="$event.target.classList.add('error')"
/>
@ -226,4 +226,4 @@ export default defineComponent({
}
}
}
</style>
</style>

View File

@ -44,9 +44,9 @@
@touchend.passive="$emit('touchend', $event)"
@touchcancel.passive="$emit('touchend', $event)"
>
<img
ref="img"
:class="['fill-block', `memories-thumb-${data.key}`]"
<XImg
ref="ximg"
:class="['ximg', 'fill-block', `memories-thumb-${data.key}`]"
draggable="false"
:src="src"
:key="data.fileid"
@ -132,7 +132,7 @@ export default defineComponent({
},
/** Clear timers */
beforeUnmount() {
beforeDestroy() {
clearTimeout(this.touchTimer);
},
@ -205,7 +205,7 @@ export default defineComponent({
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
const img = this.$refs.img as HTMLImageElement;
const img = (this.$refs.ximg as any).$el as HTMLImageElement;
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
@ -441,4 +441,4 @@ div.img-outer {
}
}
}
</style>
</style>

View File

@ -10,13 +10,13 @@
<NcCounterBubble> {{ data.count }} </NcCounterBubble>
</div>
<div class="name">
{{ data.name }}
{{ title }}
<span class="subtitle" v-if="subtitle"> {{ subtitle }} </span>
</div>
<div class="previews fill-block" ref="previews">
<div class="img-outer">
<img
<XImg
draggable="false"
class="fill-block"
:class="{ error }"
@ -75,6 +75,14 @@ export default defineComponent({
return API.TAG_PREVIEW(this.data.name);
},
title() {
if (this.tag) {
return this.t("recognize", this.tag.name);
}
return this.data.name;
},
subtitle() {
if (this.album && this.album.user !== getCurrentUser()?.uid) {
return `(${this.album.user})`;
@ -83,6 +91,10 @@ export default defineComponent({
return "";
},
tag() {
return !this.face && !this.place && !this.album ? this.data : null;
},
face() {
return this.data.flag & constants.c.FLAG_IS_FACE_RECOGNIZE ||
this.data.flag & constants.c.FLAG_IS_FACE_RECOGNITION

View File

@ -0,0 +1,81 @@
<template>
<img :alt="alt" :src="dataSrc" @load="load" />
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { fetchImage } from "./XImgCache";
const BLOB_CACHE: { [src: string]: string } = {};
const BLANK_IMG =
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
export default defineComponent({
name: "XImg",
props: {
src: {
type: String,
required: false,
},
alt: {
type: String,
default: "",
},
},
data: () => {
return {
dataSrc: BLANK_IMG,
};
},
watch: {
src() {
this.loadImage();
},
},
mounted() {
this.loadImage();
},
methods: {
async loadImage() {
if (!this.src) return;
// Just set src if not http
if (this.src.startsWith("data:") || this.src.startsWith("blob:")) {
this.dataSrc = this.src;
return;
}
// Fetch image with axios
try {
const src = this.src;
if (BLOB_CACHE[src]) {
this.dataSrc = BLOB_CACHE[src];
return;
}
const newBlob = await fetchImage(src);
if (this.src === src) {
const blobUrl = URL.createObjectURL(newBlob);
BLOB_CACHE[src] = this.dataSrc = blobUrl;
setTimeout(() => {
if (BLOB_CACHE[src] === blobUrl) delete BLOB_CACHE[src];
URL.revokeObjectURL(blobUrl);
}, 60 * 1000);
}
} catch (error) {
this.dataSrc = BLANK_IMG;
this.$emit("error", error);
}
},
load() {
if (this.dataSrc === BLANK_IMG) return;
this.$emit("load", this.dataSrc);
},
},
});
</script>

View File

@ -1,8 +1,10 @@
import { registerRoute } from "workbox-routing";
import { CacheExpiration } from "workbox-expiration";
import { API } from "../../services/API";
import axios from "@nextcloud/axios";
// Queue of requests to fetch preview images
interface FetchPreviewObject {
origUrl: string;
url: URL;
fileid: number;
reqid: number;
@ -35,7 +37,7 @@ async function flushPreviewQueue() {
// Check if only one request
if (fetchPreviewQueueCopy.length === 1) {
const p = fetchPreviewQueueCopy[0];
return p.callback(await fetch(p.url));
return p.callback(await fetchOneImage(p.origUrl));
}
// Create aggregated request body
@ -48,26 +50,15 @@ async function flushPreviewQueue() {
}));
try {
// infer the url from the first file
const firstUrl = fetchPreviewQueueCopy[0].url;
const url = new URL(firstUrl.toString());
const path = url.pathname.split("/");
const previewIndex = path.indexOf("preview");
url.pathname = path.slice(0, previewIndex).join("/") + "/multipreview";
url.searchParams.delete("x");
url.searchParams.delete("y");
url.searchParams.delete("a");
url.searchParams.delete("c");
// Fetch multipreview
const res = await fetch(url, {
method: "POST",
body: JSON.stringify(files),
const multiUrl = API.IMAGE_MULTIPREVIEW();
const res = await axios.post(multiUrl, files, {
responseType: "blob",
});
// Get blob
if (res.status !== 200) throw new Error("Error fetching multi-preview");
const blob = await res.blob();
const blob = res.data;
let idx = 0;
while (idx < blob.size) {
@ -80,8 +71,6 @@ async function flushPreviewQueue() {
const reqid = jsonParsed["reqid"];
idx += newlineIndex + 1;
console.debug("multi-preview", jsonParsed);
// Read the image data
const imgBlob = blob.slice(idx, idx + imgLen);
idx += imgLen;
@ -91,14 +80,11 @@ async function flushPreviewQueue() {
.filter((p) => p.reqid === reqid)
.forEach((p) => {
p.callback(
new Response(imgBlob, {
status: 200,
headers: {
"Content-Type": imgType,
"Content-Length": imgLen,
Expires: res.headers.get("Expires"),
"Cache-Control": res.headers.get("Cache-Control"),
},
getResponse(imgBlob, imgType, {
"Content-Type": imgType,
"Content-Length": imgLen,
"Cache-Control": res.headers["Cache-Control"],
Expires: res.headers["Expires"],
})
);
p.callback = null;
@ -119,46 +105,70 @@ async function flushPreviewQueue() {
});
}
// Intercept preview requests
registerRoute(
/^.*\/apps\/memories\/api\/image\/preview\/.*/,
async ({ url, request }) => {
// Check if in cache
const cache = await imageCache?.match(url);
if (cache) return cache;
/** Accepts a URL and returns a promise with a blob */
export async function fetchImage(url: string): Promise<Blob> {
// Check if in cache
const cache = await imageCache?.match(url);
if (cache) return await cache.blob();
// Get file id from URL
const fileid = Number(url.pathname.split("/").pop());
// Get file id from URL
const urlObj = new URL(url, window.location.origin);
const fileid = Number(urlObj.pathname.split("/").pop());
// Aggregate requests
let res: Response = await new Promise((callback) => {
// Check if preview image
const regex = /^.*\/apps\/memories\/api\/image\/preview\/.*/;
// Aggregate requests
let res: Response;
if (regex.test(url)) {
res = await new Promise((callback) => {
fetchPreviewQueue.push({
url,
origUrl: url,
url: urlObj,
fileid,
reqid: Math.random(),
callback,
});
if (!fetchPreviewTimer) {
fetchPreviewTimer = setTimeout(flushPreviewQueue, 50);
fetchPreviewTimer = setTimeout(flushPreviewQueue, 10);
}
});
// Fallback to single request
if (res.status !== 200) {
res = await fetch(url);
}
// Cache response
if (res.status === 200) {
imageCache?.put(request, res.clone());
expirationManager.updateTimestamp(request.url);
}
// Run expiration once in every 20 requests
if (Math.random() < 0.05) {
expirationManager.expireEntries();
}
return res;
}
);
// Fallback to single request
if (!res || res.status !== 200) {
res = await fetchOneImage(url);
}
// Cache response
if (res.status === 200) {
imageCache?.put(url, res.clone());
expirationManager.updateTimestamp(url.toString());
}
// Run expiration once in every 100 requests
if (Math.random() < 0.01) {
expirationManager.expireEntries();
}
return await res.blob();
}
export async function fetchOneImage(url: string) {
const res = await axios.get(url, {
responseType: "blob",
});
return getResponse(res.data, res.headers["content-type"], res.headers);
}
function getResponse(blob: Blob, type: string, headers: any = {}) {
return new Response(blob, {
status: 200,
headers: {
"Content-Type": type,
"Content-Length": blob.size.toString(),
...headers,
},
});
}

View File

@ -16,7 +16,7 @@
@click="pickAlbum(album)"
>
<template v-slot:icon="{}">
<img
<XImg
v-if="album.last_added_photo !== -1"
class="album__image"
:src="toCoverUrl(album.last_added_photo)"

View File

@ -6,7 +6,7 @@
:additionalTrapElements="trapElements"
@close="close"
>
<div class="container" @keydown.stop="true">
<div class="container" @keydown.stop="0">
<div class="head">
<span> <slot name="title"></slot> </span>
</div>

View File

@ -27,7 +27,7 @@
<div class="count" v-if="cluster.count > 1">
{{ cluster.count }}
</div>
<img
<XImg
:src="clusterPreviewUrl(cluster)"
:class="[
'thumb-important',
@ -151,6 +151,7 @@ export default defineComponent({
b: bounds(),
z: zoomStr,
},
hash: this.$route.hash,
});
// Extend bounds by 25% beyond the map

View File

@ -7,7 +7,7 @@
:key="year.year"
@click="click(year)"
>
<img class="fill-block" :src="year.url" />
<XImg class="fill-block" :src="year.url" />
<div class="overlay">
{{ year.text }}
@ -84,6 +84,7 @@ export default defineComponent({
hasRight: false,
hasLeft: false,
scrollStack: [] as number[],
resizeObserver: null as ResizeObserver,
}),
mounted() {
@ -92,18 +93,22 @@ export default defineComponent({
passive: true,
});
this.resizeObserver = new ResizeObserver(this.onScroll.bind(this));
this.resizeObserver.observe(inner);
this.refresh();
},
beforeDestroy() {
this.resizeObserver?.disconnect();
},
methods: {
onload() {
this.$emit("load");
},
async refresh() {
// Skip if disabled
if (!this.config_enableTopMemories) return;
// Look for cache
const dayIdToday = utils.dateToDayId(new Date());
const cacheUrl = `/onthisday/${dayIdToday}`;

View File

@ -0,0 +1,41 @@
import PhotoSwipe from "photoswipe";
import { isVideoContent } from "./PsVideo";
import { isLiveContent } from "./PsLivePhoto";
import { fetchImage } from "../frame/XImgCache";
export default class ImageContentSetup {
constructor(lightbox: PhotoSwipe) {
this.initLightboxEvents(lightbox);
}
initLightboxEvents(lightbox: PhotoSwipe) {
lightbox.on("contentLoad", this.onContentLoad.bind(this));
lightbox.on("contentLoadImage", this.onContentLoadImage.bind(this));
}
onContentLoad(e) {
if (isVideoContent(e.content) || isLiveContent(e.content)) return;
// Don't insert default image
e.preventDefault();
const content = e.content;
const img = document.createElement("img");
img.classList.add("pswp__img");
content.element = img;
// Fetch with Axios
fetchImage(content.data.src).then((blob) => {
const blobUrl = URL.createObjectURL(blob);
img.src = blobUrl;
img.onerror = img.onload = () => {
content.onLoaded();
URL.revokeObjectURL(blobUrl);
};
});
}
onContentLoadImage(e) {
if (isVideoContent(e.content) || isLiveContent(e.content)) return;
e.preventDefault();
}
}

View File

@ -1,7 +1,7 @@
import PhotoSwipe from "photoswipe";
import * as utils from "../../services/Utils";
function isLiveContent(content): boolean {
export function isLiveContent(content): boolean {
// Do not play live photo if the slideshow is
// playing in full screen mode.
if (document.fullscreenElement) {

View File

@ -24,7 +24,7 @@ const config_video_default_quality = Number(
* @param {Slide|Content} content Slide or Content object
* @returns Boolean
*/
function isVideoContent(content): boolean {
export function isVideoContent(content): boolean {
return content?.data?.type === "video";
}
@ -170,7 +170,7 @@ class VideoContentSetup {
});
const overrideNative = !vidjs.browser.IS_SAFARI;
content.videojs = vidjs(content.videoElement, {
const vjs = (content.videojs = vidjs(content.videoElement, {
fill: true,
autoplay: true,
controls: false,
@ -186,24 +186,22 @@ class VideoContentSetup {
nativeAudioTracks: !overrideNative,
nativeVideoTracks: !overrideNative,
},
});
}));
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.");
vjs.on("error", () => {
if (vjs.error().code === 4 && vjs.src().includes("m3u8")) {
// HLS could not be streamed
console.error("Video.js: HLS stream could not be opened.");
if (getCurrentUser()?.isAdmin) {
showError(t("memories", "Transcoding failed."));
}
content.videojs.src({
src: content.data.src,
type: "video/mp4",
});
this.updateRotation(content, 0);
if (getCurrentUser()?.isAdmin) {
showError(t("memories", "Transcoding failed, check Nextcloud logs."));
}
vjs.src({
src: content.data.src,
type: "video/mp4",
});
this.updateRotation(content, 0);
}
});

View File

@ -188,6 +188,7 @@ import * as utils from "../../services/Utils";
import ImageEditor from "./ImageEditor.vue";
import PhotoSwipe, { PhotoSwipeOptions } from "photoswipe";
import "photoswipe/style.css";
import PsImage from "./PsImage";
import PsVideo from "./PsVideo";
import PsLivePhoto from "./PsLivePhoto";
@ -434,7 +435,9 @@ export default defineComponent({
arrowPrevTitle: this.t("memories", "Previous"),
arrowNextTitle: this.t("memories", "Next"),
getViewportSizeFn: () => {
const sidebarWidth = this.sidebarOpen ? this.sidebarWidth : 0;
const isMobile = globalThis.windowInnerWidth < 768;
const sidebarWidth =
this.sidebarOpen && !isMobile ? this.sidebarWidth : 0;
this.outerWidth = `calc(100vw - ${sidebarWidth}px)`;
return {
x: globalThis.windowInnerWidth - sidebarWidth,
@ -561,6 +564,9 @@ export default defineComponent({
// Live photo support
new PsLivePhoto(<any>this.photoswipe, {});
// Image support
new PsImage(<any>this.photoswipe);
// Patch the close button to stop the slideshow
const _close = this.photoswipe.close.bind(this.photoswipe);
this.photoswipe.close = () => {
@ -772,7 +778,6 @@ export default defineComponent({
if (!photo.imageInfo) {
axios.get(API.IMAGE_INFO(photo.fileid)).then((res) => {
photo.imageInfo = res.data;
this.$forceUpdate();
});
}
@ -1028,16 +1033,20 @@ export default defineComponent({
},
handleAppSidebarOpen() {
if (this.show && this.photoswipe) {
const sidebar: HTMLElement =
document.querySelector("aside.app-sidebar");
if (sidebar) {
this.sidebarWidth = sidebar.offsetWidth - 2;
}
if (!(this.show && this.photoswipe)) return;
this.sidebarOpen = true;
this.updateSizeWithoutAnim();
const sidebar: HTMLElement = document.querySelector("aside.app-sidebar");
if (sidebar) {
this.sidebarWidth = sidebar.offsetWidth - 2;
// Stop sidebar typing from leaking to viewer
sidebar.addEventListener("keydown", (e) => {
if (e.key.length === 1) e.stopPropagation();
});
}
this.sidebarOpen = true;
this.updateSizeWithoutAnim();
},
handleAppSidebarClose() {

View File

@ -4,6 +4,7 @@ import "reflect-metadata";
import Vue from "vue";
import VueVirtualScroller from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import XImg from "./components/frame/XImg.vue";
import GlobalMixin from "./mixins/GlobalMixin";
import UserConfig from "./mixins/UserConfig";
@ -72,6 +73,7 @@ if (!globalThis.videoClientIdPersistent) {
Vue.mixin(GlobalMixin);
Vue.mixin(UserConfig);
Vue.use(VueVirtualScroller);
Vue.component("XImg", XImg);
// https://github.com/nextcloud/photos/blob/156f280c0476c483cb9ce81769ccb0c1c6500a4e/src/main.js
// TODO: remove when we have a proper fileinfo standalone library

View File

@ -5,10 +5,9 @@ import { ExpirationPlugin } from 'workbox-expiration';
precacheAndRoute(self.__WB_MANIFEST);
import './service-worker-custom';
registerRoute(/^.*\/apps\/memories\/api\/video\/transcode\/.*/, new NetworkOnly());
registerRoute(/^.*\/apps\/memories\/api\/image\/jpeg\/.*/, new NetworkOnly());
registerRoute(/^.*\/apps\/memories\/api\/image\/preview\/.*/, new NetworkOnly());
registerRoute(/^.*\/remote.php\/.*/, new NetworkOnly());
registerRoute(/^.*\/apps\/files\/ajax\/download.php?.*/, new NetworkOnly());
@ -22,7 +21,6 @@ const imageCache = new CacheFirst({
],
});
registerRoute(/^.*\/apps\/memories\/api\/image\/preview\/.*/, imageCache);
registerRoute(/^.*\/apps\/memories\/api\/video\/livephoto\/.*/, imageCache);
registerRoute(/^.*\/apps\/memories\/api\/faces\/preview\/.*/, imageCache);
registerRoute(/^.*\/apps\/memories\/api\/tags\/preview\/.*/, imageCache);

View File

@ -82,6 +82,10 @@ export class API {
return tok(gen(`${BASE}/image/preview/{fileid}`, { fileid }));
}
static IMAGE_MULTIPREVIEW() {
return tok(gen(`${BASE}/image/multipreview`));
}
static IMAGE_INFO(id: number) {
return tok(gen(`${BASE}/image/info/{id}`, { id }));
}

View File

@ -197,6 +197,7 @@ export function randomSubarray(arr: any[], size: number) {
export function convertFlags(photo: IPhoto) {
if (typeof photo.flag === "undefined") {
photo.flag = 0; // flags
photo.imageInfo = null; // make it reactive
}
if (photo.isvideo) {