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.File;
023    import java.io.IOException;
024    import java.util.Date;
025    import java.text.Format;
026    import java.text.SimpleDateFormat;
027    import java.util.ArrayList;
028    import java.util.Iterator;
029    import java.util.List;
030    
031    import net.shredzone.ifish.IFishPrefs;
032    import net.shredzone.ifish.Util;
033    import net.shredzone.ifish.ltr.LTR;
034    import net.shredzone.ifish.ltr.LTRFactory;
035    
036    
037    /**
038     * The purpose of this class is to synchronize the database with the iHP
039     * harddisk.
040     *
041     * @author    Richard Körber &lt;dev@shredzone.de&gt;
042     * @version   $Id: Sync.java 291 2009-04-28 21:29:27Z shred $
043     */
044    public class Sync {
045      private NaviDb      navi;
046      private PlaylistDb  playlist;
047      private Playlist    newEntryPlaylist = null;
048      private boolean checkModified = false;
049      private boolean deleteMacResource = false;
050      private boolean doCommentPlaylist = false;
051      private boolean doNewEntrantPlaylist = false;
052      private boolean checkTrackNumbers = false;
053      private String playlistName = "{s}";
054      
055      /**
056       * Create a new synchronizer. It only synchronizes the directory with the
057       * database. The playlists are not touched at all.
058       * 
059       * @param navi    The NaviDb to synchronize with.
060       */
061      public Sync( NaviDb navi ) {
062        this( navi, null );
063      }
064      
065      /**
066       * Create a new synchronizer. It synchronizes both the directory with the
067       * database, and the playlists.
068       * 
069       * @param navi        The NaviDb to synchronize with.
070       * @param playlist    The PlaylistDb to synchronize with.
071       */
072      public Sync( NaviDb navi, PlaylistDb playlist ) {
073        this.navi = navi;
074        this.playlist = playlist;
075      }
076      
077      /**
078       * Set if the modification date is to be checked.
079       *
080       * @param   b       true: check modification date, false: ignore
081       */
082      public void setCheckModified( boolean b ) {
083        checkModified = b;
084      }
085      
086      /**
087       * Check whether the modification date is to be checked.
088       *
089       * @return  true: check modification date, false: ignore
090       */
091      public boolean isCheckModified() {
092        return checkModified;
093      }
094      
095      /**
096       * Set if the track numbers are to be checked in the file names.
097       *
098       * @param   b       true: check track numbers, false: ignore
099       */
100      public void setCheckTrackNumbers( boolean b ) {
101        checkTrackNumbers = b;
102      }
103      
104      /**
105       * Check whether the track numbers are to be checked in the file names.
106       *
107       * @return  true: check track numbers, false: ignore
108       */
109      public boolean isCheckTrackNumbers() {
110        return checkTrackNumbers;
111      }
112      
113      /**
114       * Set if MacOS X resource fork emulation files are to be deleted
115       *
116       * @param   b       true: delete resource fork files, false: keep
117       */
118      public void setDeleteResourceFork( boolean b ) {
119        deleteMacResource = b;
120      }
121      
122      /**
123       * Check whether MacOS X resource fork emulation files are to be deleted
124       *
125       * @return  true: check modification date, false: ignore
126       */
127      public boolean isDeleteResourceFork() {
128        return deleteMacResource;
129      }
130      
131      /**
132       * Set if playlists are to be created from tag comments.
133       * 
134       * @param   b      true: Create Playlists from tag comments.
135       */
136      public void setCreateCommentPlaylist( boolean b ) {
137        doCommentPlaylist = b;
138      }
139      
140      /**
141       * Chech whether playlists are to be created from tag comments.
142       * 
143       * @return  true: Create Playlists from tag comments.
144       */
145      public boolean isCreateCommentPlaylist() {
146        return doCommentPlaylist;
147      }
148      
149      /**
150       * Set if playlists are to be created from all new entrants.
151       * 
152       * @param   b      true: Create Playlists from all new entrants
153       */
154      public void setCreateNewEntrantPlaylist( boolean b ) {
155        doNewEntrantPlaylist = b;
156      }
157      
158      /**
159       * Check whether playlists are to be created from all new entrants.
160       * 
161       * @return  true: Create Playlists from all new entrants
162       */
163      public boolean isCreateNewEntrantPlaylist() {
164        return doNewEntrantPlaylist;
165      }
166    
167      /**
168       * Set the playlist name of the New Entrant playlist. A "{s}" will be replaced
169       * by the last synchronization date, a "{d}" will be replaced by the current
170       * date.
171       * <p>
172       * Default is "{s}".
173       * 
174       * @param   s       The New Entrant playlist name
175       */
176      public void setNewEntrantName( String s ) {
177        playlistName = s;
178      }
179      
180      /**
181       * Get the playlist name of the New Entrant playlist. A "{s}" will be replaced
182       * by the last synchronization date, a "{d}" will be replaced by the current
183       * date.
184       * 
185       * @return  The new entrant playlist name
186       */
187      public String getNewEntrantName() {
188        return playlistName;
189      }
190    
191      /**
192       * Construct a playlist name for the given model and playlist. If no
193       * PlaylistDb was set, null will be returned.
194       * <p>
195       * First a short date format ("yyyyMMdd") is tried. If there already is a
196       * playlist with that name, a long date format ("yyyyMMdd-HHmmss") is tried
197       * instead.
198       * 
199       * @return  Playlist name
200       */
201      protected String createPlaylistName() {
202        if( playlist!=null ) {
203          final Format fmtDateShort = new SimpleDateFormat("yyyyMMdd");
204          final Format fmtDateLong  = new SimpleDateFormat("yyyyMMdd-HHmmss");
205          final Date   dateSync     = navi.getCreationDate();
206          final Date   dateNow      = new Date();
207          
208          String name = playlistName;
209          name = name.replaceAll( "\\{s\\}", fmtDateShort.format(dateSync) );
210          name = name.replaceAll( "\\{d\\}", fmtDateShort.format(dateNow ) );
211          if( playlist.containsPlaylist( name ) ) {
212            name = playlistName;
213            name = name.replaceAll( "\\{s\\}", fmtDateLong.format(dateSync) );
214            name = name.replaceAll( "\\{d\\}", fmtDateLong.format(dateNow ) );
215          }
216          
217          return name;
218        }else {
219          return null;
220        }
221      }
222      
223      /**
224       * Synchronizes a directory with this database. Usually this is the
225       * same directory the database file was located in.
226       *
227       * @param   base        Base directory
228       */
229      public void syncDir( File base )
230      throws IOException, DatabaseException {
231        syncDir( base, null, null );
232      }
233    
234      /**
235       * Synchronizes a directory with this database. Usually this is the
236       * same directory the database file was located in.
237       *
238       * @param   base        Base directory
239       * @param   cb          StatusCallback to be used, null for none
240       * @param   rcb         RenameCallback to be used, null for default
241       */
242      public void syncDir( File base, StatusCallback cb, RenameCallback rcb )
243      throws IOException, DatabaseException {
244        int[] cnt = new int[1];
245    
246        //--- Create LTRFactory ---
247        final LTRFactory factory = new LTRFactory();
248        factory.setID3v1Charset( IFishPrefs.instance().getID3v1Charset() );
249        factory.setID3v2Charset( IFishPrefs.instance().getID3v2Charset() );
250        
251        //--- Set to indeterminate state ---
252        if( cb!=null ) {
253          cb.setCurrentIndex( -1 );
254        }
255        
256        //--- Find all entries without file on HD ---
257        List<Entry> lDelinquents = new ArrayList<Entry>();
258        Iterator<Entry> it = navi.iterator();
259        while( it.hasNext() ) {
260          Entry entry = (Entry) it.next();
261          File file = entry.getFile();
262          if( !file.exists() ) {
263            lDelinquents.add( entry );
264          }
265        }
266        
267        //--- Count number of files ---
268        if( cb!=null ) {
269          cnt[0] = 0;
270          scancnt( base, base, cnt );
271          cb.setMaxEntries( cnt[0]+lDelinquents.size() );
272        }
273        
274        //--- Remove those orphaned entries ---
275        cnt[0] = 0;
276        if( !lDelinquents.isEmpty() ) {
277          while( !lDelinquents.isEmpty() )  {
278            Entry entry = (Entry) lDelinquents.remove(0);
279            cb.setCurrentIndex( cnt[0]++ );
280            cb.setCurrentEntry( entry );
281            navi.removeEntry( entry );
282            if( playlist!=null )
283              playlist.removeEntry( entry );
284          }
285        }
286        
287        //--- Create a Playlist for all new entries ---
288        if( playlist!=null && doNewEntrantPlaylist ) {
289          newEntryPlaylist = playlist.getPlaylist( createPlaylistName() );
290          newEntryPlaylist.setStatic( true );
291        }else {
292          newEntryPlaylist = null;
293        }
294        
295        //--- Recursively scan directory ---
296        if( playlist!=null ) playlist.markAll();      // Mark all playlists
297        scan( base, base, cnt, cb, rcb, factory );
298        if( playlist!=null ) playlist.sortMarkAll();  // Sort the new entries
299        
300        //--- Remove new entry playlist if empty ---
301        if( newEntryPlaylist!=null ) {
302          if( newEntryPlaylist.isEmpty() ) {
303            playlist.removePlaylist( newEntryPlaylist );
304            newEntryPlaylist = null;
305          }
306        }
307      }
308      
309      /**
310       * Recursively scan a sub directory of the base directory.
311       *
312       * @param   base        Base directory
313       * @param   dir         Current directory, must be a subdirectory
314       *                      of the base directory.
315       * @param   cnt         File counter (by reference, as array)
316       * @param   cb          StatusCallback to be used, or null
317       * @param   rcb         RenameCallback to be used, or null
318       * @param   factory     LTRFactory to be used
319       */
320      private void scan( File base, File dir, int[] cnt, StatusCallback cb, RenameCallback rcb, LTRFactory factory )
321      throws IOException, DatabaseException {
322        if( cb!=null ) {
323          cb.setCurrentDir( base, dir );
324        }
325        
326        File[] files = dir.listFiles();
327        
328        if( files!=null ) {
329          for( int ix=0; ix<files.length; ix++ ) {
330            File current = files[ix];
331            files[ix] = null;               // make it eligible for GC
332            if( current.isDirectory() ) {
333              scan( base, current, cnt, cb, rcb, factory );
334            }else if( current.isFile() ) {
335              if( cb!=null ) {
336                cb.setCurrentIndex( cnt[0]++ );
337              }
338              
339              if( deleteMacResource && current.getName().startsWith("._") ) {
340                //--- This is a MacOS X Resource Fork Emulation file ---
341                // We are going to delete it, but we won't bother if it should
342                // fail.
343                current.delete();
344                
345              }else if( (checkModified && navi.modifiedFile( current )) 
346                 || !navi.knowsFile( current ) ) {
347                //--- Create a new entry ---
348                try {
349                  boolean skipFile = false;
350                  boolean skipNewEntry = false;
351                  
352                  //--- Decode the file ---
353                  LTR ltr = factory.create( current );
354                  if( ltr!=null ) {
355                    // This is a music file to be added to the database!
356                    
357                    //--- Check for the track number ---
358                    if( checkTrackNumbers ) {
359                      File old = current;
360                      current = checkTrackNumbers( ltr, current );
361                      if( current!=old ) {
362                        // File has been renamed!
363                        Entry entry = navi.getEntry( old );
364                        if( entry!=null ) {
365                          // The old name was known to the database
366                          if( playlist!=null )
367                            playlist.removeEntry( entry );
368                          navi.removeEntry( entry );
369                          skipNewEntry = true;
370                        }
371                        ltr = factory.create( current );
372                      }
373                    }
374      
375                    //--- Use the RenameCallback if present ---
376                    if( rcb!=null ) {
377                      //--- Check Directory ---
378                      if( !FileEntry.isDirNameFitting( base, current ) ) {
379                        File newDir = rcb.renameDirectory( base, current.getParentFile() );
380                        if( newDir==null ) {
381                          skipFile = true;
382                        }else {
383                          throw new DatabaseException( "directory renaming is not supported yet" );
384                        }
385                      }
386                      
387                      //--- Check File ---
388                      if( !FileEntry.isFileNameFitting( base, current ) ) {
389                        File newFile = rcb.renameFile( base, current );
390                        if( newFile==null ) {
391                          skipFile = true;
392                        }else {
393                          current = newFile;
394                          ltr = factory.create( current );
395                        }
396                      }
397                    }
398                    
399                    //--- Add the file to the database ---
400                    if( !skipFile ) {
401                      FileEntry entry = new FileEntry( base, current, ltr );
402      
403                      //--- Update callback ---
404                      if( cb!=null ) cb.setCurrentEntry( entry );
405      
406                      //--- Add entry to database ---
407                      navi.addEntry( entry );
408                      
409                      //--- Add entry to comment playlists ---
410                      if( playlist!=null && doCommentPlaylist ) {
411                        playlist.addEntryByComment( entry );
412                      }
413                      
414                      //--- Add entry to new entrant playlist ---
415                      if( newEntryPlaylist!=null && !skipNewEntry ) {
416                        newEntryPlaylist.addEntry( entry );
417                      }
418                    }
419                  }
420                }catch( IllegalArgumentException e ) {}
421              }
422            }
423          }
424        }
425      }
426      
427      /**
428       * Recursively scan a sub directory of the base directory.
429       *
430       * @param   base        Base directory
431       * @param   dir         Current directory, must be a subdirectory
432       *                      of the base directory.
433       * @param   cnt         File counter (by reference, as array)
434       */
435      private void scancnt( File base, File dir, int[] cnt )
436      throws IOException {
437        /* It's a little stupid that this method is required. It's wasting
438           time, only for showing a proper progress bar that needs to know
439           the total number of files in advance. This is the quickest
440           solution I could think of. Anyhow it's still a feelable delay,
441           so better ideas are welcome!
442        */
443        File[] files = dir.listFiles();
444        if( files!=null ) {
445          cnt[0] += files.length;
446          for( int ix=0; ix<files.length; ix++ ) {
447            File current = files[ix];
448            files[ix] = null;
449            if( current.isDirectory() ) {
450              cnt[0]--;
451              scancnt( base, current, cnt );
452            }
453          }
454        }
455      }
456      
457      /**
458       * Check if the file name starts with a track number. If not, rename
459       * the file according to the track number given in the tag's track
460       * information. This method will silently fail if an error occurs. 
461       * 
462       * @param   ltr       LTR information about the file
463       * @param   file      The file to be checked
464       * @return  The new file with track name. If the file name was unchanged,
465       *    the file reference will be returned unchanged.
466       */
467      private File checkTrackNumbers( LTR ltr, File file ) {
468        try {
469          String name = file.getName();
470          
471          //--- Check the file name ---
472          // If the file starts with a number, do nothing.
473          if(! Character.isDigit( name.charAt(0) ) ) {
474            //--- Evaluate the track number ---
475            // The mechanism only works for integer results between 1 and 99.
476            String[] tay = ltr.getTrack().trim().split("\\D+");
477            if( tay.length>0 ) {
478              String tnr = tay[0].trim();
479              
480              if( tnr.length()>0 ) {
481                int track = Integer.parseInt( tnr, 10 );
482                if( track>=1 && track<=99 ) {
483                  String num = (track<10 ? "0"+track : String.valueOf(track) );  // two digits
484                  
485                  //--- Compose new filename ---
486                  String newName = num+" - "+name;
487                  File newFile = new File( file.getParent(), newName );
488                  
489                  //--- Does it exist? ---
490                  // If the file does already exist, do nothing.
491                  if(! newFile.exists() ) {
492                    //--- Rename the file ---
493                    if( Util.renameFile( file, newFile ) ) {
494                      return newFile;
495                    }
496                  }
497                }
498              }
499            }
500          }
501        }catch( Exception ignore ) {
502          // If something fails, silently ignore the exception and do not
503          // rename the file.
504        }
505        
506        return file;
507      }
508      
509    }