View Javadoc

1   package sk.baka.ambient.collection;
2   
3   import java.io.File;
4   import java.io.FileOutputStream;
5   import java.io.IOException;
6   import java.net.URL;
7   import java.util.Collection;
8   import java.util.HashMap;
9   import java.util.HashSet;
10  import java.util.Iterator;
11  import java.util.List;
12  import java.util.Map;
13  import java.util.Set;
14  
15  import sk.baka.ambient.IBackgroundTask;
16  import sk.baka.ambient.R;
17  import sk.baka.ambient.commons.IOUtils;
18  import sk.baka.ambient.commons.MiscUtils;
19  import sk.baka.ambient.commons.TagFormatter;
20  
21  /***
22   * Downloads tracks from given collection which are missing in the reference
23   * collection. All work is done in the {@link #run()} method.
24   * 
25   * @author mvy
26   */
27  public class CollectionSynchronizer implements Runnable {
28  	/***
29  	 * Download excess tracks from this collection.
30  	 */
31  	private final ICollection downloadFrom;
32  	/***
33  	 * This is the reference collection, i.e. tracks which are not to be
34  	 * synchronized.
35  	 */
36  	private final ICollection reference;
37  	/***
38  	 * Produces a relative path where the music file should be stored. Must
39  	 * produce valid paths!
40  	 */
41  	private final TagFormatter pathFormatter;
42  	/***
43  	 * This path is automatically prepended to paths produced by the
44  	 * <code>pathFormatter</code> formatter.
45  	 */
46  	private final String rootPath;
47  	private final IBackgroundTask task;
48  
49  	/***
50  	 * Creates new synchronizer.
51  	 * 
52  	 * @param reference
53  	 *            This is the reference collection, i.e. tracks which are not to
54  	 *            be synchronized.
55  	 * @param downloadFrom
56  	 *            Download excess tracks from this collection.
57  	 * @param pathFormatter
58  	 *            produces a relative path where the music file should be
59  	 *            stored. Must produce valid paths! Paths are created
60  	 *            automatically.
61  	 * @param rootPath
62  	 *            this path is automatically prepended to paths produced by the
63  	 *            <code>pathFormatter</code> formatter.
64  	 * @param task
65  	 *            used for progress reporting, may be <code>null</code>.
66  	 */
67  	public CollectionSynchronizer(final ICollection reference,
68  			final ICollection downloadFrom, final TagFormatter pathFormatter,
69  			final String rootPath, final IBackgroundTask task) {
70  		this.reference = reference;
71  		this.downloadFrom = downloadFrom;
72  		this.pathFormatter = pathFormatter;
73  		this.task = task;
74  		this.rootPath = IOUtils.removeTrailingSlash(rootPath);
75  	}
76  
77  	public void run() {
78  		try {
79  			final Map<String, CategoryItem> toSynchronize = new HashMap<String, CategoryItem>();
80  			final Map<String, CategoryItem> missingTracks = new HashMap<String, CategoryItem>();
81  			fetchAlbumsToSynchronize(toSynchronize, missingTracks);
82  			int synchronizedAlbums = 0;
83  			final int maxProgress = 1 + toSynchronize.size();
84  			for (final Map.Entry<String, CategoryItem> album : toSynchronize
85  					.entrySet()) {
86  				if (task != null) {
87  					final String progressName = MiscUtils.format(
88  							R.string.downloadingAlbum, album.getKey());
89  					task.backgroundTask(0, progressName, ++synchronizedAlbums,
90  							maxProgress);
91  				}
92  				final boolean downloadPartly = missingTracks.containsKey(album
93  						.getKey());
94  				final List<TrackMetadataBean> tracksToDownload = downloadFrom
95  						.getTracks(album.getValue());
96  				if (downloadPartly) {
97  					final List<TrackMetadataBean> localTracks = reference
98  							.getTracks(missingTracks.get(album.getKey()));
99  					removeLocalTracks(tracksToDownload, localTracks);
100 				}
101 				for (final TrackMetadataBean track : tracksToDownload) {
102 					try {
103 						downloadTrack(removeSlashes(track));
104 					} catch (Exception ex) {
105 						final String errorMsg = MiscUtils.format(
106 								R.string.failedToDownload, track
107 										.getDisplayableName(), ex.getMessage());
108 						throw new RuntimeException(errorMsg, ex);
109 					}
110 				}
111 			}
112 		} catch (final Exception ex) {
113 			throw new RuntimeException(ex);
114 		}
115 	}
116 
117 	private void removeLocalTracks(List<TrackMetadataBean> tracksToDownload,
118 			List<TrackMetadataBean> localTracks) {
119 		final Set<SameTrack> local = new HashSet<SameTrack>(localTracks.size());
120 		for (final TrackMetadataBean track : localTracks) {
121 			local.add(new SameTrack(track));
122 		}
123 		for (final Iterator<TrackMetadataBean> download = tracksToDownload
124 				.iterator(); download.hasNext();) {
125 			final SameTrack track = new SameTrack(download.next());
126 			if (local.contains(track)) {
127 				download.remove();
128 			}
129 		}
130 	}
131 
132 	private static final class SameTrack {
133 		private final TrackMetadataBean track;
134 
135 		/***
136 		 * Creates the comparator.
137 		 * 
138 		 * @param track
139 		 *            the track.
140 		 */
141 		public SameTrack(final TrackMetadataBean track) {
142 			this.track = track;
143 		}
144 
145 		@Override
146 		public boolean equals(Object o) {
147 			if (o == this) {
148 				return true;
149 			}
150 			if (!(o instanceof SameTrack)) {
151 				return false;
152 			}
153 			final TrackMetadataBean other = ((SameTrack) o).track;
154 			return MiscUtils.nullEquals(track.getDisplayableName(), other
155 					.getDisplayableName())
156 					&& MiscUtils.nullEquals(track.getAlbum(), other.getAlbum())
157 					&& MiscUtils.nullEquals(track.getArtist(), other
158 							.getArtist());
159 		}
160 
161 		@Override
162 		public int hashCode() {
163 			int result = MiscUtils.hashCode(track.getDisplayableName());
164 			result = result * 1001 + MiscUtils.hashCode(track.getAlbum());
165 			result = result * 1001 + MiscUtils.hashCode(track.getArtist());
166 			return result;
167 		}
168 	}
169 
170 	/***
171 	 * Fetches albums to be synchronized.
172 	 * 
173 	 * @param toSynchronize
174 	 *            these albums are to be downloaded. Maps to
175 	 *            {@link CategoryItem} from {@link #downloadFrom}. Key is the
176 	 *            album name.
177 	 * @param missingTracks
178 	 *            a subset of <code>toSynchronize</code>, contains albums
179 	 *            which already are partially present. Maps to
180 	 *            {@link CategoryItem} from {@link #reference}. Key is the
181 	 *            album name.
182 	 * @throws InterruptedException
183 	 * @throws CollectionException
184 	 */
185 	private void fetchAlbumsToSynchronize(
186 			Map<String, CategoryItem> toSynchronize,
187 			Map<String, CategoryItem> missingTracks)
188 			throws CollectionException, InterruptedException {
189 		final Map<String, CategoryItem> localAlbums = getNames(reference
190 				.getCategoryList(CategoryEnum.Album, null, null, false));
191 		final Map<String, CategoryItem> remoteAlbums = getNames(downloadFrom
192 				.getCategoryList(CategoryEnum.Album, null, null, false));
193 		missingTracks.putAll(localAlbums);
194 		missingTracks.keySet().retainAll(remoteAlbums.keySet());
195 		for (final Iterator<Map.Entry<String, CategoryItem>> entryIterator = missingTracks
196 				.entrySet().iterator(); entryIterator.hasNext();) {
197 			final Map.Entry<String, CategoryItem> entry = entryIterator.next();
198 			int localTrackCount = entry.getValue().songs;
199 			if (localTrackCount < 0) {
200 				localTrackCount = reference.getTracks(entry.getValue()).size();
201 			}
202 			final CategoryItem remoteAlbum = remoteAlbums.get(entry.getKey());
203 			int remoteTrackCount = remoteAlbum.songs;
204 			if (remoteTrackCount < 0) {
205 				remoteTrackCount = downloadFrom.getTracks(remoteAlbum).size();
206 			}
207 			if (localTrackCount >= remoteTrackCount) {
208 				entryIterator.remove();
209 			}
210 		}
211 		toSynchronize.putAll(remoteAlbums);
212 		localAlbums.keySet().removeAll(missingTracks.keySet());
213 		toSynchronize.keySet().removeAll(localAlbums.keySet());
214 	}
215 
216 	private void downloadTrack(TrackMetadataBean track) throws IOException {
217 		final URL source = new URL(track.getLocation());
218 		final String targetFileName = rootPath + "/"
219 				+ pathFormatter.format(track)
220 				+ IOUtils.getExt(track.getLocation()).toLowerCase();
221 		final File target = new File(targetFileName);
222 		if (target.exists()) {
223 			// the track already exists... skip
224 			return;
225 		}
226 		final File parentDir = target.getParentFile();
227 		if (!parentDir.exists()) {
228 			if (!parentDir.mkdirs()) {
229 				throw new IOException("Failed to create directory "
230 						+ parentDir.toString());
231 			}
232 		}
233 		try {
234 			IOUtils.copy(source.openStream(), new FileOutputStream(target),
235 					8192);
236 		} catch (RuntimeException ex) {
237 			target.delete();
238 			throw ex;
239 		} catch (IOException ex) {
240 			target.delete();
241 			throw ex;
242 		}
243 	}
244 
245 	private TrackMetadataBean removeSlashes(final TrackMetadataBean track) {
246 		final TrackMetadataBean.Builder b = new TrackMetadataBean.Builder();
247 		b.getData(track);
248 		b.title = removeSlashes(b.title);
249 		b.album = removeSlashes(b.album);
250 		b.artist = removeSlashes(b.artist);
251 		b.genre = removeSlashes(b.genre);
252 		b.yearReleased = removeSlashes(b.yearReleased);
253 		b.trackNumber = removeSlashes(b.trackNumber);
254 		return b.build(-1);
255 	}
256 
257 	/***
258 	 * Since /sdcard is most probably a fat32 drive, we have to remove several
259 	 * characters beside slash.
260 	 * 
261 	 * @param str
262 	 *            the string to remove invalid characters from.
263 	 * @return string having all invalid characters replaced by _
264 	 */
265 	private String removeSlashes(final String str) {
266 		if (str == null) {
267 			return null;
268 		}
269 		String result = str;
270 		for (final char c : INVALID_CHARS) {
271 			result = result.replace(c, '_');
272 		}
273 		return result;
274 	}
275 
276 	private final static char[] INVALID_CHARS = "///:*?<>|".toCharArray();
277 
278 	private Map<String, CategoryItem> getNames(
279 			final Collection<CategoryItem> items) {
280 		final Map<String, CategoryItem> result = new HashMap<String, CategoryItem>(
281 				items.size());
282 		for (final CategoryItem item : items) {
283 			if (item.name != null) {
284 				result.put(item.name, item);
285 			}
286 		}
287 		return result;
288 	}
289 }