Let publishers configure episode slugs
[mygpo.git] / mygpo / maintenance / merge.py
blob960b3684b4c2b5b46cb159de4d471a34a889498b
1 from itertools import chain, imap as map
2 import logging
3 from functools import partial
5 import restkit
7 from mygpo.core.models import Podcast, Episode, PodcastGroup
8 from mygpo.users.models import PodcastUserState, EpisodeUserState
9 from mygpo import utils
10 from mygpo.decorators import repeat_on_conflict
11 from mygpo.db.couchdb.episode import episodes_for_podcast
12 from mygpo.db.couchdb.podcast_state import all_podcast_states
13 from mygpo.db.couchdb.episode_state import all_episode_states
14 from mygpo.db.couchdb.utils import multi_request_view
17 class IncorrectMergeException(Exception):
18 pass
21 def podcast_url_wrapper(r):
22 url = r['key']
23 doc = r['doc']
24 if doc['doc_type'] == 'Podcast':
25 obj = Podcast.wrap(doc)
26 elif doc['doc_type'] == 'PodcastGroup':
27 obj = PodcastGroup.wrap(doc)
29 return obj.get_podcast_by_url(url)
32 def merge_objects(podcasts=True, podcast_states=False, episodes=False,
33 episode_states=False, dry_run=False):
34 """
35 Merges objects (podcasts, episodes, states) based on different criteria
36 """
38 # The "smaller" podcast is merged into the "greater"
39 podcast_merge_order = lambda a, b: cmp(a.subscriber_count(), b.subscriber_count())
40 no_merge_order = lambda a, b: 0
42 merger = partial(merge_from_iterator, dry_run=dry_run,
43 progress_callback=utils.progress)
46 if podcasts:
48 print 'Merging Podcasts by URL'
49 podcasts, total = get_view_count_iter(Podcast,
50 'podcasts/by_url',
51 wrap = False,
52 include_docs=True)
53 podcasts = map(podcast_url_wrapper, podcasts)
54 merger(podcasts, similar_urls, podcast_merge_order, total,
55 merge_podcasts)
56 print
59 print 'Merging Podcasts by Old-Id'
60 podcasts, total = get_view_count_iter(Podcast,
61 'podcasts/by_oldid',
62 wrap = False,
63 include_docs=True)
64 podcasts = imap(podcast_oldid_wrapper, podcasts)
65 merger(podcasts, similar_oldid, podcast_merge_order, total,
66 merge_podcasts)
67 print
70 if podcast_states:
71 print 'Merging Duplicate Podcast States'
72 states, total = get_view_count_iter(PodcastUserState,
73 'podcast_states/by_user',
74 include_docs=True)
75 should_merge = lambda a, b: a == b
76 merger(states, should_merge, no_merge_order, total,
77 merge_podcast_states)
78 print
81 if episodes:
82 print 'Merging Episodes by URL'
83 episodes, total = get_view_count_iter(Episode,
84 'episodes/by_podcast_url',
85 include_docs=True)
86 should_merge = lambda a, b: a.podcast == b.podcast and \
87 similar_urls(a, b)
88 merger(episodes, should_merge, no_merge_order, total, merge_episodes)
89 print
92 print 'Merging Episodes by Old-Id'
93 episodes, total = get_view_count_iter(Episode,
94 'episodes/by_oldid',
95 include_docs=True)
96 should_merge = lambda a, b: a.podcast == b.podcast and \
97 similar_oldid(a, b)
98 merger(episodes, should_merge, no_merge_order, total, merge_episodes)
99 print
102 if episode_states:
103 print 'Merging Duplicate Episode States'
104 states, total = get_view_count_iter(EpisodeUserState,
105 'episode_states/by_user_episode',
106 include_docs=True)
107 should_merge = lambda a, b: (a.user, a.episode) == \
108 (b.user, b.episode)
109 merger(states, should_merge, no_merge_order, total,
110 merge_episode_states)
111 print
115 def get_view_count_iter(cls, view, *args, **kwargs):
116 iterator = multi_request_view(cls, view, *args, **kwargs)
117 total = cls.view(view, limit=0).total_rows
118 return iterator, total
121 def merge_from_iterator(obj_it, should_merge, cmp, total, merge_func,
122 dry_run=False, progress_callback=lambda *args, **kwargs: None):
124 Iterates over the objects in obj_it and calls should_merge for each pair of
125 objects. This implies that the objects returned by obj_it should be sorted
126 such that potential merge-candiates appear after each other.
128 If should_merge returns True, the pair of objects is going to be merged.
129 The smaller object (according to cmp) is merged into the larger one.
130 merge_func performs the actual merge. It is passed the two objects to be
131 merged (first the larger, then the smaller one).
134 obj_it = iter(obj_it)
136 try:
137 prev = obj_it.next()
138 except StopIteration:
139 return
141 for n, p in enumerate(obj_it):
142 if should_merge(p, prev):
143 items = sorted([p, prev], cmp=cmp)
144 logging.info('merging {old} into {new}'.
145 format(old=items[1], new=items[0]))
147 merge_func(*items, dry_run=dry_run)
149 prev = p
150 progress_callback(n, total)
154 class PodcastMerger(object):
155 """ Merges podcast2 into podcast
157 Also merges the related podcast states, and re-assignes podcast2's episodes
158 to podcast, but does neither merge their episodes nor their episode states
162 def __init__(self, podcasts, actions, groups, dry_run=False):
164 for n, podcast1 in enumerate(podcasts):
165 for m, podcast2 in enumerate(podcasts):
166 if podcast1 == podcast2 and n != m:
167 raise IncorrectMergeException("can't merge podcast into itself")
169 self.podcasts = podcasts
170 self.actions = actions
171 self.groups = groups
172 self.dry_run = dry_run
175 def merge(self):
176 podcast1 = self.podcasts.pop(0)
178 for podcast2 in self.podcasts:
179 self._merge_objs(podcast1=podcast1, podcast2=podcast2)
180 self.merge_states(podcast1, podcast2)
181 self.merge_episodes()
182 self.reassign_episodes(podcast1, podcast2)
183 self._delete(podcast2=podcast2)
185 self.actions['merge-podcast'] += 1
188 def merge_episodes(self):
189 for n, episodes in self.groups:
191 episode = episodes.pop(0)
193 for ep in episodes:
195 em = EpisodeMerger(episode, ep, self.actions)
196 em.merge()
199 @repeat_on_conflict(['podcast1', 'podcast2'])
200 def _merge_objs(self, podcast1, podcast2):
202 podcast1.merged_ids = set_filter(podcast1.get_id(),
203 podcast1.merged_ids, [podcast2.get_id()], podcast2.merged_ids)
205 podcast1.merged_slugs = set_filter(podcast1.slug,
206 podcast1.merged_slugs, [podcast2.slug], podcast2.merged_slugs)
208 podcast1.merged_oldids = set_filter(podcast1.oldid,
209 podcast1.merged_oldids, [podcast2.oldid],
210 podcast2.merged_oldids)
212 # the first URL in the list represents the podcast main URL
213 main_url = podcast1.url
214 podcast1.urls = set_filter(None, podcast1.urls, podcast2.urls)
215 # so we insert it as the first again
216 podcast1.urls.remove(main_url)
217 podcast1.urls.insert(0, main_url)
219 # we ignore related_podcasts because
220 # * the elements should be roughly the same
221 # * element order is important but could not preserved exactly
223 podcast1.content_types = set_filter(None, podcast1.content_types,
224 podcast2.content_types)
226 key = lambda x: x.timestamp
227 for a, b in utils.iterate_together(
228 [podcast1.subscribers, podcast2.subscribers], key):
230 if a is None or b is None: continue
232 # avoid increasing subscriber_count when merging
233 # duplicate entries of a single podcast
234 if a.subscriber_count == b.subscriber_count:
235 continue
237 a.subscriber_count += b.subscriber_count
239 for src, tags in podcast2.tags.items():
240 podcast1.tags[src] = set_filter(None, podcast1.tags.get(src, []),
241 tags)
243 podcast1.save()
246 @repeat_on_conflict(['podcast2'])
247 def _delete(self, podcast2):
248 podcast2.delete()
251 @repeat_on_conflict(['s'])
252 def _save_state(self, s, podcast1):
253 s.podcast = podcast1.get_id()
254 s.save()
257 @repeat_on_conflict(['e'])
258 def _save_episode(self, e, podcast1):
259 e.podcast = podcast1.get_id()
260 e.save()
264 def reassign_episodes(self, podcast1, podcast2):
265 # re-assign episodes to new podcast
266 # if necessary, they will be merged later anyway
267 for e in episodes_for_podcast(podcast2):
268 self.actions['reassign-episode'] += 1
270 for s in all_episode_states(e):
271 self.actions['reassign-episode-state'] += 1
273 self._save_state(s=s, podcast1=podcast1)
275 self._save_episode(e=e, podcast1=podcast1)
278 def merge_states(self, podcast1, podcast2):
279 """Merges the Podcast states that are associated with the two Podcasts.
281 This should be done after two podcasts are merged
284 key = lambda x: x.user
285 states1 = sorted(all_podcast_states(podcast1), key=key)
286 states2 = sorted(all_podcast_states(podcast2), key=key)
288 for state, state2 in utils.iterate_together([states1, states2], key):
290 if state == state2:
291 continue
293 if state == None:
294 self.actions['move-podcast-state'] += 1
295 self._move_state(state2=state2, new_id=podcast1.get_id(),
296 new_url=podcast1.url)
298 elif state2 == None:
299 continue
301 else:
302 psm = PodcastStateMerger(state, state2, self.actions)
303 psm.merge()
306 @repeat_on_conflict(['state2'])
307 def _move_state(self, state2, new_id, new_url):
308 state2.ref_url = new_url
309 state2.podcast = new_id
310 state2.save()
312 @repeat_on_conflict(['state2'])
313 def _delete_state(state2):
314 state2.delete()
319 def similar_urls(a, b):
320 """ Two Podcasts/Episodes are merged, if they have the same URLs"""
321 return bool(utils.intersect(a.urls, b.urls))
328 class EpisodeMerger(object):
331 def __init__(self, episode1, episode2, actions, dry_run=False):
332 if episode1 == episode2:
333 raise IncorrectMergeException("can't merge episode into itself")
335 self.episode1 = episode1
336 self.episode2 = episode2
337 self.actions = actions
338 self.dry_run = dry_run
341 def merge(self):
342 self._merge_objs(episode1=self.episode1, episode2=self.episode2)
343 self.merge_states(self.episode1, self.episode2)
344 self._delete(e=self.episode2)
345 self.actions['merge-episode'] += 1
348 @repeat_on_conflict(['episode1'])
349 def _merge_objs(self, episode1, episode2):
351 episode1.urls = set_filter(None, episode1.urls, episode2.urls)
353 episode1.merged_ids = set_filter(episode1._id, episode1.merged_ids,
354 [episode2._id], episode2.merged_ids)
356 episode1.merged_slugs = set_filter(episode1.slug,
357 episode1.merged_slugs, [episode2.slug], episode2.merged_slugs)
359 episode1.save()
362 @repeat_on_conflict(['e'])
363 def _delete(self, e):
364 e.delete()
367 def merge_states(self, episode, episode2):
369 key = lambda x: x.user
370 states1 = sorted(all_episode_states(self.episode1), key=key)
371 states2 = sorted(all_episode_states(self.episode2), key=key)
373 for state, state2 in utils.iterate_together([states1, states2], key):
375 if state == state2:
376 continue
378 if state == None:
379 self.actions['move-episode-state'] += 1
380 self._move(state2=state2, podcast_id=self.episode1.podcast,
381 episode_id=self.episode1._id)
383 elif state2 == None:
384 continue
386 else:
387 esm = EpisodeStateMerger(state, state2, self.actions)
388 esm.merge()
391 @repeat_on_conflict(['state2'])
392 def _move(self, state2, podcast_id, episode_id):
393 state2.podcast = podcast_id
394 state2.episode = episode_id
395 state2.save()
402 class PodcastStateMerger(object):
403 """Merges the two given podcast states"""
405 def __init__(self, state, state2, actions, dry_run=False):
407 if state._id == state2._id:
408 raise IncorrectMergeException("can't merge podcast state into itself")
410 if state.user != state2.user:
411 raise IncorrectMergeException("states don't belong to the same user")
413 self.state = state
414 self.state2 = state2
415 self.actions = actions
416 self.dry_run = dry_run
419 def merge(self):
420 self._do_merge(state=self.state, state2=self.state2)
421 self._add_actions(state=self.state, actions=self.state2.actions)
422 self._delete(state2=self.state2)
423 self.actions['merged-podcast-state'] += 1
426 @repeat_on_conflict(['state'])
427 def _do_merge(self, state, state2):
429 # overwrite settings in state2 with state's settings
430 settings = state2.settings
431 settings.update(state.settings)
432 state.settings = settings
434 state.disabled_devices = set_filter(None, state.disabled_devices,
435 state2.disabled_devices)
437 state.merged_ids = set_filter(state._id, state.merged_ids,
438 [state2._id], state2.merged_ids)
440 state.tags = set_filter(None, state.tags, state2.tags)
442 state.save()
445 @repeat_on_conflict(['state'])
446 def _add_actions(self, state, actions):
447 try:
448 state.add_actions(actions)
449 state.save()
450 except restkit.Unauthorized:
451 # the merge could result in an invalid list of
452 # subscribe/unsubscribe actions -- we ignore it and
453 # just use the actions from state
454 return
456 @repeat_on_conflict(['state2'])
457 def _delete(self, state2):
458 state2.delete()
464 class EpisodeStateMerger(object):
465 """ Merges state2 in state """
467 def __init__(self, state, state2, actions, dry_run=False):
469 if state._id == state2._id:
470 raise IncorrectMergeException("can't merge episode state into itself")
472 if state.user != state2.user:
473 raise IncorrectMergeException("states don't belong to the same user")
475 self.state = state
476 self.state2 = state2
477 self.actions = actions
478 self.dry_run = dry_run
481 def merge(self):
482 self._merge_obj(state=self.state, state2=self.state2)
483 self._do_delete(state2=self.state2)
484 self.actions['merge-episode-state'] += 1
487 @repeat_on_conflict(['state'])
488 def _merge_obj(self, state, state2):
489 state.add_actions(state2.actions)
491 # overwrite settings in state2 with state's settings
492 settings = state2.settings
493 settings.update(state.settings)
494 state.settings = settings
496 merged_ids = set(state.merged_ids + [state2._id] + state2.merged_ids)
497 state.merged_ids = filter(None, merged_ids)
499 state.chapters = list(set(state.chapters + state2.chapters))
501 state.save()
503 @repeat_on_conflict(['state2'])
504 def _do_delete(self, state2):
505 state2.delete()
508 def set_filter(orig, *args):
509 """ chain args, and remove falsy values and orig """
510 s = set(chain.from_iterable(args))
511 s = s - set([orig])
512 s = filter(None, s)
513 return s