001package net.filebot.media;
002
003import static net.filebot.Logging.*;
004
005import java.io.File;
006import java.io.FileFilter;
007import java.io.IOException;
008import java.time.ZoneId;
009import java.time.ZoneOffset;
010import java.time.ZonedDateTime;
011import java.util.Date;
012import java.util.EnumMap;
013import java.util.LinkedHashMap;
014import java.util.Map;
015import java.util.Optional;
016import java.util.function.Function;
017import java.util.function.Predicate;
018import java.util.stream.Stream;
019
020import com.drew.imaging.ImageMetadataReader;
021import com.drew.imaging.ImageProcessingException;
022import com.drew.lang.GeoLocation;
023import com.drew.metadata.Directory;
024import com.drew.metadata.Metadata;
025import com.drew.metadata.Tag;
026import com.drew.metadata.exif.ExifIFD0Directory;
027import com.drew.metadata.exif.ExifSubIFDDirectory;
028import com.drew.metadata.exif.GpsDirectory;
029import com.drew.metadata.file.FileSystemDirectory;
030
031import net.filebot.util.ExtensionFileFilter;
032import net.filebot.web.Geocode;
033import net.filebot.web.Geocode.AddressComponent;
034
035public class ImageMetadata {
036
037        private final Metadata metadata;
038
039        public ImageMetadata(File file) throws ImageProcessingException, IOException {
040                if (!SUPPORTED_FILE_TYPES.accept(file)) {
041                        throw new IllegalArgumentException("Image type not supported: " + file);
042                }
043
044                this.metadata = ImageMetadataReader.readMetadata(file);
045        }
046
047        public Map<String, String> snapshot() {
048                return snapshot(Tag::getTagName);
049        }
050
051        public Map<String, String> snapshot(Function<Tag, String> key) {
052                return snapshot(key, d -> Stream.of("JPEG", "JFIF", "Interoperability", "Huffman", "File").noneMatch(d.getName()::equals));
053        }
054
055        public Map<String, String> snapshot(Function<Tag, String> key, Predicate<Directory> accept) {
056                Map<String, String> values = new LinkedHashMap<String, String>();
057
058                for (Directory directory : metadata.getDirectories()) {
059                        if (accept.test(directory)) {
060                                for (Tag tag : directory.getTags()) {
061                                        String v = tag.getDescription();
062                                        if (v != null && v.length() > 0) {
063                                                values.put(key.apply(tag), v);
064                                        }
065                                }
066                        }
067                }
068
069                return values;
070        }
071
072        public Optional<String> getName() {
073                return extract(m -> m.getFirstDirectoryOfType(FileSystemDirectory.class)).map(d -> d.getString(FileSystemDirectory.TAG_FILE_NAME));
074        }
075
076        public Optional<ZonedDateTime> getDateTaken() {
077                return extract(m -> {
078                        // e.g.
079                        // Date/Time Original : 2025:05:31 18:01:02
080                        // Time Zone Original : +08:00
081                        for (ExifSubIFDDirectory subifd : m.getDirectoriesOfType(ExifSubIFDDirectory.class)) {
082                                Date date = subifd.getDateOriginal();
083                                if (date != null) {
084                                        return date.toInstant().atZone(getTimeZone().orElse(ZoneOffset.UTC));
085                                }
086                        }
087                        return null;
088                });
089        }
090
091        public Optional<ZoneId> getTimeZone() {
092                return extract(m -> {
093                        // e.g.
094                        // Date/Time Original : 2025:05:31 18:01:02
095                        // Time Zone Original : +08:00
096                        for (ExifSubIFDDirectory subifd : m.getDirectoriesOfType(ExifSubIFDDirectory.class)) {
097                                String zone = subifd.getString(ExifSubIFDDirectory.TAG_TIME_ZONE_ORIGINAL);
098                                if (zone != null) {
099                                        return ZoneId.of(zone);
100                                }
101                        }
102                        return null;
103                });
104        }
105
106        public Optional<Map<CameraProperty, String>> getCameraModel() {
107                return extract(m -> m.getFirstDirectoryOfType(ExifIFD0Directory.class)).map(d -> {
108                        String maker = d.getDescription(ExifIFD0Directory.TAG_MAKE);
109                        String model = d.getDescription(ExifIFD0Directory.TAG_MODEL);
110
111                        Map<CameraProperty, String> camera = new EnumMap<CameraProperty, String>(CameraProperty.class);
112                        if (maker != null) {
113                                camera.put(CameraProperty.maker, maker);
114                        }
115                        if (model != null) {
116                                camera.put(CameraProperty.model, model);
117                        }
118
119                        return camera;
120                }).filter(m -> !m.isEmpty());
121        }
122
123        public enum CameraProperty {
124                maker, model;
125        }
126
127        public Optional<GeoLocation> getLocationTaken() {
128                return extract(m -> m.getFirstDirectoryOfType(GpsDirectory.class)).map(GpsDirectory::getGeoLocation);
129        }
130
131        public Optional<Map<AddressComponent, String>> getLocationTaken(Geocode geocode) {
132                return getLocationTaken().map(position -> {
133                        try {
134                                return geocode.locate(position.getLatitude(), position.getLongitude());
135                        } catch (Exception e) {
136                                trace(e);
137                        }
138                        return null;
139                });
140        }
141
142        public <T> Optional<T> extract(Function<Metadata, T> extract) {
143                try {
144                        return Optional.ofNullable(extract.apply(metadata));
145                } catch (Exception e) {
146                        debug.finest(cause("Failed to extract image metadata", e));
147                }
148                return Optional.empty();
149        }
150
151        public static final FileFilter SUPPORTED_FILE_TYPES = new ExtensionFileFilter("jpg", "jpeg", "heic", "png", "webp", "gif", "ico", "bmp", "tif", "tiff", "psd", "pcx", "raw", "crw", "cr2", "nef", "orf", "raf", "rw2", "rwl", "srw", "arw", "dng", "x3f");
152
153}