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
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 }