001    /**
002     * iFish - An iRiver iHP jukebox database creation tool
003     *
004     * Copyright (C) 2009 Richard "Shred" Körber
005     *   http://ifish.shredzone.org
006     *
007     * This program is free software: you can redistribute it and/or modify
008     * it under the terms of the GNU General Public License as published by
009     * the Free Software Foundation, either version 3 of the License, or
010     * (at your option) any later version.
011     *
012     * This program is distributed in the hope that it will be useful,
013     * but WITHOUT ANY WARRANTY; without even the implied warranty of
014     * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
015     * GNU General Public License for more details.
016     *
017     * You should have received a copy of the GNU General Public License
018     * along with this program.  If not, see <http://www.gnu.org/licenses/>.
019     */
020    package net.shredzone.ifish.db;
021    
022    import java.io.*;
023    import java.util.*;
024    
025    /**
026     * This class contains the database, and is able to read and write the
027     * iRivNavi.iDB file, as well as synchronize with a directory.
028     *
029     * @author    Richard Körber &lt;dev@shredzone.de&gt;
030     * @version   $Id: NaviDb.java 291 2009-04-28 21:29:27Z shred $
031     */
032    public class NaviDb {
033      protected final SortedSet<Entry> sEntries  = new TreeSet<Entry>();
034      private   final Map<File, Entry> mFiles    = new HashMap<File, Entry>();
035      private int addCounter;
036      private int updateCounter;
037      private int removeCounter;
038      private long lastModifiedDB = 0L;
039      
040      /**
041       * Create an empty database.
042       */
043      public NaviDb() {
044        resetCounters();
045      }
046      
047      /**
048       * Create a database and fill it with a database file.
049       *
050       * @param   file          Database file to be read
051       * @param   charset       Desired Charset
052       * @throws  IOException   File was erraneous or could not be read
053       */
054      public NaviDb( File file, String charset )
055      throws IOException, DatabaseException, UnsupportedEncodingException {
056        resetCounters();
057        importFile( file, charset );
058      }
059      
060      /**
061       * Add an entry to the database. If the entry does already exist
062       * (i.e. there is an Entry with the same file name), is will be
063       * replaced by the given entry (maybe having new tag attributes).
064       *
065       * @param   entry         Entry to be added
066       */
067      public void addEntry( Entry entry )
068      throws DatabaseException {
069        if( sEntries.size() >= 9999 )
070          throw new DatabaseException( "Database limit (9999 entries) exceeded" );
071        
072        //--- Add/Replace the entry ---
073        if( sEntries.contains( entry ) ) {
074          sEntries.remove( entry );     // This is because the Set will not
075          sEntries.add( entry );        // replace an existing Entry.
076          updateCounter++;
077        }else {
078          sEntries.add( entry );
079          addCounter++;
080    
081          //--- Remember seen files ---
082          File file = entry.getFile();
083          if( file!=null )
084            mFiles.put( file, entry );
085        }
086      }
087      
088      /**
089       * Remove an entry from the database. If the entry does not exist,
090       * nothing will happen. The file on the harddisk that is connected
091       * to the Entry, will be kept untouched.
092       *
093       * @param   entry         Entry to be removed
094       */
095      public void removeEntry( Entry entry ) {
096        //--- Remove the file ---
097        File file = entry.getFile();
098        if( file!=null )
099          mFiles.remove( file );
100        
101        //--- Remove entry ---
102        if( sEntries.remove( entry ) )
103          removeCounter++;
104      }
105      
106      /**
107       * Reset the add and remove counters.
108       */
109      public void resetCounters() {
110        addCounter    = 0;
111        updateCounter = 0;
112        removeCounter = 0;
113      }
114      
115      /**
116       * Get the number of entries that have been added since last counter
117       * reset. This is useful if you want to write out some statistics
118       * after an import, export or sync operation.
119       *
120       * @return    Number of entries added.
121       */
122      public int getAddCounter() {
123        return addCounter;
124      }
125      
126      /**
127       * Get the number of entries that have been updated since last counter
128       * reset. This is useful if you want to write out some statistics
129       * after an import, export or sync operation.
130       *
131       * @return    Number of entries updated.
132       */
133      public int getUpdateCounter() {
134        return updateCounter;
135      }
136    
137      /**
138       * Get the number of entries that have been removed since last counter
139       * reset. This is useful if you want to write out some statistics
140       * after an import, export or sync operation.
141       *
142       * @return    Number of entries removed.
143       */
144      public int getRemoveCounter() {
145        return removeCounter;
146      }
147      
148      /**
149       * Get the creation date of this database.
150       * 
151       * @return    Creation date
152       */
153      public Date getCreationDate() {
154        if( lastModifiedDB==0L ) {
155          return new Date();
156        }else {
157          return new Date( lastModifiedDB );
158        }
159      }
160    
161      /**
162       * Check if a File is known by this database. This is useful for
163       * database synchronisation.
164       *
165       * @param   file          File to be checked
166       * @return  true: file is known to this database, false: unknown
167       */
168      public boolean knowsFile( File file ) {
169        return mFiles.containsKey( file );
170      }
171      
172      /**
173       * Get the Entry for the given File. If there is no Entry for this
174       * file in the database, null will be returned.
175       *
176       * @param   file          File to be checked
177       * @return  Entry to this file, or null
178       */
179      public Entry getEntry( File file ) {
180        return (Entry) mFiles.get( file );
181      }
182    
183      /**
184       * Get the Entry for the given database filename (i.e. the jukebox'
185       * filename). If there is no Entry for this filename in the database,
186       * null will be returned.
187       *
188       * @param   base          The root of the jukebox directory
189       * @param   fname         Filename to be checked
190       * @return  Entry to this file, or null
191       */
192      public Entry getEntry( File base, String fname ) {
193        //--- Convert Windows to this platform's notation ---
194        if( File.separatorChar != '\\' ) {
195          fname = fname.replace( '\\', File.separatorChar );
196        }
197        
198        //--- Get the entry for this file ---
199        return getEntry( new File( base, fname ) );
200      }
201    
202      /**
203       * Check if a File has been modified after the current database has
204       * been written to the harddisk. This can be used to find out if a
205       * file needs to be updated in the database.
206       * <p>
207       * If the database was not read yet, then this method will
208       * always return true. If the file did not exist, this method will
209       * always return false.
210       *
211       * @param   file          File to be checked
212       * @return  true: file was modified after DB creation. false: wasn't.
213       */
214      public boolean modifiedFile( File file ) {
215        if( lastModifiedDB==0L ) return true;
216        if( !file.exists() ) return false;
217        return( file.lastModified() > lastModifiedDB );
218      }
219      
220      /**
221       * Import a database file into this database.
222       *
223       * @param   file          Database file to be imported
224       * @param   charset       Charset the database is written in
225       */
226      public void importFile( File file, String charset )
227      throws IOException, DatabaseException, UnsupportedEncodingException {
228        importFile( file, charset, null );
229      }
230    
231      /**
232       * Import a database file into this database. This method automatically
233       * detects whether the database file was written in UTF-16 or in an old
234       * format. If the old format was detected, the given charset is used to
235       * decode the strings.
236       *
237       * @param   file          Database file to be imported
238       * @param   charset       Charset the database is written in
239       * @param   cb            StatusCallback to be used
240       */
241      public void importFile( File file, String charset, StatusCallback cb )
242      throws IOException, DatabaseException, UnsupportedEncodingException {
243        File base = file.getParentFile();
244        
245        InputStream in = null;
246        try {
247          in = new BufferedInputStream( new FileInputStream( file ) );
248          //--- Check if it is an iRivDB file ---
249          byte[] buf = new byte[6];
250          if( 6 != in.read( buf ))
251            throw new IOException( "no database file" );
252          String header = new String( buf, "US-ASCII" );
253          if( !header.equals("iRivDB") )
254            throw new IOException( "no database file" );
255          
256          //--- Skip to the number of entries ---
257          long skipCnt = 0x0040 - 0x0006;
258          while( skipCnt>0 ) {
259            long skipped = in.skip( skipCnt );
260            if( skipped<0 )
261              throw new IOException( "premature EOF" );
262            skipCnt -= skipped;
263          }
264          
265          //--- Read number of entries ---
266          byte[] nbuf = new byte[4];
267          if( 4 != in.read( nbuf ))
268            throw new IOException( "premature EOF" );
269          int count = nbuf[0] & 0xFF;
270          count |= (nbuf[1] & 0xFF) << 8;
271          count |= (nbuf[2] & 0xFF) << 16;
272          count |= (nbuf[3] & 0xFF) << 24;
273          if( count>9999 )
274            throw new IOException( "no database file" );
275          if( cb!=null )
276            cb.setMaxEntries( count );
277          
278          //--- Skip to the data clusters ---
279          skipCnt = 0xA0C0 - 0x0044;
280          while( skipCnt>0 ) {
281            long skipped = in.skip( skipCnt );
282            if( skipped<0 )
283              throw new IOException( "premature EOF" );
284            skipCnt -= skipped;
285          }
286          
287          //--- Read the data clusters ---
288          try {
289            int cnt = 0;
290            for(;;) {
291              Entry entry = new DbEntry( base, in, charset );
292              addEntry( entry );
293              if( cb!=null ) {
294                cb.setCurrentIndex( cnt++ );
295                cb.setCurrentEntry( entry );
296              }
297            }
298          }catch( IOException e ) {}
299       
300          //--- Remember the modification date ---
301          lastModifiedDB = file.lastModified();
302          
303        }finally {
304          if( in!=null )
305            in.close();
306        }
307      }
308      
309      /**
310       * Export this database to a database file.
311       *
312       * @param   file        File to be written to.
313       * @param   charset     Charset to be used for export.
314       */
315      public void exportFile( File file, String charset )
316      throws IOException, UnsupportedEncodingException {
317        exportFile( file, charset, null );
318      }
319    
320      /**
321       * Export this database to a database file. If "UTF-16" is given as charset,
322       * the new database file format is used automatically.
323       *
324       * @param   file        File to be written to.
325       * @param   charset     Charset to be used for export.
326       * @param   cb          StatusCallback to be used.
327       */
328      public void exportFile( File file, String charset, StatusCallback cb )
329      throws IOException, UnsupportedEncodingException {
330        if( cb!=null )
331          cb.setMaxEntries( sEntries.size() );
332        
333        //--- Nullbyte Array ---
334        byte[] nulls = new byte[32];
335        
336        OutputStream out = null;
337        try {
338          out = new BufferedOutputStream( new FileOutputStream( file ) );
339          
340          //--- Write Header ---
341          if( charset.equals("UTF-16") ) {
342            out.write( "iRivDB Ver 0.16".getBytes( "US-ASCII" ) );
343          }else {
344            out.write( "iRivDB Ver 0.12".getBytes( "US-ASCII" ) );
345          }
346          out.write( nulls, 0, 17 );
347          out.write( "iRiver iHP-100 DB File".getBytes( "US-ASCII" ) );
348          out.write( nulls, 0, 10 );
349          
350          //--- Write Number of Entries ---
351          writeLong( out, sEntries.size() );
352          out.write( nulls, 0, 28 );
353          if( charset.equals("UTF-16") ) {
354            out.write( nulls, 0, 16 );
355            out.write( "APP Ver 1.70T0 \0".getBytes( "US-ASCII" ) );
356          }else {
357            out.write( nulls, 0, 32 );
358          }
359          
360          //--- Write Offset Table ---
361          int offset = 0;
362          Iterator<Entry> it = iterator();
363          while( it.hasNext() ) {
364            Entry entry = (Entry) it.next();
365            writeLong( out, offset );
366            offset += entry.size( charset );
367          }
368          
369          //--- Offset Table Padding ---
370          for( int cnt=sEntries.size(); cnt<10240; cnt++ ) {
371            out.write( nulls, 0, 4 );
372          }
373          
374          //--- Second Header ---
375          out.write( "Designed by iRiver".getBytes( "US-ASCII" ) );
376          out.write( nulls, 0, 14 );
377          out.write( nulls, 0, 32 );
378          
379          //--- Entries ---
380          it = iterator();
381          int cnt = 0;
382          while( it.hasNext() ) {
383            Entry entry = (Entry) it.next();
384            entry.write( out, charset );
385            if( cb!=null ) {
386              cb.setCurrentIndex( cnt++ );
387              cb.setCurrentEntry( entry );
388            }
389          }
390          
391          //--- Remember the modification date ---
392          lastModifiedDB = file.lastModified();
393    
394          //--- Done ---
395        }finally {
396          if( out!=null )
397            out.close();
398        }
399      }
400      
401      /**
402       * Get the number of entries in this database.
403       *
404       * @return   Number of entries
405       */
406      public int size() {
407        return sEntries.size();
408      }
409      
410      /**
411       * Get an iterator of all entries.
412       *
413       * @return  Iterator
414       */
415      public Iterator<Entry> iterator() {
416        return sEntries.iterator();
417      }
418       
419      /**
420       * Write 4 bytes in little endian order.
421       *
422       * @param   out       OutputStream to write to
423       * @param   val       Value to write
424       * @throws  IOException
425       */
426      private void writeLong( OutputStream out, int val ) throws IOException {
427        out.write( (val    ) & 0xFF );
428        out.write( (val>>8 ) & 0xFF );
429        out.write( (val>>16) & 0xFF );
430        out.write( (val>>24) & 0xFF );
431      }
432       
433    }