Merge branch 'master' into stable24
commit
04505e48c6
|
@ -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`
|
||||
|
|
10
README.md
10
README.md
|
@ -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.
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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");
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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>');
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
23
lib/Exif.php
23
lib/Exif.php
|
@ -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);
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -44,7 +44,7 @@
|
|||
</div>
|
||||
|
||||
<OnThisDay
|
||||
v-if="routeIsBase"
|
||||
v-if="routeIsBase && config_enableTopMemories"
|
||||
:key="config_timelinePath"
|
||||
:viewer="$refs.viewer"
|
||||
@load="scrollerManager.adjust()"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
|
@ -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)"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}`;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 }));
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in New Issue