001/**
002 * Copyright (C) 2014  Universidade de Aveiro, DETI/IEETA, Bioinformatics Group - http://bioinformatics.ua.pt/
003 *
004 * This file is part of Dicoogle/dicoogle.
005 *
006 * Dicoogle/dicoogle is free software: you can redistribute it and/or modify
007 * it under the terms of the GNU General Public License as published by
008 * the Free Software Foundation, either version 3 of the License, or
009 * (at your option) any later version.
010 *
011 * Dicoogle/dicoogle is distributed in the hope that it will be useful,
012 * but WITHOUT ANY WARRANTY; without even the implied warranty of
013 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
014 * GNU General Public License for more details.
015 *
016 * You should have received a copy of the GNU General Public License
017 * along with Dicoogle.  If not, see <http://www.gnu.org/licenses/>.
018 */
019package pt.ua.dicoogle.server.web.utils;
020
021import java.io.ByteArrayInputStream;
022import java.io.ByteArrayOutputStream;
023import java.io.File;
024import java.io.FileInputStream;
025import java.io.FileOutputStream;
026import java.io.IOException;
027import java.io.InputStream;
028import java.net.URI;
029import java.nio.file.Files;
030import java.nio.file.Path;
031import java.nio.file.Paths;
032import java.util.Collection;
033import java.util.Objects;
034import java.util.concurrent.ConcurrentLinkedQueue;
035import java.util.concurrent.ConcurrentMap;
036import java.util.concurrent.ConcurrentSkipListMap;
037import pt.ua.dicoogle.server.web.dicom.Convert2PNG;
038
039/**
040 * Handles the caching of PNG images generated by the Image Servlet.
041 * The cached images are kept inside a temporary directory created inside the user (or system) temporary directory.
042 * These are maintained for a maximum period of time if they are not used, and deleted on a regular basis to save disk space while not hurting the cache performance.
043 *
044 * @author António Novo <antonio.novo@ua.pt>
045 */
046public class LocalImageCache extends Thread implements ImageRetriever
047{
048        /**
049         * The number of milliseconds to wait between pool cache directory pooling.
050         */
051        private volatile int interval;
052        /**
053         * The number of milliseconds that a file can stay in the cache without being used/read.
054         */
055        private volatile int maxAge;
056
057        private final File cacheFolder;
058        private volatile boolean running;
059
060        private final ConcurrentMap<String,File> filesBeingWritten;
061    
062    private final ImageRetriever under;
063
064        /**
065         * Creates a local image cache that pools its cache directory at interval rates and deletes files older than maxAge.
066         *
067         * @param name the name of the cache directory.
068         * @param interval the number of seconds to wait between pool cache directory pooling.
069         * @param maxAge the number of seconds that a file can stay in the cache without being used/read.
070     * @param under the underlying image retriever
071         */
072        public LocalImageCache(String name, int interval, int maxAge, ImageRetriever under)
073        {
074        super("cache-" + name);
075        Objects.requireNonNull(under);
076
077                if (interval < 1) {
078                        this.interval = 1;
079                } else {
080                        this.interval = interval;
081                }
082                this.interval *= 1000;
083
084                if (maxAge < 1) {
085            throw new IllegalArgumentException("Illegal maxAge");
086        }
087        this.maxAge = maxAge * 1000;
088
089                filesBeingWritten = new ConcurrentSkipListMap<>();
090                running = false;
091        
092        this.setDaemon(true);
093        this.under = under;
094        
095                // create the temporary directory
096                File sysTmpDir = new File(System.getProperty("java.io.tmpdir"));
097                cacheFolder = new File(sysTmpDir, name);
098        }
099
100        /**
101         * Deletes a directory and all its contents.
102         *
103         * @param dir the directory to delete.
104         */
105        private static void deleteDirectory(File dir)
106        {
107                if ((dir == null) || (! dir.exists())) {
108                        return;
109                }
110
111                // delete all the files and folders inside this folder
112                for (File f : dir.listFiles()) {
113                        if (f.isDirectory()) {
114                                // if it's a sub-directory then delete its content
115                                deleteDirectory(f);
116                        } else {
117                                f.delete();
118                        }
119                }
120
121                // remove the folder
122                dir.delete();
123        }
124
125        @Override
126        public void start()
127        {
128                // abort if we couldn't make the temp dir
129                if (cacheFolder == null)
130                        return;
131                if (! cacheFolder.exists())
132                        if (! cacheFolder.mkdirs())
133                                return;
134                cacheFolder.deleteOnExit();
135
136                // start running
137                super.start();
138        }
139
140        @Override
141        public void run()
142        {
143                // if the cache isn't setup abort
144                if (! cacheFolder.exists())
145                        return;
146
147                running = true;
148                do
149                {
150                        // check if there are any files worh deleting and if so do it
151                        checkAndRemoveOldFiles();
152
153                        // wait for the defined interval to be over
154                        try {
155                                Thread.sleep(interval);
156                        } catch (InterruptedException ex) {
157                                // do nothing
158                        }
159                }
160                while (isRunning());
161        }
162
163        /**
164         * Stop this cache from checking for old files.
165         */
166        public synchronized void terminate()
167        {
168                this.running = false;
169
170                // if needed wake the thread from its sleeping state
171                this.interrupt();
172
173                // clear and delete the temporary folder
174                deleteDirectory(cacheFolder);
175        }
176
177        /**
178         * Loops through the cache folder and tries to removes old/un-used files.
179         */
180        private synchronized void checkAndRemoveOldFiles()
181        {
182                // if the cache isn't setup abort
183                if (! cacheFolder.exists())
184                        return;
185
186                long currentTime = System.currentTimeMillis();
187
188                // go through all the files inside the temp folder and check their last access
189                for (File f : cacheFolder.listFiles())
190                {
191                        // skip if the current file is a folder
192                        if (f.isDirectory())
193                                continue;
194
195                        // check the last access done to the file, and if it's "past its due" tries to delete it
196                        if (currentTime - f.lastModified() > maxAge)
197                                f.delete();
198                }
199        }
200
201        /**
202         * @return the interval
203         */
204        public int getInterval()
205        {
206                return interval;
207        }
208
209        /**
210         * @param interval the interval to set
211         */
212        public void setInterval(int interval)
213        {
214                if (interval < 1) {
215                        this.interval = 1;
216                } else {
217                        this.interval = interval;
218                }
219                this.interval *= 1000;
220        }
221
222        /**
223         * @return the maxAge
224         */
225        public int getMaxAge()
226        {
227                return maxAge;
228        }
229
230        /**
231         * @param maxAge the maxAge to set
232         */
233        public void setMaxAge(int maxAge)
234        {
235                if (maxAge > 1) {
236                        this.maxAge = maxAge;
237                }
238        }
239    
240    protected static String toFileName(String imageUri, int frameNumber, boolean thumbnail) {
241        String filename = imageUri.replace('/', '_') + "_" + frameNumber;
242        filename = filename.replace(':', '_') ; // only for windows.
243        if (thumbnail) {
244            filename += "__thumb";
245        }
246        return filename + ".png";
247    }
248    
249    @Override
250    public InputStream get(URI uri, final int frameNumber, final boolean thumbnail) throws IOException {
251        File f = this.implGetFile(toFileName(uri.toString(), frameNumber, thumbnail));
252        synchronized(f) {
253            if (f.exists()) {
254                return new FileInputStream(f);
255            }
256        }
257        this.implCreateFile(f);
258        byte[] imageArray;
259        synchronized (f) {
260            InputStream istream = this.under.get(uri, frameNumber, thumbnail);
261            ByteArrayOutputStream result = Convert2PNG.DICOM2PNGStream(istream, frameNumber);
262            imageArray = result.toByteArray();
263
264            // create new cache file with the converted image
265            try (FileOutputStream fout = new FileOutputStream(f)) {
266                fout.write(imageArray);
267            }
268            filesBeingWritten.remove(f.getAbsolutePath());
269        }
270        
271        // and return it
272        return new ByteArrayInputStream(imageArray);
273    }
274
275        private File implGetFile(String fileName) throws IOException {
276                // check if the file is currently being written to
277        String thatFilePath = new File(fileName).getAbsolutePath();
278        File f = this.filesBeingWritten.get(thatFilePath);
279        if (f == null) {
280            f = new File(cacheFolder, fileName);
281        }
282        return f;
283        }
284    
285    private File implCreateFile(File f) throws IOException {
286        //f.createNewFile();
287        f.deleteOnExit();
288                // if it does not exist add it to the list of files being written (because new content is going to be written to it outside this class) and create the empty file
289                filesBeingWritten.put(f.getAbsolutePath(), f);
290                // return the common handle to the currently created and empty file
291        return f;
292    }
293
294        /**
295         * @return if the caching mechanism for checking and removing old/un-used cache files is still running
296         */
297        public boolean isRunning()
298        {
299                return running;
300        }
301}