Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
23 changes: 22 additions & 1 deletion lib/Db/ExifFields.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,32 @@ class ExifFields
* This is mostly only used for the metadata view.
*/
public const EXIF_FIELDS_LIST = [
// Date/Time
// Original date fields
'SubSecDateTimeOriginal' => true,
'DateTimeOriginal' => true,
'SonyDateTime' => true,

// Create date fields
'SubSecCreateDate' => true,
'CreationDate' => true,
'CreationDateValue' => true,
'CreateDate' => true,
'TrackCreateDate' => true,
'MediaCreateDate' => true,
'FileCreateDate' => true,

// ModifyDate fields
'SubSecModifyDate' => true,
'ModifyDate' => true,
'TrackModifyDate' => true,
'MediaModifyDate' => true,
'FileModifyDate' => true,

// Timezone Offsets
'OffsetTimeOriginal' => true,
'OffsetTime' => true,
'TimeZone' => true,
'OffsetTimeDigitized' => true,

// Generated date fields
'DateTimeEpoch' => true,
Expand Down
42 changes: 26 additions & 16 deletions lib/Db/TimelineWrite.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,14 @@ public function processFile(
// Get exif data
$exif = Exif::getExifFromFile($file);

// -n argument breaks how some date strings are returned from exiftool
// So we make a separate array to get numeric values only
$exifNumeric = Exif::getExifFromFile($file, ['-n']);
$exif['GPSLatitude'] = $exifNumeric['GPSLatitude'] ?? null;
$exif['GPSLongitude'] = $exifNumeric['GPSLongitude'] ?? null;

// Check if EXIF is blank, which is probably wrong
if (0 === \count($exif)) {
if (0 === \count($exif) || 0 === \count($exifNumeric)) {
throw new \Exception('No EXIF data could be read');
}

Expand All @@ -115,27 +121,31 @@ public function processFile(
// Video parameters
$videoDuration = round((float) ($isvideo ? ($exif['Duration'] ?? $exif['TrackDuration'] ?? 0) : 0));

// Process location data
// This also modifies the exif array in-place to set the LocationTZID
// Process location data to set the LocationTZID
// and drop the GPS data if it is not valid
[$lat, $lon, $mapCluster] = $this->processExifLocation($fileId, $exif, $prevRow);
[$lat, $lon, $mapCluster] = $this->processExifLocation($fileId, $exif, $exifNumeric, $prevRow);

// Get date parameters (after setting timezone offset)
$dateTaken = Exif::getDateTaken($file, $exif);

// Store the acutal epoch with the EXIF data
// Store the actual epoch with the EXIF data
$epoch = $exif['DateTimeEpoch'] = $dateTaken->getTimestamp();

// Store the date taken in the database as UTC (local date) only
// Basically, assume everything happens in Greenwich
$dateLocalUtc = Exif::forgetTimezone($dateTaken)->getTimestamp();
$dateTakenStr = gmdate('Y-m-d H:i:s', $dateLocalUtc);

// We need to use the local time in UTC for the dayId
// This way two photos in different timezones on the same date locally
// end up in the same dayId group
$dayId = floor($dateLocalUtc / 86400);

// Extract and store the timezone offset from the $dateTaken object
// This preserves the timezone that Exif::getDateTaken() determined
$exif['OffsetTimeOriginal'] = $dateTaken->format('P'); // e.g., "-05:00"

// Store the datetaken string in the database as UTC only
$dateTakenUtc = clone $dateTaken;
$dateTakenUtc->setTimezone(new \DateTimeZone('UTC'));
$dateTakenStr = $dateTakenUtc->format('Y-m-d H:i:s');

// Find midnight for the real date taken, give it UTC without timeshift for use with the floor function
$midnight = \DateTime::createFromFormat('Y-m-d H:i:s', $dateTaken->format('Y-m-d') . ' 00:00:00', new \DateTimeZone('UTC'));

// Gives a day group based on the timezone the photo was taken in
$dayId = (int) floor($midnight->getTimestamp() / 86400);

// Get size of image
[$w, $h] = Exif::getDimensions($exif);

Expand Down
134 changes: 108 additions & 26 deletions lib/Db/TimelineWritePlaces.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,15 @@ public function updatePlacesData(int $fileId, ?float $lat, ?float $lon): array
*
* @param int $fileId The file ID
* @param array $exif The exif data (will change)
* @param array $exif The exif numeric data
* @param ?array $prevRow The previous row of data
*
* @return array Update values
*/
protected function processExifLocation(int $fileId, array &$exif, ?array $prevRow): array
protected function processExifLocation(int $fileId, array &$exif, array &$exifNumeric, ?array $prevRow): array
{
// Store location data
[$lat, $lon] = self::readCoord($exif);
[$lat, $lon] = self::readCoord($exifNumeric);
$oldLat = $prevRow ? (float) $prevRow['lat'] : null;
$oldLon = $prevRow ? (float) $prevRow['lon'] : null;
$mapCluster = $prevRow ? (int) $prevRow['mapcluster'] : -1;
Expand All @@ -128,7 +129,7 @@ protected function processExifLocation(int $fileId, array &$exif, ?array $prevRo
$mapCluster = $mapCluster <= 0 ? null : $mapCluster;

// Set tzid from location if not present
$this->setTzidFromLocation($exif, $osmIds);
$this->setTzidFromLocation($exif, $exifNumeric, $osmIds, $lat, $lon);

// Return update values
return [$lat, $lon, $mapCluster, $osmIds];
Expand All @@ -138,43 +139,124 @@ protected function processExifLocation(int $fileId, array &$exif, ?array $prevRo
* Set timezone offset from location if not present.
*
* @param array $exif The exif data
* @param array $exif The exif numeric data
* @param array $osmIds The list of osm_id of the places
* @param ?float $lat The latitude
* @param ?float $lon The longitude
*/
private function setTzidFromLocation(array &$exif, array $osmIds): void
private function setTzidFromLocation(array &$exif, array &$exifNumeric, array $osmIds, ?float $lat, ?float $lon): void
{
// Make sure we have some places
if (empty($osmIds)) {
return;
if (!empty($osmIds)) {

// Get timezone offset from places
$query = $this->connection->getQueryBuilder();
$query->select('name')
->from('memories_planet')
->where($query->expr()->in('osm_id', $query->createNamedParameter($osmIds, IQueryBuilder::PARAM_INT_ARRAY)))
->andWhere($query->expr()->eq('admin_level', $query->expr()->literal(-7, IQueryBuilder::PARAM_INT)))
;

// Get name of timezone
$tzName = $query->executeQuery()->fetchOne();
if ($tzName !== false && $tzName !== '') {
$exif['LocationTZID'] = $tzName;
return;
} else {
// No values use fallback
$tzName = null;
}
}

// Get timezone offset from places
$query = $this->connection->getQueryBuilder();
$query->select('name')
->from('memories_planet')
->where($query->expr()->in('osm_id', $query->createNamedParameter($osmIds, IQueryBuilder::PARAM_INT_ARRAY)))
->andWhere($query->expr()->eq('admin_level', $query->expr()->literal(-7, IQueryBuilder::PARAM_INT)))
;

// Get name of timezone
$tzName = $query->executeQuery()->fetchOne();
if ($tzName) {
$exif['LocationTZID'] = $tzName;
// Timezone precheck, will skip unnecessary slow Python timezone lookups in most cases whengetTimezoneFromPython is called
// Will still be unnecessarily called occasionally if timezone wasn't in the below fields but was in a date field e.g.
// exiftool found one we didn't check for.
$hasTimezone = false;
try {
$tzStr = $exif['OffsetTimeOriginal']
?? $exif['OffsetTime']
?? $exif['OffsetTimeDigitized']
?? $exif['TimeZone']
?? throw new \Exception();

/** @psalm-suppress ArgumentTypeCoercion */
$exifTz = new \DateTimeZone((string) $tzStr);
$hasTimezone = true;
} catch (\Exception $e) {
$hasTimezone = false;
} catch (\ValueError $e) {
$hasTimezone = false;
}

// Fallback to Python timezonefinder if database is unavailable
if ($lat !== null && $lon !== null && $hasTimezone === false) {
$tzName = $this->getTimezoneFromPython($lat, $lon);
if ($tzName !== null) {
$exif['LocationTZID'] = $tzName;
}
}
}

/**
* Get timezone using Python timezonefinder as a fallback.
*
* @param ?float $lat The latitude
* @param ?float $lon The longitude
*
* @return ?string The timezone name or null if not found
*/
private function getTimezoneFromPython(?float $lat, ?float $lon): ?string
{
// Validate coordinates
if (null === $lat || null === $lon) {
return null;
}

try {
// Get timezone using Python timezonefinder
$scriptPath = \dirname(__DIR__, 2) . '/python/findtimezone.py';
$command = sprintf('python3 %s %f %f', escapeshellarg($scriptPath), $lat, $lon);
$output = shell_exec($command);
$trimmedOutput = trim($output);

// Retry with python command instead of python3 if not found
if (strpos($trimmedOutput, 'not found') !== false) {
$command = sprintf('python %s %f %f', escapeshellarg($scriptPath), $lat, $lon);
$output = shell_exec($command);
$trimmedOutput = trim($output);
}

// Check if output contains error messages
if (strpos($trimmedOutput, 'Error:') !== false || strpos($trimmedOutput, 'not found') !== false) {
$this->logger->warning("Python timezone script failed: {$trimmedOutput}", ['app' => 'memories']);
return null;
}

// Return the trimmed output
if ($output && !empty($trimmedOutput)) {
return $trimmedOutput;
}

return null;
} catch (\Exception $e) {
$this->logger->error("Error calling Python timezone script: {$e->getMessage()}", ['app' => 'memories']);
return null;
}
}

/**
* Read coordinates from array and round to 6 decimal places.
*
* Modifies the EXIF array to remove invalid coordinates.
* Modifies the EXIF Numeric array to remove invalid coordinates.
*
* @return (null|float)[]
*
* @psalm-return list{float|null, float|null}
*/
private static function readCoord(array &$exif): array
private static function readCoord(array &$exifNumeric): array
{
$lat = \array_key_exists(LAT_KEY, $exif) ? round((float) $exif[LAT_KEY], 6) : null;
$lon = \array_key_exists(LON_KEY, $exif) ? round((float) $exif[LON_KEY], 6) : null;
$lat = \array_key_exists(LAT_KEY, $exifNumeric) ? round((float) $exifNumeric[LAT_KEY], 6) : null;
$lon = \array_key_exists(LON_KEY, $exifNumeric) ? round((float) $exifNumeric[LON_KEY], 6) : null;

// Make sure we have valid coordinates
if (null === $lat || null === $lon
Expand All @@ -184,11 +266,11 @@ private static function readCoord(array &$exif): array
}

// Remove invalid coordinates
if (null === $lat && \array_key_exists(LAT_KEY, $exif)) {
unset($exif[LAT_KEY]);
if (null === $lat && \array_key_exists(LAT_KEY, $exifNumeric)) {
unset($exifNumeric[LAT_KEY]);
}
if (null === $lon && \array_key_exists(LON_KEY, $exif)) {
unset($exif[LON_KEY]);
if (null === $lon && \array_key_exists(LON_KEY, $exifNumeric)) {
unset($exifNumeric[LON_KEY]);
}

return [$lat, $lon];
Expand Down
Loading